diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html
index fad8bf35e..9b97f565d 100644
--- a/src/app/cashier/branch-day/branch-day.component.html
+++ b/src/app/cashier/branch-day/branch-day.component.html
@@ -125,13 +125,102 @@
Opening cash (leave blank to carry forward)
-
+ 0"
+ placeholder="Carried from last close"
+ />
- 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.
+
+
+ @if (openDenomTotal > 0) {
+
+ Denomination total: ₹{{ openDenomTotal | number: '1.2-2' }}
+
+ }
+
Open Day
@@ -174,29 +263,133 @@
+ Cash deposits taken (approved)
₹{{ preview.deposits | number: '1.2-2' }}
-
- + Cash drawn from vault
- ₹{{ preview.vaultIn | number: '1.2-2' }}
-
− Cash paid out (approved withdrawals)
₹{{ preview.withdrawals | number: '1.2-2' }}
-
- − Cash settled back to vault
- ₹{{ preview.settledOut | number: '1.2-2' }}
-
Cash the branch should be holding
₹{{ preview.expectedCash | number: '1.2-2' }}
+ @if (preview.vaultIn > 0 || preview.settledOut > 0) {
+
+ 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.
+
+ }
+
+ }
+
+
+ @if (eodLoading) {
+ Fetching vault and cashier positions…
+ }
+
+
+ @if (branchTotalSummary; as bt) {
+
+
+ account_balance_wallet
+ Branch Total (Vault + All Cashiers)
+
+
+ Vault ₹{{ bt.vaultTotal | number: '1.2-2' }}
+ + Cashiers ₹{{ bt.cashierTotal | number: '1.2-2' }}
+ = ₹{{ bt.grandTotal | number: '1.2-2' }}
+ vs. book ₹{{ bt.expected | number: '1.2-2' }}
+
+ @if (bt.difference === 0) {
+
Branch Tallied
+ } @else {
+
+ {{ bt.difference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(bt.difference) | number: '1.2-2' }}
+
+ }
+
+ }
+
+ @for (t of eodTellers; track t.tellerId) {
+
+
+ account_balance
+ {{ t.tellerName }}
+
+ vault holds
+ {{ t.vault?.hasBreakdown ? '₹' + (t.vault.totalAmount | number: '1.2-2') : 'no breakdown recorded' }}
+
+
+
+ @if (t.vault?.hasBreakdown && t.vault.denominations.length > 0) {
+
+ @for (d of t.vault.denominations; track d.denominationId) {
+ ₹{{ d.denominationValue }} × {{ d.count }}
+ }
+
+ }
+
+ @if (t.cashiers.length === 0) {
+
No cashiers allocated under this vault.
+ }
+ @for (c of t.cashiers; track c.cashierId) {
+
+
+ {{ c.cashierName }}
+
+ morning ₹{{ c.morningTotal | number: '1.2-2' }} → current ₹{{ c.currentTotal | number: '1.2-2' }} ·
+ {{ c.txnCount ?? '—' }} txn{{ c.txnCount === 1 ? '' : 's' }} today
+
+
+
+ @if (c.reconciled === true) {
+ Reconciled
+ } @else if (c.reconciled === false) {
+ Not Reconciled
+ }
+ @if (c.alreadySettled === true) {
+ Settled to Vault
+ } @else if (c.alreadySettled === false) {
+ Not Settled
+ }
+
+ @if (c.denominations.length > 0) {
+
+ @for (d of c.denominations; track d.denominationId) {
+ 0"
+ [class.bd-eod__chip--down]="d.delta < 0"
+ >
+ ₹{{ d.denominationValue }}: {{ d.morningCount }} → {{ d.count }}
+ @if (d.delta !== 0) {
+ ({{ d.delta > 0 ? '+' : '' }}{{ d.delta }})
+ }
+
+ }
+
+ } @else {
+
No balance.
+ }
+
+ }
}
- Physical cash counted
-
+ Physical cash counted (or count denominations below)
+ 0"
+ placeholder="0.00"
+ />
@if (preview && liveDifference !== null) {
@if (liveDifference === 0) {
@@ -222,9 +415,91 @@
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.
+
+ @if (closeDenomTotal > 0) {
+
+ Denomination total: ₹{{ closeDenomTotal | number: '1.2-2' }}
+
+ }
+
@if (isLateClose) {
Why was this day left open? (required)
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss
index 4f7dae34e..334ee94ba 100644
--- a/src/app/cashier/branch-day/branch-day.component.scss
+++ b/src/app/cashier/branch-day/branch-day.component.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts
index 83b8d0dc0..f6eb1cc20 100644
--- a/src/app/cashier/branch-day/branch-day.component.ts
+++ b/src/app/cashier/branch-day/branch-day.component.ts
@@ -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 = {};
+ closeCounts: Record = {};
+
+ 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, d: Denom): number {
+ return counts[d.id] || 0;
+ }
+
+ subtotalFor(counts: Record, d: Denom): number {
+ return this.countFor(counts, d) * d.value;
+ }
+
+ onCountChange(counts: Record, d: Denom, raw: any): void {
+ counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
+ }
+
+ bumpCount(counts: Record, d: Denom, delta: number): void {
+ counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta);
+ }
+
+ totalOf(counts: Record): 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) {
+ 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();
+ (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 '—';
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.html b/src/app/cashier/cash-transfer/cash-transfer.component.html
index 82cb5f963..4c09120a9 100644
--- a/src/app/cashier/cash-transfer/cash-transfer.component.html
+++ b/src/app/cashier/cash-transfer/cash-transfer.component.html
@@ -50,7 +50,7 @@
Date
-
+
@@ -59,11 +59,28 @@
+
+ @if (fromTellerId && !loadingVault) {
+ @if (vaultHasBreakdown) {
+
+ account_balance
+ Vault available: ₹{{ vaultTotalAvailable | number: '1.2-2' }}
+
+ } @else {
+
+ warning
+ No denomination breakdown recorded for this vault — per-note availability can't be checked, only the total
+ opening balance.
+
+ }
+ }
+
Denomination
+ Available
Count
Amount
@@ -71,14 +88,17 @@
@if (notes.length) {
- Bank Notes
+ Bank Notes
}
@for (d of notes; track d.id) {
-
+
{{ d.label }}
+
+ {{ vaultHasBreakdown ? availableFor(d) : '—' }}
+
−
@@ -91,14 +111,17 @@
}
@if (coins.length) {
- Coins
+ Coins
}
@for (d of coins; track d.id) {
-
+
{{ d.label }}
+
+ {{ vaultHasBreakdown ? availableFor(d) : '—' }}
+
−
@@ -111,13 +134,24 @@
}
@if (denoms.length === 0) {
- No active denominations. Add some in Cash Denomination.
+ No active denominations. Add some in Cash Denomination.
}
+ @if (anyDenomExceedsAvailable) {
+
+ One or more notes/coins above are counted higher than the vault currently holds — reduce them before recording the
+ transfer.
+
+ } @else if (exceedsVaultTotal) {
+
+ Total exceeds what the vault has available (₹{{ vaultTotalAvailable | number: '1.2-2' }}).
+
+ }
+
Total to transfer:
₹{{ total | number: '1.2-2' }}
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.scss b/src/app/cashier/cash-transfer/cash-transfer.component.scss
new file mode 100644
index 000000000..78dfe9ead
--- /dev/null
+++ b/src/app/cashier/cash-transfer/cash-transfer.component.scss
@@ -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;
+}
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts
index 84f6ad450..fa5de03f9 100644
--- a/src/app/cashier/cash-transfer/cash-transfer.component.ts
+++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts
@@ -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 = {};
+ 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 = {};
+ ((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 {
diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts
index 370356f54..5097f9403 100644
--- a/src/app/cashier/cashier-routing.module.ts
+++ b/src/app/cashier/cashier-routing.module.ts
@@ -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' }
}
]
},
diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts
index ebaab0617..7725e5e36 100644
--- a/src/app/cashier/cashier.service.ts
+++ b/src/app/cashier/cashier.service.ts
@@ -37,8 +37,10 @@ export class CashierService {
}
// ── Tellers / Cashiers ──
- getTellers(): Observable {
- return this.http.get('/tellers');
+ getTellers(officeId?: number): Observable {
+ let params = new HttpParams();
+ if (officeId) params = params.set('officeId', String(officeId));
+ return this.http.get('/tellers', { params });
}
getAllCashiers(): Observable {
return this.http.get('/cashiers');
@@ -227,11 +229,17 @@ export class CashierService {
return this.http.get('/branchday/close-preview', { params });
}
- openBranchDay(businessDate: string, openingCash?: number, officeId?: number): Observable {
+ openBranchDay(
+ businessDate: string,
+ openingCash?: number,
+ officeId?: number,
+ denominations?: { denominationId: number; denominationValue: number; count: number }[]
+ ): Observable {
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 {
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 {
+ 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 {
+ 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 {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html
index 12498d5b3..76de4e137 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.html
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.html
@@ -120,12 +120,9 @@
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
- ~
- Withdrawal Slip
- Cheque
- Disbursement
- Enter Bank Transfer
- DD (Demand Draft)
+ @for (t of typOptions; track t.value) {
+ {{ t.label }}
+ }
@@ -145,7 +142,6 @@
(click)="$event.stopPropagation()"
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
- — Select —
@for (pt of paymentTypesForRow(row); track pt.id) {
{{ pt.name }}
}
@@ -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()"
/>
- } @else if (row.typ !== 'Withdrawal Slip') {
+ } @else if (isClerk && row.typ !== 'Withdrawal Slip') {
CHEQUE NO.
TYPE
- ~
- Withdrawal Slip
- Cheque
- Disbursement
- Enter Bank Transfer
- DD (Demand Draft)
+ @for (t of typOptions; track t.value) {
+ {{ t.label }}
+ }
@@ -1351,7 +1348,6 @@
[(ngModel)]="row.paymentTypeId"
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
- — Select —
@for (pt of paymentTypesForRow(row); track pt.id) {
{{ pt.name }}
}
@@ -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 @@
MEETING DATE
- } @else {
+ } @else if (isClerk) {
CHEQUE NO.
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index d9ae66adb..e1bfb4d24 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -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(
diff --git a/src/app/cashier/vault-position/vault-position.component.html b/src/app/cashier/vault-position/vault-position.component.html
new file mode 100644
index 000000000..4d2337845
--- /dev/null
+++ b/src/app/cashier/vault-position/vault-position.component.html
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+ @if (tellers.length === 1) {
+
+ Vault
+
+
+ } @else {
+
+ Vault (optional)
+
+ All Vaults
+ @for (t of tellers; track t.id) {
+ {{ t.name }}
+ }
+
+
+ }
+
+
+ As of Date
+
+
+
+
+ bar_chart Load Position
+
+
+
+ @if (errorMsg) {
+
{{ errorMsg }}
+ }
+
+ @if (loading) {
+
Loading…
+ }
+
+ @for (pos of results; track pos.tellerId) {
+
+
+
+ @if (!pos.hasBreakdown) {
+
+ 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.
+
+ } @else {
+ @if (pos.anchorDate && pos.anchorDate !== pos.asOfDate) {
+
+ Carried forward from the opening breakdown recorded on {{ pos.anchorDate }}.
+
+ }
+
+ @if (pos.denominations.length === 0) {
+
No stock — everything opened for this vault has been handed out.
+ }
+
+ @if (notes(pos).length > 0) {
+
Notes
+
+
+
+ Denomination
+ Current Count
+ Amount
+
+
+
+ @for (d of notes(pos); track d.denominationId) {
+
+
+ ₹{{ d.denominationValue }}
+
+ {{ d.count }}
+ ₹{{ d.amount | number: '1.2-2' }}
+
+ }
+
+
+ }
+
+ @if (coins(pos).length > 0) {
+
Coins
+
+
+
+ Denomination
+ Current Count
+ Amount
+
+
+
+ @for (d of coins(pos); track d.denominationId) {
+
+
+ ₹{{ d.denominationValue }}
+
+ {{ d.count }}
+ ₹{{ d.amount | number: '1.2-2' }}
+
+ }
+
+
+ }
+ }
+
+ }
+
+ @if (!loading && results.length === 0 && !errorMsg) {
+
Select filters and click "Load Position".
+ }
+
diff --git a/src/app/cashier/vault-position/vault-position.component.ts b/src/app/cashier/vault-position/vault-position.component.ts
new file mode 100644
index 000000000..d91cbdb2d
--- /dev/null
+++ b/src/app/cashier/vault-position/vault-position.component.ts
@@ -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');
+ }
+}
diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.html b/src/app/clerk/disbursement-review/disbursement-review.component.html
index f9aa574ce..7ad308fd8 100644
--- a/src/app/clerk/disbursement-review/disbursement-review.component.html
+++ b/src/app/clerk/disbursement-review/disbursement-review.component.html
@@ -6,16 +6,21 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
+
+
-
-
-
-
- {{ isBankTransferMode ? 'Bank Transfer Review' : isCollectionMode ? 'Collection Review' : 'Disbursement Review' }}
-
- ← Back to Inbox
+
+ {{ isDialog ? '✕ Close' : '← Back to Inbox' }}
diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.scss b/src/app/clerk/disbursement-review/disbursement-review.component.scss
index c3a42906e..a95e0293a 100644
--- a/src/app/clerk/disbursement-review/disbursement-review.component.scss
+++ b/src/app/clerk/disbursement-review/disbursement-review.component.scss
@@ -36,6 +36,16 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
flex-direction: column;
min-height: 100vh;
background: $bg-page;
+
+ /* Opened as a popup rather than a full page — sizes to its own content (capped so a long
+ entries table still scrolls inside the dialog) instead of stretching to the viewport. */
+ &--dialog {
+ min-height: 0;
+ max-height: 85vh;
+ overflow-y: auto;
+ width: min(760px, 92vw);
+ border-radius: 10px;
+ }
}
// ── Nav bar ────────────────────────────────────────────────────────────────
@@ -48,29 +58,6 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
border-bottom: 1px solid $border;
box-shadow: $shadow-sm;
- &__item {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- padding: 7px 16px;
- border-radius: 8px;
- font-size: 13px;
- font-weight: 600;
- border: none;
- cursor: pointer;
- transition: all 0.15s;
-
- &--primary {
- background: $brand-light;
- color: $brand;
- }
-
- &--active {
- background: $brand;
- color: #fff;
- }
- }
-
&__esc-hint {
margin-left: auto;
font-size: 12px;
@@ -88,6 +75,12 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
}
}
+.dri-header-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: $text-mid;
+}
+
// ── Loading / empty ────────────────────────────────────────────────────────
.dri-loading {
display: flex;
diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.ts b/src/app/clerk/disbursement-review/disbursement-review.component.ts
index 82d37434e..82d96762f 100644
--- a/src/app/clerk/disbursement-review/disbursement-review.component.ts
+++ b/src/app/clerk/disbursement-review/disbursement-review.component.ts
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatIconModule } from '@angular/material/icon';
+import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
@@ -33,7 +34,8 @@ interface CreditEntry {
CommonModule,
FormsModule,
MatSnackBarModule,
- MatIconModule
+ MatIconModule,
+ MatDialogModule
],
templateUrl: './disbursement-review.component.html',
styleUrl: './disbursement-review.component.scss'
@@ -43,6 +45,11 @@ export class DisbursementReviewComponent implements OnInit {
private router = inject(Router);
private service = inject(ClerkDisbursementService);
private snackBar = inject(MatSnackBar);
+ /** Present only when opened as a popup from the requests list; absent when reached by direct
+ * route (e.g. a bookmarked/shared link). Both entry points stay supported — this only changes
+ * how "done" is signalled: closing the dialog vs. navigating back to the inbox route. */
+ private dialogRef = inject(MatDialogRef
, { optional: true });
+ private dialogData = inject<{ requestId: number } | null>(MAT_DIALOG_DATA, { optional: true });
requestId: number | null = null;
request: any = null;
@@ -53,9 +60,17 @@ export class DisbursementReviewComponent implements OnInit {
showRejectForm = false;
rejectionReason = '';
+ get isDialog(): boolean {
+ return !!this.dialogRef;
+ }
+
ngOnInit(): void {
- const id = this.route.snapshot.params['id'];
- this.requestId = id != null ? Number(id) : null;
+ if (this.dialogData?.requestId) {
+ this.requestId = this.dialogData.requestId;
+ } else {
+ const id = this.route.snapshot.params['id'];
+ this.requestId = id != null ? Number(id) : null;
+ }
if (this.requestId) this.loadRequest();
}
@@ -93,9 +108,11 @@ export class DisbursementReviewComponent implements OnInit {
} else {
const msg = this.isBankTransferMode
? 'Bank transfer approved successfully.'
- : 'Disbursement approved successfully.';
+ : this.isFundTransferMode
+ ? 'Fund transfer approved successfully.'
+ : 'Disbursement approved successfully.';
this.snackBar.open(msg, 'Close', { duration: 3000 });
- this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ this.finish();
}
});
}
@@ -112,15 +129,31 @@ export class DisbursementReviewComponent implements OnInit {
this.snackBar.open(res.error, 'Close', { duration: 5000 });
} else {
this.snackBar.open('Request rejected.', 'Close', { duration: 3000 });
- this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ this.finish();
}
});
}
+ /** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a
+ * dialog, or returns to the inbox route otherwise. */
+ private finish(): void {
+ if (this.dialogRef) {
+ this.dialogRef.close({ refreshed: true });
+ } else {
+ this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ }
+ }
+
get isBankTransferMode(): boolean {
return this.request?.request_type === 'BANK_TRANSFER';
}
+ /** Mechanically identical to a Disbursement (same 1 GL debit -> N account credits flow) — only
+ * the label differs, so a checker doesn't read this as a loan payout. */
+ get isFundTransferMode(): boolean {
+ return this.request?.request_type === 'FUND_TRANSFER';
+ }
+
get isCollectionMode(): boolean {
if (this.isBankTransferMode) return false;
return this.creditEntries.some((e) => e.dbCr === 'Dr');
@@ -189,6 +222,10 @@ export class DisbursementReviewComponent implements OnInit {
}
backToInbox(): void {
- this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ if (this.dialogRef) {
+ this.dialogRef.close();
+ } else {
+ this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ }
}
}
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index d124305c7..95f7c3741 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -700,7 +700,7 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts
index a5f84ca38..f75312831 100644
--- a/src/app/organization/organization.service.ts
+++ b/src/app/organization/organization.service.ts
@@ -888,4 +888,51 @@ export class OrganizationService {
deleteLoanOriginator(originatorId: number): Observable {
return this.http.delete(`/loan-originators/${originatorId}`);
}
+
+ // ── Institution Profile ──
+
+ /** @returns {Observable} The bank's own profile (name, legal name, code, contact...). */
+ getInstitutionProfile(): Observable {
+ return this.http.get('/institutionprofile');
+ }
+
+ /** @returns {Observable} */
+ updateInstitutionProfile(payload: any): Observable {
+ return this.http.put('/institutionprofile', payload);
+ }
+
+ // ── Branch Profile ──
+
+ /** @returns {Observable} Every office joined with its branch profile fields, if any. */
+ getBranchProfiles(): Observable {
+ return this.http.get('/branchprofile');
+ }
+
+ /** @returns {Observable} One office's branch profile, joined with its office fields. */
+ getBranchProfile(officeId: number): Observable {
+ const params = new HttpParams().set('officeId', String(officeId));
+ return this.http.get('/branchprofile', { params });
+ }
+
+ /** @returns {Observable} */
+ updateBranchProfile(officeId: number, payload: any): Observable {
+ return this.http.put(`/branchprofile/${officeId}`, payload);
+ }
+
+ // ── Clearing GL Configuration ──
+
+ /** @returns {Observable} Every clearing role (CTS/NACH/RTGS...) and its mapped GL account, if any. */
+ getClearingGlConfig(): Observable {
+ return this.http.get('/clearingglconfig');
+ }
+
+ /** @returns {Observable} Selectable GL accounts for mapping a clearing role. */
+ getClearingGlConfigTemplate(): Observable {
+ return this.http.get('/clearingglconfig/template');
+ }
+
+ /** @returns {Observable} */
+ updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable {
+ return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId });
+ }
}
diff --git a/src/app/shared/footer/footer.component.ts b/src/app/shared/footer/footer.component.ts
index c895dc100..a6cd40fa4 100644
--- a/src/app/shared/footer/footer.component.ts
+++ b/src/app/shared/footer/footer.component.ts
@@ -182,12 +182,16 @@ export class FooterComponent implements OnInit, OnDestroy {
* Get the Configuration for Business Date
*/
getConfigurations(): void {
- if (this.authenticationService.isAuthenticated()) {
- // Reading global configuration needs the READ_CONFIGURATION permission, which teller-style
- // roles (cashier, clerk) do not carry. This runs from the footer on every authenticated
- // page, so an unpermitted role was greeted by an "Unauthorized Request" modal at login for
- // a check it never asked for. The footer only uses this to decide whether to show a
- // business date, so a denied read is not an error here — it degrades to the system date.
+ // Reading global configuration needs the READ_CONFIGURATION permission, which teller-style
+ // roles (cashier, clerk) never carry by design — the read would always come back 403. The
+ // footer only uses this to decide whether to show a business date, so a role that can never
+ // read it just never asks, instead of round-tripping a request every page load (and every
+ // 60s poll while a day is open) purely to have it rejected.
+ if (
+ this.authenticationService.isAuthenticated() &&
+ !this.authenticationService.isCashierUser() &&
+ !this.authenticationService.isClerkUser()
+ ) {
this.systemService.getConfigurationByName(SettingsService.businessDateConfigName, true).subscribe({
next: (configurationData: any) => {
this.isBusinessDateEnabled = configurationData.enabled;
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html
index 34feac5a2..ffc799586 100644
--- a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html
+++ b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html
@@ -124,23 +124,8 @@
{{ r.voucher_no || '—' }}
-
- {{
- r.request_type === 'BANK_TRANSFER'
- ? 'Bank Transfer'
- : r.request_type === 'COLLECTION'
- ? 'Collection'
- : 'Disbursement'
- }}
+
+ {{ typeLabel(r) }}
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts
index e9f382ffe..335c88610 100644
--- a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts
+++ b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts
@@ -9,10 +9,11 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { Router, RouterModule } from '@angular/router';
+import { RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { MatDialog } from '@angular/material/dialog';
import { MatNativeDateModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@@ -22,6 +23,7 @@ import { of } from 'rxjs';
import { ClerkDisbursementService } from '../../../cashier/clerk-disbursement.service';
import { SettingsService } from '../../../settings/settings.service';
+import { DisbursementReviewComponent } from '../../../clerk/disbursement-review/disbursement-review.component';
@Component({
selector: 'mifosx-disbursement-requests',
@@ -43,9 +45,9 @@ import { SettingsService } from '../../../settings/settings.service';
})
export class DisbursementRequestsComponent implements OnInit {
private service = inject(ClerkDisbursementService);
- private router = inject(Router);
private snackBar = inject(MatSnackBar);
private settingsService = inject(SettingsService);
+ private dialog = inject(MatDialog);
requests: any[] = [];
totalRecords = 0;
@@ -164,11 +166,13 @@ export class DisbursementRequestsComponent implements OnInit {
}
review(req: any): void {
- this.router.navigate([
- '/checker-inbox-and-tasks/disbursement-requests',
- req.id,
- 'review'
- ]);
+ this.dialog
+ .open(DisbursementReviewComponent, {
+ data: { requestId: req.id },
+ autoFocus: false
+ })
+ .afterClosed()
+ .subscribe(() => this.load());
}
creditCount(req: any): number {
@@ -180,6 +184,24 @@ export class DisbursementRequestsComponent implements OnInit {
}
}
+ typeLabel(req: any): string {
+ const map: Record = {
+ BANK_TRANSFER: 'Bank Transfer',
+ COLLECTION: 'Collection',
+ FUND_TRANSFER: 'Fund Transfer'
+ };
+ return map[req.request_type] ?? 'Disbursement';
+ }
+
+ typeColor(req: any): string {
+ const map: Record = {
+ BANK_TRANSFER: '#e3f2fd',
+ COLLECTION: '#fce4ec',
+ FUND_TRANSFER: '#ede7f6'
+ };
+ return map[req.request_type] ?? '#e8f5e9';
+ }
+
statusClass(status: string): string {
const map: Record = {
PENDING: 'wri-status--pending',
diff --git a/src/environments/.env.ts b/src/environments/.env.ts
index ea6d58ac9..f0cbf38b0 100644
--- a/src/environments/.env.ts
+++ b/src/environments/.env.ts
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
- 'version': '260827',
- 'hash': '79ffde51'
+ 'version': '260828',
+ 'hash': 'ce840d73'
},
'allow_switching_backend_instance': true
};