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');
}
}
@@ -6,16 +6,21 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="dri">
<!-- ── NAV BAR ── -->
<div class="dri" [class.dri--dialog]="isDialog">
<!-- ── HEADER ── -->
<nav class="cashier-nav cashier-nav--clerk">
<button type="button" class="cashier-nav__item cashier-nav__item--primary cashier-nav__item--active">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M2 12h20" />
</svg>
{{ isBankTransferMode ? 'Bank Transfer Review' : isCollectionMode ? 'Collection Review' : 'Disbursement Review' }}
</button>
<span class="cashier-nav__esc-hint" (click)="backToInbox()">← Back to Inbox</span>
<span class="dri-header-title">
{{
isBankTransferMode
? 'Bank Transfer Review'
: isCollectionMode
? 'Collection Review'
: isFundTransferMode
? 'Fund Transfer Review'
: 'Disbursement Review'
}}
</span>
<span class="cashier-nav__esc-hint" (click)="backToInbox()">{{ isDialog ? '✕ Close' : '← Back to Inbox' }}</span>
</nav>
<!-- ── LOADING ── -->
@@ -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;
@@ -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<DisbursementReviewComponent>, { 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']);
}
}
}
+4 -1
View File
@@ -700,7 +700,7 @@
<!-- Audit & Compliance -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Audit &amp; Compliance</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/organization/institution-profile']">
<fa-icon icon="building" class="menu-item-icon" style="color: #7c3aed"></fa-icon>
Bank Profile
</button>
@@ -1032,6 +1032,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
</mat-menu>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
@@ -0,0 +1,163 @@
<!--
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="bp-container">
<div class="bp-header">
<button mat-stroked-button [routerLink]="['/organization']" class="bp-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="bp-header__title">Branch Profile</h2>
</div>
@if (errorMsg) {
<div class="bp-alert bp-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="bp-alert bp-alert--success">{{ successMsg }}</div>
}
<div class="bp-card">
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-code-branch"></i>
<h3>Select Branch</h3>
</div>
<div class="bp-body">
@if (loadingOffices) {
<span class="bp-muted">Loading branches…</span>
} @else {
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Branch</mat-label>
<mat-select [value]="selectedOfficeId" (selectionChange)="selectOffice($event.value)">
@for (o of offices; track o.officeId) {
<mat-option [value]="o.officeId">{{ officeLabel(o) }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
@if (loadingProfile) {
<div class="bp-loading">Loading…</div>
}
@if (selectedOfficeId && !loadingProfile) {
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-id-card"></i>
<h3>Branch Details</h3>
</div>
<div class="bp-body">
<mat-form-field appearance="outline" class="bp-field bp-field--half">
<mat-label>Branch Code</mat-label>
<input matInput formControlName="branchCode" placeholder="e.g. BR001" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field bp-field--half">
<mat-label>Branch Type</mat-label>
<mat-select formControlName="branchType">
@for (t of branchTypes; track t.value) {
<mat-option [value]="t.value">{{ t.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Address</mat-label>
<textarea matInput formControlName="address" rows="2"></textarea>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field bp-field--half">
<mat-label>Contact Phone</mat-label>
<input matInput formControlName="contactPhone" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field bp-field--half">
<mat-label>Contact Email</mat-label>
<input matInput type="email" formControlName="contactEmail" />
</mat-form-field>
</div>
</div>
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-landmark"></i>
<h3>Branch Identifiers</h3>
</div>
<div class="bp-body">
<mat-form-field appearance="outline" class="bp-field bp-field--half">
<mat-label>IFSC Code</mat-label>
<input matInput formControlName="ifscCode" placeholder="e.g. SUCB0000123" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field bp-field--half">
<mat-label>MICR Code</mat-label>
<input matInput formControlName="micrCode" />
</mat-form-field>
</div>
<p class="bp-note">
SWIFT/BIC is set once for the whole bank, not per branch — see
<a [routerLink]="['/organization/institution-profile']">Bank Profile</a>.
</p>
</div>
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-map-marked-alt"></i>
<h3>Location (optional)</h3>
</div>
<div class="bp-body">
<mat-form-field appearance="outline" class="bp-field bp-field--half">
<mat-label>Latitude</mat-label>
<input matInput type="number" step="0.000001" formControlName="latitude" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field bp-field--half">
<mat-label>Longitude</mat-label>
<input matInput type="number" step="0.000001" formControlName="longitude" />
</mat-form-field>
</div>
</div>
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-clock"></i>
<h3>Working Hours</h3>
</div>
<div class="bp-body">
<mat-form-field appearance="outline" class="bp-field bp-field--third">
<mat-label>Opens At</mat-label>
<input matInput type="time" formControlName="workingHoursStart" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field bp-field--third">
<mat-label>Closes At</mat-label>
<input matInput type="time" formControlName="workingHoursEnd" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field bp-field--third">
<mat-label>Working Days</mat-label>
<input matInput formControlName="workingDays" placeholder="e.g. Mon-Sat" />
</mat-form-field>
</div>
</div>
@if (lastUpdated) {
<div class="bp-lastupdated">Last updated by {{ lastUpdated.by }} · {{ lastUpdated.at | dateFormat }}</div>
}
<div class="bp-actions">
<button
type="submit"
mat-flat-button
color="primary"
[disabled]="form.pristine || saving"
*mifosxHasPermission="'UPDATE_BRANCHPROFILE'"
class="bp-actions__submit"
>
<i class="fas fa-check"></i> Save
</button>
</div>
</form>
}
</div>
</div>
@@ -0,0 +1,175 @@
/**
* 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/.
*/
$primary: #1353a0;
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-900: #202124;
.bp-container {
max-width: 760px;
margin: 0 auto;
padding: 20px 16px;
}
.bp-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
&__title {
font-size: 18px;
font-weight: 600;
color: $gray-900;
margin: 0;
}
}
.bp-back {
border-radius: 8px !important;
font-size: 12px !important;
font-weight: 600 !important;
height: 32px !important;
border-color: $border !important;
color: $gray-500 !important;
i {
margin-right: 6px;
}
&:hover {
color: $primary !important;
border-color: $primary !important;
}
}
.bp-alert {
padding: 10px 14px;
margin-bottom: 14px;
border-radius: 8px;
font-size: 13px;
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
&--success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #14532d;
}
}
.bp-muted {
color: $gray-500;
font-size: 13px;
}
.bp-loading {
padding: 24px;
text-align: center;
color: $gray-500;
}
.bp-card {
background: #fff;
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
}
.bp-section {
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
}
.bp-body {
padding: 20px 24px;
display: flex;
flex-wrap: wrap;
gap: 0 16px;
}
.bp-field {
width: 100%;
margin-bottom: 4px;
&--half {
width: calc(50% - 8px);
}
&--third {
width: calc(33.33% - 11px);
}
}
.bp-note {
margin: 0 24px 16px;
padding: 8px 12px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
font-size: 11px;
color: #1e40af;
a {
color: $primary;
font-weight: 600;
}
}
.bp-lastupdated {
padding: 0 24px 10px;
font-size: 11px;
color: $gray-500;
}
.bp-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 24px;
border-top: 1px solid $gray-200;
background: $gray-50;
&__submit {
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
height: 36px !important;
box-shadow: 0 1px 3px rgba($primary, 0.3) !important;
i {
margin-right: 6px;
font-size: 12px;
}
}
}
@@ -0,0 +1,151 @@
/**
* 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 { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { catchError, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
const BRANCH_TYPES = [
{ value: 'HEAD_OFFICE', label: 'Head Office' },
{ value: 'BRANCH', label: 'Branch' },
{ value: 'REGIONAL_OFFICE', label: 'Regional Office' },
{ value: 'ZONAL_OFFICE', label: 'Zonal Office' }
];
/**
* The real-world operational fields behind a branch — code, address, contact, type and working
* hours — kept as their own screen on top of the stock Manage Offices flow, since m_office (the
* core Fineract entity) was deliberately left untouched. Pick an office, fill in what real-world
* UCB branch setup expects, save.
*/
@Component({
selector: 'mifosx-branch-profile',
templateUrl: './branch-profile.component.html',
styleUrls: ['./branch-profile.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class BranchProfileComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private organizationService = inject(OrganizationService);
branchTypes = BRANCH_TYPES;
offices: any[] = [];
selectedOfficeId: number | null = null;
form: UntypedFormGroup;
loadingOffices = true;
loadingProfile = false;
saving = false;
errorMsg = '';
successMsg = '';
lastUpdated: any = null;
ngOnInit(): void {
this.form = this.formBuilder.group({
branchCode: [''],
branchType: [null],
address: [''],
contactPhone: [''],
contactEmail: [''],
workingHoursStart: [''],
workingHoursEnd: [''],
workingDays: [''],
ifscCode: [''],
micrCode: [''],
latitude: [null],
longitude: [null]
});
this.organizationService
.getBranchProfiles()
.pipe(
catchError((err: any) => {
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load branches.';
return of([]);
})
)
.subscribe((res: any[]) => {
this.loadingOffices = false;
this.offices = res || [];
if (this.offices.length === 1) {
this.selectOffice(this.offices[0].officeId);
}
});
}
selectOffice(officeId: number): void {
this.selectedOfficeId = officeId;
this.loadingProfile = true;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.getBranchProfile(officeId)
.pipe(
catchError((err: any) => {
this.loadingProfile = false;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load this branch.';
return of(null);
})
)
.subscribe((res: any) => {
this.loadingProfile = false;
if (!res) return;
this.form.patchValue({
branchCode: res.branchCode ?? '',
branchType: res.branchType ?? null,
address: res.address ?? '',
contactPhone: res.contactPhone ?? '',
contactEmail: res.contactEmail ?? '',
workingHoursStart: res.workingHoursStart ?? '',
workingHoursEnd: res.workingHoursEnd ?? '',
workingDays: res.workingDays ?? '',
ifscCode: res.ifscCode ?? '',
micrCode: res.micrCode ?? '',
latitude: res.latitude ?? null,
longitude: res.longitude ?? null
});
this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null;
this.form.markAsPristine();
// Reflect the just-loaded office in the list so the selector stays accurate without a reload.
const idx = this.offices.findIndex((o) => o.officeId === officeId);
if (idx >= 0) this.offices[idx] = res;
});
}
officeLabel(o: any): string {
return o.branchCode ? `${o.officeName} (${o.branchCode})` : o.officeName;
}
submit(): void {
if (this.saving || !this.selectedOfficeId) return;
this.saving = true;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.updateBranchProfile(this.selectedOfficeId, this.form.value)
.pipe(
catchError((err: any) => {
this.saving = false;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this branch.';
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (!res) return;
this.successMsg = 'Branch profile saved.';
this.form.markAsPristine();
this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null;
const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId);
if (idx >= 0) this.offices[idx] = res;
});
}
}
@@ -0,0 +1,70 @@
<!--
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="cgc-container">
<div class="cgc-header">
<button mat-stroked-button [routerLink]="['/organization']" class="cgc-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="cgc-header__title">Clearing GL Configuration</h2>
</div>
<p class="cgc-sub">
Which GL account backs each clearing role. An unmapped role posts nowhere — map it here before that clearing channel
goes live.
</p>
@if (errorMsg) {
<div class="cgc-alert cgc-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="cgc-alert cgc-alert--success">{{ successMsg }}</div>
}
@if (loading) {
<div class="cgc-loading">Loading…</div>
} @else {
<div class="cgc-card">
<table class="cgc-table">
<thead>
<tr>
<th>Clearing Role</th>
<th>Description</th>
<th>Mapped GL Account</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.configKey) {
<tr>
<td>
<span class="cgc-key">{{ row.configKey }}</span>
</td>
<td class="cgc-desc">{{ row.description }}</td>
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="cgc-select">
<mat-select
[value]="row.glAccountId"
[disabled]="savingKey === row.configKey"
(selectionChange)="onSelect(row, $event.value)"
>
<mat-option [value]="null">— Not mapped —</mat-option>
@for (o of glAccountOptions; track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (!row.glAccountId) {
<span class="cgc-unmapped">Unmapped</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,137 @@
/**
* 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/.
*/
$primary: #1353a0;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-900: #202124;
.cgc-container {
max-width: 900px;
margin: 0 auto;
padding: 20px 16px;
}
.cgc-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 6px;
&__title {
font-size: 18px;
font-weight: 600;
color: $gray-900;
margin: 0;
}
}
.cgc-sub {
margin: 0 0 16px;
font-size: 12px;
color: $gray-500;
}
.cgc-back {
border-radius: 8px !important;
font-size: 12px !important;
font-weight: 600 !important;
height: 32px !important;
border-color: $border !important;
color: $gray-500 !important;
i {
margin-right: 6px;
}
&:hover {
color: $primary !important;
border-color: $primary !important;
}
}
.cgc-alert {
padding: 10px 14px;
margin-bottom: 14px;
border-radius: 8px;
font-size: 13px;
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
&--success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #14532d;
}
}
.cgc-loading {
padding: 24px;
text-align: center;
color: $gray-500;
}
.cgc-card {
background: #fff;
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
}
.cgc-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
padding: 10px 16px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $gray-500;
}
td {
padding: 10px 16px;
border-bottom: 1px solid $gray-200;
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
}
.cgc-key {
font-family: monospace;
font-weight: 700;
color: $primary;
}
.cgc-desc {
color: #475569;
}
.cgc-select {
min-width: 260px;
}
.cgc-unmapped {
margin-left: 8px;
font-size: 11px;
font-weight: 700;
color: #b45309;
}
@@ -0,0 +1,75 @@
/**
* 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 { catchError, forkJoin, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
/**
* Which GL account backs each clearing role (CTS inward/outward suspense, NACH suspense, RTGS
* settlement, ...). The roles themselves are fixed; this only changes which GL account each one
* points at.
*/
@Component({
selector: 'mifosx-clearing-gl-config',
templateUrl: './clearing-gl-config.component.html',
styleUrls: ['./clearing-gl-config.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class ClearingGlConfigComponent implements OnInit {
private organizationService = inject(OrganizationService);
rows: any[] = [];
glAccountOptions: any[] = [];
loading = true;
savingKey: string | null = null;
errorMsg = '';
successMsg = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
forkJoin({
rows: this.organizationService.getClearingGlConfig().pipe(catchError(() => of([]))),
template: this.organizationService.getClearingGlConfigTemplate().pipe(catchError(() => of(null)))
}).subscribe(({ rows, template }: any) => {
this.loading = false;
this.rows = rows || [];
this.glAccountOptions = template?.glAccountOptions || [];
if (!rows || rows.length === 0) {
this.errorMsg = 'Could not load clearing GL configuration.';
}
});
}
onSelect(row: any, glAccountId: number | null): void {
this.savingKey = row.configKey;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.updateClearingGlConfig(row.configKey, glAccountId)
.pipe(
catchError((err: any) => {
this.savingKey = null;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.';
return of(null);
})
)
.subscribe((res: any[]) => {
this.savingKey = null;
if (!res) return;
this.rows = res;
this.successMsg = `${row.configKey} updated.`;
});
}
}
@@ -0,0 +1,392 @@
<!--
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="ip-container">
<div class="ip-header">
<button mat-stroked-button [routerLink]="['/organization']" class="ip-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="ip-header__title">Bank Profile</h2>
</div>
@if (errorMsg) {
<div class="ip-alert ip-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="ip-alert ip-alert--success">{{ successMsg }}</div>
}
@if (loading) {
<div class="ip-loading">Loading…</div>
} @else {
<div class="ip-card">
<form [formGroup]="form" (ngSubmit)="submit()">
<!-- 1. Basic Information -->
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-university"></i>
<h3>Basic Information</h3>
</div>
<div class="ip-body">
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Bank / Institution Name</mat-label>
<input matInput formControlName="bankName" placeholder="e.g. Socity Urban Co-operative Bank" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Short Name / Abbreviation</mat-label>
<input matInput formControlName="shortName" placeholder="e.g. SUCB" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field">
<mat-label>Legal / Registered Name</mat-label>
<input matInput formControlName="legalName" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Bank Code</mat-label>
<input matInput formControlName="bankCode" placeholder="e.g. SUCB001" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Institution Code</mat-label>
<input matInput formControlName="institutionCode" placeholder="e.g. INST001" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Institution Type</mat-label>
<mat-select formControlName="bankType">
@for (t of bankTypes; track t.value) {
<mat-option [value]="t.value">{{ t.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Ownership Type</mat-label>
<mat-select formControlName="ownershipType">
@for (o of ownershipTypes; track o.value) {
<mat-option [value]="o.value">{{ o.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Parent Institution (if any)</mat-label>
<input matInput formControlName="parentInstitutionName" placeholder="Leave blank if not applicable" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Established Date</mat-label>
<input matInput [matDatepicker]="estPicker" formControlName="establishedDate" />
<mat-datepicker-toggle matSuffix [for]="estPicker"></mat-datepicker-toggle>
<mat-datepicker #estPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--quarter">
<mat-label>Status</mat-label>
<mat-select formControlName="status">
@for (s of statuses; track s.value) {
<mat-option [value]="s.value">{{ s.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--quarter">
<mat-label>Country</mat-label>
<input matInput formControlName="country" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--quarter">
<mat-label>Currency</mat-label>
<input matInput [value]="currencyCode" readonly disabled />
<mat-hint>Set once for the whole system, not editable here</mat-hint>
</mat-form-field>
</div>
</div>
<!-- 2. Regulatory Information -->
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-gavel"></i>
<h3>Regulatory Information</h3>
</div>
<div class="ip-body">
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Regulatory Authority</mat-label>
<input
matInput
formControlName="regulatoryAuthority"
placeholder="e.g. RBI / Registrar of Cooperative Societies"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Registration Date</mat-label>
<input matInput [matDatepicker]="regPicker" formControlName="registrationDate" />
<mat-datepicker-toggle matSuffix [for]="regPicker"></mat-datepicker-toggle>
<mat-datepicker #regPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Registration Number</mat-label>
<input matInput formControlName="registrationNumber" placeholder="RCS registration no." />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>License Number</mat-label>
<input matInput formControlName="licenseNumber" placeholder="RBI licence no." />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>License Status</mat-label>
<mat-select formControlName="licenseStatus">
@for (l of licenseStatuses; track l.value) {
<mat-option [value]="l.value">{{ l.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>License Expiry Date</mat-label>
<input matInput [matDatepicker]="licExpPicker" formControlName="licenseExpiryDate" />
<mat-datepicker-toggle matSuffix [for]="licExpPicker"></mat-datepicker-toggle>
<mat-datepicker #licExpPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>PAN</mat-label>
<input matInput formControlName="pan" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>TAN</mat-label>
<input matInput formControlName="tan" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>GSTIN</mat-label>
<input matInput formControlName="gstin" />
</mat-form-field>
</div>
</div>
<!-- 3. Address -->
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-map-marker-alt"></i>
<h3>Registered Office Address</h3>
</div>
<div class="ip-body">
<mat-form-field appearance="outline" class="ip-field">
<mat-label>Address Line 1</mat-label>
<input matInput formControlName="addressLine1" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field">
<mat-label>Address Line 2</mat-label>
<input matInput formControlName="addressLine2" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--quarter">
<mat-label>City</mat-label>
<input matInput formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--quarter">
<mat-label>District</mat-label>
<input matInput formControlName="district" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--quarter">
<mat-label>State</mat-label>
<input matInput formControlName="state" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--quarter">
<mat-label>PIN Code</mat-label>
<input matInput formControlName="pincode" />
</mat-form-field>
</div>
</div>
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-building"></i>
<h3>Head Office / Corporate Address</h3>
</div>
<div class="ip-body">
<mat-checkbox formControlName="headOfficeSameAsRegistered" class="ip-checkbox">
Same as Registered Office Address
</mat-checkbox>
@if (!form.value.headOfficeSameAsRegistered) {
<mat-form-field appearance="outline" class="ip-field">
<mat-label>Head Office Address</mat-label>
<textarea matInput formControlName="headOfficeAddress" rows="2"></textarea>
</mat-form-field>
}
</div>
</div>
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-envelope-open-text"></i>
<h3>Communication Address</h3>
</div>
<div class="ip-body">
<mat-checkbox formControlName="communicationSameAsRegistered" class="ip-checkbox">
Same as Registered Office Address
</mat-checkbox>
@if (!form.value.communicationSameAsRegistered) {
<mat-form-field appearance="outline" class="ip-field">
<mat-label>Communication Address</mat-label>
<textarea matInput formControlName="communicationAddress" rows="2"></textarea>
</mat-form-field>
}
</div>
</div>
<!-- 4. Contact -->
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-address-card"></i>
<h3>Contact Details</h3>
</div>
<div class="ip-body">
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Official Email</mat-label>
<input matInput type="email" formControlName="contactEmail" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Phone Number</mat-label>
<input matInput formControlName="contactPhone" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Customer Care Number</mat-label>
<input matInput formControlName="customerCareNumber" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Grievance / Support Email</mat-label>
<input matInput type="email" formControlName="grievanceEmail" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field">
<mat-label>Website</mat-label>
<input matInput formControlName="website" placeholder="https://" />
</mat-form-field>
</div>
</div>
<!-- 5. Banking Configuration (reference only — not stored here) -->
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-cogs"></i>
<h3>Banking Configuration</h3>
</div>
<div class="ip-body">
<p class="ip-refnote">
Business date, financial year, time zone, working days and the holiday calendar are configured on their
own screens rather than duplicated here, so there is exactly one place each is set:
</p>
<div class="ip-reflinks">
<a [routerLink]="['/organization/working-days']" class="ip-reflink"
><i class="fas fa-calendar-alt"></i> Working Days</a
>
<a [routerLink]="['/bank-calendar']" class="ip-reflink"
><i class="fas fa-calendar-check"></i> Holiday Calendar</a
>
<a [routerLink]="['/accounting/closing-entries']" class="ip-reflink"
><i class="fas fa-calendar-day"></i> Financial Year / Closing Entries</a
>
<a [routerLink]="['/branch-ops/day-register']" class="ip-reflink"
><i class="fas fa-clock"></i> Business Date (Day Register)</a
>
</div>
</div>
</div>
<!-- 6. Banking Identifiers -->
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-landmark"></i>
<h3>Banking Identifiers</h3>
</div>
<div class="ip-body">
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>SWIFT / BIC</mat-label>
<input matInput formControlName="swiftBic" placeholder="If applicable" />
</mat-form-field>
</div>
<p class="ip-note">
IFSC and MICR are branch-level identifiers, not institution-level — a bank with multiple branches has a
different IFSC/MICR per branch, so those are set on each branch's own
<a [routerLink]="['/organization/branch-profile']">Branch Profile</a> instead.
</p>
</div>
<!-- 7. Branding / Documents -->
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-file-invoice"></i>
<h3>Document Headers</h3>
</div>
<div class="ip-body">
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Statement Header Name</mat-label>
<input matInput formControlName="statementHeaderName" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Passbook Header Name</mat-label>
<input matInput formControlName="passbookHeaderName" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--third">
<mat-label>Receipt Header Name</mat-label>
<input matInput formControlName="receiptHeaderName" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field">
<mat-label>Terms &amp; Conditions</mat-label>
<textarea matInput formControlName="termsAndConditions" rows="4"></textarea>
</mat-form-field>
</div>
</div>
<!-- 8. Authorized Representative -->
<div class="ip-section">
<div class="ip-section__header">
<i class="fas fa-user-tie"></i>
<h3>Authorized Representative</h3>
</div>
<div class="ip-body">
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Name</mat-label>
<input matInput formControlName="authorizedPersonName" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Designation</mat-label>
<input matInput formControlName="authorizedPersonDesignation" placeholder="e.g. Managing Director" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Contact Number</mat-label>
<input matInput formControlName="authorizedPersonContact" />
</mat-form-field>
<mat-form-field appearance="outline" class="ip-field ip-field--half">
<mat-label>Official Email</mat-label>
<input matInput type="email" formControlName="authorizedPersonEmail" />
</mat-form-field>
</div>
</div>
<!-- 9. Audit -->
@if (audit) {
<div class="ip-audit">
@if (audit.createdBy) {
<span>Created by {{ audit.createdBy }} · {{ audit.createdAt | dateFormat }}</span>
}
@if (audit.updatedBy) {
<span>Last updated by {{ audit.updatedBy }} · {{ audit.updatedAt | dateFormat }}</span>
}
@if (audit.activationDate) {
<span>Activated {{ audit.activationDate }}</span>
}
@if (audit.deactivationDate) {
<span>Deactivated {{ audit.deactivationDate }}</span>
}
</div>
}
<div class="ip-actions">
<button
type="submit"
mat-flat-button
color="primary"
[disabled]="form.pristine || saving"
*mifosxHasPermission="'UPDATE_INSTITUTIONPROFILE'"
class="ip-actions__submit"
>
<i class="fas fa-check"></i> Save
</button>
</div>
</form>
</div>
}
</div>
@@ -0,0 +1,226 @@
/**
* 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/.
*/
$primary: #1353a0;
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-700: #5f6368;
$gray-900: #202124;
.ip-container {
max-width: 760px;
margin: 0 auto;
padding: 20px 16px;
}
.ip-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
&__title {
font-size: 18px;
font-weight: 600;
color: $gray-900;
margin: 0;
}
}
.ip-back {
border-radius: 8px !important;
font-size: 12px !important;
font-weight: 600 !important;
height: 32px !important;
border-color: $border !important;
color: $gray-500 !important;
i {
margin-right: 6px;
}
&:hover {
color: $primary !important;
border-color: $primary !important;
}
}
.ip-alert {
padding: 10px 14px;
margin-bottom: 14px;
border-radius: 8px;
font-size: 13px;
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
&--success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #14532d;
}
}
.ip-loading {
padding: 24px;
text-align: center;
color: $gray-500;
}
.ip-card {
background: #fff;
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
}
.ip-section {
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
}
.ip-body {
padding: 20px 24px;
display: flex;
flex-wrap: wrap;
gap: 0 16px;
}
.ip-field {
width: 100%;
margin-bottom: 4px;
&--half {
width: calc(50% - 8px);
}
&--third {
width: calc(33.33% - 11px);
}
&--quarter {
width: calc(25% - 12px);
}
}
.ip-note {
margin: 0 24px 16px;
padding: 8px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
font-size: 11px;
color: #92400e;
a {
color: $primary;
font-weight: 600;
}
}
.ip-checkbox {
display: block;
margin-bottom: 8px;
font-size: 13px;
}
.ip-refnote {
margin: 0 0 10px;
font-size: 12px;
color: #64748b;
}
.ip-reflinks {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ip-reflink {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #fff;
border: 1px solid $border;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: $primary;
text-decoration: none;
&:hover {
background: #eff6ff;
border-color: $primary;
}
i {
font-size: 11px;
color: $accent;
}
}
.ip-lastupdated {
padding: 0 24px 10px;
font-size: 11px;
color: $gray-500;
}
.ip-audit {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 24px 10px;
font-size: 11px;
color: $gray-500;
}
.ip-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 24px;
border-top: 1px solid $gray-200;
background: $gray-50;
&__submit {
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
height: 36px !important;
box-shadow: 0 1px 3px rgba($primary, 0.3) !important;
i {
margin-right: 6px;
font-size: 12px;
}
}
}
@@ -0,0 +1,208 @@
/**
* 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 { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { catchError, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
import { SettingsService } from 'app/settings/settings.service';
const BANK_TYPES = [
{ value: 'COOPERATIVE', label: 'Co-operative Bank' },
{ value: 'COMMERCIAL', label: 'Commercial Bank' },
{ value: 'RRB', label: 'Regional Rural Bank' },
{ value: 'NBFC', label: 'NBFC' },
{ value: 'OTHER', label: 'Other' }
];
const OWNERSHIP_TYPES = [
{ value: 'GOVERNMENT', label: 'Government' },
{ value: 'PRIVATE', label: 'Private' },
{ value: 'COOPERATIVE', label: 'Cooperative' },
{ value: 'OTHER', label: 'Other' }
];
const LICENSE_STATUSES = [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'EXPIRED', label: 'Expired' },
{ value: 'SUSPENDED', label: 'Suspended' },
{ value: 'NOT_APPLICABLE', label: 'Not Applicable' }
];
const STATUSES = [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'INACTIVE', label: 'Inactive' }
];
/**
* The bank's own "Bank Master" profile — identity/classification, regulatory details, address
* (registered, with optional head-office/communication overrides), contact, the institution-level
* banking identifier (SWIFT/BIC), document header names and terms, the authorised representative,
* and audit fields. A singleton screen: there is only ever one institution running this tenant.
*
* IFSC/MICR are deliberately NOT here — those identify a specific branch, not the institution, and
* live on Branch Profile instead. Business Date, Financial Year, Time Zone, Working Days and Holiday
* Calendar are likewise not stored here — they already exist as their own configuration, and this
* screen only links to them (see the Operational Configuration section) instead of holding a second,
* driftable copy.
*/
@Component({
selector: 'mifosx-institution-profile',
templateUrl: './institution-profile.component.html',
styleUrls: ['./institution-profile.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class InstitutionProfileComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
bankTypes = BANK_TYPES;
ownershipTypes = OWNERSHIP_TYPES;
licenseStatuses = LICENSE_STATUSES;
statuses = STATUSES;
form: UntypedFormGroup;
loading = true;
saving = false;
errorMsg = '';
successMsg = '';
currencyCode = '';
audit: any = null;
private static readonly DATE_FIELDS = [
'establishedDate',
'registrationDate',
'licenseExpiryDate'
];
ngOnInit(): void {
this.form = this.formBuilder.group({
bankName: [''],
legalName: [''],
shortName: [''],
bankCode: [''],
institutionCode: [''],
bankType: [null],
ownershipType: [null],
parentInstitutionName: [''],
establishedDate: [null],
status: ['ACTIVE'],
country: ['India'],
registrationNumber: [''],
licenseNumber: [''],
regulatoryAuthority: [''],
registrationDate: [null],
licenseStatus: [null],
licenseExpiryDate: [null],
pan: [''],
tan: [''],
gstin: [''],
addressLine1: [''],
addressLine2: [''],
city: [''],
district: [''],
state: [''],
pincode: [''],
headOfficeSameAsRegistered: [true],
headOfficeAddress: [''],
communicationSameAsRegistered: [true],
communicationAddress: [''],
contactPhone: [''],
contactEmail: [''],
customerCareNumber: [''],
grievanceEmail: [''],
website: [''],
swiftBic: [''],
statementHeaderName: [''],
passbookHeaderName: [''],
receiptHeaderName: [''],
termsAndConditions: [''],
authorizedPersonName: [''],
authorizedPersonDesignation: [''],
authorizedPersonContact: [''],
authorizedPersonEmail: ['']
});
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.organizationService
.getInstitutionProfile()
.pipe(
catchError((err: any) => {
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load the bank profile.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
if (!res) return;
this.applyResult(res);
this.form.markAsPristine();
});
}
submit(): void {
if (this.saving) return;
this.saving = true;
this.errorMsg = '';
this.successMsg = '';
const raw = this.form.value;
const payload: any = { ...raw, locale: this.settingsService.language.code };
for (const field of InstitutionProfileComponent.DATE_FIELDS) {
payload[field] = raw[field] ? this.formatDate(raw[field]) : null;
}
this.organizationService
.updateInstitutionProfile(payload)
.pipe(
catchError((err: any) => {
this.saving = false;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save the bank profile.';
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (!res) return;
this.successMsg = 'Bank profile saved.';
this.applyResult(res);
this.form.markAsPristine();
});
}
private applyResult(res: any): void {
const patch: any = { ...res };
for (const field of InstitutionProfileComponent.DATE_FIELDS) {
patch[field] = res[field] ? new Date(res[field]) : null;
}
this.form.patchValue(patch);
this.currencyCode = res.currencyCode ?? '';
this.audit = {
createdBy: res.createdByUsername,
createdAt: res.createdAt,
updatedBy: res.updatedByUsername,
updatedAt: res.updatedAt,
activationDate: res.activationDate,
deactivationDate: res.deactivationDate
};
}
private formatDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -237,6 +237,30 @@ const routes: Routes = [
}
]
},
{
// Bank's own profile — name, legal name, code, registration, address/contact. A
// singleton, unlike every other screen under Organization, so it has no create/list step.
path: 'institution-profile',
loadComponent: () =>
import('./institution-profile/institution-profile.component').then((m) => m.InstitutionProfileComponent),
data: { title: 'Bank Profile', breadcrumb: 'Bank Profile' }
},
{
// The real-world fields (code, address, contact, type, working hours) behind a branch —
// kept as its own screen rather than folded into Manage Offices, since the underlying
// m_office record is a stock Fineract entity this CBS deliberately left untouched.
path: 'branch-profile',
loadComponent: () =>
import('./branch-profile/branch-profile.component').then((m) => m.BranchProfileComponent),
data: { title: 'Branch Profile', breadcrumb: 'Branch Profile' }
},
{
// Which GL account backs each clearing suspense/settlement role (CTS, NACH, RTGS, ...).
path: 'clearing-gl-config',
loadComponent: () =>
import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent),
data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' }
},
{
path: 'employees',
data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' },
@@ -34,6 +34,48 @@
</a>
</div>
<!-- Bank Profile -->
<div #institutionProfile>
<a class="org-card" [routerLink]="['institution-profile']" *mifosxHasPermission="'READ_INSTITUTIONPROFILE'">
<div class="org-card__icon org-card__icon--blue">
<i class="fas fa-university"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">Bank Profile</h3>
<p class="org-card__desc">Bank name, legal name, code, registration and contact details</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- Branch Profile -->
<div #branchProfile>
<a class="org-card" [routerLink]="['branch-profile']" *mifosxHasPermission="'READ_BRANCHPROFILE'">
<div class="org-card__icon org-card__icon--blue">
<i class="fas fa-code-branch"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">Branch Profile</h3>
<p class="org-card__desc">Branch code, address, contact, type and working hours per office</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- Clearing GL Configuration -->
<div #clearingGlConfig>
<a class="org-card" [routerLink]="['clearing-gl-config']" *mifosxHasPermission="'READ_CLEARINGGLCONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-exchange-alt"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">Clearing GL Configuration</h3>
<p class="org-card__desc">Map CTS, NACH and RTGS clearing roles to their GL accounts</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- Currency Configuration -->
<div #addEditCurrency>
<a class="org-card" [routerLink]="['currencies']" *mifosxHasPermission="'READ_CURRENCY'">
@@ -888,4 +888,51 @@ export class OrganizationService {
deleteLoanOriginator(originatorId: number): Observable<any> {
return this.http.delete(`/loan-originators/${originatorId}`);
}
// ── Institution Profile ──
/** @returns {Observable<any>} The bank's own profile (name, legal name, code, contact...). */
getInstitutionProfile(): Observable<any> {
return this.http.get('/institutionprofile');
}
/** @returns {Observable<any>} */
updateInstitutionProfile(payload: any): Observable<any> {
return this.http.put('/institutionprofile', payload);
}
// ── Branch Profile ──
/** @returns {Observable<any[]>} Every office joined with its branch profile fields, if any. */
getBranchProfiles(): Observable<any> {
return this.http.get('/branchprofile');
}
/** @returns {Observable<any>} One office's branch profile, joined with its office fields. */
getBranchProfile(officeId: number): Observable<any> {
const params = new HttpParams().set('officeId', String(officeId));
return this.http.get('/branchprofile', { params });
}
/** @returns {Observable<any>} */
updateBranchProfile(officeId: number, payload: any): Observable<any> {
return this.http.put(`/branchprofile/${officeId}`, payload);
}
// ── Clearing GL Configuration ──
/** @returns {Observable<any[]>} Every clearing role (CTS/NACH/RTGS...) and its mapped GL account, if any. */
getClearingGlConfig(): Observable<any> {
return this.http.get('/clearingglconfig');
}
/** @returns {Observable<any>} Selectable GL accounts for mapping a clearing role. */
getClearingGlConfigTemplate(): Observable<any> {
return this.http.get('/clearingglconfig/template');
}
/** @returns {Observable<any>} */
updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable<any> {
return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId });
}
}
+10 -6
View File
@@ -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;
@@ -124,23 +124,8 @@
<tr class="wri-row" (click)="review(r)">
<td class="cell-mono wri-voucher">{{ r.voucher_no || '—' }}</td>
<td>
<span
class="wri-credit-badge"
[style.background]="
r.request_type === 'BANK_TRANSFER'
? '#e3f2fd'
: r.request_type === 'COLLECTION'
? '#fce4ec'
: '#e8f5e9'
"
>
{{
r.request_type === 'BANK_TRANSFER'
? 'Bank Transfer'
: r.request_type === 'COLLECTION'
? 'Collection'
: 'Disbursement'
}}
<span class="wri-credit-badge" [style.background]="typeColor(r)">
{{ typeLabel(r) }}
</span>
</td>
<td>
@@ -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<string, string> = {
BANK_TRANSFER: 'Bank Transfer',
COLLECTION: 'Collection',
FUND_TRANSFER: 'Fund Transfer'
};
return map[req.request_type] ?? 'Disbursement';
}
typeColor(req: any): string {
const map: Record<string, string> = {
BANK_TRANSFER: '#e3f2fd',
COLLECTION: '#fce4ec',
FUND_TRANSFER: '#ede7f6'
};
return map[req.request_type] ?? '#e8f5e9';
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'wri-status--pending',
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260827',
'hash': '79ffde51'
'version': '260828',
'hash': 'ce840d73'
},
'allow_switching_backend_instance': true
};