mirror of
https://github.com/frappe/books.git
synced 2025-02-02 12:08:27 +00:00
incr: add indentation support, format PNL
This commit is contained in:
parent
ad22005cc2
commit
79650d6c8e
@ -23,6 +23,7 @@ type ReferenceType =
|
||||
export class GeneralLedger extends LedgerReport {
|
||||
static title = t`General Ledger`;
|
||||
static reportName = 'general-ledger';
|
||||
usePagination: boolean = true;
|
||||
|
||||
ascending: boolean = false;
|
||||
reverted: boolean = false;
|
||||
@ -102,19 +103,23 @@ export class GeneralLedger extends LedgerReport {
|
||||
|
||||
_getRowFromEntry(entry: LedgerEntry, columns: ColumnField[]): ReportRow {
|
||||
if (entry.name === -3) {
|
||||
return columns.map((c) => ({
|
||||
value: '',
|
||||
width: c.width ?? 1,
|
||||
})) as ReportRow;
|
||||
return {
|
||||
cells: columns.map((c) => ({
|
||||
rawValue: '',
|
||||
value: '',
|
||||
width: c.width ?? 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const row: ReportRow = [];
|
||||
const row: ReportRow = { cells: [] };
|
||||
for (const col of columns) {
|
||||
const align = col.align ?? 'left';
|
||||
const width = col.width ?? 1;
|
||||
const fieldname = col.fieldname;
|
||||
|
||||
let value = entry[fieldname as keyof LedgerEntry];
|
||||
const rawValue = value;
|
||||
if (value === null || value === undefined) {
|
||||
value = '';
|
||||
}
|
||||
@ -133,10 +138,11 @@ export class GeneralLedger extends LedgerReport {
|
||||
value = String(value);
|
||||
}
|
||||
|
||||
row.push({
|
||||
row.cells.push({
|
||||
italics: entry.name === -1,
|
||||
bold: entry.name === -2,
|
||||
value,
|
||||
rawValue,
|
||||
align,
|
||||
width,
|
||||
});
|
||||
|
@ -35,6 +35,13 @@ interface Account {
|
||||
parentAccount: string | null;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
type Tree = Record<string, TreeNode>;
|
||||
|
||||
type AccountTree = Record<string, AccountTreeNode>;
|
||||
interface AccountTreeNode extends Account {
|
||||
children?: AccountTreeNode[];
|
||||
@ -53,7 +60,7 @@ const PNL_ROOT_TYPES: AccountRootType[] = [
|
||||
AccountRootTypeEnum.Expense,
|
||||
];
|
||||
|
||||
const ACC_NAME_WIDTH = 1.5;
|
||||
const ACC_NAME_WIDTH = 2;
|
||||
const ACC_BAL_WIDTH = 1;
|
||||
|
||||
export class ProfitAndLoss extends LedgerReport {
|
||||
@ -65,6 +72,7 @@ export class ProfitAndLoss extends LedgerReport {
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
singleColumn: boolean = false;
|
||||
hideGroupBalance: boolean = false;
|
||||
periodicity: Periodicity = 'Monthly';
|
||||
basedOn: BasedOn = 'Date Range';
|
||||
|
||||
@ -98,29 +106,204 @@ export class ProfitAndLoss extends LedgerReport {
|
||||
delete accountTree[name];
|
||||
}
|
||||
|
||||
const accountList = convertAccountTreeToAccountList(accountTree);
|
||||
this.reportData = this.getReportDataFromAccountList(accountList);
|
||||
const rootNodeList = Object.values(accountTree);
|
||||
const incomeTree = {
|
||||
[AccountRootTypeEnum.Income]: rootNodeList.find(
|
||||
(n) => n.rootType === AccountRootTypeEnum.Income
|
||||
)!,
|
||||
};
|
||||
const expenseTree = {
|
||||
[AccountRootTypeEnum.Expense]: rootNodeList.find(
|
||||
(n) => n.rootType === AccountRootTypeEnum.Expense
|
||||
)!,
|
||||
};
|
||||
|
||||
const incomeList = convertAccountTreeToAccountList(incomeTree);
|
||||
const expenseList = convertAccountTreeToAccountList(expenseTree);
|
||||
const incomeRows = this.getPnlRowsFromAccountList(incomeList);
|
||||
const expenseRows = this.getPnlRowsFromAccountList(expenseList);
|
||||
this.reportData = await this.getReportDataFromRows(
|
||||
incomeRows,
|
||||
expenseRows,
|
||||
incomeTree,
|
||||
expenseTree
|
||||
);
|
||||
}
|
||||
|
||||
getReportDataFromAccountList(accountList: AccountList): ReportData {
|
||||
const dateKeys = [...accountList[0].valueMap!.keys()].sort(
|
||||
(a, b) => b.toDate.toMillis() - a.toDate.toMillis()
|
||||
async getReportDataFromRows(
|
||||
incomeRows: ReportData,
|
||||
expenseRows: ReportData,
|
||||
incomeTree: AccountTree,
|
||||
expenseTree: AccountTree
|
||||
): Promise<ReportData> {
|
||||
const totalIncome = await this.getTotalNode(
|
||||
incomeTree,
|
||||
t`Total Income (Credit)`
|
||||
);
|
||||
const totalExpense = await this.getTotalNode(
|
||||
expenseTree,
|
||||
t`Total Expense (Debit)`
|
||||
);
|
||||
|
||||
return accountList.map((al) => {
|
||||
const nameCell = { value: al.name, align: 'left', width: ACC_NAME_WIDTH };
|
||||
const balanceCells = dateKeys.map(
|
||||
(k) =>
|
||||
const totalValueMap = new Map();
|
||||
for (const key of totalIncome.valueMap!.keys()) {
|
||||
const income = totalIncome.valueMap!.get(key)!;
|
||||
const expense = totalExpense.valueMap!.get(key)!;
|
||||
totalValueMap.set(key, income - expense);
|
||||
}
|
||||
|
||||
const totalProfit = {
|
||||
name: t`Total Profit`,
|
||||
valueMap: totalValueMap,
|
||||
level: 0,
|
||||
} as AccountListNode;
|
||||
|
||||
const dateKeys = this.getSortedDateKeys(totalValueMap);
|
||||
const totalIncomeRow = this.getRowFromAccountListNode(
|
||||
totalIncome,
|
||||
dateKeys
|
||||
);
|
||||
const totalExpenseRow = this.getRowFromAccountListNode(
|
||||
totalExpense,
|
||||
dateKeys
|
||||
);
|
||||
|
||||
const totalProfitRow = this.getRowFromAccountListNode(
|
||||
totalProfit,
|
||||
dateKeys
|
||||
);
|
||||
totalProfitRow.cells.forEach((c) => {
|
||||
c.bold = true;
|
||||
if (typeof c.rawValue !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (c.rawValue > 0) {
|
||||
c.color = 'green';
|
||||
} else if (c.rawValue < 0) {
|
||||
c.color = 'red';
|
||||
}
|
||||
});
|
||||
|
||||
const emptyRow = await this.getEmptyRow();
|
||||
|
||||
return [
|
||||
incomeRows,
|
||||
totalIncomeRow,
|
||||
emptyRow,
|
||||
expenseRows,
|
||||
totalExpenseRow,
|
||||
emptyRow,
|
||||
emptyRow,
|
||||
totalProfitRow,
|
||||
].flat() as ReportData;
|
||||
}
|
||||
|
||||
async getEmptyRow(): Promise<ReportRow> {
|
||||
const columns = await this.getColumns();
|
||||
return {
|
||||
cells: columns.map(
|
||||
(c) =>
|
||||
({
|
||||
value: this.fyo.format(al.valueMap?.get(k)!, 'Currency'),
|
||||
align: 'right',
|
||||
width: ACC_BAL_WIDTH,
|
||||
value: '',
|
||||
rawValue: '',
|
||||
width: c.width,
|
||||
align: 'left',
|
||||
} as ReportCell)
|
||||
);
|
||||
return [nameCell, balanceCells].flat() as ReportRow;
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async getTotalNode(
|
||||
accountTree: AccountTree,
|
||||
name: string
|
||||
): Promise<AccountListNode> {
|
||||
const leafNodes = getListOfLeafNodes(accountTree) as AccountTreeNode[];
|
||||
let keys: DateRange[] | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Keys need to be from the nodes cause they are ref keys.
|
||||
*/
|
||||
for (const node of leafNodes) {
|
||||
const drs = [...(node?.valueMap?.keys() ?? [])];
|
||||
if (!drs || !drs.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
keys = drs;
|
||||
if (keys && keys.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!keys || !keys.length) {
|
||||
keys = await this._getDateRanges();
|
||||
}
|
||||
|
||||
const totalMap = leafNodes.reduce((acc, node) => {
|
||||
for (const key of keys!) {
|
||||
const bal = acc.get(key) ?? 0;
|
||||
const val = node.valueMap?.get(key) ?? 0;
|
||||
acc.set(key, bal + val);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, new Map() as ValueMap);
|
||||
|
||||
return { name, valueMap: totalMap, level: 0 } as AccountListNode;
|
||||
}
|
||||
|
||||
getPnlRowsFromAccountList(accountList: AccountList): ReportData {
|
||||
const dateKeys = this.getSortedDateKeys(accountList[0].valueMap!);
|
||||
|
||||
return accountList.map((al) => {
|
||||
return this.getRowFromAccountListNode(al, dateKeys);
|
||||
});
|
||||
}
|
||||
|
||||
getSortedDateKeys(valueMap: ValueMap) {
|
||||
return [...valueMap.keys()].sort(
|
||||
(a, b) => b.toDate.toMillis() - a.toDate.toMillis()
|
||||
);
|
||||
}
|
||||
|
||||
getRowFromAccountListNode(al: AccountListNode, dateKeys: DateRange[]) {
|
||||
const nameCell = {
|
||||
value: al.name,
|
||||
rawValue: al.name,
|
||||
align: 'left',
|
||||
width: ACC_NAME_WIDTH,
|
||||
bold: !al.level,
|
||||
italics: al.isGroup,
|
||||
indent: al.level ?? 0,
|
||||
} as ReportCell;
|
||||
|
||||
const balanceCells = dateKeys.map((k) => {
|
||||
const rawValue = al.valueMap?.get(k) ?? 0;
|
||||
let value = this.fyo.format(rawValue, 'Currency');
|
||||
if (this.hideGroupBalance && al.isGroup) {
|
||||
value = '';
|
||||
}
|
||||
|
||||
return {
|
||||
rawValue,
|
||||
value,
|
||||
align: 'right',
|
||||
width: ACC_BAL_WIDTH,
|
||||
} as ReportCell;
|
||||
});
|
||||
|
||||
return {
|
||||
cells: [nameCell, balanceCells].flat(),
|
||||
level: al.level,
|
||||
isGroup: !!al.isGroup,
|
||||
folded: false,
|
||||
foldedBelow: false,
|
||||
} as ReportRow;
|
||||
}
|
||||
|
||||
// async getTotalProfitNode()
|
||||
|
||||
async _getGroupedByDateRanges(
|
||||
map: GroupedMap
|
||||
): Promise<AccountNameValueMapMap> {
|
||||
@ -346,6 +529,11 @@ export class ProfitAndLoss extends LedgerReport {
|
||||
label: t`Single Column`,
|
||||
fieldname: 'singleColumn',
|
||||
} as Field,
|
||||
{
|
||||
fieldtype: 'Check',
|
||||
label: t`Hide Group Balance`,
|
||||
fieldname: 'hideGroupBalance',
|
||||
} as Field,
|
||||
].flat();
|
||||
}
|
||||
|
||||
@ -439,17 +627,13 @@ function setValueMapOnAccountTreeNodes(
|
||||
* also prevent pruning of the parent accounts.
|
||||
*/
|
||||
let parentAccountName: string | null = accountTree[name].parentAccount;
|
||||
let parentValueMap = valueMap;
|
||||
|
||||
while (parentAccountName !== null) {
|
||||
const update = updateParentAccountWithChildValues(
|
||||
parentAccountName = updateParentAccountWithChildValues(
|
||||
accountTree,
|
||||
parentAccountName,
|
||||
parentValueMap
|
||||
valueMap
|
||||
);
|
||||
|
||||
parentAccountName = update.parentAccountName;
|
||||
parentValueMap = update.parentValueMap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -457,24 +641,18 @@ function setValueMapOnAccountTreeNodes(
|
||||
function updateParentAccountWithChildValues(
|
||||
accountTree: AccountTree,
|
||||
parentAccountName: string,
|
||||
parentValueMap: ValueMap
|
||||
): {
|
||||
parentAccountName: string | null;
|
||||
parentValueMap: ValueMap;
|
||||
} {
|
||||
valueMap: ValueMap
|
||||
): string {
|
||||
const parentAccount = accountTree[parentAccountName];
|
||||
parentAccount.prune = false;
|
||||
parentAccount.valueMap ??= new Map();
|
||||
|
||||
for (const key of parentValueMap.keys()) {
|
||||
for (const key of valueMap.keys()) {
|
||||
const value = parentAccount.valueMap!.get(key) ?? 0;
|
||||
parentAccount.valueMap!.set(key, value + parentValueMap.get(key)!);
|
||||
parentAccount.valueMap!.set(key, value + valueMap.get(key)!);
|
||||
}
|
||||
|
||||
return {
|
||||
parentAccountName: parentAccount.parentAccount,
|
||||
parentValueMap: parentAccount.valueMap!,
|
||||
};
|
||||
return parentAccount.parentAccount!;
|
||||
}
|
||||
|
||||
function setChildrenOnAccountTreeNodes(accountTree: AccountTree) {
|
||||
@ -559,3 +737,22 @@ function pushToAccountList(
|
||||
pushToAccountList(childNode, accountList, childLevel);
|
||||
}
|
||||
}
|
||||
|
||||
function getListOfLeafNodes(tree: Tree): TreeNode[] {
|
||||
const nonGroupChildren: TreeNode[] = [];
|
||||
for (const node of Object.values(tree)) {
|
||||
const groupChildren = node.children ?? [];
|
||||
|
||||
while (groupChildren.length) {
|
||||
const child = groupChildren.shift()!;
|
||||
if (!child?.children?.length) {
|
||||
nonGroupChildren.push(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
groupChildren.unshift(...(child.children ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
return nonGroupChildren;
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ export abstract class Report extends Observable<RawValue> {
|
||||
columns: ColumnField[] = [];
|
||||
filters: Field[] = [];
|
||||
reportData: ReportData;
|
||||
usePagination: boolean = false;
|
||||
|
||||
constructor(fyo: Fyo) {
|
||||
super();
|
||||
|
@ -9,9 +9,18 @@ export interface ReportCell {
|
||||
align?: 'left' | 'right' | 'center';
|
||||
width?: number;
|
||||
value: string;
|
||||
rawValue: RawValue | undefined | Date;
|
||||
indent?: number;
|
||||
color?: 'red' | 'green';
|
||||
}
|
||||
|
||||
export type ReportRow = ReportCell[];
|
||||
export interface ReportRow {
|
||||
cells: ReportCell[];
|
||||
level?: number;
|
||||
isGroup?: boolean;
|
||||
folded?: boolean;
|
||||
foldedBelow?: boolean;
|
||||
}
|
||||
export type ReportData = ReportRow[];
|
||||
export interface ColumnField extends BaseField {
|
||||
align?: 'left' | 'right' | 'center';
|
||||
|
@ -30,41 +30,47 @@
|
||||
@scroll="scroll"
|
||||
>
|
||||
<!-- Report Rows -->
|
||||
<div
|
||||
v-for="(row, r) in report.reportData.slice(pageStart, pageEnd)"
|
||||
:key="r + '-row'"
|
||||
class="flex items-center w-max"
|
||||
:style="{ height: `${hconst}px` }"
|
||||
:class="r !== pageEnd - 1 ? 'border-b' : ''"
|
||||
>
|
||||
<!-- Report Cell -->
|
||||
<template v-for="(row, r) in dataSlice" :key="r + '-row'">
|
||||
<div
|
||||
v-for="(cell, c) in row"
|
||||
:key="`${c}-${r}-cell`"
|
||||
:style="getCellStyle(cell, c)"
|
||||
class="
|
||||
text-gray-900 text-base
|
||||
px-3
|
||||
flex-shrink-0
|
||||
overflow-x-scroll
|
||||
whitespace-nowrap
|
||||
"
|
||||
v-if="!row.folded"
|
||||
class="flex items-center w-max"
|
||||
:style="{ height: `${hconst}px` }"
|
||||
:class="[
|
||||
r !== pageEnd - 1 ? 'border-b' : '',
|
||||
row.isGroup ? 'hover:bg-gray-100 cursor-pointer' : '',
|
||||
]"
|
||||
@click="() => onRowClick(row, r)"
|
||||
>
|
||||
{{ cell.value }}
|
||||
<!-- Report Cell -->
|
||||
<div
|
||||
v-for="(cell, c) in row.cells"
|
||||
:key="`${c}-${r}-cell`"
|
||||
:style="getCellStyle(cell, c)"
|
||||
class="
|
||||
text-gray-900 text-base
|
||||
px-3
|
||||
flex-shrink-0
|
||||
overflow-x-scroll
|
||||
whitespace-nowrap
|
||||
"
|
||||
>
|
||||
{{ cell.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</WithScroll>
|
||||
<!-- Report Rows Container -->
|
||||
</div>
|
||||
|
||||
<!-- Pagination Footer -->
|
||||
<div class="mt-auto flex-shrink-0">
|
||||
<div class="mt-auto flex-shrink-0" v-if="report.usePagination">
|
||||
<hr />
|
||||
<Paginator
|
||||
:item-count="report?.reportData?.length ?? 0"
|
||||
@index-change="setPageIndices"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-4" />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
@ -85,6 +91,15 @@ export default defineComponent({
|
||||
pageEnd: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dataSlice() {
|
||||
if (this.report?.usePagination) {
|
||||
return this.report.reportData.slice(this.pageStart, this.pageEnd);
|
||||
}
|
||||
|
||||
return this.report.reportData;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
scroll({ scrollLeft }) {
|
||||
this.$refs.titlerow.scrollLeft = scrollLeft;
|
||||
@ -93,24 +108,55 @@ export default defineComponent({
|
||||
this.pageStart = start;
|
||||
this.pageEnd = end;
|
||||
},
|
||||
onRowClick(clickedRow, r) {
|
||||
if (!clickedRow.isGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
r += 1;
|
||||
clickedRow.foldedBelow = !clickedRow.foldedBelow;
|
||||
const folded = clickedRow.foldedBelow;
|
||||
let row = this.dataSlice[r];
|
||||
|
||||
while (row && row.level > clickedRow.level) {
|
||||
row.folded = folded;
|
||||
r += 1;
|
||||
row = this.dataSlice[r];
|
||||
}
|
||||
},
|
||||
getCellStyle(cell, i) {
|
||||
const styles = {};
|
||||
const width = cell.width ?? 1;
|
||||
const align = cell.align ?? 'left';
|
||||
styles['width'] = `${width * this.wconst}rem`;
|
||||
styles['text-align'] = align;
|
||||
|
||||
if (cell.bold) {
|
||||
styles['font-weight'] = 'bold';
|
||||
}
|
||||
|
||||
if (cell.italics) {
|
||||
styles['font-style'] = 'italic';
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
styles['padding-left'] = '0px';
|
||||
}
|
||||
|
||||
if (i === this.report.columns.length - 1) {
|
||||
styles['padding-right'] = '0px';
|
||||
}
|
||||
|
||||
if (cell.indent) {
|
||||
styles['padding-left'] = `${cell.indent * 2}rem`;
|
||||
}
|
||||
|
||||
if (cell.color === 'red') {
|
||||
styles['color'] = '#e53e3e';
|
||||
} else if (cell.color === 'green') {
|
||||
styles['color'] = '#38a169';
|
||||
}
|
||||
|
||||
return styles;
|
||||
},
|
||||
},
|
||||
|
@ -24,7 +24,7 @@
|
||||
:show-label="field.fieldtype === 'Check'"
|
||||
:key="field.fieldname + '-filter'"
|
||||
class="bg-gray-100 rounded"
|
||||
:class="field.fieldtype === 'Check' ? 'flex pl-2' : ''"
|
||||
:class="field.fieldtype === 'Check' ? 'flex pl-2 py-1' : ''"
|
||||
input-class="bg-transparent text-sm"
|
||||
:df="field"
|
||||
:value="report.get(field.fieldname)"
|
||||
|
Loading…
x
Reference in New Issue
Block a user