feat(cashier,organization): denomination-aware vault UI, Bank/Branch Profile screens, popup disbursement review
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s

Branch Day open/close now captures a denomination-wise count (carried
forward automatically to the next day), and shows an auto-fetched EOD
summary at close time -- vault + every cashier's position, a branch-total
reconciliation against the book figure, and each cashier's
reconciliation/settlement status and transaction count -- so closing the day
no longer means separately checking Vault Position and every cashier's own
screen by hand. New Vault Position screen (branch-ops menu) mirrors Cashier
Position for the teller/vault. Cash Transfer now shows the vault's live
per-denomination availability while counting a handout, with a client-side
warning before it can be over-counted.

New Organization screens for Bank Profile, Branch Profile and Clearing GL
Configuration, wired into the Organization landing page and the
previously-dead "Bank Profile" admin menu button.

Cashier Transaction Entry: TYPE is now Receipt/Payment only (cash-only TXN
TP.) on the cashier side, with no change to the clerk dashboard's own
Withdrawal Slip/Cheque/Disbursement/Enter Bank Transfer/DD flow beyond
adding a new Fund Transfer type alongside Disbursement for a GL credit that
isn't a loan payout. Neither TYPE nor TXN TP. offers a blank placeholder
option anymore -- a new row starts on a real default instead. Footer no
longer polls the business-date config for cashier/clerk roles, who never
have permission to read it.

