diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 6ff299f77..8c9416ea2 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -117,6 +117,59 @@ const routes: Routes = [ import('../clerk/photo-signature/photo-signature.component').then((m) => m.PhotoSignatureComponent), data: { title: 'Photo & Signature', breadcrumb: 'Photo & Signature' } }, + // The same register the cashier screens use, without their cash-only filter: a clerk raises + // book entries — a closure settled to savings, a share redemption, an interest posting the + // system made on their approval — and cash-only would hide exactly those. + { + path: 'received-report', + component: VoucherReportComponent, + data: { + title: 'Received Vouchers', + breadcrumb: 'Received Vouchers', + side: 'CR', + cashOnly: false, + cashiersOnly: true + } + }, + { + path: 'payment-report', + component: VoucherReportComponent, + data: { + title: 'Payment Vouchers', + breadcrumb: 'Payment Vouchers', + side: 'DR', + cashOnly: false, + cashiersOnly: true + } + }, + // Both sides in one list, scoped to whoever is signed in — the "what did I do today" + // question the two registers above cannot answer, because an approval raises no voucher. + { + path: 'my-work', + loadComponent: () => import('../cashier/my-work/my-work.component').then((m) => m.MyWorkComponent), + data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' } + }, + { + path: 'end-of-day', + loadComponent: () => + import('../cashier/end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent), + data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' } + }, + // The system's own postings — interest, charges, escheats, journals. Both sides in one + // list: a night's run credits and debits, and splitting it across two screens would hide + // half of it from whichever one you opened. + { + path: 'system-vouchers', + component: VoucherReportComponent, + data: { + title: 'System Vouchers', + breadcrumb: 'System Vouchers', + side: 'ALL', + cashOnly: false, + cashiersOnly: false, + systemOnly: true + } + }, { path: 'standing-instructions', loadComponent: () => @@ -172,6 +225,23 @@ const routes: Routes = [ path: 'vault-position', component: VaultPositionComponent, data: { title: 'Vault Position', breadcrumb: 'Vault Position' } + }, + { + // The same day book the clerk and cashier read, reached from where a principal officer + // signing the day off actually looks. It carries the Officer Verification Sheet — each + // person's own credit, debit and net — which is the branch-level reading of the day, not + // a counter one. + path: 'day-book', + component: DayBookComponent, + data: { title: 'Day Book & Verification', breadcrumb: 'Day Book' } + }, + // The one sheet the day is signed off on: the transaction summary, the drawer counted + // against the books, and the notes that say why it cannot be closed yet. + { + path: 'end-of-day', + loadComponent: () => + import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent), + data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' } } ] }, @@ -235,18 +305,41 @@ const routes: Routes = [ { path: 'received-report', component: VoucherReportComponent, - data: { title: 'Received Report', breadcrumb: 'Received Report', side: 'CR', cashOnly: true } + data: { + title: 'Received Report', + breadcrumb: 'Received Report', + side: 'CR', + cashOnly: true, + cashiersOnly: true + } }, { path: 'payment-report', component: VoucherReportComponent, - data: { title: 'Payment Report', breadcrumb: 'Payment Report', side: 'DR', cashOnly: true } + data: { + title: 'Payment Report', + breadcrumb: 'Payment Report', + side: 'DR', + cashOnly: true, + cashiersOnly: true + } + }, + { + path: 'my-work', + loadComponent: () => import('./my-work/my-work.component').then((m) => m.MyWorkComponent), + data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' } }, { path: 'closing-report', component: ClosingCashReportComponent, data: { title: 'Closing Cash Report', breadcrumb: 'Closing Cash Report' } }, + { + path: 'end-of-day', + loadComponent: () => + import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent), + data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' } + }, { path: 'excess-report', component: ExcessTransactionReportComponent, diff --git a/src/app/cashier/cashier-shared.scss b/src/app/cashier/cashier-shared.scss index 09ece3b90..58ac3ab4b 100644 --- a/src/app/cashier/cashier-shared.scss +++ b/src/app/cashier/cashier-shared.scss @@ -714,3 +714,15 @@ } } } + +/* Names a figure strip before the reader meets it. A run of tiles ending in a total reads as + arithmetic to somebody who already knows the screen and as five unrelated numbers to somebody + who does not. */ +.closing-lede { + margin: 0 0 -4px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #475569; +} diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index d9c9853a5..9845ce8a4 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -138,7 +138,9 @@ export class CashierService { to: string, username?: string, voucherType?: string, - cashOnly?: boolean + cashOnly?: boolean, + cashiersOnly?: boolean, + systemOnly?: boolean ): Observable { let params = new HttpParams().set('from', from).set('to', to); if (username) { @@ -152,9 +154,30 @@ export class CashierService { if (cashOnly) { params = params.set('cashOnly', 'true'); } + // Opt-in the same way: the counter registers ask for the drawer-holders only, the back-office + // ones keep seeing whoever raised a voucher. + if (cashiersOnly) { + params = params.set('cashiersOnly', 'true'); + } + // The postings nobody filed a request for: interest a nightly run credited, a charge taken from + // a balance, an escheat, a journal keyed to the ledger. Nowhere else shows them — My Vouchers is + // the reader's own work and a scheduled job is nobody's. + if (systemOnly) { + params = params.set('systemOnly', 'true'); + } return this.http.get('/vouchers/report', { params }); } + /** + * The signed-in user's own day: every voucher they raised and every one they verified, receipts + * and payments in one list. Always scoped to the caller by the server — there is no username + * parameter, because "my work" for somebody else is the register's job, not this screen's. + */ + myVoucherWork(from: string, to: string): Observable { + const params = new HttpParams().set('from', from).set('to', to); + return this.http.get('/vouchers/my-work', { params }); + } + /** * Account register for one date: what opened and closed that day, each account's balance as it * stood then, and what it holds now. diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html index 0f9ad0019..b6faf9d5c 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.html +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html @@ -9,9 +9,8 @@
-
CASHIER · REPORT

Closing Cash Report

-

Denomination-wise deposit, withdrawal and net cash position for the selected day.

+

How much cash you started with, what came in and went out, and how much you must hand back.

