feat(cashier,organization): denomination-aware vault UI, Bank/Branch Profile screens, popup disbursement review
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:
@@ -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' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +700,7 @@
|
||||
<!-- Audit & Compliance -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Audit & 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 & 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-17
@@ -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>
|
||||
|
||||
+29
-7
@@ -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',
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260827',
|
||||
'hash': '79ffde51'
|
||||
'version': '260828',
|
||||
'hash': 'ce840d73'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user