Disbursement & Transfer Requests: "Review" now opens the review screen as a
popup instead of navigating to a separate route, refreshing the list on
close; the review screen recognises the new Fund Transfer request type
too, and its non-functional "+ Disbursement Review" tab-styled label is now
a plain heading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 14:40:13 +05:30
co-authored by Claude Sonnet 5
parent ce840d73d7
commit da1f57c3b6
32 changed files with 3006 additions and 136 deletions
@@ -11,9 +11,16 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { catchError, of } from 'rxjs';
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { CashierService } from '../cashier.service';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
interface BranchDay {
id: number;
officeId: number;
@@ -92,9 +99,75 @@ export class BranchDayComponent implements OnInit {
forceReason = '';
closing = false;
// Denomination breakdown, shared by both the open and close forms — a physical note/coin count
// is the true source of the lump-sum figure, not a typed-in guess. Counted separately per form
// since opening and closing are two independent physical counts.
notes: Denom[] = [];
coins: Denom[] = [];
openCounts: Record<number, number> = {};
closeCounts: Record<number, number> = {};
get allDenoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
this.load();
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x: any) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
}
countFor(counts: Record<number, number>, d: Denom): number {
return counts[d.id] || 0;
}
subtotalFor(counts: Record<number, number>, d: Denom): number {
return this.countFor(counts, d) * d.value;
}
onCountChange(counts: Record<number, number>, d: Denom, raw: any): void {
counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpCount(counts: Record<number, number>, d: Denom, delta: number): void {
counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta);
}
totalOf(counts: Record<number, number>): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(counts, d), 0);
}
/** Denominations with a positive count, shaped for the API payload. */
private denominationPayload(counts: Record<number, number>) {
return this.allDenoms
.filter((d) => this.countFor(counts, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(counts, d) }));
}
get openDenomTotal(): number {
return this.totalOf(this.openCounts);
}
get closeDenomTotal(): number {
return this.totalOf(this.closeCounts);
}
load(): void {
@@ -138,6 +211,7 @@ export class BranchDayComponent implements OnInit {
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
// over it is how a carried balance drifts.
this.openingCash = null;
this.openCounts = {};
this.successMsg = '';
this.errorMsg = '';
}
@@ -150,8 +224,9 @@ export class BranchDayComponent implements OnInit {
if (!this.openDate) return;
this.opening = true;
this.errorMsg = '';
const denominations = this.denominationPayload(this.openCounts);
this.service
.openBranchDay(this.openDate, this.openingCash ?? undefined)
.openBranchDay(this.openDate, this.openingCash ?? undefined, undefined, denominations)
.pipe(
catchError((err: any) => {
this.errorMsg =
@@ -176,6 +251,7 @@ export class BranchDayComponent implements OnInit {
startClose(day: BranchDay): void {
this.closeTarget = day;
this.closingCash = null;
this.closeCounts = {};
this.closeRemarks = '';
this.forceReason = '';
this.successMsg = '';
@@ -196,12 +272,125 @@ export class BranchDayComponent implements OnInit {
this.closingCash = Number(res.expectedCash);
}
});
this.loadEodSummary(day);
}
// ── EOD denomination summary: what's in the vault, and what every cashier under it is
// currently holding, fetched automatically so closing the day doesn't require separately
// checking Vault Position and every cashier's own Cashier Position screen by hand. ──
eodLoading = false;
eodTellers: any[] = [];
/**
* Vault + every cashier's current holding, added up across the whole branch and checked against
* the book figure — the question a manager actually needs answered at close: does the BRANCH
* tally, not just the vault alone. Computed on read rather than stored, so it never has to be
* kept in sync across the two independent loads (the book preview and the EOD summary) that feed
* it.
*/
get branchTotalSummary(): {
vaultTotal: number;
cashierTotal: number;
grandTotal: number;
expected: number;
difference: number;
} | null {
if (!this.preview || this.eodTellers.length === 0) return null;
let vaultTotal = 0;
let cashierTotal = 0;
for (const t of this.eodTellers) {
vaultTotal += Number(t.vault?.totalAmount ?? 0);
for (const c of t.cashiers) {
cashierTotal += Number(c.currentTotal ?? 0);
}
}
const grandTotal = vaultTotal + cashierTotal;
const expected = Number(this.preview.expectedCash ?? 0);
return { vaultTotal, cashierTotal, grandTotal, expected, difference: grandTotal - expected };
}
private loadEodSummary(day: BranchDay): void {
this.eodLoading = true;
this.eodTellers = [];
this.service
.getTellers(day.officeId)
.pipe(
switchMap((tellers) => {
const list = tellers || [];
if (!list.length) return of([]);
return forkJoin(list.map((t: any) => this.loadTellerSummary(t, day.businessDate)));
}),
catchError(() => of([]))
)
.subscribe((results: any[]) => {
this.eodLoading = false;
this.eodTellers = results;
});
}
private loadTellerSummary(teller: any, date: string) {
return forkJoin({
vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))),
cashiers: this.service.getCashiersForTeller(teller.id).pipe(catchError(() => of({ cashiers: [] })))
}).pipe(
switchMap(({ vault, cashiers }) => {
const cashierList = cashiers?.cashiers || [];
if (!cashierList.length) {
return of({ tellerId: teller.id, tellerName: teller.name, vault, cashiers: [] });
}
return forkJoin(cashierList.map((c: any) => this.loadCashierSummary(c, date))).pipe(
map((cashierSummaries: any[]) => ({
tellerId: teller.id,
tellerName: teller.name,
vault,
cashiers: cashierSummaries
}))
);
})
);
}
private loadCashierSummary(cashier: any, date: string) {
return forkJoin({
position: this.service.cashierPositionReport(cashier.id, date).pipe(catchError(() => of(null))),
morning: this.service.getCashierMorningBalance(cashier.id, date).pipe(catchError(() => of(null))),
reconciliation: this.service.checkReconciliation(cashier.id, date).pipe(catchError(() => of(null))),
settlement: this.service.checkSettlement(cashier.id, date).pipe(catchError(() => of(null))),
glEntries: this.service.getGlEntries(date, cashier.id, undefined, 0, 1).pipe(catchError(() => of(null)))
}).pipe(
map(({ position, morning, reconciliation, settlement, glEntries }: any) => {
const morningMap = new Map<number, number>();
(morning?.denominations || []).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0));
return {
cashierId: cashier.id,
cashierName: cashier.staffName,
morningTotal: Number(morning?.totalMorning ?? 0),
currentTotal: Number(position?.totalAmount ?? 0),
reconciled: reconciliation?.reconciled ?? null,
alreadySettled: settlement?.alreadySettled ?? null,
txnCount: glEntries?.totalFilteredRecords ?? null,
denominations: ((position?.denominations || []) as any[]).map((d: any) => ({
...d,
morningCount: morningMap.get(d.denominationId) ?? 0,
delta: d.count - (morningMap.get(d.denominationId) ?? 0)
}))
};
})
);
}
/** The counted figure the form would submit right now — the denomination total when any note/coin
* has been counted, otherwise the plain typed-in amount. */
get effectiveClosingFigure(): number | null {
return this.closeDenomTotal > 0 ? this.closeDenomTotal : this.closingCash;
}
/** Difference the form would record right now, so it is visible before saving. */
get liveDifference(): number | null {
if (!this.preview || this.closingCash == null) return null;
return Number(this.closingCash) - Number(this.preview.expectedCash ?? 0);
if (!this.preview || this.effectiveClosingFigure == null) return null;
return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0);
}
/** Puts the expected figure back after an edit. */
@@ -213,6 +402,7 @@ export class BranchDayComponent implements OnInit {
cancelClose(): void {
this.closeTarget = null;
this.eodTellers = [];
}
/** A date earlier than today is a late close, and has to say why it was left open. */
@@ -221,22 +411,27 @@ export class BranchDayComponent implements OnInit {
}
get canSubmitClose(): boolean {
if (!this.closeTarget || this.closingCash == null || this.closing) return false;
const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
if (!this.closeTarget || !hasFigure || this.closing) return false;
return !this.isLateClose || !!this.forceReason.trim();
}
submitClose(): void {
const day = this.closeTarget;
if (!day || this.closingCash == null) return;
const denominations = this.denominationPayload(this.closeCounts);
const figure = denominations.length ? this.closeDenomTotal : this.closingCash;
if (!day || figure == null) return;
this.closing = true;
this.errorMsg = '';
this.service
.closeBranchDay(
day.businessDate,
Number(this.closingCash),
Number(figure),
this.closeRemarks.trim() || undefined,
this.isLateClose ? this.forceReason.trim() : undefined
this.isLateClose ? this.forceReason.trim() : undefined,
undefined,
denominations
)
.pipe(
catchError((err: any) => {
@@ -259,6 +454,10 @@ export class BranchDayComponent implements OnInit {
});
}
absValue(n: number): number {
return Math.abs(n);
}
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
differenceLabel(d: number | null): string {
if (d == null) return '—';