- - @if (!showGl && (rows.length > 0 || ddCashTotal > 0)) { - -
+ + @if (!showGl && scopedToCashier && hasTodayLegs) { + +

Your cash for the day, step by step

+
- Total Deposits (IN) + Cash you got this morning + ₹{{ todayVaultIn | number: '1.2-2' }} +
+
+
+
+ Cash customers gave you ₹{{ depositTotalWithDd | number: '1.2-2' }} @if (ddCashTotal > 0) { - incl. ₹{{ ddCashTotal | number: '1.2-2' }} not itemised ({{ ddCashCount }} cash DD{{ + ₹{{ ddCashTotal | number: '1.2-2' }} of this was for {{ ddCashCount }} draft{{ ddCashCount === 1 ? '' : 's' - }}) + }}, notes not listed }
−
- Total Withdrawals (OUT) + Cash you gave customers ₹{{ withdrawalTotal | number: '1.2-2' }}
-
=
-
- - Net Counter Movement - ₹{{ netTotal | number: '1.2-2' }} -
-
- } - - - @if (!showGl && scopedToCashier && closingBalance !== null) { -
-
- Opening Balance - ₹{{ openingBalance | number: '1.2-2' }} -
-
+
-
- Cash Movement - ₹{{ cashMovement | number: '1.2-2' }} +
−
+
+ Cash already returned + ₹{{ todaySettledOut | number: '1.2-2' }}
=
+
- Closing Balance - ₹{{ closingBalance | number: '1.2-2' }} + Cash left with you now + ₹{{ expectedInDrawer | number: '1.2-2' }}
- @if (cashMovement !== null && netTotal !== cashMovement) { - +

+ Count your cash now — it should come to ₹{{ expectedInDrawer | number: '1.2-2' }}. Hand that amount back and + your day is done. +

+ + @if ((todayInterCashierIn ?? 0) > 0 || (todayInterCashierOut ?? 0) > 0) {

- Cash movement differs from counter movement by ₹{{ cashMovement - netTotal | number: '1.2-2' }} — vault, - inter-cashier and exchange legs that did not pass over the counter. + This already counts cash swapped with another cashier — ₹{{ todayInterCashierIn | number: '1.2-2' }} taken + from them, ₹{{ todayInterCashierOut | number: '1.2-2' }} given to them.

} } @@ -111,13 +102,16 @@ - - - - - - - + + + + + + @@ -126,15 +120,16 @@ - - - - + + + - - - + } @@ -170,13 +163,11 @@ - - - - - + + + @@ -190,13 +181,11 @@
DenominationDeposit PcsDeposit AmtWithdrawal PcsWithdrawal AmtNet PcsNet AmountNoteCame inWent outLeft with youWorth
₹{{ r.denominationValue }} {{ r.depositCount }}₹{{ r.depositAmount | number: '1.2-2' }}{{ r.withdrawalCount }}₹{{ r.withdrawalAmount | number: '1.2-2' }}{{ r.vaultInCount + r.depositCount || '—' }}{{ r.withdrawalCount + r.settledOutCount || '—' }} - {{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }} + {{ r.netCount }} 0) {
- Not itemised + Notes not listed {{ ddCashCount }} cash DD{{ ddCashCount === 1 ? '' : 's' }} —₹{{ ddCashTotal | number: '1.2-2' }} —₹0.00 —+₹{{ ddCashTotal | number: '1.2-2' }}₹{{ ddCashTotal | number: '1.2-2' }}
Total{{ noteDepositCount }}₹{{ noteDepositAmount | number: '1.2-2' }}{{ noteWithdrawalCount }}₹{{ noteWithdrawalAmount | number: '1.2-2' }}{{ noteVaultInCount + noteDepositCount || '—' }}{{ noteWithdrawalCount + noteSettledOutCount || '—' }}{{ noteNetCount }} - {{ noteNetAmount >= 0 ? '+' : '−' }}₹{{ + {{ noteNetAmount >= 0 ? '' : '−' }}₹{{ (noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2' }}
- - - - - - - + + + + + @@ -205,15 +194,16 @@ - - - - + + + + + @if (systemOnly) { + + } @@ -209,6 +218,14 @@ } + @if (systemOnly) { + + } } diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss index 690eb1893..1fe7a3b97 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.scss +++ b/src/app/cashier/voucher-report/voucher-report.component.scss @@ -70,3 +70,25 @@ display: none !important; } } + +/* The ledger date on the system register. Marked when it differs from the day the voucher was + written: that is the whole reason the column exists, and a date the reader has to compare against + the Time column by eye is a date they will misread. */ +.vr-ledgerdate { + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.vr-ledgerdate--prior { + color: #5b21b6; +} + +.vr-priorflag { + display: block; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #7c3aed; +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts index 1d34966fa..8444b2813 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.ts +++ b/src/app/cashier/voucher-report/voucher-report.component.ts @@ -20,7 +20,7 @@ import { CashierService } from '../cashier.service'; * 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route, * so the two screens are the same component pointed at opposite sides. */ -export type VoucherSide = 'CR' | 'DR'; +export type VoucherSide = 'CR' | 'DR' | 'ALL'; /** One voucher as the register shows it: the number, the two sides, and who raised it. */ interface VoucherRow { @@ -28,6 +28,10 @@ interface VoucherRow { voucherType: string | null; amount: number | null; createdAt: string | null; + /** The business date the posting belongs to, which is not always the day it was written. */ + ledgerDate?: string | null; + /** True when the voucher was written after the day it posts against had already closed. */ + postedAfterClose?: boolean; userId: number | null; username: string | null; branchName: string | null; @@ -105,6 +109,25 @@ export class VoucherReportComponent implements OnInit { */ cashOnly = false; + /** + * Whether this register is the counter's own — only the people who hold a drawer. + * + * These two screens reconcile the counter: what came in over it and what went out. Everyone + * else's work is their own to account for, and each person reads it on My Vouchers. Mixed in here + * it buried the cashier rows the registers exist for and left the totals matching no drawer. + */ + cashiersOnly = false; + + /** + * Whether this register is the system's own postings rather than anybody's work. + * + * Interest a nightly run credited, a charge taken from a balance, an escheat, a journal keyed + * straight to the ledger. These belong to no member of staff, so My Vouchers cannot show them — + * it is scoped to the reader — and the two counter registers deliberately exclude them. That left + * no screen answering "what did the system post last night". + */ + systemOnly = false; + report: VoucherReport | null = null; loading = false; error: string | null = null; @@ -112,6 +135,8 @@ export class VoucherReportComponent implements OnInit { ngOnInit(): void { this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR'; this.cashOnly = this.route.snapshot.data['cashOnly'] === true; + this.cashiersOnly = this.route.snapshot.data['cashiersOnly'] === true; + this.systemOnly = this.route.snapshot.data['systemOnly'] === true; this.load(); } @@ -120,10 +145,23 @@ export class VoucherReportComponent implements OnInit { } get title(): string { + if (this.systemOnly) { + return 'System Vouchers'; + } return this.isReceived ? 'Received Report' : 'Payment Report'; } get subtitle(): string { + if (this.systemOnly) { + return 'What the system posted on its own — interest, charges, escheats and journals. Nobody raised these, so they appear on no one’s own list.'; + } + // Said plainly, because a register that silently omits the reader's own entries is worse than + // one that explains why: their work is on My Vouchers, and the line points them there. + if (this.cashiersOnly) { + return this.isReceived + ? 'Cash taken in at the counter by each cashier. Other staff read their own work on My Vouchers.' + : 'Cash paid out at the counter by each cashier. Other staff read their own work on My Vouchers.'; + } if (this.cashOnly) { return this.isReceived ? 'Cash taken in over a day or a month, and who took it — book entries left out.' @@ -136,6 +174,9 @@ export class VoucherReportComponent implements OnInit { /** The word for one row on this side, for counts and the empty state. */ get noun(): string { + if (this.systemOnly) { + return 'posting'; + } return this.isReceived ? 'receipt' : 'payment'; } @@ -150,7 +191,9 @@ export class VoucherReportComponent implements OnInit { to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined, - this.cashOnly + this.cashOnly, + this.cashiersOnly, + this.systemOnly ) .pipe( catchError((err: any) => { @@ -199,7 +242,7 @@ export class VoucherReportComponent implements OnInit { */ get rows(): VoucherRow[] { const all = this.report?.vouchers ?? []; - return all.filter((row) => this.sideOf(row) === this.side); + return this.side === 'ALL' ? all : all.filter((row) => this.sideOf(row) === this.side); } /** diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 7338b6eb3..ea35032d0 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -184,6 +184,44 @@ Today's Activity } + + + + + + + My Vouchers + + + + + + + + Received Vouchers + + + + + + + + + Payment Vouchers + ESC Show Header } @else { @@ -245,6 +283,9 @@ > Cash History + + My Vouchers + Accounting + + + @@ -1041,6 +1090,17 @@ + + + + + + + + + + + + + diff --git a/src/app/shared/theme-toggle/theming.service.ts b/src/app/shared/theme-toggle/theming.service.ts index 7684a3fdb..d9e3d7841 100644 --- a/src/app/shared/theme-toggle/theming.service.ts +++ b/src/app/shared/theme-toggle/theming.service.ts @@ -24,27 +24,51 @@ export class ThemingService { theme = new BehaviorSubject('light-theme'); // <- initial theme constructor() { - // Initially check if dark mode is enabled on system - this.darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - - // If dark mode is enabled then directly switch to the dark-theme + // The user's own choice, kept in localStorage by the theme toggle, is the only thing that + // decides the theme. + // + // The OS preference used to decide it instead, and a media-query listener kept it in step with + // Windows. That is what turned a page black after printing: Chrome re-evaluates + // prefers-color-scheme around the print dialog, the listener fired on the way back, and the app + // flipped to dark against the user's saved choice — on every screen, and only after a print, + // which is exactly how it was reported. A member of staff who set the app light does not expect + // printing a report to change it, and the toggle they used had no way to win against the OS. + // + // Nothing here reads the OS any more, so there is no listener to fire and nothing to fight the + // toggle. isDarkMode() still reports the OS preference for the one caller that offers it as a + // default on first use. + this.darkModeOn = !!this.storedPreference(); this.setDarkMode(this.darkModeOn); - - // Watch for changes of the preference - window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => { - const turnOn = e.matches; - this.theme.next(turnOn ? 'dark-theme' : 'light-theme'); - - // Trigger refresh of UI - this.ref.tick(); - }); } + /** The saved choice, or null when the user has never set one. */ + private storedPreference(): boolean | null { + try { + const raw = localStorage.getItem('mifosXThemeDarkEnabled'); + return raw === null ? null : JSON.parse(raw); + } catch { + // A blocked or corrupt localStorage must not stop the app from rendering; light is the + // default the rest of the stylesheet already assumes. + return null; + } + } + + /** + * Whether the app is currently in dark mode. + * + * Reports the app's own state, not the OS preference. It used to read the media query and assign + * the result to darkModeOn on the way past — so merely asking the question overwrote the user's + * saved choice, and setInitialDarkMode() then applied whatever the OS happened to say. + */ isDarkMode(): boolean { - this.darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; return this.darkModeOn; } + /** The OS preference, offered as a starting point when the user has never chosen for themselves. */ + isSystemDarkMode(): boolean { + return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); + } + setDarkMode(isDarkMode: boolean) { this.darkModeOn = isDarkMode; if (isDarkMode) { diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 819f5824c..4322db0df 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260902', - 'hash': '63134f17' + 'version': '260908', + 'hash': 'fbce73fd' }, 'allow_switching_backend_instance': true };
DenominationDeposit PcsDeposit AmtWithdrawal PcsWithdrawal AmtNet PcsNet AmountCoinCame inWent outLeft with youWorth
₹{{ r.denominationValue }} {{ r.depositCount }}₹{{ r.depositAmount | number: '1.2-2' }}{{ r.withdrawalCount }}₹{{ r.withdrawalAmount | number: '1.2-2' }}{{ r.vaultInCount + r.depositCount || '—' }}{{ r.withdrawalCount + r.settledOutCount || '—' }} - {{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }} + {{ r.netCount }}
- @if (scopedToCashier && closingBalance !== null) { - No cash crossed the counter on this date. The drawer still holds ₹{{ closingBalance | number: '1.2-2' }}, - carried forward. + @if (scopedToCashier && hasTodayLegs && expectedInDrawer !== 0) { + No cash crossed the counter on this date. The drawer should hold ₹{{ expectedInDrawer | number: '1.2-2' }} + from the day's vault and settlement legs. } @else { No cash denomination data for this date. } diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts index a7d938a0a..b5876e77e 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -65,6 +65,19 @@ export class ClosingCashReportComponent implements OnInit { */ openingBalance: number | null = null; closingBalance: number | null = null; + + /** + * The day's own cash legs, which is what a cashier reconciles against the drawer. + * + * A cashier carries no balance: they draw from the vault in the morning and hand back at close. + * The cumulative opening/closing figures answer a question nobody at the counter asks, and one + * carrying years of history answers it with a number that cannot be counted against anything on + * the desk. + */ + todayVaultIn: number | null = null; + todaySettledOut: number | null = null; + todayInterCashierIn: number | null = null; + todayInterCashierOut: number | null = null; /** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */ cashMovement: number | null = null; /** Whether the figures above are one cashier's drawer or the whole branch's counter work. */ @@ -95,6 +108,29 @@ export class ClosingCashReportComponent implements OnInit { selectedTellerId: number | null = null; selectedCashierId: number | null = null; + /** + * What should be in the drawer at the end of the day, from the day's own legs. + * + * Drawn from the vault, plus anything taken over the counter and from another cashier, less what + * went back out. This is the figure a cashier counts against — it does not carry a balance + * forward, because a cashier does not. + */ + get expectedInDrawer(): number { + return ( + Number(this.todayVaultIn ?? 0) + + Number(this.todayInterCashierIn ?? 0) + + Number(this.depositTotalWithDd ?? 0) - + Number(this.withdrawalTotal ?? 0) - + Number(this.todaySettledOut ?? 0) - + Number(this.todayInterCashierOut ?? 0) + ); + } + + /** True when any of the day's own legs carries a figure, so the panel is worth showing. */ + get hasTodayLegs(): boolean { + return this.todayVaultIn !== null; + } + get noteRows(): any[] { return this.rows.filter((r) => r.kind !== 'coin'); } @@ -122,8 +158,35 @@ export class ClosingCashReportComponent implements OnInit { return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0); } + get noteVaultInCount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.vaultInCount ?? 0), 0); + } + + get noteVaultInAmount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.vaultInAmount ?? 0), 0); + } + + get noteSettledOutCount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.settledOutCount ?? 0), 0); + } + + get noteSettledOutAmount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0); + } + + get noteNetCount(): number { + return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount; + } + + /** + * What is left in the drawer in notes: everything that came in, less everything that went out. + * + * Was deposits minus withdrawals, which is the counter's movement rather than the drawer's + * contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net + * of zero and had nothing to count against. + */ get noteNetAmount(): number { - return this.noteDepositAmount - this.noteWithdrawalAmount; + return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount; } get coinRows(): any[] { return this.rows.filter((r) => r.kind === 'coin'); @@ -160,6 +223,10 @@ export class ClosingCashReportComponent implements OnInit { this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance); this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance); this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement); + this.todayVaultIn = res?.todayVaultIn == null ? null : Number(res.todayVaultIn); + this.todaySettledOut = res?.todaySettledOut == null ? null : Number(res.todaySettledOut); + this.todayInterCashierIn = res?.todayInterCashierIn == null ? null : Number(res.todayInterCashierIn); + this.todayInterCashierOut = res?.todayInterCashierOut == null ? null : Number(res.todayInterCashierOut); this.loading = false; this.loaded = true; }, @@ -176,6 +243,10 @@ export class ClosingCashReportComponent implements OnInit { this.openingBalance = null; this.closingBalance = null; this.cashMovement = null; + this.todayVaultIn = null; + this.todaySettledOut = null; + this.todayInterCashierIn = null; + this.todayInterCashierOut = null; this.loading = false; this.loaded = true; } diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html index 54d302add..5c5215145 100644 --- a/src/app/cashier/day-book/day-book.component.html +++ b/src/app/cashier/day-book/day-book.component.html @@ -9,11 +9,10 @@
-
CASHIER · DAY BOOK
-

