From 79650d6c8e4e3f7a8d0afb14dec53c97d623568e Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Mon, 16 May 2022 12:24:58 +0530 Subject: [PATCH] incr: add indentation support, format PNL --- reports/GeneralLedger/GeneralLedger.ts | 18 +- reports/ProfitAndLoss/ProfitAndLoss.ts | 261 ++++++++++++++++++++++--- reports/Report.ts | 1 + reports/types.ts | 13 +- src/components/Report/ListReport.vue | 88 +++++++-- src/pages/Report.vue | 2 +- 6 files changed, 321 insertions(+), 62 deletions(-) diff --git a/reports/GeneralLedger/GeneralLedger.ts b/reports/GeneralLedger/GeneralLedger.ts index 8461073f..ae279aab 100644 --- a/reports/GeneralLedger/GeneralLedger.ts +++ b/reports/GeneralLedger/GeneralLedger.ts @@ -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, }); diff --git a/reports/ProfitAndLoss/ProfitAndLoss.ts b/reports/ProfitAndLoss/ProfitAndLoss.ts index 44cdfa39..19a95d73 100644 --- a/reports/ProfitAndLoss/ProfitAndLoss.ts +++ b/reports/ProfitAndLoss/ProfitAndLoss.ts @@ -35,6 +35,13 @@ interface Account { parentAccount: string | null; } +interface TreeNode { + name: string; + children?: TreeNode[]; +} + +type Tree = Record; + type AccountTree = Record; 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 { + 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 { + 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 { + 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 { @@ -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; +} diff --git a/reports/Report.ts b/reports/Report.ts index a7ac4748..05085b87 100644 --- a/reports/Report.ts +++ b/reports/Report.ts @@ -13,6 +13,7 @@ export abstract class Report extends Observable { columns: ColumnField[] = []; filters: Field[] = []; reportData: ReportData; + usePagination: boolean = false; constructor(fyo: Fyo) { super(); diff --git a/reports/types.ts b/reports/types.ts index c7e82176..ad0b5f97 100644 --- a/reports/types.ts +++ b/reports/types.ts @@ -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'; @@ -58,4 +67,4 @@ export interface LedgerEntry { reverts: string; } -export type GroupedMap = Map; \ No newline at end of file +export type GroupedMap = Map; diff --git a/src/components/Report/ListReport.vue b/src/components/Report/ListReport.vue index dcaaabd5..ccbb16bf 100644 --- a/src/components/Report/ListReport.vue +++ b/src/components/Report/ListReport.vue @@ -30,41 +30,47 @@ @scroll="scroll" > -
- +
-
+

+