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
@@ -125,13 +125,102 @@
</label>
<label class="pg-field">
<span>Opening cash (leave blank to carry forward)</span>
<input type="number" step="0.01" [(ngModel)]="openingCash" placeholder="Carried from last close" />
<input
type="number"
step="0.01"
[(ngModel)]="openingCash"
[disabled]="openDenomTotal > 0"
placeholder="Carried from last close"
/>
</label>
</div>
<p class="bd-hint">
Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance
from drifting.
Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which
is what keeps the opening balance from drifting. Counting the drawer below is what actually records the
note/coin stock the branch can check a vault handout against later.
</p>
<div class="pg-card bd-denom">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button
type="button"
(click)="bumpCount(openCounts, d, -1)"
[disabled]="countFor(openCounts, d) === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="countFor(openCounts, d)"
(input)="onCountChange(openCounts, d, $any($event.target).value)"
/>
<button type="button" (click)="bumpCount(openCounts, d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button
type="button"
(click)="bumpCount(openCounts, d, -1)"
[disabled]="countFor(openCounts, d) === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="countFor(openCounts, d)"
(input)="onCountChange(openCounts, d, $any($event.target).value)"
/>
<button type="button" (click)="bumpCount(openCounts, d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
@if (openDenomTotal > 0) {
<p class="bd-hint">
Denomination total: <strong>₹{{ openDenomTotal | number: '1.2-2' }}</strong>
</p>
}
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="opening || !openDate">
Open Day
@@ -174,29 +263,133 @@
<span>+ Cash deposits taken (approved)</span>
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash drawn from vault</span>
<span class="bd-calc__amt">₹{{ preview.vaultIn | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash paid out (approved withdrawals)</span>
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash settled back to vault</span>
<span class="bd-calc__amt">₹{{ preview.settledOut | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--total">
<span>Cash the branch should be holding</span>
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
</div>
@if (preview.vaultIn > 0 || preview.settledOut > 0) {
<div class="bd-calc__memo">
Vault activity today — moves cash within the branch, not counted in the total above: ₹{{
preview.vaultIn | number: '1.2-2'
}}
handed to a cashier, ₹{{ preview.settledOut | number: '1.2-2' }} settled back to the vault.
</div>
}
</div>
}
<!-- What every cash point under this branch is actually holding right now, fetched
automatically — so closing the day doesn't mean separately checking Vault Position and
every cashier's own Cashier Position screen by hand. -->
@if (eodLoading) {
<p class="db-muted">Fetching vault and cashier positions…</p>
}
<!-- The question that actually matters at close: does the WHOLE branch tally, not just the
vault. Only shown once both the book preview and every teller/cashier position have
loaded, since it adds them together. -->
@if (branchTotalSummary; as bt) {
<div class="bd-eod bd-eod--total">
<div class="bd-eod__head">
<mat-icon>account_balance_wallet</mat-icon>
<strong>Branch Total (Vault + All Cashiers)</strong>
</div>
<div class="bd-eod__totals">
<span>Vault ₹{{ bt.vaultTotal | number: '1.2-2' }}</span>
<span>+ Cashiers ₹{{ bt.cashierTotal | number: '1.2-2' }}</span>
<span class="bd-eod__totals-sum">= ₹{{ bt.grandTotal | number: '1.2-2' }}</span>
<span class="db-muted">vs. book ₹{{ bt.expected | number: '1.2-2' }}</span>
</div>
@if (bt.difference === 0) {
<span class="db-badge db-badge--ok">Branch Tallied</span>
} @else {
<span class="db-badge db-badge--rejected">
{{ bt.difference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(bt.difference) | number: '1.2-2' }}
</span>
}
</div>
}
@for (t of eodTellers; track t.tellerId) {
<div class="bd-eod">
<div class="bd-eod__head">
<mat-icon>account_balance</mat-icon>
<strong>{{ t.tellerName }}</strong>
<span class="db-muted">
vault holds
{{ t.vault?.hasBreakdown ? '₹' + (t.vault.totalAmount | number: '1.2-2') : 'no breakdown recorded' }}
</span>
</div>
@if (t.vault?.hasBreakdown && t.vault.denominations.length > 0) {
<div class="bd-eod__denoms">
@for (d of t.vault.denominations; track d.denominationId) {
<span class="bd-eod__chip">₹{{ d.denominationValue }} × {{ d.count }}</span>
}
</div>
}
@if (t.cashiers.length === 0) {
<p class="db-muted bd-eod__empty">No cashiers allocated under this vault.</p>
}
@for (c of t.cashiers; track c.cashierId) {
<div class="bd-eod__cashier">
<div class="bd-eod__cashier-head">
<span>{{ c.cashierName }}</span>
<span class="db-muted">
morning ₹{{ c.morningTotal | number: '1.2-2' }} → current ₹{{ c.currentTotal | number: '1.2-2' }} ·
{{ c.txnCount ?? '—' }} txn{{ c.txnCount === 1 ? '' : 's' }} today
</span>
</div>
<div class="bd-eod__status">
@if (c.reconciled === true) {
<span class="db-badge db-badge--ok">Reconciled</span>
} @else if (c.reconciled === false) {
<span class="db-badge db-badge--pending">Not Reconciled</span>
}
@if (c.alreadySettled === true) {
<span class="db-badge db-badge--ok">Settled to Vault</span>
} @else if (c.alreadySettled === false) {
<span class="db-badge">Not Settled</span>
}
</div>
@if (c.denominations.length > 0) {
<div class="bd-eod__denoms">
@for (d of c.denominations; track d.denominationId) {
<span
class="bd-eod__chip"
[class.bd-eod__chip--up]="d.delta > 0"
[class.bd-eod__chip--down]="d.delta < 0"
>
₹{{ d.denominationValue }}: {{ d.morningCount }} → {{ d.count }}
@if (d.delta !== 0) {
({{ d.delta > 0 ? '+' : '' }}{{ d.delta }})
}
</span>
}
</div>
} @else {
<span class="db-muted bd-eod__empty">No balance.</span>
}
</div>
}
</div>
}
<div class="db-panel__body">
<label class="pg-field">
<span>Physical cash counted</span>
<input type="number" step="0.01" [(ngModel)]="closingCash" placeholder="0.00" />
<span>Physical cash counted (or count denominations below)</span>
<input
type="number"
step="0.01"
[(ngModel)]="closingCash"
[disabled]="closeDenomTotal > 0"
placeholder="0.00"
/>
</label>
@if (preview && liveDifference !== null) {
@if (liveDifference === 0) {
@@ -222,9 +415,91 @@
<p class="bd-hint">
Pre-filled with what the books expect — change it only if the drawer counted something different, and that
difference is then recorded against the day.
difference is then recorded against the day. Counting the denominations below overrides the typed figure with
what was actually counted, note by note.
</p>
<div class="pg-card bd-denom">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button
type="button"
(click)="bumpCount(closeCounts, d, -1)"
[disabled]="countFor(closeCounts, d) === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="countFor(closeCounts, d)"
(input)="onCountChange(closeCounts, d, $any($event.target).value)"
/>
<button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button
type="button"
(click)="bumpCount(closeCounts, d, -1)"
[disabled]="countFor(closeCounts, d) === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="countFor(closeCounts, d)"
(input)="onCountChange(closeCounts, d, $any($event.target).value)"
/>
<button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
@if (closeDenomTotal > 0) {
<p class="bd-hint">
Denomination total: <strong>₹{{ closeDenomTotal | number: '1.2-2' }}</strong>
</p>
}
@if (isLateClose) {
<label class="pg-field bd-panel__wide">
<span>Why was this day left open? (required)</span>
@@ -157,6 +157,14 @@
font-weight: 700;
white-space: nowrap;
}
&__memo {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
font-size: 11px;
color: #94a3b8;
}
}
.bd-hint {
@@ -170,3 +178,105 @@
font-size: 11px;
line-height: 1.4;
}
/* The denomination grid inside the open/close panels — same table chrome as pg-card elsewhere,
just tightened to sit inside the panel instead of spanning the page. */
.bd-denom {
margin: 4px 0 0;
max-width: 520px;
}
/* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */
.bd-eod {
margin: 0 0 12px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #0ea5e9;
}
}
&__denoms {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 8px;
}
&__chip {
padding: 2px 8px;
background: #f1f5f9;
border-radius: 999px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: #334155;
&--up {
background: #dcfce7;
color: #15803d;
}
&--down {
background: #fee2e2;
color: #b91c1c;
}
}
&__cashier {
padding: 6px 0;
border-top: 1px dashed #e2e8f0;
&-head {
display: flex;
justify-content: space-between;
gap: 12px;
font-weight: 600;
color: #0d1b2a;
margin-bottom: 2px;
}
}
&__empty {
font-size: 11px;
}
&__status {
display: flex;
gap: 6px;
margin: 2px 0 6px;
}
&--total {
border-left: 4px solid #0ea5e9;
background: #f0f9ff;
}
&__totals {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
font-size: 13px;
font-variant-numeric: tabular-nums;
&-sum {
font-weight: 800;
font-size: 15px;
color: #0d1b2a;
}
}
}
@@ -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 '—';
@@ -50,7 +50,7 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
@@ -59,11 +59,28 @@
</mat-form-field>
</div>
<!-- What the vault actually has, before anyone starts counting a handout it can't cover. -->
@if (fromTellerId && !loadingVault) {
@if (vaultHasBreakdown) {
<div class="ct-vault-banner">
<mat-icon>account_balance</mat-icon>
Vault available: <strong>₹{{ vaultTotalAvailable | number: '1.2-2' }}</strong>
</div>
} @else {
<div class="ct-vault-banner ct-vault-banner--warn">
<mat-icon>warning</mat-icon>
No denomination breakdown recorded for this vault — per-note availability can't be checked, only the total
opening balance.
</div>
}
}
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Available</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
@@ -71,14 +88,17 @@
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
<td colspan="4">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<tr [class.ct-row--over]="exceedsAvailable(d)">
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td class="num" [class.ct-available--over]="exceedsAvailable(d)">
{{ vaultHasBreakdown ? availableFor(d) : '—' }}
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -91,14 +111,17 @@
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
<td colspan="4">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<tr [class.ct-row--over]="exceedsAvailable(d)">
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td class="num" [class.ct-available--over]="exceedsAvailable(d)">
{{ vaultHasBreakdown ? availableFor(d) : '—' }}
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -111,13 +134,24 @@
}
@if (denoms.length === 0) {
<tr>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
<td colspan="4" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
@if (anyDenomExceedsAvailable) {
<div class="ct-limit-warn">
One or more notes/coins above are counted higher than the vault currently holds — reduce them before recording the
transfer.
</div>
} @else if (exceedsVaultTotal) {
<div class="ct-limit-warn">
Total exceeds what the vault has available (₹{{ vaultTotalAvailable | number: '1.2-2' }}).
</div>
}
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
@@ -0,0 +1,51 @@
/**
* 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/.
*/
.ct-vault-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
margin-bottom: 10px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
font-size: 12px;
color: #1e40af;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
}
.ct-row--over {
background: #fef2f2;
}
.ct-available--over {
color: #b91c1c;
font-weight: 700;
}
.ct-limit-warn {
margin: 8px 0;
padding: 8px 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
color: #991b1b;
}
@@ -29,7 +29,10 @@ interface Denom {
@Component({
selector: 'mifosx-cash-transfer',
templateUrl: './cash-transfer.component.html',
styleUrls: ['../cashier-shared.scss'],
styleUrls: [
'../cashier-shared.scss',
'./cash-transfer.component.scss'
],
standalone: true,
imports: [
CommonModule,
@@ -70,6 +73,14 @@ export class CashTransferComponent implements OnInit {
];
}
/** What the selected vault currently holds, denomination-wise — fetched so a clerk can see
* before counting whether the vault even has enough of a given note, instead of finding out
* only after the backend rejects the transfer. */
vaultAvailable: Record<number, number> = {};
vaultTotalAvailable = 0;
vaultHasBreakdown = true;
loadingVault = false;
ngOnInit(): void {
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -101,7 +112,8 @@ export class CashTransferComponent implements OnInit {
});
}
/** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown. */
/** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown, and the
* vault's own current denomination-wise stock so it can be shown against every count field. */
onTellerSelect(tellerId: number): void {
this.fromTellerId = tellerId;
const teller = this.tellers.find((t) => t.id === tellerId);
@@ -109,6 +121,7 @@ export class CashTransferComponent implements OnInit {
this.toParty = '';
this.toCashierId = null;
this.cashiers = [];
this.loadVaultAvailability();
if (!tellerId) {
return;
}
@@ -125,6 +138,45 @@ export class CashTransferComponent implements OnInit {
});
}
/** Refetched whenever the vault or the transfer date changes — the vault's stock is only ever
* meaningful as of a specific business date. */
loadVaultAvailability(): void {
this.vaultAvailable = {};
this.vaultTotalAvailable = 0;
this.vaultHasBreakdown = true;
if (!this.fromTellerId) return;
this.loadingVault = true;
this.service
.vaultPosition(this.fromTellerId, this.transferDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.loadingVault = false;
this.vaultHasBreakdown = !!res?.hasBreakdown;
this.vaultTotalAvailable = Number(res?.totalAmount ?? 0);
const map: Record<number, number> = {};
((res?.denominations || []) as any[]).forEach((d: any) => (map[d.denominationId] = d.count));
this.vaultAvailable = map;
});
}
onDateChange(value: string): void {
this.transferDate = value;
this.loadVaultAvailability();
}
/** How many of this denomination the vault currently holds — 0, not undefined, when the vault's
* breakdown is known but simply doesn't list this denomination (i.e. it truly has none left). */
availableFor(d: Denom): number {
return this.vaultAvailable[d.id] ?? 0;
}
/** Whether the count entered for this denomination is more than the vault is known to hold —
* only flagged once the vault's breakdown has actually loaded, so an empty in-flight state
* doesn't flash every row as over-limit. */
exceedsAvailable(d: Denom): boolean {
return this.vaultHasBreakdown && this.countFor(d) > this.availableFor(d);
}
/** When a To-cashier is chosen, capture its id and name. */
onCashierSelect(cashierId: number): void {
this.toCashierId = cashierId;
@@ -152,8 +204,24 @@ export class CashTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
/** True once any counted denomination is over what the vault holds — checked client-side purely
* so the clerk sees it before submitting; the backend enforces this too regardless. */
get anyDenomExceedsAvailable(): boolean {
return this.denoms.some((d) => this.exceedsAvailable(d));
}
get exceedsVaultTotal(): boolean {
return this.vaultHasBreakdown && this.total > this.vaultTotalAvailable && this.total > 0;
}
get valid(): boolean {
return this.total > 0 && !!this.fromTellerId && !!this.toCashierId;
return (
this.total > 0 &&
!!this.fromTellerId &&
!!this.toCashierId &&
!this.anyDenomExceedsAvailable &&
!this.exceedsVaultTotal
);
}
clear(): void {
@@ -22,6 +22,7 @@ import { CashHistoryComponent } from './cash-history/cash-history.component';
import { DayBookComponent } from './day-book/day-book.component';
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
@@ -156,6 +157,13 @@ const routes: Routes = [
path: 'account-register',
component: AccountRegisterComponent,
data: { title: 'Account Register', breadcrumb: 'Account Register' }
},
{
// Same branch-level scope as the day register: this is the vault's own stock, not any
// one cashier's float, so it sits with the other branch-wide screens.
path: 'vault-position',
component: VaultPositionComponent,
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
}
]
},
+30 -4
View File
@@ -37,8 +37,10 @@ export class CashierService {
}
// ── Tellers / Cashiers ──
getTellers(): Observable<any[]> {
return this.http.get<any[]>('/tellers');
getTellers(officeId?: number): Observable<any[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<any[]>('/tellers', { params });
}
getAllCashiers(): Observable<any[]> {
return this.http.get<any[]>('/cashiers');
@@ -227,11 +229,17 @@ export class CashierService {
return this.http.get('/branchday/close-preview', { params });
}
openBranchDay(businessDate: string, openingCash?: number, officeId?: number): Observable<any> {
openBranchDay(
businessDate: string,
openingCash?: number,
officeId?: number,
denominations?: { denominationId: number; denominationValue: number; count: number }[]
): Observable<any> {
return this.http.post('/branchday/open', {
businessDate,
openingCash: openingCash != null ? String(openingCash) : null,
officeId: officeId ?? null,
denominations: denominations?.length ? denominations : null,
locale: 'en'
});
}
@@ -242,7 +250,8 @@ export class CashierService {
closingCash: number,
remarks?: string,
forceCloseReason?: string,
officeId?: number
officeId?: number,
denominations?: { denominationId: number; denominationValue: number; count: number }[]
): Observable<any> {
return this.http.post('/branchday/close', {
businessDate,
@@ -250,9 +259,26 @@ export class CashierService {
remarks: remarks ?? null,
forceCloseReason: forceCloseReason ?? null,
officeId: officeId ?? null,
denominations: denominations?.length ? denominations : null,
locale: 'en'
});
}
/** The opening/closing denomination breakdown recorded for one business day, if any was captured. */
branchDayDenominations(officeId?: number, businessDate?: string): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
if (businessDate) params = params.set('businessDate', businessDate);
return this.http.get('/branchday/denominations', { params });
}
/** Denomination-wise stock a vault (teller) currently holds — "how much of what does the bank have". */
vaultPosition(tellerId?: number, asOfDate?: string): Observable<any> {
let params = new HttpParams();
if (tellerId) params = params.set('tellerId', String(tellerId));
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/vault-position', { params });
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -120,12 +120,9 @@
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
<option value="~">~</option>
<option value="Withdrawal Slip">Withdrawal Slip</option>
<option value="Cheque">Cheque</option>
<option value="Disbursement">Disbursement</option>
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
<option value="DD">DD (Demand Draft)</option>
@for (t of typOptions; track t.value) {
<option [value]="t.value">{{ t.label }}</option>
}
</select>
</div>
<div class="ws-f ws-f--branch">
@@ -145,7 +142,6 @@
(click)="$event.stopPropagation()"
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
<option [ngValue]="null">— Select —</option>
@for (pt of paymentTypesForRow(row); track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
@@ -160,6 +156,7 @@
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
@@ -205,7 +202,10 @@
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer'
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
(click)="row.dbCr = 'Dr'"
>
@@ -286,7 +286,7 @@
(click)="$event.stopPropagation()"
/>
</div>
} @else if (row.typ !== 'Withdrawal Slip') {
} @else if (isClerk && row.typ !== 'Withdrawal Slip') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
<input
@@ -1327,12 +1327,9 @@
<div class="ws-f ws-f--type">
<label class="ws-f__lbl">TYPE</label>
<select class="ws-f__ctl" [(ngModel)]="row.typ">
<option value="~">~</option>
<option value="Withdrawal Slip">Withdrawal Slip</option>
<option value="Cheque">Cheque</option>
<option value="Disbursement">Disbursement</option>
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
<option value="DD">DD (Demand Draft)</option>
@for (t of typOptions; track t.value) {
<option [value]="t.value">{{ t.label }}</option>
}
</select>
</div>
<div class="ws-f ws-f--branch">
@@ -1351,7 +1348,6 @@
[(ngModel)]="row.paymentTypeId"
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
<option [ngValue]="null">— Select —</option>
@for (pt of paymentTypesForRow(row); track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
@@ -1365,6 +1361,7 @@
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
@@ -1406,7 +1403,10 @@
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer'
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
@@ -1476,7 +1476,7 @@
<label class="ws-f__lbl">MEETING DATE</label>
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
</div>
} @else {
} @else if (isClerk) {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">CHEQUE NO.</label>
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
@@ -794,6 +794,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
*/
paymentTypesForRow(row: TxnRow): any[] {
const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined);
if (!this.isClerk) {
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
// clerk/back-office approval flows, never straight through the cash drawer.
return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
}
if (row.acTyp === 'SHR') {
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
}
@@ -914,6 +919,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
'T'
];
private readonly clerkTypOptions: { value: string; label: string }[] = [
{ value: 'Withdrawal Slip', label: 'Withdrawal Slip' },
{ value: 'Cheque', label: 'Cheque' },
{ value: 'Disbursement', label: 'Disbursement' },
// Mechanically identical to Disbursement (1 GL debit -> N account credits, same maker-checker
// double-entry flow) but labelled separately: "Disbursement" reads as a loan payout, which is
// wrong for crediting a GL amount into a savings/current account for any other reason (e.g.
// funding a newly-activated account's opening balance from a GL source).
{ value: 'Fund Transfer', label: 'Fund Transfer' },
// NOTE: the exact same TxnRow.typ string is used at every dispatch/default-setting site below —
// search for 'Disbursement' whenever this needs to change again, since 'Fund Transfer' always
// rides alongside it in every one of those checks rather than having its own separate case.
{ value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
{ value: 'DD', label: 'DD (Demand Draft)' }
];
/** A cashier only ever takes cash in (Receipt) or pays cash out (Payment) — Cheque, DD,
* Disbursement and Bank Transfer are clerk/back-office instruments with their own approval
* flows and never appear at the cashier counter. */
private readonly cashierTypOptions: { value: string; label: string }[] = [
{ value: 'Receipt', label: 'Receipt' },
{ value: 'Payment', label: 'Payment' }
];
get typOptions(): { value: string; label: string }[] {
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
}
// ── Denomination ──────────────────────────────────────────────────────────
notes: Denomination[] = [];
coins: Denomination[] = [];
@@ -943,7 +976,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.cashierService
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
.subscribe((types: any[]) => {
this.paymentTypes = types ?? [];
// addRow() ran before this resolved, so the very first row's default TXN TP. (Cash, for
// Withdrawal Slip / Receipt / Payment) couldn't be looked up yet — backfill it now that
// the payment types are actually loaded. Only ever touches a row still sitting on the
// null it was created with, so this never overwrites anything already picked on screen.
for (const row of this.rows) {
if (!row.paymentTypeId && [
'Withdrawal Slip',
'Receipt',
'Payment'
].includes(row.typ)) {
row.paymentTypeId = this.findCashPaymentTypeId();
}
}
});
this.acNoInput$
.pipe(
@@ -1234,8 +1282,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
addRow(): void {
const active = this.selectedRow;
// Disbursement/Bank Transfer: auto-add mirror CR row when active row is DR
if ((active?.typ === 'Disbursement' || active?.typ === 'Enter Bank Transfer') && active.dbCr === 'Dr') {
// Disbursement/Fund Transfer/Bank Transfer: auto-add mirror CR row when active row is DR
if (
(active?.typ === 'Disbursement' || active?.typ === 'Fund Transfer' || active?.typ === 'Enter Bank Transfer') &&
active.dbCr === 'Dr'
) {
const mirror: TxnRow = {
typ: active.typ,
br: active.br ?? 1,
@@ -1261,20 +1312,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
return;
}
// Neither TYPE nor TXN TP. ever offers a blank/placeholder option any more, so a fresh row
// can't start in an unselected state either — it needs a real, valid TYPE from the moment it
// exists. Cashier: Receipt (money in) is the far more common starting point than Payment
// (money out, which needs an approved token first). Clerk: Withdrawal Slip, the plainest of
// the five original types — same TYPE the old blank-then-choose flow most often ended on
// anyway, just without the extra click to get there.
const row: TxnRow = {
typ: '~',
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
br: 1,
trTp: '',
acTyp: 'SD',
acNo: '',
ecsAcNo: '',
dbCr: '',
dbCr: this.isClerk ? 'Dr' : 'Cr',
chequeNo: '',
cheqDate: '',
amount: null,
description: '',
tokNo: '',
paymentTypeId: null,
paymentTypeId: this.findCashPaymentTypeId(),
bankName: '',
routingCode: '',
ddCharges: null
@@ -1296,6 +1353,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.paymentTypeId = this.findCashPaymentTypeId();
break;
case 'Disbursement':
case 'Fund Transfer':
if (!row.dbCr) row.dbCr = 'Dr';
row.paymentTypeId = this.findTransferPaymentTypeId();
break;
@@ -1303,6 +1361,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (!row.dbCr) row.dbCr = 'Dr';
row.paymentTypeId = this.findTransferPaymentTypeId();
break;
// Cashier-only: Receipt = cash coming in (Cr), Payment = cash going out (Dr). The cashier
// TXN TP. dropdown only ever offers Cash (see paymentTypesForRow()), so that's the only
// sensible default here.
case 'Receipt':
row.dbCr = 'Cr';
row.paymentTypeId = this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
break;
case 'Payment':
row.dbCr = 'Dr';
row.paymentTypeId = this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
break;
}
}
@@ -1358,6 +1431,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private submitDisbursementDoubleEntry(): void {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
// Captured now, not re-read from this.rows inside the async success callback below — the rows
// array gets reset to a fresh blank row as soon as that callback runs.
const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer';
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
@@ -1450,7 +1526,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
paymentTypeId: firstRow?.paymentTypeId,
paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null,
description: firstRow?.description || null,
creditEntries: JSON.stringify(entries)
creditEntries: JSON.stringify(entries),
// Without this the backend defaults every request from this screen to DISBURSEMENT — a
// Collection or Fund Transfer submitted here was being saved (and shown in the checker's
// inbox) as a plain Disbursement.
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT'
};
this.saving = true;
@@ -1470,7 +1550,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.subscribe((res: any) => {
this.saving = false;
if (res) {
const modeLabel = isCollectionMode ? 'Collection' : 'Disbursement';
const modeLabel = isCollectionMode ? 'Collection' : isFundTransfer ? 'Fund Transfer' : 'Disbursement';
const summaryLine = isCollectionMode
? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}`
: `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`;
@@ -2953,8 +3033,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete)
if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) {
// Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete).
// Fund Transfer rides the exact same double-entry flow as Disbursement (see the TYPE options
// comment above) — only the label differs.
if (
this.isClerk &&
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -2996,7 +3082,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const title = isCollectionMode ? 'Confirm Collection Request' : 'Confirm Disbursement Request';
const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer';
const title = isCollectionMode
? 'Confirm Collection Request'
: isFundTransfer
? 'Confirm Fund Transfer Request'
: 'Confirm Disbursement Request';
const confirmLines = isCollectionMode ? [
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`,
...drRows.map(
@@ -0,0 +1,134 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · VAULT POSITION</div>
<h1 class="pg-title">Vault Position</h1>
<p class="pg-sub">
Denomination-wise stock a vault currently holds — the branch's own opening breakdown, plus whatever has been
settled back into it, minus whatever it has handed out to cashiers since.
</p>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Vault</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Vault (optional)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="selectedTellerId = $event.value">
<mat-option [value]="null">All Vaults</mat-option>
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="asOfDate = $any($event.target).value" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>bar_chart</mat-icon> Load Position
</button>
</div>
@if (errorMsg) {
<div class="pg-empty" style="color: #dc2626">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@for (pos of results; track pos.tellerId) {
<div class="pg-card" style="margin-bottom: 14px">
<div class="pos-header">
<span class="pos-cashier">{{ pos.tellerName || labelForTeller(pos.tellerId) }}</span>
<span class="pos-total">₹{{ pos.totalAmount | number: '1.2-2' }}</span>
</div>
@if (!pos.hasBreakdown) {
<div class="pg-empty">
No denomination breakdown was recorded for this vault's opening day on or before {{ pos.asOfDate }}. Open (or
re-open) the business day with a note/coin count to start tracking its stock.
</div>
} @else {
@if (pos.anchorDate && pos.anchorDate !== pos.asOfDate) {
<div class="pg-empty" style="padding: 8px 14px; text-align: left">
Carried forward from the opening breakdown recorded on {{ pos.anchorDate }}.
</div>
}
@if (pos.denominations.length === 0) {
<div class="pg-empty">No stock — everything opened for this vault has been handed out.</div>
}
@if (notes(pos).length > 0) {
<div class="pos-section-label">Notes</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Current Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of notes(pos); track d.denominationId) {
<tr>
<td>
<span class="pg-chip">₹{{ d.denominationValue }}</span>
</td>
<td style="text-align: center; font-weight: 700">{{ d.count }}</td>
<td class="num">₹{{ d.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
@if (coins(pos).length > 0) {
<div class="pos-section-label pos-section-label--coin">Coins</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Current Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of coins(pos); track d.denominationId) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">₹{{ d.denominationValue }}</span>
</td>
<td style="text-align: center; font-weight: 700">{{ d.count }}</td>
<td class="num">₹{{ d.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
}
</div>
}
@if (!loading && results.length === 0 && !errorMsg) {
<div class="pg-empty">Select filters and click "Load Position".</div>
}
</div>
@@ -0,0 +1,115 @@
/**
* 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 { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
interface DenomRow {
denominationId: number;
denominationValue: number;
kind: string;
count: number;
amount: number;
}
interface VaultPos {
tellerId: number;
tellerName?: string;
asOfDate: string;
anchorDate?: string;
hasBreakdown: boolean;
totalAmount: number;
denominations: DenomRow[];
}
/**
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the bank
* have", the same question cashier-position answers for a cashier's own float.
*/
@Component({
selector: 'mifosx-vault-position',
templateUrl: './vault-position.component.html',
styleUrls: [
'../cashier-shared.scss',
'../cashier-position/cashier-position.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class VaultPositionComponent implements OnInit {
private service = inject(CashierService);
tellers: { id: number; name: string }[] = [];
selectedTellerId: number | null = null;
asOfDate = '';
loading = false;
results: VaultPos[] = [];
errorMsg = '';
ngOnInit(): void {
const d = new Date();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.selectedTellerId = this.tellers[0].id;
}
this.load();
});
}
load(): void {
this.loading = true;
this.results = [];
this.errorMsg = '';
this.service
.vaultPosition(this.selectedTellerId ?? undefined, this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
this.loading = false;
if (!data) {
this.errorMsg = 'Failed to load vault position.';
return;
}
const arr: any[] = Array.isArray(data) ? data : [data];
this.results = arr;
});
}
labelForTeller(id: number): string {
const t = this.tellers.find((x) => x.id === id);
return t ? t.name : `Vault #${id}`;
}
notes(pos: VaultPos): DenomRow[] {
return pos.denominations.filter((d) => d.kind !== 'coin');
}
coins(pos: VaultPos): DenomRow[] {
return pos.denominations.filter((d) => d.kind === 'coin');
}
}