2
0
mirror of https://github.com/frappe/books.git synced 2025-01-03 07:12:21 +00:00

incr: start rewriting reports

- list type report eg GL almost done
This commit is contained in:
18alantom 2022-05-12 20:29:56 +05:30
parent c95473c6b0
commit cc493e9fb1
9 changed files with 240 additions and 458 deletions

View File

@ -22,6 +22,7 @@ interface RawLedgerEntry {
}
interface LedgerEntry {
index?: string;
name: number;
account: string;
date: Date | null;
@ -49,8 +50,8 @@ export class GeneralLedger extends Report {
super(fyo);
if (!this.toField) {
this.toField = DateTime.now().toISODate();
this.fromField = DateTime.now().minus({ years: 1 }).toISODate();
this.toDate = DateTime.now().toISODate();
this.fromDate = DateTime.now().minus({ years: 1 }).toISODate();
}
}
@ -60,13 +61,14 @@ export class GeneralLedger extends Report {
}
const map = this._getGroupedMap();
this._setIndexOnEntries(map);
const { totalDebit, totalCredit } = this._getTotalsAndSetBalance(map);
const consolidated = this._consolidateEntries(map);
/**
* Push a blank row if last row isn't blank
*/
if (consolidated.at(-1)!.name !== -3) {
if (consolidated.at(-1)?.name !== -3) {
this._pushBlankEntry(consolidated);
}
@ -90,42 +92,52 @@ export class GeneralLedger extends Report {
this.reportData = this._convertEntriesToReportData(consolidated);
}
_setIndexOnEntries(map: GroupedMap) {
let i = 1;
for (const key of map.keys()) {
for (const entry of map.get(key)!) {
entry.index = String(i);
i = i + 1;
}
}
}
_convertEntriesToReportData(entries: LedgerEntry[]): ReportData {
const reportData = [];
const fieldnames = this.columns.map((f) => f.fieldname);
for (const entry of entries) {
const row = this._getRowFromEntry(entry, fieldnames);
const row = this._getRowFromEntry(entry, this.columns);
reportData.push(row);
}
return reportData;
}
_getRowFromEntry(entry: LedgerEntry, fieldnames: string[]) {
_getRowFromEntry(entry: LedgerEntry, columns: ColumnField[]) {
if (entry.name === -3) {
return Array(fieldnames.length).fill({ value: '' });
return Array(columns.length).fill({ value: '' });
}
const row = [];
for (const n of fieldnames) {
let value = entry[n as keyof LedgerEntry];
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];
if (value === null || value === undefined) {
row.push({ value: '' });
continue;
value = '';
}
let align = 'left';
if (value instanceof Date) {
value = this.fyo.format(value, FieldTypeEnum.Date);
}
if (typeof value === 'number') {
align = 'right';
if (typeof value === 'number' && fieldname !== 'index') {
value = this.fyo.format(value, FieldTypeEnum.Currency);
}
if (typeof value === 'boolean' && n === 'reverted' && value) {
value = t`Reverted`;
if (typeof value === 'boolean' && fieldname === 'reverted') {
value = value ? t`Reverted` : '';
}
row.push({
@ -133,6 +145,7 @@ export class GeneralLedger extends Report {
bold: entry.name === -2,
value,
align,
width,
});
}
@ -266,7 +279,7 @@ export class GeneralLedger extends Report {
'reverts',
];
const filters = this._getQueryFilters();
const filters = {} ?? this._getQueryFilters();
const entries = (await this.fyo.db.getAllRaw(
ModelNameEnum.AccountingLedgerEntry,
{
@ -338,16 +351,16 @@ export class GeneralLedger extends Report {
{ label: t`Journal Entries`, value: 'JournalEntry' },
],
label: t`Reference Type`,
label: t`Ref Type`,
fieldname: 'referenceType',
placeholder: t`Reference Type`,
placeholder: t`Ref Type`,
default: 'All',
},
{
fieldtype: 'DynamicLink',
placeholder: t`Reference Name`,
label: t`Ref Name`,
references: 'referenceType',
label: t`Reference Name`,
placeholder: t`Ref Name`,
fieldname: 'referenceName',
},
{
@ -377,19 +390,7 @@ export class GeneralLedger extends Report {
fieldname: 'toDate',
},
{
fieldtype: 'Check',
default: false,
label: t`Cancelled`,
fieldname: 'reverted',
},
{
fieldtype: 'Check',
default: false,
label: t`Ascending`,
fieldname: 'ascending',
},
{
fieldtype: 'Check',
fieldtype: 'Select',
default: 'none',
label: t`Group By`,
fieldname: 'groupBy',
@ -400,11 +401,30 @@ export class GeneralLedger extends Report {
{ label: t`Reference`, value: 'referenceName' },
],
},
{
fieldtype: 'Check',
default: false,
label: t`Include Cancelled`,
fieldname: 'reverted',
},
{
fieldtype: 'Check',
default: false,
label: t`Ascending Order`,
fieldname: 'ascending',
},
] as Field[];
}
getColumns(): ColumnField[] {
let columns = [
{
label: t`#`,
fieldtype: 'Int',
fieldname: 'index',
align: 'right',
width: 0.5,
},
{
label: t`Account`,
fieldtype: 'Link',
@ -415,41 +435,43 @@ export class GeneralLedger extends Report {
label: t`Date`,
fieldtype: 'Date',
fieldname: 'date',
width: 0.75,
},
{
label: t`Debit`,
fieldtype: 'Currency',
fieldname: 'debit',
align: 'right',
width: 1.25,
},
{
label: t`Credit`,
fieldtype: 'Currency',
fieldname: 'credit',
align: 'right',
width: 1.25,
},
{
label: t`Balance`,
fieldtype: 'Currency',
fieldname: 'balance',
align: 'right',
width: 1.25,
},
{
label: t`Reference Type`,
fieldtype: 'Data',
fieldname: 'referenceType',
},
{
label: t`Reference Name`,
fieldtype: 'Data',
fieldname: 'referenceName',
},
{
label: t`Party`,
fieldtype: 'Link',
fieldname: 'party',
},
{
label: t`Ref Name`,
fieldtype: 'Data',
fieldname: 'referenceName',
},
{
label: t`Ref Type`,
fieldtype: 'Data',
fieldname: 'referenceType',
},
{
label: t`Reverted`,
fieldtype: 'Check',
@ -467,4 +489,12 @@ export class GeneralLedger extends Report {
getActions(): Action[] {
return [];
}
/**
* TODO: Order by date and then the number
* TODO: Something is wrong with dummy data, there are entries from 2022 Dec
* TODO: Add pagination
* TODO: Always visible scrollbar
* TODO: Extract out list view report to a different component
*/
}

View File

@ -1,2 +1,2 @@
import { GeneralLedger } from './GeneralLedger/GeneralLedger';
export { GeneralLedger };
export const reports = { GeneralLedger };

View File

@ -7,12 +7,14 @@ export interface ReportCell {
bold?: boolean;
italics?: boolean;
align?: 'left' | 'right' | 'center';
width?: number;
value: string;
}
export type ReportRow = ReportCell[];
export type ReportData = ReportRow[];
export interface ColumnField extends BaseField {
align?: 'left' | 'right' | 'center';
width?: number;
}

View File

@ -134,7 +134,7 @@ export default {
},
async getFilters() {
const { schemaName, fieldname } = this.df;
const getFilters = fyo.models[schemaName].filters[fieldname];
const getFilters = fyo.models[schemaName]?.filters?.[fieldname];
if (getFilters === undefined) {
return {};

View File

@ -1,6 +1,7 @@
<template>
<div class="flex flex-col max-w-full">
<PageHeader :title="report.title">
<div class="flex flex-col w-full h-full">
<PageHeader :title="title">
<!--
<DropdownWithActions
v-for="group of actionGroups"
:key="group.label"
@ -11,428 +12,164 @@
{{ group.label }}
</DropdownWithActions>
<DropdownWithActions :actions="actions" />
-->
</PageHeader>
<div class="flex px-8 mt-2 text-base" v-if="report.filterFields">
<div
class="w-40 ml-2 first:ml-0"
:class="
df.fieldtype === 'Check' &&
'flex justify-between items-center bg-gray-100 px-2 rounded'
"
v-for="df in report.filterFields"
:key="df.fieldname"
>
<div v-if="df.fieldtype === 'Check'" class="text-sm mr-2">
{{ df.label }}
</div>
<FormControl
size="small"
input-class="bg-gray-100"
:df="df"
:value="filters[df.fieldname]"
@change="(value) => onFilterChange(df, value)"
/>
</div>
<!-- Filters -->
<div v-if="report" class="mx-4 grid grid-cols-5 gap-2">
<FormControl
v-for="field in report.filters"
:show-label="field.fieldtype === 'Check'"
:key="field.fieldname + '-filter'"
class="bg-gray-100 rounded"
:class="field.fieldtype === 'Check' ? 'flex pl-3' : ''"
input-class="bg-transparent px-3 py-2 text-base"
:df="field"
:value="report.get(field.fieldname)"
:read-only="loading"
@change="async (value) => await report.set(field.fieldname, value)"
/>
</div>
<div class="px-8 mt-4">
<div>
<div ref="header" class="overflow-hidden">
<Row gap="2rem" :grid-template-columns="gridTemplateColumns">
<div
class="py-4 text-base truncate"
:class="[
getColumnAlignClass(column),
loading ? 'text-gray-100' : 'text-gray-600',
]"
v-for="column in columns"
:key="column.label"
>
<span :class="{ 'bg-gray-100': loading }">
{{ column.label }}
</span>
</div>
</Row>
</div>
<WithScroll
@scroll="onBodyScroll"
class="flex-1 overflow-auto"
:style="`height: ${height}`"
>
<Row
v-show="row.isShown"
v-for="(row, i) in rows.slice(sliceIndex.from, sliceIndex.to)"
:key="i"
gap="2rem"
:grid-template-columns="gridTemplateColumns"
>
<div
class="py-4 text-base overflow-scroll no-scrollbar"
:class="getCellClasses(row, column)"
v-for="column in columns"
:key="column.label"
@click="toggleChildren(row, i)"
>
<div class="inline-flex">
<feather-icon
v-if="row.isBranch && !row.isLeaf && column === columns[0]"
class="flex-shrink-0 w-4 h-4 mr-2"
:name="row.expanded ? 'chevron-down' : 'chevron-right'"
/>
<span class="truncate" :class="{ 'bg-gray-100': loading }">
<component
:is="cellComponent(row[column.fieldname], column)"
/>
</span>
</div>
</div>
</Row>
</WithScroll>
<!-- Report Outer Container -->
<div
v-if="report"
class="mx-4 mt-4 overflow-x-scroll inline-block overflow-y-hidden"
>
<div class="inline-block">
<!-- Title Row -->
<div
v-if="usePagination"
class="flex w-full justify-center mt-2.5 text-base"
class="flex items-center border-b"
:style="{ height: `${hconst}px` }"
ref="titleRow"
>
<div class="flex justify-center items-center gap-1 text-gray-800">
<feather-icon
name="chevron-left"
class="text-gray-600 w-4 h-4 cursor-pointer"
v-show="pageNo > 1"
@click="pageNo -= 1"
/>
<div class="w-4 h-4" v-show="pageNo <= 1" />
<div class="flex gap-1 bg-gray-100 rounded pr-2">
<input
type="number"
class="
w-6
text-right
outline-none
bg-transparent
focus:text-gray-900
"
v-model="pageNo"
min="1"
max="maxPages"
/>
<p class="text-gray-600">/</p>
<p class="w-5">{{ maxPages }}</p>
<p
v-for="(col, c) in report.columns"
:key="c + '-col'"
:style="getCellStyle(col, c)"
class="
text-gray-600 text-base
px-3
flex-shrink-0
overflow-x-scroll
whitespace-nowrap
"
>
{{ col.label }}
</p>
</div>
<!-- Report Rows Continer -->
<div
class="overflow-y-scroll"
:style="{ height: `${hconst * maxRows + 5}px` }"
>
<!-- Report Rows -->
<div
v-for="(row, r) in report.reportData"
:key="r + '-row'"
class="border-b flex items-center"
:style="{ height: `${hconst}px` }"
>
<!-- Report Cell -->
<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
"
>
{{ cell.value }}
</div>
<div class="w-4 h-4" v-show="pageNo >= maxPages" />
<feather-icon
name="chevron-right"
class="text-gray-600 w-4 h-4 cursor-pointer"
v-show="pageNo < maxPages"
@click="pageNo += 1"
/>
</div>
</div>
</div>
</div>
<div class="h-10 mx-4 mb-4 p-2 bg-red-100 text-base border-t">
add pagination here
</div>
</div>
</template>
<script>
import { getReportData } from 'reports/index';
import reportViewConfig from 'reports/view';
import Button from 'src/components/Button';
import { reports } from 'reports';
import FormControl from 'src/components/Controls/FormControl.vue';
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
import FeatherIcon from 'src/components/FeatherIcon.vue';
import PageHeader from 'src/components/PageHeader';
import Row from 'src/components/Row';
import WithScroll from 'src/components/WithScroll';
import PageHeader from 'src/components/PageHeader.vue';
import { fyo } from 'src/initFyo';
import { h, markRaw, nextTick } from 'vue';
import { defineComponent } from 'vue';
export default {
name: 'Report',
props: ['reportName', 'defaultFilters'],
components: {
PageHeader,
Button,
Row,
FormControl,
WithScroll,
DropdownWithActions,
FeatherIcon,
},
provide() {
return {
doc: this.filters,
};
export default defineComponent({
props: {
reportName: String,
},
data() {
let filters = {};
for (let df of reportViewConfig[this.reportName].filterFields) {
filters[df.fieldname] = null;
}
return {
loading: true,
filters,
pageNo: 1,
pageLen: 25,
reportData: {
rows: [],
columns: [],
},
wconst: 8,
hconst: 48,
loading: false,
report: null,
};
},
async activated() {
this.reportData.columns = this.report.getColumns({ filters: this.filters });
await this.setDefaultFilters();
await this.fetchReportData();
components: { PageHeader, FormControl },
async mounted() {
await this.setReportData();
},
methods: {
onBodyScroll({ scrollLeft }) {
nextTick(() => {
this.$refs.header.scrollLeft = scrollLeft;
});
},
async fetchReportData() {
let data = await getReportData(this.report.method, this.filters);
let rows;
if (data.rows) {
rows = data.rows;
} else {
rows = data;
}
this.reportData.columns = markRaw(
this.report.getColumns({
filters: this.filters,
data,
})
);
if (!rows) {
rows = [];
}
this.reportData.rows = markRaw(this.addTreeMeta(rows));
this.loading = false;
},
addTreeMeta(rows) {
return rows.map((row) => {
if ('indent' in row) {
row.isBranch = true;
row.expanded = true;
row.isLeaf = !row.isGroup;
}
row.isShown = true;
return row;
});
},
toggleChildren(row, rowIndex) {
if (!row.isBranch) return;
let flag;
if (row.expanded) {
row.expanded = false;
flag = false;
} else {
row.expanded = true;
flag = true;
}
let _rows = this.rows.slice(rowIndex + 1);
for (let _row of _rows) {
if (row.isBranch && _row.indent > row.indent) {
_row.expanded = flag;
_row.isShown = flag;
continue;
}
break;
}
},
onFilterChange(df, value) {
this.filters[df.fieldname] = value;
this.fetchReportData();
},
async resetFilters() {
await this.setDefaultFilters();
await this.fetchReportData();
},
async setDefaultFilters() {
for (let df of this.report.filterFields) {
let defaultValue = null;
if (df.default) {
if (typeof df.default === 'function') {
defaultValue = await df.default();
} else {
defaultValue = df.default;
}
}
this.filters[df.fieldname] = defaultValue;
}
if (this.defaultFilters) {
Object.assign(this.filters, this.defaultFilters);
}
},
cellComponent(cellValue, column) {
if (typeof cellValue === 'object') {
// cellValue has a component definition
return cellValue;
}
if (column.component) {
// column has a component definition
return column.component(cellValue, column);
}
// default cell component
let formattedValue =
cellValue != null && cellValue !== ''
? fyo.format(cellValue, column)
: '';
return {
render() {
return h('span', formattedValue);
},
};
},
getColumnAlignClass(column) {
return {
'text-right': ['Int', 'Float', 'Currency'].includes(column.fieldtype),
};
},
getCellClasses(row, column) {
let padding = ['pl-0', 'pl-6', 'pl-12', 'pl-18', 'pl-20'];
let treeCellClasses;
if (row.isBranch && column === this.columns[0]) {
treeCellClasses = [
padding[row.indent],
'hover:bg-gray-100 cursor-pointer',
];
}
return [
this.getColumnAlignClass(column),
treeCellClasses,
this.loading ? 'text-gray-100' : 'text-gray-900',
];
},
getReportData() {
return { rows: this.rows, columns: this.columns, filters: this.filters };
},
getCurriedAction(action) {
return (...args) => action(this.getReportData, ...args);
},
async activated() {
await this.setReportData();
if (fyo.store.isDevelopment) {
window.rep = this;
}
},
computed: {
usePagination() {
return (
this.reportName === 'general-ledger' && this.rows.length > this.pageLen
);
maxRows() {
return 18 - Math.ceil(this.report.filters.length / 5);
},
height() {
if (this.usePagination && this.platform === 'Windows') {
return 'calc(100vh - 14.5rem)';
} else if (this.usePagination) {
return 'calc(100vh - 13rem)';
}
return 'calc(100vh - 12rem)';
},
sliceIndex() {
if (!this.usePagination) {
return {
from: 0,
to: this.rows.length,
};
}
return {
from: (this.pageNo - 1) * this.pageLen,
to: this.pageNo * this.pageLen,
};
},
maxPages() {
return Math.ceil(this.rows.length / this.pageLen);
},
actions() {
return [
...(this.report.actions
?.filter((action) => !action.group)
.map((action) =>
Object.assign({}, action, {
action: this.getCurriedAction(action.action),
})
) ?? []),
{
label: this.t`Reset Filters`,
action: this.resetFilters,
},
];
},
actionGroups() {
const groups =
this.report.actions
?.filter((action) => action.group)
.reduce((acc, action) => {
acc[action.group] ??= { type: action.type, actions: [] };
const actionWithoutGroup = Object.assign({}, action);
actionWithoutGroup.action = this.getCurriedAction(action.action);
delete actionWithoutGroup.group;
acc[action.group].actions.push(actionWithoutGroup);
return acc;
}, {}) ?? {};
return Object.keys(groups).map((label) => ({ label, ...groups[label] }));
},
columns() {
return this.loading
? this.blankStateData.columns
: this.reportData.columns;
},
rows() {
return this.loading ? this.blankStateData.rows : this.reportData.rows;
},
blankStateData() {
let columns = Array.from(new Array(6)).map((v, i) => {
return {
fieldtype: 'Data',
fieldname: `Test ${i + 1}`,
label: `Test ${i + 1}`,
};
});
let rows = Array.from(new Array(14)).map(() => {
return columns.reduce((obj, col) => {
obj[col.fieldname] = 'Test Data ' + col.fieldname;
obj.isShown = true;
return obj;
}, {});
});
return {
columns,
rows,
};
},
report() {
return reportViewConfig[this.reportName];
},
columnWidth() {
return 'minmax(7rem, 1fr)';
},
gridTemplateColumns() {
return this.columns
.map((col) => {
let multiplier = col.width;
if (!multiplier) {
multiplier = 1;
}
let minWidth = `${7 * multiplier}rem`;
let maxWidth = `${9 * multiplier}rem`;
return `minmax(${minWidth}, ${maxWidth})`;
})
.join(' ');
title() {
return reports[this.reportName]?.title ?? t`Report`;
},
},
};
methods: {
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';
}
return styles;
},
async setReportData() {
const Report = reports[this.reportName];
if (this.report === null) {
this.report = new Report(fyo);
}
if (!this.report.reportData.length) {
await this.report.setReportData();
}
},
},
});
</script>

View File

@ -1,6 +1,5 @@
import { ipcRenderer } from 'electron';
import { ConfigKeys } from 'fyo/core/types';
import { GeneralLedger } from 'reports';
import { IPC_ACTIONS } from 'utils/messages';
import { App as VueApp, createApp } from 'vue';
import App from './App.vue';
@ -106,6 +105,3 @@ function setOnWindow() {
window.fyo = fyo;
}
}
// @ts-ignore
window.GL = GeneralLedger;

View File

@ -8,7 +8,7 @@ import JournalEntryForm from 'src/pages/JournalEntryForm.vue';
import ListView from 'src/pages/ListView/ListView.vue';
import PrintView from 'src/pages/PrintView/PrintView.vue';
import QuickEditForm from 'src/pages/QuickEditForm.vue';
// import Report from 'src/pages/Report.vue';
import Report from 'src/pages/Report.vue';
import Settings from 'src/pages/Settings/Settings.vue';
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { fyo } from './initFyo';
@ -87,14 +87,12 @@ const routes: RouteRecordRaw[] = [
component: PrintView,
props: true,
},
/*
{
path: '/report/:reportName',
name: 'Report',
component: Report,
props: true,
},
*/
{
path: '/chart-of-accounts',
name: 'Chart Of Accounts',

View File

@ -132,18 +132,18 @@ function getCompleteSidebar(): SidebarConfig {
},
],
},
/*
{
label: t`Reports`,
name: t`reports`,
icon: 'reports',
route: '/report/general-ledger',
route: '/report/GeneralLedger',
items: [
{
label: t`General Ledger`,
name: 'general-ledger',
route: '/report/general-ledger',
route: '/report/GeneralLedger',
},
/*
{
label: t`Profit And Loss`,
name: 'profit-and-loss',
@ -171,9 +171,9 @@ function getCompleteSidebar(): SidebarConfig {
route: '/report/gstr-2',
hidden: () => fyo.singles.AccountingSettings!.country !== 'India',
},
*/
],
},
*/
{
label: t`Setup`,
name: t`setup`,

View File

@ -104,3 +104,22 @@ export function invertMap(map: Record<string, string>): Record<string, string> {
return inverted;
}
export function time<K, T>(func: (...args: K[]) => T, ...args: K[]): T {
const name = func.name;
console.time(name);
const stuff = func(...args);
console.timeEnd(name);
return stuff;
}
export async function timeAsync<K, T>(
func: (...args: K[]) => Promise<T>,
...args: K[]
): Promise<T> {
const name = func.name;
console.time(name);
const stuff = await func(...args);
console.timeEnd(name);
return stuff;
}