Day Book

+

{{ title }}

- Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank - transfers, with credit and debit totals. + Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections, interest and + journals, with each person's own credit, debit and net.

@@ -103,11 +102,90 @@ Totals count approved vouchers only — pending and rejected rows are listed but never move the money.

+ + @if (byUser.length > 0) { +
+
+ Officer Verification Sheet + Each person's own credit, debit and net for the day + {{ byUser.length }} {{ byUser.length === 1 ? 'person' : 'people' }} +
+
+ + + + + + + + + + + + + @for (u of byUser; track u.name) { + + + + + + + + + } + + + + + + + + + + + + +
Handled byItemsCredit (CR)Debit (DR)NetPending
{{ u.name }}{{ u.items }}₹{{ u.credit | number: '1.2-2' }}₹{{ u.debit | number: '1.2-2' }} + ₹{{ u.net | number: '1.2-2' }} + + @if (u.pending > 0) { + {{ u.pending }} + } @else { + — + } +
All staff{{ byUserTotals.items }}₹{{ byUserTotals.credit | number: '1.2-2' }}₹{{ byUserTotals.debit | number: '1.2-2' }} + ₹{{ byUserTotals.net | number: '1.2-2' }} + + @if (byUserTotals.pending > 0) { + {{ byUserTotals.pending }} + } @else { + — + } +
+
+
+ } + @for (s of sections; track s.key) { -
+ +
{{ s.label }} {{ s.hint }} diff --git a/src/app/cashier/day-book/day-book.component.scss b/src/app/cashier/day-book/day-book.component.scss index 32ee649b3..c9e21ddf1 100644 --- a/src/app/cashier/day-book/day-book.component.scss +++ b/src/app/cashier/day-book/day-book.component.scss @@ -36,6 +36,36 @@ from the others, so the three are not added up as though they were one list. */ .db-section { margin-bottom: 14px; + border-left: 3px solid #cbd5e1; + + /* Cash that actually crossed the counter — the section a drawer is checked against, so it + carries the darker green. */ + &--CASHIER { + border-left-color: #15803d; + + .db-section__head { + background: #f0fdf4; + } + } + + /* Raised but not actioned: no cash has moved yet, so the lighter shade of the same green. */ + &--CLERK { + border-left-color: #86efac; + + .db-section__head { + background: #f7fef9; + } + } + + /* Postings with no counter behind them — interest, charges, journals. Not the branch's cash at + all, so a different colour rather than a third green that would read as a third cash state. */ + &--SYSTEM { + border-left-color: #6366f1; + + .db-section__head { + background: #f5f5ff; + } + } &__head { display: flex; @@ -110,11 +140,19 @@ background: #f1f5f9; color: #475569; + /* Passed, but nothing handed over yet — the lighter green. */ &--ok { background: #dcfce7; color: #15803d; } + /* Cash has actually left the counter. Filled and dark, so the final state is the one the eye + lands on: on this page the difference between "approved" and "paid out" is the whole question. */ + &--done { + background: #15803d; + color: #fff; + } + &--pending { background: #fef3c7; color: #b45309; @@ -352,3 +390,52 @@ color: #334155; border-top: 1px solid #e2e8f0; } + +/* Officer Verification Sheet — the day totalled per person, for whoever signs the book off. + Numeric cells use the shared `.num` from cashier-shared.scss rather than a class of their own: + that one already right-aligns and tabular-figures both the heading and the cell, which is what + lines a money column up. A local class only styled the cells, leaving every heading left-aligned + over right-aligned figures. */ +.db-byuser { + /* Not one of the three sections below, so no section colour: this is the whole day summed by + person, and giving it one of their edges would read as a fourth section. */ + border-left-color: #94a3b8; + + &__name { + font-weight: 600; + color: #0f172a; + } + + /* Received green, paid-out red — the convention a passbook already uses, so the two columns are + told apart without reading the headings. */ + &__cr { + color: #15803d; + } + + &__dr { + color: #b91c1c; + } + + &__net { + font-weight: 600; + + &--pos { + color: #15803d; + } + + &--neg { + color: #b91c1c; + } + } + + /* The sheet's own total sits against a tinted band so the eye lands on the line that has to + agree with the day's figures above. */ + &__total { + background: #f8fafc; + font-weight: 600; + + td { + border-top: 2px solid #cbd5e1; + } + } +} diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index 418c340fa..8ce3c0436 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; +import { Router } from '@angular/router'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; @@ -20,7 +21,7 @@ import { CashierService } from '../cashier.service'; * the bank shifting its own money, not a customer transaction, and it is read on the Cashier * Position and Closing Cash Report where the drawer itself is the subject. */ -type DayBookSection = 'CASHIER' | 'CLERK'; +type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM'; interface DayBookRow { voucherNo: string | null; @@ -61,6 +62,18 @@ interface DayBookRow { }) export class DayBookComponent implements OnInit { private service = inject(CashierService); + private router = inject(Router); + + /** + * The heading, taken from the route the screen was opened by. + * + * One screen serves three menus, and only the Branch Ops reading is a different thing: there it + * is the branch's book with the verification sheet an officer signs off, rather than one desk's + * view of the day. + */ + get title(): string { + return this.router.url.includes('/branch-ops/') ? 'Day Book & Officer Verification' : 'Day Book'; + } date = ''; side: '' | 'CR' | 'DR' = ''; @@ -145,7 +158,15 @@ export class DayBookComponent implements OnInit { }[] { const order: { key: DayBookSection; label: string; hint: string }[] = [ { key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' }, - { key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' } + { key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' }, + // Postings that reached the ledger without a request behind them: interest the nightly run + // credited, a charge taken from a balance, a manual journal. These are the day's work too, + // and an officer accounting for the day cannot do it from the counter registers alone. + { + key: 'SYSTEM', + label: 'System & Journal', + hint: 'Interest, charges and journals posted without a counter request' + } ]; return order .map((s) => { @@ -206,11 +227,90 @@ export class DayBookComponent implements OnInit { return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN'; } + /** + * The day totalled per person, for the officer who signs the book off. + * + * A branch's day book is read two ways. Entry by entry, to trace one voucher — which the sections + * below already give. And person by person, to ask what each member of staff handled and whether + * their column adds up: that is the reconciliation a principal officer actually performs, and + * without it they are adding a day of rows in their head. + * + * Built from whoever appears in the day's own rows rather than from a list of staff, so it needs + * no configuring: one person working alone gets one line, five people get five, and somebody who + * handled nothing today is simply absent instead of showing as a row of zeros. + * + * Only approved money counts, the same rule the section totals use — a pending request is not + * cash anybody is answerable for yet. Pending and rejected rows are still counted as ITEMS, so a + * person whose work is all awaiting approval does not vanish from the sheet. + * + * Ordered by the largest turnover first: the columns worth checking hardest are the ones that + * moved the most money. + */ + get byUser(): { + name: string; + credit: number; + debit: number; + net: number; + items: number; + pending: number; + }[] { + const map = new Map(); + + for (const r of this.filteredRows) { + // Rows the registers could not attribute are grouped rather than dropped: an unattributed + // amount is exactly what an officer needs to see, and hiding it would leave the sheet's own + // total disagreeing with the day's. + const name = (r.handledBy ?? '').trim() || 'Unattributed'; + const entry = map.get(name) ?? { name, credit: 0, debit: 0, items: 0, pending: 0 }; + + entry.items++; + if (this.countsAsMoney(r)) { + const amount = Number(r.amount ?? 0); + if (r.side === 'CR') { + entry.credit += amount; + } else { + entry.debit += amount; + } + } else { + entry.pending++; + } + map.set(name, entry); + } + + return Array.from(map.values()) + .map((e) => ({ ...e, net: e.credit - e.debit })) + .sort((a, b) => b.credit + b.debit - (a.credit + a.debit)); + } + + /** The sheet's own totals, which must equal the day's — that is what makes it verifiable. */ + get byUserTotals(): { credit: number; debit: number; net: number; items: number; pending: number } { + return this.byUser.reduce( + (t, u) => ({ + credit: t.credit + u.credit, + debit: t.debit + u.debit, + net: t.net + u.net, + items: t.items + u.items, + pending: t.pending + u.pending + }), + { credit: 0, debit: 0, net: 0, items: 0, pending: 0 } + ); + } + + /** + * Approved and paid out are not the same state, and the day book is where the difference matters: + * an approved withdrawal is a promise, a withdrawn one is money gone. Both used to render in the + * same green, so a reader could not tell from the badge whether the customer had actually been + * handed the cash — the one thing this page is read to find out. + */ statusClass(status: string): string { switch ((status ?? '').toUpperCase()) { - case 'APPROVED': - case 'COMPLETED': case 'WITHDRAWN': + case 'COMPLETED': + case 'CLOSED': + // Cash has left the counter. The darker, filled badge — the final state. + return 'db-badge db-badge--done'; + case 'APPROVED': + // Passed, but nobody has handed anything over yet. return 'db-badge db-badge--ok'; case 'PENDING': return 'db-badge db-badge--pending'; diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.html b/src/app/cashier/end-of-day-report/end-of-day-report.component.html new file mode 100644 index 000000000..6a8d5eac7 --- /dev/null +++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.html @@ -0,0 +1,606 @@ + + +
+ +
+
+

End of the Day Report

+

What moved today, whether the cash agrees with the books, and who signs off.

+
+
+ + + +
+
+ + @if (loading) { +
Loading the day’s figures…
+ } + + @if (loaded) { +
+ +
+

Daily EOD & Transaction Report

+
+
+ Date + {{ date | date: 'dd MMM yyyy' }} +
+
+ Branch + {{ branchName || '—' }} +
+
+ Prepared by + {{ preparedBy || '—' }} +
+
+ Scope + {{ scopedToCashier ? 'My counter' : 'Branch counter' }} +
+
+
+ + +
+

1 Transaction Summary

+ + @if (sectionLines.length) { +

Where the money came from

+ + + + + + + + + + + @for (l of sectionLines; track l.label) { + + + + + + + } + + + + + + + + + +
SourceEntriesMoney inMoney out
{{ l.label }}{{ l.count }}{{ l.credit ? (l.credit | currency: 'INR') : '—' }}{{ l.debit ? (l.debit | currency: 'INR') : '—' }}
Total + {{ txnCount }} + + {{ summaryCreditTotal | currency: 'INR' }} + + {{ summaryDebitTotal | currency: 'INR' }} +
+ +

What kind of entries

+ + + + + + + + + + + @for (l of kindLines; track l.label) { + + + + + + + } + +
KindEntriesMoney inMoney out
{{ l.label }}{{ l.count }}{{ l.credit ? (l.credit | currency: 'INR') : '—' }}{{ l.debit ? (l.debit | currency: 'INR') : '—' }}
+ +

+ Only entries that actually moved money. Anything still pending is listed at the bottom. These figures match + the Day Book for the same date. +

+ } @else { +
No transactions on this date.
+ } +
+ + +
+

2 Cash

+ + @if (scopedToCashier) { +

Your drawer today

+ + + + + + + + + + + @if (interCashierIn) { + + + + + } + + + + + @if (settledOut) { + + + + + } + @if (interCashierOut) { + + + + + } + + + + + + + + + @if (variance !== null) { + + + + + } + +
Cash you took from the vault this morning{{ openingFloat | currency: 'INR' }}
Cash you took from members deposits, including DD cash+{{ cashDeposits | currency: 'INR' }}
Cash another cashier gave you+{{ interCashierIn | currency: 'INR' }}
Cash you paid to members withdrawals−{{ cashWithdrawals | currency: 'INR' }}
Cash you handed back to the vault−{{ settledOut | currency: 'INR' }}
Cash you gave another cashier−{{ interCashierOut | currency: 'INR' }}
So your drawer should hold + @if (expectedCash !== null) { + {{ expectedCash | currency: 'INR' }} + } @else { + — + } +
System says you hold + @if (actualCash !== null) { + {{ actualCash | currency: 'INR' }} + } @else { + nothing recorded + } +
Difference + {{ variance | currency: 'INR' }} +
+ + @if (variance !== null) { + @if (varianceOk) { +

+ check_circle + Your cash agrees. Nothing short, nothing over. +

+ } @else { +

+ error + {{ variance! > 0 ? 'Extra' : 'Short' }} by + {{ (variance! > 0 ? variance : -variance!) | currency: 'INR' }}. Explain this before closing. +

+ } + } @else { +

Today’s cash legs were not sent for this login, so nothing is compared.

+ } + } @else { +

Cash that moved across the branch today

+ + + + + + + + + + + + + + + +
Cash taken from members deposits+{{ branchDeposits ?? 0 | currency: 'INR' }}
Cash paid to members withdrawals and closures−{{ branchWithdrawals ?? 0 | currency: 'INR' }}
Net change today + {{ branchNetMovement | currency: 'INR' }} +
+ +

Cash the branch is holding right now

+ + + + + + + + + + + + + + + +
In the vaults{{ vaultTotal ?? 0 | currency: 'INR' }}
With the cashiers{{ drawersTotal ?? 0 | currency: 'INR' }}
Total cash in the branch + @if (branchHeldTotal !== null) { + {{ branchHeldTotal | currency: 'INR' }} + } @else { + nothing recorded + } +
+ +

+ These are two different things and are not subtracted from each other. The first is today’s movement; + the second is everything the branch has accumulated to date. Each cashier’s own drawer is checked + against their own day on their login, and the branch figure is signed off on the Day Open / Day Close + screen. +

+ } +
+ + +
+

3 Handled By

+ + @if (byUser.length) { +

What each person handled

+ + + + + + + + + + + + @for (u of byUser; track u.label) { + + + + + + + + } + + + + + + + + + + +
Handled byEntriesMoney inMoney outNet
{{ u.label }}{{ u.count }}{{ u.credit ? (u.credit | currency: 'INR') : '—' }}{{ u.debit ? (u.debit | currency: 'INR') : '—' }}{{ u.credit - u.debit | currency: 'INR' }}
Total + {{ txnCount }} + + {{ summaryCreditTotal | currency: 'INR' }} + + {{ summaryDebitTotal | currency: 'INR' }} + + {{ summaryCreditTotal - summaryDebitTotal | currency: 'INR' }} +
+ } @else { +
Nobody moved money on this date.
+ } + + @if (cashierPositions.length) { +

What each cashier is holding

+ + + + + + + + + @for (p of cashierPositions; track p.cashierId) { + + + + + } + + @if (cashierPositions.length > 1) { + + + + + + + } +
CashierCash held
{{ p.cashierName }}{{ p.totalAmount | currency: 'INR' }}
Total + {{ cashierPositionTotal | currency: 'INR' }} +
+

+ This is what each drawer should contain right now — a running total, not just today. Count against it + before settling to the vault. +

+ } +
+ + +
+

4 Every Transaction

+ + @if (approvedRows.length) { + + + + + + + + + + + + + + @for (r of approvedRows; track r.voucherNo + '|' + r.txnId) { + + + + + + + + + + } + + + + + + + + +
VoucherKindAccountPartyHandled byMoney inMoney out
{{ r.voucherNo || '—' }}{{ kindLabel(r.kind) }}{{ r.accountNo || '—' }}{{ r.party || '—' }}{{ r.handledBy || '—' }}{{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}{{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}
+ Total — {{ txnCount }} entries + + {{ summaryCreditTotal | currency: 'INR' }} + + {{ summaryDebitTotal | currency: 'INR' }} +
+ } @else { +
No transactions on this date.
+ } + + @if (pendingRows.length) { +

Still pending — no money has moved on these

+ + + + + + + + + + + + + @for (r of pendingRows; track r.voucherNo + '|' + r.txnId) { + + + + + + + + + } + +
VoucherKindAccountPartyRaised byAmount
{{ r.voucherNo || '—' }}{{ kindLabel(r.kind) }}{{ r.accountNo || '—' }}{{ r.party || '—' }}{{ r.handledBy || '—' }}{{ r.amount | currency: 'INR' }}
+ } +
+ + + @if (priorDayRows.length) { +
+

+ nights_stay + Posted Overnight — work of + {{ priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day' }} +

+ + +
+
+ These belong to + {{ + priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day' + }} + the day the system posted them against — already closed +
+
→
+
+ Showing on + {{ date | date: 'dd MMM yyyy' }} + today’s report, because the run finished after midnight +
+
+ +

+ The nightly run posted these after + {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} had already closed and been signed + off, so they surface on today’s report instead. In the ledger they sit on + {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'that date' }}, and they are + not counted in any figure above. +

+ + + + + + + + + + + + + + + @for (r of priorDayRows; track r.voucherNo + '|' + r.txnId) { + + + + + + + + + + + } + + + + + + + + +
VoucherKindAccountPartyLedger dateMoney inMoney out
{{ r.voucherNo || '—' }}{{ kindLabel(r.kind) }}{{ r.accountNo || '—' }}{{ r.party || '—' }}{{ r.effectiveDate ? (r.effectiveDate | date: 'dd MMM yyyy') : '—' }}{{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}{{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}
+ Total — {{ priorDayRows.length }} entries + + {{ priorDayCredit | currency: 'INR' }} + + {{ priorDayDebit | currency: 'INR' }} +
+
+ } + + +
+

5 Anything Outstanding

+ +
    +
  • + Entries handled + {{ txnCount }} entries · {{ kindLines.length }} kind(s) +
  • +
  • + Waiting for approval + + @if (pendingRows.length) { + {{ pendingRows.length }} entry(ies) — the day cannot close until these are done + } @else { + None + } + +
  • +
  • + Slips that did not match + + @if (mismatchRows.length) { + {{ mismatchRows.length }} slip(s) do not agree with the system + } @else { + None + } + +
  • +
  • + Slips not checked yet + + {{ + unverifiedRows.length + ? unverifiedRows.length + ' entry(ies) still to be checked against paper' + : 'All checked' + }} + +
  • + @if (priorDayRows.length) { +
  • + Posted overnight + + {{ priorDayRows.length }} entry(ies) for + {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} — listed in their own + section, not in today’s figures + +
  • + } +
  • + Status + + {{ + allClear + ? 'Everything is clear — the day is ready to close.' + : 'Sort out the items above before closing the day.' + }} + +
  • +
+
+ + +
+
+
+
{{ supervising ? 'Prepared by' : 'Cashier' }}
+
{{ preparedBy || '' }}
+
+
+
+
Manager / Principal Officer
+
 
+
+
+ +

+ Generated from the day book and the closing cash report for {{ date | date: 'dd MMM yyyy' }}. Figures move with + the underlying vouchers — reprint after any reversal. +

+
+ } +
diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.scss b/src/app/cashier/end-of-day-report/end-of-day-report.component.scss new file mode 100644 index 000000000..15bd8bdab --- /dev/null +++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.scss @@ -0,0 +1,448 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* The sheet itself, which is what gets signed. Bordered on screen so it reads as one document + rather than three panels, and the same borders carry into print. */ + +/* Centred in the page, because it is a document rather than a screen: a signed sheet pinned to the + left edge of a wide monitor reads as a panel that failed to fill its space. */ +.eod-sheet { + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 28px 32px 24px; + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +/* The toolbar and the loading line above it share the sheet's own edges, not the viewport's. */ +.eod .pg-head, +.eod > .pg-empty { + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +.eod-sheet__head { + border-bottom: 2px solid #0f172a; + padding-bottom: 14px; + margin-bottom: 22px; +} + +.eod-sheet__title { + margin: 0 0 14px; + font-size: 19px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #0f172a; + text-align: center; +} + +.eod-sheet__meta { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px 18px; +} + +.eod-meta { + display: flex; + flex-direction: column; + gap: 2px; +} + +.eod-meta__label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; +} + +.eod-meta__value { + font-size: 13px; + font-weight: 600; + color: #0f172a; +} + +/* ── Sections ── */ +.eod-section { + margin-bottom: 26px; + + /* A section must not be split across two printed pages: a reconciliation whose expected line is + on page one and whose variance is on page two is not a document anybody can sign. */ + break-inside: avoid; + page-break-inside: avoid; +} + +.eod-section__title { + display: flex; + align-items: center; + gap: 9px; + margin: 0 0 12px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #0f172a; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 7px; +} + +.eod-section__no { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + background: #0f172a; + color: #fff; + font-size: 11px; + font-weight: 700; + flex: 0 0 auto; +} + +.eod-note { + margin: 8px 0 0; + font-size: 11.5px; + color: #64748b; + line-height: 1.5; +} + +/* Names the table below it, so two tables in one section do not read as one long list. */ +.eod-caption { + margin: 0 0 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #475569; +} + +.eod-caption--spaced { + margin-top: 18px; +} + +/* The line-by-line list is wider than the rest of the sheet, so it scrolls in its own box rather + than making the whole page scroll sideways. */ +.eod-section--wide { + .pg-table { + min-width: 100%; + } +} + +.eod-txns { + font-size: 12px; + + td, + th { + white-space: nowrap; + } +} + +/* Voucher numbers and account numbers are read digit by digit, so they get a fixed pitch. */ +.eod-mono { + font-family: 'Roboto Mono', ui-monospace, 'Courier New', monospace; + font-size: 11.5px; +} + +/* Last night's batch. Set apart on purpose: these figures belong to a day that has already been + signed off, so the block has to read as an appendix rather than as part of the day being counted. + Indigo, because that is already the day book's colour for system postings. */ +.eod-section--prior { + background: #f5f3ff; + border: 1px solid #ddd6fe; + border-radius: 8px; + padding: 16px 18px 14px; + margin-top: 30px; + + .eod-section__title { + color: #5b21b6; + border-bottom-color: #ddd6fe; + } + + .pg-table th { + background: #ede9fe; + color: #5b21b6; + } +} + +/* Sits in the title row in place of the numbered chip: this block is outside the numbered sequence, + because it is not a step in counting today. */ +.eod-section__icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #7c3aed; + flex: 0 0 auto; +} + +.eod-note--lead { + margin: 0 0 12px; + color: #5b21b6; +} + +/* The two dates, stated as a pair with an arrow between them. Spelling out "belongs to X, showing + on Y" is the only way a reader can be sure which of the two a figure is counted under — a single + date in a heading leaves exactly the ambiguity this block exists to remove. */ +.eod-datepair { + display: flex; + align-items: center; + gap: 14px; + margin: 0 0 14px; + padding: 12px 14px; + background: #fff; + border: 1px solid #ddd6fe; + border-radius: 6px; + flex-wrap: wrap; +} + +.eod-datepair__item { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1 1 200px; +} + +.eod-datepair__label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7c3aed; +} + +.eod-datepair__value { + font-size: 15px; + font-weight: 700; + color: #0f172a; +} + +.eod-datepair__hint { + font-size: 10.5px; + color: #64748b; + line-height: 1.4; +} + +.eod-datepair__arrow { + font-size: 20px; + color: #a78bfa; + flex: 0 0 auto; +} + +/* Dates in the row are read against the ledger, so they do not shrink into the grey the way a + secondary column normally would. */ +.eod-datecell { + font-size: 11.5px; + font-weight: 600; + color: #5b21b6; + white-space: nowrap; +} + +.eod-hint { + display: block; + font-size: 10.5px; + font-weight: 400; + color: #94a3b8; +} + +.eod-blank { + color: #94a3b8; + font-style: italic; +} + +/* ── Reconciliation table ── */ + +/* Two columns only, so the label takes the room and the figure sits in a fixed money column. */ +.eod-recon { + td:first-child { + width: auto; + } + + td:last-child { + width: 190px; + } +} + +.eod-recon__expected td { + border-top: 1px solid #94a3b8; + background: #f8fafc; +} + +.eod-recon__variance td { + border-top: 2px solid #0f172a; +} + +.eod-recon__variance.eod-ok td { + background: #f0fdf4; + color: #15803d; +} + +.eod-recon__variance.eod-bad td { + background: #fef2f2; + color: #b91c1c; +} + +/* ── Verdict line ── */ +.eod-verdict { + display: flex; + align-items: center; + gap: 7px; + margin: 12px 0 0; + padding: 9px 12px; + border-radius: 6px; + font-size: 12.5px; + font-weight: 600; + line-height: 1.45; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + flex: 0 0 auto; + } +} + +.eod-verdict--ok { + background: #f0fdf4; + border: 1px solid #86efac; + color: #15803d; +} + +.eod-verdict--bad { + background: #fef2f2; + border: 1px solid #fca5a5; + color: #b91c1c; +} + +/* ── Operational notes ── */ +.eod-notes { + list-style: none; + margin: 0; + padding: 0; + + li { + display: flex; + gap: 14px; + padding: 8px 10px; + border-bottom: 1px solid #f1f5f9; + font-size: 12.5px; + + &:last-child { + border-bottom: 0; + } + } +} + +.eod-notes__label { + flex: 0 0 190px; + font-weight: 600; + color: #475569; +} + +.eod-notes__value { + color: #0f172a; +} + +.eod-notes--warn { + background: #fffbeb; + + .eod-notes__value { + color: #b45309; + font-weight: 600; + } +} + +.eod-notes--ok { + background: #f0fdf4; + + .eod-notes__value { + color: #15803d; + font-weight: 600; + } +} + +/* ── Signatures ── */ +.eod-sign { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 40px; + margin-top: 46px; + break-inside: avoid; + page-break-inside: avoid; +} + +.eod-sign__block { + text-align: center; +} + +.eod-sign__line { + border-bottom: 1px solid #0f172a; + height: 34px; + margin-bottom: 7px; +} + +.eod-sign__role { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #475569; +} + +.eod-sign__name { + font-size: 12px; + color: #64748b; + margin-top: 2px; +} + +.eod-foot { + margin: 26px 0 0; + padding-top: 12px; + border-top: 1px dashed #cbd5e1; + font-size: 10.5px; + color: #94a3b8; + text-align: center; + line-height: 1.5; +} + +/* ── Print ── + The sheet is the document; the shell, the toolbar and the app chrome are not. Colours are forced + so the variance verdict survives a printer that would otherwise drop backgrounds. */ +@media print { + .no-print, + .pg-head { + display: none !important; + } + + .eod-sheet { + border: 0; + border-radius: 0; + padding: 0; + max-width: none; + } + + .eod-sheet, + .eod-recon__variance td, + .eod-verdict, + .eod-notes--warn, + .eod-notes--ok { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .eod-sheet__title { + font-size: 16px; + } + + .pg-table { + font-size: 11px; + } +} diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.ts b/src/app/cashier/end-of-day-report/end-of-day-report.component.ts new file mode 100644 index 000000000..88d8f9720 --- /dev/null +++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.ts @@ -0,0 +1,536 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { CashierService } from '../cashier.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; + +/** One part of the day's work — a section of the day book, or a kind of entry within it. */ +interface SummaryLine { + label: string; + credit: number; + debit: number; + count: number; +} + +/** + * The single sheet a cashier signs the day off with. + * + * Every other cashier screen answers one question — what is in the drawer, which vouchers are + * unverified, what did each person handle. At close of day somebody has to sign one piece of paper + * saying the money was counted and it agreed, and until now that meant reading four screens and + * copying figures onto a form by hand. + * + * Nothing here is computed on the server that was not already being computed: the closing report + * supplies the drawer legs, the day book supplies the transactions, and the cashier position + * supplies what the system believes is held. This screen assembles them in the order a person + * verifying the day reads them in, and prints. + */ +@Component({ + selector: 'mifosx-end-of-day-report', + templateUrl: './end-of-day-report.component.html', + styleUrls: [ + '../cashier-shared.scss', + './end-of-day-report.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class EndOfDayReportComponent implements OnInit { + private service = inject(CashierService); + private settings = inject(SettingsService); + private auth = inject(AuthenticationService); + + date = ''; + loading = false; + loaded = false; + + /** Who is signing, and for which branch — read from the session, never typed in. */ + preparedBy = ''; + branchName = ''; + + // ── Section 1: transaction summary ── + rows: any[] = []; + creditTotal = 0; + debitTotal = 0; + + // ── Section 2: cash drawer ── + /** Drawn from the vault in the morning: a cashier's opening float. */ + openingFloat = 0; + cashDeposits = 0; + interCashierIn = 0; + cashWithdrawals = 0; + settledOut = 0; + interCashierOut = 0; + /** Whether the server sent this day's own cash legs, which only a cashier-scoped call gets. */ + hasDayLegs = false; + /** Whether the drawer figures are one cashier's or the whole branch's counter work. */ + scopedToCashier = false; + + /** What the drawers hold between them, per the system. */ + drawersTotal: number | null = null; + + /** + * Each cashier and what their drawer holds. + * + * A clerk holds no drawer of their own — they supervise the counter — so a single "actual cash" + * figure says nothing to them. The position endpoint already answers for whoever is asking: one + * drawer for a cashier, every drawer for a clerk or officer, so the same call serves both and the + * screen reads differently depending on who opened it. + */ + cashierPositions: { cashierId: number | null; cashierName: string; totalAmount: number }[] = []; + + /** + * The branch's own expected cash, from the day register. + * + * A branch-wide reading cannot be assembled from the per-cashier day legs: those are only + * returned when the call is scoped to one cashier. The branch day register already keeps this + * arithmetic for the close-day gate — opening cash, plus approved deposits, less what was + * withdrawn — so the sheet reads it rather than inventing a second version of the same sum. + * + * Null when no day has been opened for this date, which is a real state: there is nothing to + * reconcile against on a day the branch never opened. + */ + branchOpeningCash: number | null = null; + branchDeposits: number | null = null; + branchWithdrawals: number | null = null; + branchExpectedCash: number | null = null; + + /** + * What the vaults hold, which a branch-wide count must include. + * + * The branch register's expected cash covers the whole branch — vault and every drawer together — + * because cash moved from the vault to a cashier has not left the branch. Counting only the + * drawers against it would report the entire vault as a shortage. + */ + vaultTotal: number | null = null; + + ngOnInit(): void { + this.date = this.businessDate(); + this.preparedBy = this.currentUserName(); + this.branchName = this.currentOfficeName(); + this.load(); + } + + /** + * The date the books are open on, not the wall clock. + * + * An EOD sheet dated by the browser would be filed against the wrong day whenever the branch is + * still working yesterday's business date, which is exactly when a day is being closed. + */ + private businessDate(): string { + const d = this.settings.businessDate; + const safe = d instanceof Date && !isNaN(d.getTime()) ? d : new Date(); + return `${safe.getFullYear()}-${String(safe.getMonth() + 1).padStart(2, '0')}-${String(safe.getDate()).padStart(2, '0')}`; + } + + /** The member of staff behind the login, falling back to the username when no staff is linked. */ + private currentUserName(): string { + const c = this.auth.getCredentials(); + return c ? c.staffDisplayName || c.username || '' : ''; + } + + private currentOfficeName(): string { + return this.auth.getCredentials()?.officeName || ''; + } + + load(): void { + this.loading = true; + this.loaded = false; + + this.service.dayBook(this.date).subscribe({ + next: (res: any) => { + this.rows = res?.rows || []; + this.creditTotal = Number(res?.creditTotal || 0); + this.debitTotal = Number(res?.debitTotal || 0); + this.loading = false; + this.loaded = true; + }, + error: () => { + this.rows = []; + this.creditTotal = 0; + this.debitTotal = 0; + this.loading = false; + this.loaded = true; + } + }); + + this.service.closingCashReport(this.date).subscribe({ + next: (res: any) => { + // The four day legs are only sent for a cashier-scoped call. Read as absent rather than + // zero: a leg that is missing and a leg that is genuinely nil mean different things, and + // treating the first as the second is what let the sheet claim an expected balance of zero + // for a whole branch. + this.hasDayLegs = res?.todayVaultIn != null; + this.openingFloat = Number(res?.todayVaultIn || 0); + this.cashDeposits = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0); + this.cashWithdrawals = Number(res?.withdrawalTotal || 0); + this.settledOut = Number(res?.todaySettledOut || 0); + this.interCashierIn = Number(res?.todayInterCashierIn || 0); + this.interCashierOut = Number(res?.todayInterCashierOut || 0); + this.scopedToCashier = res?.scopedToCashier === true; + }, + error: () => { + this.openingFloat = 0; + this.cashDeposits = 0; + this.cashWithdrawals = 0; + this.settledOut = 0; + this.interCashierIn = 0; + this.interCashierOut = 0; + this.scopedToCashier = false; + this.hasDayLegs = false; + } + }); + + // The branch's expected cash for the date, which is the only basis a branch-wide reading can + // reconcile on. Fails quietly for a plain cashier, who is not allowed to operate the day. + this.service.branchDayClosePreview(this.date).subscribe({ + next: (res: any) => { + this.branchOpeningCash = res?.openingCash == null ? null : Number(res.openingCash); + this.branchDeposits = res?.deposits == null ? null : Number(res.deposits); + this.branchWithdrawals = res?.withdrawals == null ? null : Number(res.withdrawals); + this.branchExpectedCash = res?.expectedCash == null ? null : Number(res.expectedCash); + }, + error: () => { + this.branchOpeningCash = null; + this.branchDeposits = null; + this.branchWithdrawals = null; + this.branchExpectedCash = null; + } + }); + + // One object for a cashier, an array of every cashier for a clerk or officer — the server + // decides from the caller's own roles, so this screen does not have to ask who is reading it. + this.service.cashierPositionReport(undefined, this.date).subscribe({ + next: (res: any) => { + const list = Array.isArray(res) ? res : res ? [res] : []; + this.cashierPositions = list + .filter((p: any) => p && p.cashierId != null) + .map((p: any) => ({ + cashierId: p.cashierId == null ? null : Number(p.cashierId), + cashierName: p.cashierName || `Cashier ${p.cashierId}`, + totalAmount: Number(p.totalAmount || 0) + })) + .sort((a: any, b: any) => b.totalAmount - a.totalAmount); + this.drawersTotal = this.cashierPositions.length + ? this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0) + : null; + }, + error: () => { + // Nothing to count against, so the variance line stays blank rather than reading zero — a + // variance of zero is a claim that somebody counted and it agreed. + this.cashierPositions = []; + this.drawersTotal = null; + } + }); + + // Vault stock, so a branch-wide count is of the whole branch and not just the counters. + this.service.vaultPosition(undefined, this.date).subscribe({ + next: (res: any) => { + const list = Array.isArray(res) ? res : res ? [res] : []; + this.vaultTotal = list.reduce((s: number, v: any) => s + Number(v?.totalAmount || 0), 0); + }, + error: () => { + this.vaultTotal = null; + } + }); + } + + // ── Section 1 ── + + /** + * Only the entries that actually moved money. + * + * A pending request is listed on the day book so nobody loses sight of it, but it has taken no + * cash — counting it into an EOD summary would make the sheet disagree with the drawer. + */ + /** + * Last night's batch: posted against yesterday, surfacing on this report. + * + * A voucher's timestamp is the minute the job ran, while the transaction and the journal entry + * behind it carry the business date. So the interest run that fires just after midnight stamps + * itself with today although the ledger says yesterday — and yesterday was counted, reported and + * signed off hours before. Re-opening that day would invalidate a signed sheet, so the work is + * listed here instead, under its own heading, saying which day it belongs to. It is excluded from + * every total on this page: it is not today's money. + */ + get priorDayRows(): any[] { + return this.rows.filter((r) => r.section === 'SYSTEM_PRIOR'); + } + + get priorDayCredit(): number { + return this.priorDayRows.reduce((s, r) => s + (r.side === 'CR' ? Number(r.amount || 0) : 0), 0); + } + + get priorDayDebit(): number { + return this.priorDayRows.reduce((s, r) => s + (r.side === 'DR' ? Number(r.amount || 0) : 0), 0); + } + + /** The business date last night's batch worked on, for the heading. */ + get priorDayDate(): string | null { + const withDate = this.priorDayRows.find((r) => r.effectiveDate); + return withDate ? withDate.effectiveDate : null; + } + + get approvedRows(): any[] { + return this.rows.filter( + (r) => + r.section !== 'SYSTEM_PRIOR' && + (r.status === 'APPROVED' || r.status === 'COMPLETED' || r.status === 'WITHDRAWN') + ); + } + + /** + * The day split the way this system actually records it: where the entry came from. + * + * Not split by payment mode, which is the obvious shape for a retail till but the wrong one here. + * A payment type is only recorded against a denomination breakdown, and a breakdown is only + * written for physical cash — so a UPI or transfer deposit carries no payment type at all, and a + * mode-wise table would file every non-cash entry under "not specified" while claiming to be a + * breakdown by mode. The day book's own sections are recorded on every row, so this agrees with + * the day book for the same date instead of quietly disagreeing with it. + */ + get sectionLines(): SummaryLine[] { + const order = [ + 'CASHIER', + 'CLERK', + 'SYSTEM' + ]; + const labels: Record = { + CASHIER: 'Cash counter', + CLERK: 'Clerk desk', + SYSTEM: 'System — interest, charges, journals' + }; + const map = new Map(); + for (const r of this.approvedRows) { + const key = order.includes(r.section) ? r.section : 'SYSTEM'; + let line = map.get(key); + if (!line) { + line = { label: labels[key] || key, credit: 0, debit: 0, count: 0 }; + map.set(key, line); + } + this.addTo(line, r); + } + return order.filter((k) => map.has(k)).map((k) => map.get(k)!); + } + + /** + * What kind of entry it was — deposit, withdrawal, interest posting, charge. + * + * The sections above say where the day's work came from; this says what the work was, which is + * the question somebody checking a figure against a register actually asks. + */ + get kindLines(): SummaryLine[] { + const map = new Map(); + for (const r of this.approvedRows) { + const key = (r.kind || 'OTHER').toString(); + let line = map.get(key); + if (!line) { + line = { label: this.titleCase(key), credit: 0, debit: 0, count: 0 }; + map.set(key, line); + } + this.addTo(line, r); + } + return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit)); + } + + /** Adds one day-book row onto a summary line, on the side the row belongs to. */ + private addTo(line: SummaryLine, row: any): void { + const amt = Number(row.amount || 0); + if (row.side === 'CR') { + line.credit += amt; + } else { + line.debit += amt; + } + line.count += 1; + } + + /** DD CASH, INTEREST_POSTING and the like, as a person would read them. */ + kindLabel(raw: string | null | undefined): string { + if (!raw) { + return '—'; + } + return raw + .toString() + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + + private titleCase(raw: string): string { + return this.kindLabel(raw); + } + + get txnCount(): number { + return this.approvedRows.length; + } + + /** More than one drawer in view means a supervisor is reading, not a cashier. */ + get supervising(): boolean { + return this.cashierPositions.length > 1 || (!this.scopedToCashier && this.cashierPositions.length > 0); + } + + get cashierPositionTotal(): number { + return this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0); + } + + /** + * Each person who handled the day's work, with their own credit, debit and net. + * + * The clerk signing the day off is verifying other people's work, so the sheet has to say whose. + * Read off the day book's own handledBy, which every register stamps. + */ + get byUser(): SummaryLine[] { + const map = new Map(); + for (const r of this.approvedRows) { + const key = (r.handledBy || 'Unattributed').toString(); + let line = map.get(key); + if (!line) { + line = { label: key, credit: 0, debit: 0, count: 0 }; + map.set(key, line); + } + this.addTo(line, r); + } + return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit)); + } + + get summaryCreditTotal(): number { + return this.sectionLines.reduce((s, l) => s + l.credit, 0); + } + + get summaryDebitTotal(): number { + return this.sectionLines.reduce((s, l) => s + l.debit, 0); + } + + // ── Section 2 ── + + /** Everything that came into the drawer today, over the counter and from another cashier. */ + get totalReceived(): number { + return this.cashDeposits + this.interCashierIn; + } + + /** Everything that left it: paid to members, handed to the vault, passed to another cashier. */ + get totalPaidOut(): number { + return this.cashWithdrawals + this.settledOut + this.interCashierOut; + } + + /** What should be on the desk: the float, plus what came in, less what went out. */ + get expectedClosing(): number { + return this.openingFloat + this.totalReceived - this.totalPaidOut; + } + + /** + * Whether an expected-against-held comparison can honestly be made. + * + * Only for a single cashier, and only when the server sent that cashier's day legs. Both halves + * then describe the same thing: today's legs, against a drawer that carries nothing forward + * because a cashier draws in the morning and hands back at close. + * + * Never branch-wide. The branch register's expected cash is *today's* arithmetic — yesterday's + * closing, plus today's deposits, less today's withdrawals — while the vault and drawer positions + * are running totals accumulated since the system went live. Subtracting one from the other + * compares an afternoon against a lifetime: it reported ₹2,11,932 extra on a day when ₹100 came + * in and ₹100 went out. There is no branch-wide running total to compare against, so the sheet + * shows the branch its movement and each drawer's position, and states no difference at all + * rather than a confident wrong one. + */ + get canReconcile(): boolean { + return this.scopedToCashier && this.hasDayLegs; + } + + /** The cash the system believes this cashier is holding. */ + get actualCash(): number | null { + return this.scopedToCashier ? this.drawersTotal : null; + } + + /** What today's legs say should be in the drawer. */ + get expectedCash(): number | null { + return this.canReconcile ? this.expectedClosing : null; + } + + /** Held less expected, for one cashier's own day. Null wherever that comparison is not honest. */ + get variance(): number | null { + const expected = this.expectedCash; + if (expected === null || this.actualCash === null) { + return null; + } + return this.actualCash - expected; + } + + /** Vaults and drawers together — what the branch is holding, per the system. */ + get branchHeldTotal(): number | null { + if (this.drawersTotal === null && this.vaultTotal === null) { + return null; + } + return Number(this.drawersTotal ?? 0) + Number(this.vaultTotal ?? 0); + } + + /** Today's net cash movement across the branch: what came in less what went out. */ + get branchNetMovement(): number { + return Number(this.branchDeposits ?? 0) - Number(this.branchWithdrawals ?? 0); + } + + /** True only when a count exists and it agrees to the paisa. */ + get varianceOk(): boolean { + return this.variance !== null && Math.abs(this.variance) < 0.005; + } + + get varianceClass(): string { + if (this.variance === null) { + return ''; + } + return this.varianceOk ? 'eod-ok' : 'eod-bad'; + } + + // ── Section 3 ── + + /** Entries still waiting on somebody, which is what a day cannot be closed over. */ + get pendingRows(): any[] { + return this.rows.filter((r) => r.status === 'PENDING' || r.status === 'SUBMITTED'); + } + + /** Cash entries whose paper slip nobody has checked against the screen. */ + get unverifiedRows(): any[] { + return this.approvedRows.filter((r) => !r.verifyStatus); + } + + /** A slip that was checked and did not agree with what was keyed in. */ + get mismatchRows(): any[] { + return this.rows.filter((r) => r.verifyStatus === 'MISMATCH' || r.verifyStatus === 'DISPUTED'); + } + + /** + * Whether the day is ready to close. + * + * Nothing pending, no slip disagreeing, and — where a cash comparison was actually made — the + * cash agreeing. A branch-wide reading makes no comparison, so it is not held to one: requiring + * `varianceOk` there meant the sheet could never say a day was clear however clean it was. + */ + get allClear(): boolean { + if (this.pendingRows.length > 0 || this.mismatchRows.length > 0) { + return false; + } + return this.variance === null || this.varianceOk; + } + + reload(): void { + this.load(); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html new file mode 100644 index 000000000..3ff8b6343 --- /dev/null +++ b/src/app/cashier/my-work/my-work.component.html @@ -0,0 +1,203 @@ + + +
+
+
+
MY WORK
+

My Vouchers

+

+ Everything you entered and everything you approved — receipts, payments and journals in one list. +

+
+
+ + + +
+
+ + @if (error) { +
{{ error }}
+ } + + @if (loading) { +
Loading…
+ } + + @if (report && !loading) { +
+ {{ report.displayName || report.username }} + · + {{ report.from | date: 'dd MMM yyyy' }} + @if (report.from !== report.to) { + to {{ report.to | date: 'dd MMM yyyy' }} + } +
+ + +
+
+
Received
+
₹{{ report.receivedTotal | number: '1.2-2' }}
+
+
+
Payments
+
₹{{ report.paymentTotal | number: '1.2-2' }}
+
+
+
Journal
+
₹{{ report.journalTotal | number: '1.2-2' }}
+
+
+
Entered
+
{{ report.raisedCount }}
+
+
+
Approved
+
{{ report.verifiedCount }}
+
+
+ + @if (report.count > 0) { +
+ + + @if (isFiltered) { + + } +
+ } + + @if (rows.length === 0) { +
+ @if (report.count === 0) { + Nothing recorded against you for this period. + } @else { + No vouchers match these filters — {{ report.count }} hidden. + } +
+ } @else { +
+ Showing {{ rows.length }} + @if (isFiltered) { + of {{ report.count }} + } + {{ rows.length === 1 ? 'voucher' : 'vouchers' }} + Total ₹{{ filteredTotal | number: '1.2-2' }} +
+ + + @for (g of groups; track g.key) { +
+
+ {{ g.label }} + {{ g.hint }} + + {{ g.rows.length }} {{ g.rows.length === 1 ? 'voucher' : 'vouchers' }} + ₹{{ g.total | number: '1.2-2' }} + +
+
+ + + + + + + + + + + + + + + + @for (r of g.rows; track r.voucherNo) { + + + + + + + + + + + + + } + +
VoucherSideTypeDebitCreditAmountMy partStatusTime
{{ r.voucherNo }} + {{ sideLabel(r.side) }} + {{ r.voucherType }} + {{ r.debitParty || '—' }} + @if (r.debitAccount) { + {{ r.debitAccount }} + } + + {{ r.creditParty || '—' }} + @if (r.creditAccount) { + {{ r.creditAccount }} + } + @if (r.extraLines > 0) { + +{{ r.extraLines }} more + } + ₹{{ r.amount | number: '1.2-2' }} + + {{ roleIcon(r.myRole) }} + {{ roleLabel(r.myRole) }} + + + @if (r.myRole === 'VERIFIED' && r.raisedBy) { + by {{ r.raisedBy }} + } + + + {{ statusLabel(r.verificationStatus) }} + + {{ r.createdAt | date: 'HH:mm' }}
+
+
+ } + } + } +
diff --git a/src/app/cashier/my-work/my-work.component.scss b/src/app/cashier/my-work/my-work.component.scss new file mode 100644 index 000000000..674708b5e --- /dev/null +++ b/src/app/cashier/my-work/my-work.component.scss @@ -0,0 +1,294 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* Who and when, above the day's figures. */ +.mw-who { + font-size: 13px; + color: #475569; + margin: 0 0 12px; + + strong { + color: #0f172a; + } + + &__sep { + margin: 0 6px; + color: #cbd5e1; + } +} + +/* The day at a glance: the two sides a branch balances, then what the user's part was. */ +.mw-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.mw-tile { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 10px 12px; + + &__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + margin-bottom: 4px; + } + + &__value { + font-size: 18px; + font-weight: 600; + color: #0f172a; + + /* Received green, paid-out red — the convention a passbook already uses, so the two totals + are told apart without reading the labels. */ + &--cr { + color: #15803d; + } + + &--dr { + color: #b91c1c; + } + } +} + +.mw-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px; + margin-bottom: 12px; +} + +.mw-count { + font-size: 12px; + color: #64748b; + margin-bottom: 8px; + + &__total { + float: right; + font-weight: 600; + color: #0f172a; + } +} + +.mw-table { + font-size: 13px; +} + +/* Money cells use the shared `.num` from cashier-shared.scss, which right-aligns and + tabular-figures the heading as well as the cell. A local class only reached the cells, leaving + every heading left-aligned over right-aligned figures. */ + +.mw-vno { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + white-space: nowrap; +} + +.mw-type { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #64748b; + white-space: nowrap; +} + +.mw-time { + white-space: nowrap; + color: #64748b; +} + +/* The account number or the maker's name, under the party it belongs to. */ +.mw-acct { + display: block; + font-size: 11px; + color: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.mw-more { + display: inline-block; + margin-left: 6px; + font-size: 10px; + padding: 1px 5px; + border-radius: 8px; + background: #f1f5f9; + color: #64748b; +} + +.mw-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 7px; + border-radius: 10px; + white-space: nowrap; + + &--CR { + background: #dcfce7; + color: #15803d; + } + + &--DR { + background: #fee2e2; + color: #b91c1c; + } + + &--JV { + background: #e0e7ff; + color: #4338ca; + } +} + +/* Whose work the row is, marked on the row itself. + A coloured left edge and a faint tint, rather than a different text colour: the eye finds the + edge while scanning a column of figures, which is how a day's list is actually read. Kept faint + so the amounts stay the thing being read. */ +.mw-row { + border-left: 3px solid transparent; + + &--RAISED { + border-left-color: #86efac; + } + + /* Somebody else's entry that this person passed. The row a reviewer came looking for, so it + carries the darker of the two greens. */ + &--VERIFIED { + border-left-color: #15803d; + background: #f0fdf4; + } + + /* Entered and approved by the same person. Legitimate in a small branch, but the one combination + an auditor stops on, so it is the only row given a warm tint. */ + &--RAISED_AND_VERIFIED { + border-left-color: #d97706; + background: #fffbf3; + } +} + +/* One section per part played. The heading carries the section's own count and total, so a maker's + tally and a checker's are each readable without adding rows up. */ +.mw-group { + margin-bottom: 16px; + + /* Two shades of one colour rather than two colours: entering and approving are stages of the + same piece of work, and the darker edge reads as the later one. */ + border-left: 3px solid #86efac; + + /* Same shades the row edges use, so a section and its rows read as one thing. */ + &--VERIFIED { + border-left-color: #15803d; + } + + &--RAISED_AND_VERIFIED { + border-left-color: #d97706; + } + + &__head { + display: flex; + align-items: baseline; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + + strong { + font-size: 13px; + color: #0f172a; + } + } + + &__hint { + font-size: 11px; + color: #64748b; + } + + &__count { + margin-left: auto; + font-size: 11px; + color: #64748b; + white-space: nowrap; + } + + &__total { + margin-left: 8px; + font-weight: 700; + color: #0f172a; + } +} + +/* Where the voucher stands. Three states, because there are three things a reader does: nothing + (done), chase it (pending), or look into it (bad). */ +.mw-status { + font-size: 11px; + font-weight: 600; + white-space: nowrap; + + /* Approved, posted — settled but nothing handed over. Lighter than 'paid' but still dark enough + to read as body text; a pale green on white fails at this size. */ + &--done { + color: #16a34a; + } + + /* Cash actually gone. Darker and filled, the same way the day book marks it. */ + &--paid { + background: #15803d; + color: #fff; + padding: 2px 8px; + border-radius: 999px; + } + + &--pending { + color: #b45309; + } + + &--bad { + color: #b91c1c; + } +} + +/* The part played, as a small pill with its own glyph. */ +.mw-part { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 2px 8px 2px 6px; + border-radius: 10px; + white-space: nowrap; + + &__icon { + font-size: 13px; + width: 13px; + height: 13px; + line-height: 13px; + } + + &--RAISED { + background: #dcfce7; + color: #166534; + } + + &--VERIFIED { + background: #15803d; + color: #fff; + } + + &--RAISED_AND_VERIFIED { + background: #fef3c7; + color: #b45309; + } +} diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts new file mode 100644 index 000000000..c4644ba0b --- /dev/null +++ b/src/app/cashier/my-work/my-work.component.ts @@ -0,0 +1,288 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +/** What the signed-in user's part in a voucher was. */ +export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; + +/** Which register the voucher was numbered in: receipt, payment, or journal. */ +export type VoucherSide = 'CR' | 'DR' | 'JV'; + +interface MyWorkRow { + voucherNo: string; + side: VoucherSide | null; + voucherType: string | null; + amount: number | null; + createdAt: string | null; + raisedBy: string | null; + branchName: string | null; + verificationStatus: string | null; + verifiedByName: string | null; + verifiedAt: string | null; + debitParty: string | null; + debitAccount: string | null; + creditParty: string | null; + creditAccount: string | null; + /** Sides beyond the one debit and one credit shown — a deposit that also took a charge. */ + extraLines: number; + myRole: MyRole; +} + +interface MyWorkReport { + from: string; + to: string; + username: string; + displayName: string; + vouchers: MyWorkRow[]; + count: number; + raisedCount: number; + verifiedCount: number; + receivedTotal: number; + paymentTotal: number; + journalTotal: number; +} + +/** + * One person's day at the branch: what they entered and what they approved, in one list. + * + * The Received and Payment registers answer "what moved today" across everyone. This answers the + * question each member of staff asks at a close — "what did I do today" — which those two cannot, + * because a checker's approval raises no voucher of its own and leaves its only trace on the + * verification row. + */ +@Component({ + selector: 'mifosx-my-work', + templateUrl: './my-work.component.html', + styleUrls: [ + '../cashier-shared.scss', + './my-work.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class MyWorkComponent implements OnInit { + private service = inject(CashierService); + + /** Day is the working default; a month is what a close or an audit asks for. */ + period: 'DAY' | 'MONTH' = 'DAY'; + anchor = this.todayIso(); + + /** Narrow to one side, or to the part the user played. Both applied in the browser. */ + sideFilter: '' | VoucherSide = ''; + roleFilter: '' | MyRole = ''; + + report: MyWorkReport | null = null; + loading = false; + error: string | null = null; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.error = null; + + const { from, to } = this.range(); + this.service + .myVoucherWork(from, to) + .pipe( + catchError((err: any) => { + this.error = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + 'Your work for this period could not be loaded.'; + return of(null); + }) + ) + .subscribe((res: MyWorkReport | null) => { + this.loading = false; + this.report = res; + }); + } + + /** + * The rows after the two on-screen filters. + * + * Filtered here rather than at the server: the day's own work is a small list, and narrowing it + * without a round trip is what makes the filters feel like part of reading the page. + */ + get rows(): MyWorkRow[] { + const all = this.report?.vouchers ?? []; + return all.filter( + (r) => (!this.sideFilter || r.side === this.sideFilter) && (!this.roleFilter || r.myRole === this.roleFilter) + ); + } + + /** + * The rows split by the part the user played, each group with its own total. + * + * Entering a request and passing somebody else's are different jobs answerable to different + * people, and a branch reads them separately: "what did I put through" is a maker's own tally, + * "what did I let through" is a checker's. One list mixed them, so neither total could be read + * off the page — a manager who entered one voucher and approved twenty saw twenty-one rows and + * had to sort them by eye. + * + * An empty group is dropped rather than shown as a heading over nothing, so a clerk who never + * approves anything sees one section and not a permanently empty second one. + */ + get groups(): { key: MyRole; label: string; hint: string; rows: MyWorkRow[]; total: number }[] { + const order: { key: MyRole; label: string; hint: string }[] = [ + { key: 'RAISED', label: 'Entered by me', hint: 'Requests and postings you put through' }, + { key: 'VERIFIED', label: 'Approved by me', hint: "Other people's work you passed" }, + { + key: 'RAISED_AND_VERIFIED', + label: 'Entered & approved by me', + hint: 'Both roles on the same voucher — kept separate for the record' + } + ]; + return order + .map((g) => { + const rows = this.rows.filter((r) => r.myRole === g.key); + return { ...g, rows, total: rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0) }; + }) + .filter((g) => g.rows.length > 0); + } + + /** True when a filter is hiding part of what was loaded, so the count can say so. */ + get isFiltered(): boolean { + return !!this.sideFilter || !!this.roleFilter; + } + + clearFilters(): void { + this.sideFilter = ''; + this.roleFilter = ''; + } + + /** The total of the rows on screen, which is not the day's total once a filter is on. */ + get filteredTotal(): number { + return this.rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0); + } + + sideLabel(side: VoucherSide | null): string { + if (side === 'CR') { + return 'Received'; + } + if (side === 'DR') { + return 'Payment'; + } + return 'Journal'; + } + + roleLabel(role: MyRole): string { + if (role === 'RAISED') { + return 'Entered'; + } + if (role === 'VERIFIED') { + return 'Approved'; + } + return 'Entered & approved'; + } + + /** + * Where the voucher stands, in words a branch uses. + * + * A voucher with no status at all is not unknown — it is a posting that never went through a + * request: interest the nightly run credited, a charge taken from a balance. Those are done the + * moment they exist, so they read as Posted rather than as a blank a reader has to interpret. + */ + statusLabel(status: string | null): string { + const s = (status ?? '').toUpperCase(); + if (!s) { + return 'Posted'; + } + if (s === 'WITHDRAWN') { + return 'Paid out'; + } + if (s === 'APPROVED') { + return 'Approved'; + } + if (s === 'PENDING') { + return 'Pending'; + } + if (s === 'REJECTED') { + return 'Rejected'; + } + if (s === 'CLOSED') { + return 'Closed'; + } + // OK / MISMATCH come from a physical cash verification rather than a request. + return s.charAt(0) + s.slice(1).toLowerCase(); + } + + /** + * Groups the statuses by what a reader does about them. + * + * 'paid' is kept apart from 'done' because approved and paid out are different facts: one is a + * promise, the other is money gone. Sharing a colour hid exactly the thing somebody opens this + * page to check. + */ + statusClass(status: string | null): string { + const s = (status ?? '').toUpperCase(); + if (s === 'PENDING') { + return 'pending'; + } + if (s === 'REJECTED' || s === 'MISMATCH') { + return 'bad'; + } + if (s === 'WITHDRAWN' || s === 'COMPLETED' || s === 'CLOSED') { + return 'paid'; + } + return 'done'; + } + + /** + * A glyph per role, so the part played reads before the words do. + * + * A pen for an entry the user made, a tick for one they approved, and a warning for both on the + * same voucher — which a small branch does legitimately, and an auditor should still see marked. + */ + roleIcon(role: MyRole): string { + if (role === 'RAISED') { + return 'edit'; + } + if (role === 'VERIFIED') { + return 'check_circle'; + } + return 'warning'; + } + + /** The period the anchor date names: that day, or the whole calendar month around it. */ + private range(): { from: string; to: string } { + if (this.period === 'DAY') { + return { from: this.anchor, to: this.anchor }; + } + const [ + y, + m + ] = this.anchor.split('-').map(Number); + const first = `${y}-${String(m).padStart(2, '0')}-01`; + // Day 0 of the next month is the last day of this one, which avoids a table of month lengths + // and gets February right in a leap year. + const last = new Date(y, m, 0); + const lastIso = `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String( + last.getDate() + ).padStart(2, '0')}`; + return { from: first, to: lastIso }; + } + + private todayIso(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html index 0fac35882..73ac94478 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.html +++ b/src/app/cashier/voucher-report/voucher-report.component.html @@ -170,6 +170,15 @@
Credit Amount UserLedger dateTime
{{ row.amount | number: '1.2-2' }} {{ row.username || '—' }} + {{ row.ledgerDate ? (row.ledgerDate | date: 'dd MMM yyyy') : '—' }} + @if (row.postedAfterClose) { + after close + } + {{ row.createdAt | date: 'dd MMM, HH:mm' }}