2
0
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:
18alantom 2022-05-16 12:24:58 +05:30
parent ad22005cc2
commit 79650d6c8e
6 changed files with 321 additions and 62 deletions

View File

@ -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) => ({
return {
cells: columns.map((c) => ({
rawValue: '',
value: '',
width: c.width ?? 1,
})) as ReportRow;
})),
};
}
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,
});

View File

@ -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)`
);
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: '',
rawValue: '',
width: c.width,
align: 'left',
} as ReportCell)
),
};
}
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) => {
const nameCell = { value: al.name, align: 'left', width: ACC_NAME_WIDTH };
const balanceCells = dateKeys.map(
(k) =>
({
value: this.fyo.format(al.valueMap?.get(k)!, 'Currency'),
align: 'right',
width: ACC_BAL_WIDTH,
} as ReportCell)
);
return [nameCell, balanceCells].flat() as ReportRow;
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;
}

View File

@ -13,6 +13,7 @@ export abstract class Report extends Observable<RawValue> {
columns: ColumnField[] = [];
filters: Field[] = [];
reportData: ReportData;
usePagination: boolean = false;
constructor(fyo: Fyo) {
super();

View File

@ -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';

View File

@ -30,16 +30,20 @@
@scroll="scroll"
>
<!-- Report Rows -->
<template v-for="(row, r) in dataSlice" :key="r + '-row'">
<div
v-for="(row, r) in report.reportData.slice(pageStart, pageEnd)"
:key="r + '-row'"
v-if="!row.folded"
class="flex items-center w-max"
:style="{ height: `${hconst}px` }"
:class="r !== pageEnd - 1 ? 'border-b' : ''"
:class="[
r !== pageEnd - 1 ? 'border-b' : '',
row.isGroup ? 'hover:bg-gray-100 cursor-pointer' : '',
]"
@click="() => onRowClick(row, r)"
>
<!-- Report Cell -->
<div
v-for="(cell, c) in row"
v-for="(cell, c) in row.cells"
:key="`${c}-${r}-cell`"
:style="getCellStyle(cell, c)"
class="
@ -53,18 +57,20 @@
{{ cell.value }}
</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;
},
},

View File

@ -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)"