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>
|
||||||
<label class="pg-field">
|
<label class="pg-field">
|
||||||
<span>Opening cash (leave blank to carry forward)</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="bd-hint">
|
<p class="bd-hint">
|
||||||
Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance
|
Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which
|
||||||
from drifting.
|
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>
|
</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">
|
<div class="db-panel__actions">
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="opening || !openDate">
|
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="opening || !openDate">
|
||||||
Open Day
|
Open Day
|
||||||
@@ -174,29 +263,133 @@
|
|||||||
<span>+ Cash deposits taken (approved)</span>
|
<span>+ Cash deposits taken (approved)</span>
|
||||||
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
|
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
|
||||||
</div>
|
</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">
|
<div class="bd-calc__row bd-calc__row--out">
|
||||||
<span>− Cash paid out (approved withdrawals)</span>
|
<span>− Cash paid out (approved withdrawals)</span>
|
||||||
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
|
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
|
||||||
</div>
|
</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">
|
<div class="bd-calc__row bd-calc__row--total">
|
||||||
<span>Cash the branch should be holding</span>
|
<span>Cash the branch should be holding</span>
|
||||||
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
|
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
|
||||||
</div>
|
</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>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="db-panel__body">
|
<div class="db-panel__body">
|
||||||
<label class="pg-field">
|
<label class="pg-field">
|
||||||
<span>Physical cash counted</span>
|
<span>Physical cash counted (or count denominations below)</span>
|
||||||
<input type="number" step="0.01" [(ngModel)]="closingCash" placeholder="0.00" />
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
[(ngModel)]="closingCash"
|
||||||
|
[disabled]="closeDenomTotal > 0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
@if (preview && liveDifference !== null) {
|
@if (preview && liveDifference !== null) {
|
||||||
@if (liveDifference === 0) {
|
@if (liveDifference === 0) {
|
||||||
@@ -222,9 +415,91 @@
|
|||||||
|
|
||||||
<p class="bd-hint">
|
<p class="bd-hint">
|
||||||
Pre-filled with what the books expect — change it only if the drawer counted something different, and that
|
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>
|
</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) {
|
@if (isLateClose) {
|
||||||
<label class="pg-field bd-panel__wide">
|
<label class="pg-field bd-panel__wide">
|
||||||
<span>Why was this day left open? (required)</span>
|
<span>Why was this day left open? (required)</span>
|
||||||
|
|||||||
@@ -157,6 +157,14 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__memo {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bd-hint {
|
.bd-hint {
|
||||||
@@ -170,3 +178,105 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.4;
|
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 { FormsModule } from '@angular/forms';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
interface Denom {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
kind: 'note' | 'coin';
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BranchDay {
|
interface BranchDay {
|
||||||
id: number;
|
id: number;
|
||||||
officeId: number;
|
officeId: number;
|
||||||
@@ -92,9 +99,75 @@ export class BranchDayComponent implements OnInit {
|
|||||||
forceReason = '';
|
forceReason = '';
|
||||||
closing = false;
|
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 {
|
ngOnInit(): void {
|
||||||
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
|
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
|
||||||
this.load();
|
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 {
|
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
|
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
|
||||||
// over it is how a carried balance drifts.
|
// over it is how a carried balance drifts.
|
||||||
this.openingCash = null;
|
this.openingCash = null;
|
||||||
|
this.openCounts = {};
|
||||||
this.successMsg = '';
|
this.successMsg = '';
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
}
|
}
|
||||||
@@ -150,8 +224,9 @@ export class BranchDayComponent implements OnInit {
|
|||||||
if (!this.openDate) return;
|
if (!this.openDate) return;
|
||||||
this.opening = true;
|
this.opening = true;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
|
const denominations = this.denominationPayload(this.openCounts);
|
||||||
this.service
|
this.service
|
||||||
.openBranchDay(this.openDate, this.openingCash ?? undefined)
|
.openBranchDay(this.openDate, this.openingCash ?? undefined, undefined, denominations)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError((err: any) => {
|
catchError((err: any) => {
|
||||||
this.errorMsg =
|
this.errorMsg =
|
||||||
@@ -176,6 +251,7 @@ export class BranchDayComponent implements OnInit {
|
|||||||
startClose(day: BranchDay): void {
|
startClose(day: BranchDay): void {
|
||||||
this.closeTarget = day;
|
this.closeTarget = day;
|
||||||
this.closingCash = null;
|
this.closingCash = null;
|
||||||
|
this.closeCounts = {};
|
||||||
this.closeRemarks = '';
|
this.closeRemarks = '';
|
||||||
this.forceReason = '';
|
this.forceReason = '';
|
||||||
this.successMsg = '';
|
this.successMsg = '';
|
||||||
@@ -196,12 +272,125 @@ export class BranchDayComponent implements OnInit {
|
|||||||
this.closingCash = Number(res.expectedCash);
|
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. */
|
/** Difference the form would record right now, so it is visible before saving. */
|
||||||
get liveDifference(): number | null {
|
get liveDifference(): number | null {
|
||||||
if (!this.preview || this.closingCash == null) return null;
|
if (!this.preview || this.effectiveClosingFigure == null) return null;
|
||||||
return Number(this.closingCash) - Number(this.preview.expectedCash ?? 0);
|
return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts the expected figure back after an edit. */
|
/** Puts the expected figure back after an edit. */
|
||||||
@@ -213,6 +402,7 @@ export class BranchDayComponent implements OnInit {
|
|||||||
|
|
||||||
cancelClose(): void {
|
cancelClose(): void {
|
||||||
this.closeTarget = null;
|
this.closeTarget = null;
|
||||||
|
this.eodTellers = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A date earlier than today is a late close, and has to say why it was left open. */
|
/** 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 {
|
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();
|
return !this.isLateClose || !!this.forceReason.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
submitClose(): void {
|
submitClose(): void {
|
||||||
const day = this.closeTarget;
|
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.closing = true;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
this.service
|
this.service
|
||||||
.closeBranchDay(
|
.closeBranchDay(
|
||||||
day.businessDate,
|
day.businessDate,
|
||||||
Number(this.closingCash),
|
Number(figure),
|
||||||
this.closeRemarks.trim() || undefined,
|
this.closeRemarks.trim() || undefined,
|
||||||
this.isLateClose ? this.forceReason.trim() : undefined
|
this.isLateClose ? this.forceReason.trim() : undefined,
|
||||||
|
undefined,
|
||||||
|
denominations
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError((err: any) => {
|
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. */
|
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
|
||||||
differenceLabel(d: number | null): string {
|
differenceLabel(d: number | null): string {
|
||||||
if (d == null) return '—';
|
if (d == null) return '—';
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Date</mat-label>
|
<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>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
||||||
@@ -59,11 +59,28 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</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">
|
<div class="pg-card">
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Denomination</th>
|
<th>Denomination</th>
|
||||||
|
<th class="num">Available</th>
|
||||||
<th style="text-align: center">Count</th>
|
<th style="text-align: center">Count</th>
|
||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -71,14 +88,17 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@if (notes.length) {
|
@if (notes.length) {
|
||||||
<tr class="pg-section">
|
<tr class="pg-section">
|
||||||
<td colspan="3">Bank Notes</td>
|
<td colspan="4">Bank Notes</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (d of notes; track d.id) {
|
@for (d of notes; track d.id) {
|
||||||
<tr>
|
<tr [class.ct-row--over]="exceedsAvailable(d)">
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ d.label }}</span>
|
<span class="pg-chip">{{ d.label }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="num" [class.ct-available--over]="exceedsAvailable(d)">
|
||||||
|
{{ vaultHasBreakdown ? availableFor(d) : '—' }}
|
||||||
|
</td>
|
||||||
<td style="display: flex; justify-content: center">
|
<td style="display: flex; justify-content: center">
|
||||||
<div class="pg-step">
|
<div class="pg-step">
|
||||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||||
@@ -91,14 +111,17 @@
|
|||||||
}
|
}
|
||||||
@if (coins.length) {
|
@if (coins.length) {
|
||||||
<tr class="pg-section">
|
<tr class="pg-section">
|
||||||
<td colspan="3">Coins</td>
|
<td colspan="4">Coins</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (d of coins; track d.id) {
|
@for (d of coins; track d.id) {
|
||||||
<tr>
|
<tr [class.ct-row--over]="exceedsAvailable(d)">
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="num" [class.ct-available--over]="exceedsAvailable(d)">
|
||||||
|
{{ vaultHasBreakdown ? availableFor(d) : '—' }}
|
||||||
|
</td>
|
||||||
<td style="display: flex; justify-content: center">
|
<td style="display: flex; justify-content: center">
|
||||||
<div class="pg-step">
|
<div class="pg-step">
|
||||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||||
@@ -111,13 +134,24 @@
|
|||||||
}
|
}
|
||||||
@if (denoms.length === 0) {
|
@if (denoms.length === 0) {
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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">
|
<div class="pg-total-bar">
|
||||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
<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({
|
@Component({
|
||||||
selector: 'mifosx-cash-transfer',
|
selector: 'mifosx-cash-transfer',
|
||||||
templateUrl: './cash-transfer.component.html',
|
templateUrl: './cash-transfer.component.html',
|
||||||
styleUrls: ['../cashier-shared.scss'],
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./cash-transfer.component.scss'
|
||||||
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
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 {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
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 {
|
onTellerSelect(tellerId: number): void {
|
||||||
this.fromTellerId = tellerId;
|
this.fromTellerId = tellerId;
|
||||||
const teller = this.tellers.find((t) => t.id === tellerId);
|
const teller = this.tellers.find((t) => t.id === tellerId);
|
||||||
@@ -109,6 +121,7 @@ export class CashTransferComponent implements OnInit {
|
|||||||
this.toParty = '';
|
this.toParty = '';
|
||||||
this.toCashierId = null;
|
this.toCashierId = null;
|
||||||
this.cashiers = [];
|
this.cashiers = [];
|
||||||
|
this.loadVaultAvailability();
|
||||||
if (!tellerId) {
|
if (!tellerId) {
|
||||||
return;
|
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. */
|
/** When a To-cashier is chosen, capture its id and name. */
|
||||||
onCashierSelect(cashierId: number): void {
|
onCashierSelect(cashierId: number): void {
|
||||||
this.toCashierId = cashierId;
|
this.toCashierId = cashierId;
|
||||||
@@ -152,8 +204,24 @@ export class CashTransferComponent implements OnInit {
|
|||||||
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
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 {
|
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 {
|
clear(): void {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { CashHistoryComponent } from './cash-history/cash-history.component';
|
|||||||
import { DayBookComponent } from './day-book/day-book.component';
|
import { DayBookComponent } from './day-book/day-book.component';
|
||||||
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
|
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
|
||||||
import { BranchDayComponent } from './branch-day/branch-day.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 { AccountRegisterComponent } from './account-register/account-register.component';
|
||||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||||
@@ -156,6 +157,13 @@ const routes: Routes = [
|
|||||||
path: 'account-register',
|
path: 'account-register',
|
||||||
component: AccountRegisterComponent,
|
component: AccountRegisterComponent,
|
||||||
data: { title: 'Account Register', breadcrumb: 'Account Register' }
|
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 ──
|
// ── Tellers / Cashiers ──
|
||||||
getTellers(): Observable<any[]> {
|
getTellers(officeId?: number): Observable<any[]> {
|
||||||
return this.http.get<any[]>('/tellers');
|
let params = new HttpParams();
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
return this.http.get<any[]>('/tellers', { params });
|
||||||
}
|
}
|
||||||
getAllCashiers(): Observable<any[]> {
|
getAllCashiers(): Observable<any[]> {
|
||||||
return this.http.get<any[]>('/cashiers');
|
return this.http.get<any[]>('/cashiers');
|
||||||
@@ -227,11 +229,17 @@ export class CashierService {
|
|||||||
return this.http.get('/branchday/close-preview', { params });
|
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', {
|
return this.http.post('/branchday/open', {
|
||||||
businessDate,
|
businessDate,
|
||||||
openingCash: openingCash != null ? String(openingCash) : null,
|
openingCash: openingCash != null ? String(openingCash) : null,
|
||||||
officeId: officeId ?? null,
|
officeId: officeId ?? null,
|
||||||
|
denominations: denominations?.length ? denominations : null,
|
||||||
locale: 'en'
|
locale: 'en'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -242,7 +250,8 @@ export class CashierService {
|
|||||||
closingCash: number,
|
closingCash: number,
|
||||||
remarks?: string,
|
remarks?: string,
|
||||||
forceCloseReason?: string,
|
forceCloseReason?: string,
|
||||||
officeId?: number
|
officeId?: number,
|
||||||
|
denominations?: { denominationId: number; denominationValue: number; count: number }[]
|
||||||
): Observable<any> {
|
): Observable<any> {
|
||||||
return this.http.post('/branchday/close', {
|
return this.http.post('/branchday/close', {
|
||||||
businessDate,
|
businessDate,
|
||||||
@@ -250,9 +259,26 @@ export class CashierService {
|
|||||||
remarks: remarks ?? null,
|
remarks: remarks ?? null,
|
||||||
forceCloseReason: forceCloseReason ?? null,
|
forceCloseReason: forceCloseReason ?? null,
|
||||||
officeId: officeId ?? null,
|
officeId: officeId ?? null,
|
||||||
|
denominations: denominations?.length ? denominations : null,
|
||||||
locale: 'en'
|
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[]> {
|
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
|
||||||
let params = new HttpParams().set('threshold', String(threshold));
|
let params = new HttpParams().set('threshold', String(threshold));
|
||||||
if (fromDate) params = params.set('fromDate', fromDate);
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
|||||||
@@ -120,12 +120,9 @@
|
|||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="onTypChange(row)"
|
(change)="onTypChange(row)"
|
||||||
>
|
>
|
||||||
<option value="~">~</option>
|
@for (t of typOptions; track t.value) {
|
||||||
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
<option [value]="t.value">{{ t.label }}</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>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
@@ -145,7 +142,6 @@
|
|||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
|
||||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
@@ -160,6 +156,7 @@
|
|||||||
(change)="
|
(change)="
|
||||||
row.acTyp === 'GL' &&
|
row.acTyp === 'GL' &&
|
||||||
row.typ !== 'Disbursement' &&
|
row.typ !== 'Disbursement' &&
|
||||||
|
row.typ !== 'Fund Transfer' &&
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
row.typ !== 'Enter Bank Transfer' &&
|
||||||
(row.dbCr = 'Cr')
|
(row.dbCr = 'Cr')
|
||||||
"
|
"
|
||||||
@@ -205,7 +202,10 @@
|
|||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="
|
[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'"
|
(click)="row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
@@ -286,7 +286,7 @@
|
|||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
} @else if (row.typ !== 'Withdrawal Slip') {
|
} @else if (isClerk && row.typ !== 'Withdrawal Slip') {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||||
<input
|
<input
|
||||||
@@ -1327,12 +1327,9 @@
|
|||||||
<div class="ws-f ws-f--type">
|
<div class="ws-f ws-f--type">
|
||||||
<label class="ws-f__lbl">TYPE</label>
|
<label class="ws-f__lbl">TYPE</label>
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
||||||
<option value="~">~</option>
|
@for (t of typOptions; track t.value) {
|
||||||
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
<option [value]="t.value">{{ t.label }}</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>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
@@ -1351,7 +1348,6 @@
|
|||||||
[(ngModel)]="row.paymentTypeId"
|
[(ngModel)]="row.paymentTypeId"
|
||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
|
||||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
@@ -1365,6 +1361,7 @@
|
|||||||
(change)="
|
(change)="
|
||||||
row.acTyp === 'GL' &&
|
row.acTyp === 'GL' &&
|
||||||
row.typ !== 'Disbursement' &&
|
row.typ !== 'Disbursement' &&
|
||||||
|
row.typ !== 'Fund Transfer' &&
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
row.typ !== 'Enter Bank Transfer' &&
|
||||||
(row.dbCr = 'Cr')
|
(row.dbCr = 'Cr')
|
||||||
"
|
"
|
||||||
@@ -1406,7 +1403,10 @@
|
|||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="
|
[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'"
|
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
@@ -1476,7 +1476,7 @@
|
|||||||
<label class="ws-f__lbl">MEETING DATE</label>
|
<label class="ws-f__lbl">MEETING DATE</label>
|
||||||
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else if (isClerk) {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
<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[] {
|
paymentTypesForRow(row: TxnRow): any[] {
|
||||||
const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined);
|
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') {
|
if (row.acTyp === 'SHR') {
|
||||||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||||||
}
|
}
|
||||||
@@ -914,6 +919,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
'T'
|
'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 ──────────────────────────────────────────────────────────
|
// ── Denomination ──────────────────────────────────────────────────────────
|
||||||
notes: Denomination[] = [];
|
notes: Denomination[] = [];
|
||||||
coins: Denomination[] = [];
|
coins: Denomination[] = [];
|
||||||
@@ -943,7 +976,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.cashierService
|
this.cashierService
|
||||||
.getPaymentTypes()
|
.getPaymentTypes()
|
||||||
.pipe(catchError(() => of([])))
|
.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$
|
this.acNoInput$
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -1234,8 +1282,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
addRow(): void {
|
addRow(): void {
|
||||||
const active = this.selectedRow;
|
const active = this.selectedRow;
|
||||||
// Disbursement/Bank Transfer: auto-add mirror CR row when active row is DR
|
// Disbursement/Fund Transfer/Bank Transfer: auto-add mirror CR row when active row is DR
|
||||||
if ((active?.typ === 'Disbursement' || active?.typ === 'Enter Bank Transfer') && active.dbCr === 'Dr') {
|
if (
|
||||||
|
(active?.typ === 'Disbursement' || active?.typ === 'Fund Transfer' || active?.typ === 'Enter Bank Transfer') &&
|
||||||
|
active.dbCr === 'Dr'
|
||||||
|
) {
|
||||||
const mirror: TxnRow = {
|
const mirror: TxnRow = {
|
||||||
typ: active.typ,
|
typ: active.typ,
|
||||||
br: active.br ?? 1,
|
br: active.br ?? 1,
|
||||||
@@ -1261,20 +1312,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
return;
|
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 = {
|
const row: TxnRow = {
|
||||||
typ: '~',
|
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
|
||||||
br: 1,
|
br: 1,
|
||||||
trTp: '',
|
trTp: '',
|
||||||
acTyp: 'SD',
|
acTyp: 'SD',
|
||||||
acNo: '',
|
acNo: '',
|
||||||
ecsAcNo: '',
|
ecsAcNo: '',
|
||||||
dbCr: '',
|
dbCr: this.isClerk ? 'Dr' : 'Cr',
|
||||||
chequeNo: '',
|
chequeNo: '',
|
||||||
cheqDate: '',
|
cheqDate: '',
|
||||||
amount: null,
|
amount: null,
|
||||||
description: '',
|
description: '',
|
||||||
tokNo: '',
|
tokNo: '',
|
||||||
paymentTypeId: null,
|
paymentTypeId: this.findCashPaymentTypeId(),
|
||||||
bankName: '',
|
bankName: '',
|
||||||
routingCode: '',
|
routingCode: '',
|
||||||
ddCharges: null
|
ddCharges: null
|
||||||
@@ -1296,6 +1353,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||||
break;
|
break;
|
||||||
case 'Disbursement':
|
case 'Disbursement':
|
||||||
|
case 'Fund Transfer':
|
||||||
if (!row.dbCr) row.dbCr = 'Dr';
|
if (!row.dbCr) row.dbCr = 'Dr';
|
||||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||||
break;
|
break;
|
||||||
@@ -1303,6 +1361,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
if (!row.dbCr) row.dbCr = 'Dr';
|
if (!row.dbCr) row.dbCr = 'Dr';
|
||||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||||
break;
|
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 {
|
private submitDisbursementDoubleEntry(): void {
|
||||||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||||||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
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 isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
|
||||||
const isDisbursementMode = drRows.length === 1 && drRows[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,
|
paymentTypeId: firstRow?.paymentTypeId,
|
||||||
paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null,
|
paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null,
|
||||||
description: firstRow?.description || 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;
|
this.saving = true;
|
||||||
@@ -1470,7 +1550,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe((res: any) => {
|
.subscribe((res: any) => {
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
if (res) {
|
if (res) {
|
||||||
const modeLabel = isCollectionMode ? 'Collection' : 'Disbursement';
|
const modeLabel = isCollectionMode ? 'Collection' : isFundTransfer ? 'Fund Transfer' : 'Disbursement';
|
||||||
const summaryLine = isCollectionMode
|
const summaryLine = isCollectionMode
|
||||||
? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}`
|
? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}`
|
||||||
: `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`;
|
: `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`;
|
||||||
@@ -2953,8 +3033,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete)
|
// 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')) {
|
// 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 drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||||||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||||||
|
|
||||||
@@ -2996,7 +3082,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
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 ? [
|
const confirmLines = isCollectionMode ? [
|
||||||
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`,
|
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`,
|
||||||
...drRows.map(
|
...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/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="dri">
|
<div class="dri" [class.dri--dialog]="isDialog">
|
||||||
<!-- ── NAV BAR ── -->
|
<!-- ── HEADER ── -->
|
||||||
<nav class="cashier-nav cashier-nav--clerk">
|
<nav class="cashier-nav cashier-nav--clerk">
|
||||||
<button type="button" class="cashier-nav__item cashier-nav__item--primary cashier-nav__item--active">
|
<span class="dri-header-title">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
{{
|
||||||
<path d="M12 2v20M2 12h20" />
|
isBankTransferMode
|
||||||
</svg>
|
? 'Bank Transfer Review'
|
||||||
{{ isBankTransferMode ? 'Bank Transfer Review' : isCollectionMode ? 'Collection Review' : 'Disbursement Review' }}
|
: isCollectionMode
|
||||||
</button>
|
? 'Collection Review'
|
||||||
<span class="cashier-nav__esc-hint" (click)="backToInbox()">← Back to Inbox</span>
|
: isFundTransferMode
|
||||||
|
? 'Fund Transfer Review'
|
||||||
|
: 'Disbursement Review'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="cashier-nav__esc-hint" (click)="backToInbox()">{{ isDialog ? '✕ Close' : '← Back to Inbox' }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- ── LOADING ── -->
|
<!-- ── LOADING ── -->
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg-page;
|
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 ────────────────────────────────────────────────────────────────
|
// ── Nav bar ────────────────────────────────────────────────────────────────
|
||||||
@@ -48,29 +58,6 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
|||||||
border-bottom: 1px solid $border;
|
border-bottom: 1px solid $border;
|
||||||
box-shadow: $shadow-sm;
|
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 {
|
&__esc-hint {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 12px;
|
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 ────────────────────────────────────────────────────────
|
// ── Loading / empty ────────────────────────────────────────────────────────
|
||||||
.dri-loading {
|
.dri-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
@@ -33,7 +34,8 @@ interface CreditEntry {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
MatDialogModule
|
||||||
],
|
],
|
||||||
templateUrl: './disbursement-review.component.html',
|
templateUrl: './disbursement-review.component.html',
|
||||||
styleUrl: './disbursement-review.component.scss'
|
styleUrl: './disbursement-review.component.scss'
|
||||||
@@ -43,6 +45,11 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private service = inject(ClerkDisbursementService);
|
private service = inject(ClerkDisbursementService);
|
||||||
private snackBar = inject(MatSnackBar);
|
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;
|
requestId: number | null = null;
|
||||||
request: any = null;
|
request: any = null;
|
||||||
@@ -53,9 +60,17 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
showRejectForm = false;
|
showRejectForm = false;
|
||||||
rejectionReason = '';
|
rejectionReason = '';
|
||||||
|
|
||||||
|
get isDialog(): boolean {
|
||||||
|
return !!this.dialogRef;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const id = this.route.snapshot.params['id'];
|
if (this.dialogData?.requestId) {
|
||||||
this.requestId = id != null ? Number(id) : null;
|
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();
|
if (this.requestId) this.loadRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +108,11 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
} else {
|
} else {
|
||||||
const msg = this.isBankTransferMode
|
const msg = this.isBankTransferMode
|
||||||
? 'Bank transfer approved successfully.'
|
? 'Bank transfer approved successfully.'
|
||||||
: 'Disbursement approved successfully.';
|
: this.isFundTransferMode
|
||||||
|
? 'Fund transfer approved successfully.'
|
||||||
|
: 'Disbursement approved successfully.';
|
||||||
this.snackBar.open(msg, 'Close', { duration: 3000 });
|
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 });
|
this.snackBar.open(res.error, 'Close', { duration: 5000 });
|
||||||
} else {
|
} else {
|
||||||
this.snackBar.open('Request rejected.', 'Close', { duration: 3000 });
|
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 {
|
get isBankTransferMode(): boolean {
|
||||||
return this.request?.request_type === 'BANK_TRANSFER';
|
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 {
|
get isCollectionMode(): boolean {
|
||||||
if (this.isBankTransferMode) return false;
|
if (this.isBankTransferMode) return false;
|
||||||
return this.creditEntries.some((e) => e.dbCr === 'Dr');
|
return this.creditEntries.some((e) => e.dbCr === 'Dr');
|
||||||
@@ -189,6 +222,10 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
backToInbox(): void {
|
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 -->
|
<!-- Audit & Compliance -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Audit & Compliance</div>
|
<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>
|
<fa-icon icon="building" class="menu-item-icon" style="color: #7c3aed"></fa-icon>
|
||||||
Bank Profile
|
Bank Profile
|
||||||
</button>
|
</button>
|
||||||
@@ -1032,6 +1032,9 @@
|
|||||||
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
|
<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
|
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
|
||||||
</button>
|
</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>
|
</mat-menu>
|
||||||
|
|
||||||
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
|
<!-- 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',
|
path: 'employees',
|
||||||
data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' },
|
data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' },
|
||||||
|
|||||||
@@ -34,6 +34,48 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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 -->
|
<!-- Currency Configuration -->
|
||||||
<div #addEditCurrency>
|
<div #addEditCurrency>
|
||||||
<a class="org-card" [routerLink]="['currencies']" *mifosxHasPermission="'READ_CURRENCY'">
|
<a class="org-card" [routerLink]="['currencies']" *mifosxHasPermission="'READ_CURRENCY'">
|
||||||
|
|||||||
@@ -888,4 +888,51 @@ export class OrganizationService {
|
|||||||
deleteLoanOriginator(originatorId: number): Observable<any> {
|
deleteLoanOriginator(originatorId: number): Observable<any> {
|
||||||
return this.http.delete(`/loan-originators/${originatorId}`);
|
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
|
* Get the Configuration for Business Date
|
||||||
*/
|
*/
|
||||||
getConfigurations(): void {
|
getConfigurations(): void {
|
||||||
if (this.authenticationService.isAuthenticated()) {
|
// Reading global configuration needs the READ_CONFIGURATION permission, which teller-style
|
||||||
// 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
|
||||||
// roles (cashier, clerk) do not carry. This runs from the footer on every authenticated
|
// footer only uses this to decide whether to show a business date, so a role that can never
|
||||||
// page, so an unpermitted role was greeted by an "Unauthorized Request" modal at login for
|
// read it just never asks, instead of round-tripping a request every page load (and every
|
||||||
// a check it never asked for. The footer only uses this to decide whether to show a
|
// 60s poll while a day is open) purely to have it rejected.
|
||||||
// business date, so a denied read is not an error here — it degrades to the system date.
|
if (
|
||||||
|
this.authenticationService.isAuthenticated() &&
|
||||||
|
!this.authenticationService.isCashierUser() &&
|
||||||
|
!this.authenticationService.isClerkUser()
|
||||||
|
) {
|
||||||
this.systemService.getConfigurationByName(SettingsService.businessDateConfigName, true).subscribe({
|
this.systemService.getConfigurationByName(SettingsService.businessDateConfigName, true).subscribe({
|
||||||
next: (configurationData: any) => {
|
next: (configurationData: any) => {
|
||||||
this.isBusinessDateEnabled = configurationData.enabled;
|
this.isBusinessDateEnabled = configurationData.enabled;
|
||||||
|
|||||||
+2
-17
@@ -124,23 +124,8 @@
|
|||||||
<tr class="wri-row" (click)="review(r)">
|
<tr class="wri-row" (click)="review(r)">
|
||||||
<td class="cell-mono wri-voucher">{{ r.voucher_no || '—' }}</td>
|
<td class="cell-mono wri-voucher">{{ r.voucher_no || '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span class="wri-credit-badge" [style.background]="typeColor(r)">
|
||||||
class="wri-credit-badge"
|
{{ typeLabel(r) }}
|
||||||
[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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
+29
-7
@@ -9,10 +9,11 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatNativeDateModule } from '@angular/material/core';
|
import { MatNativeDateModule } from '@angular/material/core';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
@@ -22,6 +23,7 @@ import { of } from 'rxjs';
|
|||||||
|
|
||||||
import { ClerkDisbursementService } from '../../../cashier/clerk-disbursement.service';
|
import { ClerkDisbursementService } from '../../../cashier/clerk-disbursement.service';
|
||||||
import { SettingsService } from '../../../settings/settings.service';
|
import { SettingsService } from '../../../settings/settings.service';
|
||||||
|
import { DisbursementReviewComponent } from '../../../clerk/disbursement-review/disbursement-review.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-disbursement-requests',
|
selector: 'mifosx-disbursement-requests',
|
||||||
@@ -43,9 +45,9 @@ import { SettingsService } from '../../../settings/settings.service';
|
|||||||
})
|
})
|
||||||
export class DisbursementRequestsComponent implements OnInit {
|
export class DisbursementRequestsComponent implements OnInit {
|
||||||
private service = inject(ClerkDisbursementService);
|
private service = inject(ClerkDisbursementService);
|
||||||
private router = inject(Router);
|
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
requests: any[] = [];
|
requests: any[] = [];
|
||||||
totalRecords = 0;
|
totalRecords = 0;
|
||||||
@@ -164,11 +166,13 @@ export class DisbursementRequestsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
review(req: any): void {
|
review(req: any): void {
|
||||||
this.router.navigate([
|
this.dialog
|
||||||
'/checker-inbox-and-tasks/disbursement-requests',
|
.open(DisbursementReviewComponent, {
|
||||||
req.id,
|
data: { requestId: req.id },
|
||||||
'review'
|
autoFocus: false
|
||||||
]);
|
})
|
||||||
|
.afterClosed()
|
||||||
|
.subscribe(() => this.load());
|
||||||
}
|
}
|
||||||
|
|
||||||
creditCount(req: any): number {
|
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 {
|
statusClass(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
PENDING: 'wri-status--pending',
|
PENDING: 'wri-status--pending',
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260827',
|
'version': '260828',
|
||||||
'hash': '79ffde51'
|
'hash': 'ce840d73'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user