KYC management: stage-wise notices, pagination, RD base chart
This commit is contained in:
+852
@@ -0,0 +1,852 @@
|
||||
<!--
|
||||
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="bkd">
|
||||
<div class="bkd-head">
|
||||
<div class="bkd-head-left">
|
||||
<div class="bkd-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
|
||||
<div>
|
||||
<h2 class="bkd-title">ALM Time-Bucket Breakdown</h2>
|
||||
<p class="bkd-sub">Day-wise breakdown of FD / RD / Loan from the as-on date</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bkd-head-actions">
|
||||
@if (view === 'config') {
|
||||
<button type="button" class="bkd-btn" [class.bkd-btn-on]="editMode" (click)="toggleEdit()">
|
||||
{{ editMode ? 'Done editing lines' : 'Edit lines' }}
|
||||
</button>
|
||||
<button type="button" class="bkd-btn" (click)="reset()">Reset to RBI defaults</button>
|
||||
}
|
||||
<button class="bkd-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bkd-tabs" role="tablist">
|
||||
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'days'" (click)="view = 'days'">
|
||||
Day-wise breakdown
|
||||
</button>
|
||||
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'config'" (click)="view = 'config'">
|
||||
Configuration table
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bkd-body">
|
||||
@if (view === 'config') {
|
||||
<p class="bkd-note">
|
||||
<fa-icon icon="info-circle"></fa-icon> Each line shows which bucket its money goes to. You can change the
|
||||
<strong>%</strong> and the rule. Changes are saved in this browser straight away. Hover over a line name to see
|
||||
why it is slotted that way.
|
||||
</p>
|
||||
|
||||
@if (problemCount > 0) {
|
||||
<p class="bkd-warn bkd-banner">
|
||||
{{ problemCount }} line(s) have a % total that is not 100 or is not filled in yet. See the red or yellow
|
||||
totals below.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
@if (view === 'config') {
|
||||
<!-- The date the figures are as on, and how they are read -->
|
||||
<div class="bkd-toolbar">
|
||||
<label>
|
||||
<span>As-on date</span>
|
||||
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="loadBreakdown()" aria-label="As-on date" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Amount unit</span>
|
||||
<select [(ngModel)]="unit" aria-label="Unit">
|
||||
@for (u of units; track u.value) {
|
||||
<option [value]="u.value">{{ u.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<div class="bkd-toolbar-state">
|
||||
@if (loading) {
|
||||
<span>Loading data from accounts…</span>
|
||||
} @else if (loadError) {
|
||||
<span class="bkd-warn">{{ loadError }}</span>
|
||||
} @else if (breakdown) {
|
||||
<span class="bkd-ok"> FD, RD and Loan data as on {{ breakdown.asOnDate }}. </span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (view === 'days') {
|
||||
<section class="bkd-days-view">
|
||||
<h4 class="bkd-h4 bkd-h4-first">1. Select</h4>
|
||||
<div class="bkd-modes" role="tablist">
|
||||
<button type="button" class="bkd-mode" [class.on]="selectBy === 'types'" (click)="setSelectBy('types')">
|
||||
By account type
|
||||
</button>
|
||||
<button type="button" class="bkd-mode" [class.on]="selectBy === 'accounts'" (click)="setSelectBy('accounts')">
|
||||
Individual accounts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (selectBy === 'types') {
|
||||
<p class="bkd-days-intro">Tick one or more. You can pick from both sides together.</p>
|
||||
@for (grp of pickGroups; track grp.side) {
|
||||
<div class="bkd-pick-group">
|
||||
<div
|
||||
class="bkd-pick-title"
|
||||
[class.bkd-pick-liab]="grp.side === 'liability'"
|
||||
[class.bkd-pick-asset]="grp.side === 'asset'"
|
||||
>
|
||||
{{ grp.title }}
|
||||
</div>
|
||||
<div class="bkd-pick-list">
|
||||
@for (t of grp.items; track t.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="bkd-pick-opt"
|
||||
[class.on]="isTypePicked(t.value)"
|
||||
role="checkbox"
|
||||
[attr.aria-checked]="isTypePicked(t.value)"
|
||||
(click)="toggleType(t.value)"
|
||||
>
|
||||
<span class="bkd-pick-box">{{ isTypePicked(t.value) ? '✓' : '' }}</span>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="bkd-pick-actions">
|
||||
<button type="button" class="bkd-link" (click)="selectAllTypes()">Select all</button>
|
||||
<button type="button" class="bkd-link" (click)="clearTypes()">Clear</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="bkd-search">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="onSearchInput()"
|
||||
placeholder="Search by account number or customer name"
|
||||
aria-label="Search accounts"
|
||||
/>
|
||||
<select [(ngModel)]="searchKind" (ngModelChange)="onSearchInput()" aria-label="Kind of account">
|
||||
<option value="">All accounts</option>
|
||||
<option value="FD">FD</option>
|
||||
<option value="RD">RD</option>
|
||||
<option value="LOAN">Loan</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (searching) {
|
||||
<p class="bkd-days-note">Searching…</p>
|
||||
} @else if (searchError) {
|
||||
<p class="bkd-warn">{{ searchError }}</p>
|
||||
} @else if (searchText.trim().length < 2) {
|
||||
<p class="bkd-days-note">Type at least 2 characters of an account number or a customer name.</p>
|
||||
} @else if (!searchResults.length) {
|
||||
<p class="bkd-days-note">No accounts found.</p>
|
||||
}
|
||||
|
||||
@if (searchResults.length) {
|
||||
<div class="bkd-hits">
|
||||
@for (hit of searchResults; track hit.kind + '-' + hit.id) {
|
||||
<button type="button" class="bkd-hit" [class.picked]="isPicked(hit)" (click)="pick(hit)">
|
||||
<span class="bkd-hit-kind">{{ hit.kind }}</span>
|
||||
<span class="bkd-hit-main">
|
||||
<strong>{{ hit.accountNo }}</strong> · {{ hit.customer }}
|
||||
<small
|
||||
>{{ hit.product }}
|
||||
@if (hit.date) {
|
||||
· matures {{ hit.date | date: 'dd-MMM-yyyy' }}
|
||||
}
|
||||
</small>
|
||||
</span>
|
||||
<span class="bkd-hit-amt">{{ amountText(hit.amount) }}</span>
|
||||
<span class="bkd-hit-state">{{ isPicked(hit) ? 'Selected' : 'Select' }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (picked.length) {
|
||||
<div class="bkd-picked">
|
||||
<strong>Selected ({{ picked.length }})</strong>
|
||||
@for (p of picked; track p.kind + '-' + p.id) {
|
||||
<span class="bkd-pill-sel">
|
||||
{{ p.kind }} {{ p.accountNo }}
|
||||
<button type="button" (click)="unpick(p)" aria-label="Remove">✕</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="bkd-link" (click)="clearPicked()">Clear all</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<h4 class="bkd-h4">2. Date and unit</h4>
|
||||
<div class="bkd-submit-row">
|
||||
<label>
|
||||
<span>As-on date</span>
|
||||
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="onAsOnChange()" aria-label="As-on date" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Amount unit</span>
|
||||
<select [(ngModel)]="unit" aria-label="Amount unit">
|
||||
@for (u of units; track u.value) {
|
||||
<option [value]="u.value">{{ u.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="bkd-btn bkd-btn-on bkd-submit" [disabled]="!canSubmit" (click)="submitReport()">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (reportShown && selectBy === 'types') {
|
||||
<section class="bkd-report-out">
|
||||
<h4 class="bkd-h4 bkd-report-title">
|
||||
{{ typeColumns.length }} account {{ typeColumns.length === 1 ? 'type' : 'types' }}
|
||||
<small>as on {{ asOnIso }} · Amounts in {{ unitWord }}</small>
|
||||
</h4>
|
||||
@if (loading) {
|
||||
<p class="bkd-days-note">Loading data from accounts…</p>
|
||||
}
|
||||
@if (loadError) {
|
||||
<p class="bkd-warn">{{ loadError }}</p>
|
||||
}
|
||||
@if (breakdown) {
|
||||
@for (c of typeColumns; track c.value) {
|
||||
@if (c.notice) {
|
||||
<p class="bkd-warn bkd-banner">{{ c.label }}: {{ c.notice }}</p>
|
||||
}
|
||||
}
|
||||
<div class="bkd-acc-wrap bkd-matrix-wrap">
|
||||
<table class="bkd-tbl bkd-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Days</th>
|
||||
<th>Dates</th>
|
||||
@for (c of typeColumns; track c.value) {
|
||||
<th
|
||||
class="bkd-num"
|
||||
[class.bkd-pick-liab]="c.side === 'liability'"
|
||||
[class.bkd-pick-asset]="c.side === 'asset'"
|
||||
>
|
||||
{{ c.label }}
|
||||
</th>
|
||||
}
|
||||
@if (typeHasLiability && typeHasAsset) {
|
||||
<th class="bkd-num bkd-pick-liab">Liabilities total</th>
|
||||
<th class="bkd-num bkd-pick-asset">Assets total</th>
|
||||
} @else if (typeColumns.length > 1) {
|
||||
<th class="bkd-num">Total</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of typeMatrix; track r.label) {
|
||||
<tr [class.bkd-dim]="!r.liab && !r.asset">
|
||||
<td class="bkd-b-name">{{ r.label }}</td>
|
||||
<td>{{ r.days }}</td>
|
||||
<td>{{ r.dates }}</td>
|
||||
@for (v of r.cells; track $index) {
|
||||
<td class="bkd-num">{{ amountText(v) }}</td>
|
||||
}
|
||||
@if (typeHasLiability && typeHasAsset) {
|
||||
<td class="bkd-num">{{ amountText(r.liab) }}</td>
|
||||
<td class="bkd-num">{{ amountText(r.asset) }}</td>
|
||||
} @else if (typeColumns.length > 1) {
|
||||
<td class="bkd-num">{{ amountText(r.liab + r.asset) }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
@for (c of typeColumns; track c.value) {
|
||||
<td class="bkd-num">{{ amountText(c.total) }}</td>
|
||||
}
|
||||
@if (typeHasLiability && typeHasAsset) {
|
||||
<td class="bkd-num">{{ amountText(liabilityGrandTotal) }}</td>
|
||||
<td class="bkd-num">{{ amountText(assetGrandTotal) }}</td>
|
||||
} @else if (typeColumns.length > 1) {
|
||||
<td class="bkd-num">{{ amountText(liabilityGrandTotal + assetGrandTotal) }}</td>
|
||||
}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p class="bkd-days-note">
|
||||
Deposits and term loans are slotted by their own dates: a deposit once on its maturity date, a loan once
|
||||
for each unpaid instalment on its due date. Anything already due goes in the first bucket (a loan
|
||||
instalment follows the Overdue rule). CC / OD and NPA loans are divided by the bucket-wise % set for
|
||||
them in the Configuration table. Deposit amounts are balances, loan amounts are principal; interest is a
|
||||
separate line of the statement.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (reportShown && selectBy === 'accounts') {
|
||||
<section class="bkd-report-out">
|
||||
<h4 class="bkd-h4 bkd-report-title">
|
||||
{{ picked.length }} {{ picked.length === 1 ? 'account' : 'accounts' }}
|
||||
<small>as on {{ asOnIso }} · Amounts in {{ unitWord }}</small>
|
||||
</h4>
|
||||
@if (reportLoading) {
|
||||
<p class="bkd-days-note">Loading…</p>
|
||||
}
|
||||
@if (reportError) {
|
||||
<p class="bkd-warn">{{ reportError }}</p>
|
||||
}
|
||||
@if (report; as rep) {
|
||||
<table class="bkd-tbl bkd-focus-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Days</th>
|
||||
<th>Dates</th>
|
||||
<th class="bkd-num">Amount</th>
|
||||
<th class="bkd-num">%</th>
|
||||
<th class="bkd-num">Entries</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of reportBuckets; track r.label) {
|
||||
<tr [class.bkd-dim]="!r.amount">
|
||||
<td class="bkd-b-name">{{ r.label }}</td>
|
||||
<td>{{ r.days }}</td>
|
||||
<td>{{ r.dates }}</td>
|
||||
<td class="bkd-num">{{ amountText(r.amount) }}</td>
|
||||
<td class="bkd-num">{{ r.amount ? (r.pct | number: '1.0-1') + '%' : '' }}</td>
|
||||
<td class="bkd-num">{{ r.count || '' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="bkd-num">{{ amountText(rep.total) }}</td>
|
||||
<td class="bkd-num">{{ rep.total ? '100%' : '' }}</td>
|
||||
<td class="bkd-num">{{ rep.count }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p class="bkd-days-note">
|
||||
A deposit counts once, on its maturity date; a loan counts once for each unpaid instalment, on its due
|
||||
date. Anything already due goes in the first bucket (a loan instalment follows the Overdue rule).
|
||||
Deposit amounts are balances, loan amounts are the principal of each instalment; interest is a separate
|
||||
line of the statement.
|
||||
</p>
|
||||
|
||||
<h4 class="bkd-h4">Each entry</h4>
|
||||
<div class="bkd-acc-wrap">
|
||||
<table class="bkd-tbl bkd-acc">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Customer</th>
|
||||
<th>Product</th>
|
||||
<th>Due / maturity date</th>
|
||||
<th class="bkd-num">Days</th>
|
||||
<th>Bucket</th>
|
||||
<th>Status</th>
|
||||
<th class="bkd-num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rep.rows; track $index) {
|
||||
<tr>
|
||||
<td class="bkd-b-name">
|
||||
{{ r.kind }} {{ r.accountNo }}
|
||||
@if (r.installment) {
|
||||
<span class="bkd-b-hint">instalment {{ r.installment }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.customer }}</td>
|
||||
<td>
|
||||
{{ r.product }}
|
||||
@if (r.note) {
|
||||
<div class="bkd-warn">{{ r.note }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.date ? (r.date | date: 'dd-MMM-yyyy') : '—' }}</td>
|
||||
<td class="bkd-num">{{ r.days === null ? '' : r.days }}</td>
|
||||
<td>{{ bucketName(r.bucket) }}</td>
|
||||
<td>{{ r.status }}</td>
|
||||
<td class="bkd-num">{{ amountText(r.amount) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="bkd-empty">Nothing to show for these accounts on this date.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<section class="bkd-setup">
|
||||
<div class="bkd-range-head">
|
||||
<h4 class="bkd-h4 bkd-h4-first">Bucket ranges</h4>
|
||||
<button type="button" class="bkd-btn bkd-btn-sm" [class.bkd-btn-on]="rangeEdit" (click)="toggleRangeEdit()">
|
||||
{{ rangeEdit ? 'Done' : 'Edit' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="bkd-days-intro">
|
||||
@if (rangeEdit) {
|
||||
Change the Start / End of a bucket, or add and remove buckets. When you change the End of a bucket, the
|
||||
Start of the next one and its heading change with it.
|
||||
} @else {
|
||||
Start and End of each bucket. Click Edit to change, add or remove buckets.
|
||||
}
|
||||
</p>
|
||||
<table class="bkd-tbl bkd-range">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Heading</th>
|
||||
<th>Days / Dates</th>
|
||||
@if (rangeEdit) {
|
||||
<th></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<tr>
|
||||
<td class="bkd-range-n">{{ $index + 1 }}</td>
|
||||
<td>
|
||||
@if (fromOf($index); as f) {
|
||||
@if (rangeEdit) {
|
||||
<span class="bkd-span-edit">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[ngModel]="f.n"
|
||||
(ngModelChange)="setFrom($index, { n: $event })"
|
||||
aria-label="Start"
|
||||
/>
|
||||
<select
|
||||
[ngModel]="f.unit"
|
||||
(ngModelChange)="setFrom($index, { unit: $event })"
|
||||
aria-label="Start unit"
|
||||
>
|
||||
<option value="day">d</option>
|
||||
<option value="month">m</option>
|
||||
<option value="year">y</option>
|
||||
</select>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="bkd-range-val">{{ f.n }} {{ unitLetter(f.unit) }}</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (b.upTo; as t) {
|
||||
@if (rangeEdit) {
|
||||
<span class="bkd-span-edit">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[ngModel]="t.n"
|
||||
(ngModelChange)="setTo($index, { n: $event })"
|
||||
aria-label="End"
|
||||
/>
|
||||
<select
|
||||
[ngModel]="t.unit"
|
||||
(ngModelChange)="setTo($index, { unit: $event })"
|
||||
aria-label="End unit"
|
||||
>
|
||||
<option value="day">d</option>
|
||||
<option value="month">m</option>
|
||||
<option value="year">y</option>
|
||||
</select>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="bkd-range-val">{{ t.n }} {{ unitLetter(t.unit) }}</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="bkd-none">end</span>
|
||||
}
|
||||
</td>
|
||||
<td class="bkd-b-name">
|
||||
{{ b.label }}
|
||||
@if (rangeProblem($index); as problem) {
|
||||
<div class="bkd-warn">{{ problem }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="bkd-b-hint">
|
||||
@if (windows[$index]; as w) {
|
||||
{{ dayRange(w) }} · {{ dateRange(w) }}
|
||||
}
|
||||
</td>
|
||||
@if (rangeEdit) {
|
||||
<td>
|
||||
@if (cfg.buckets.length > 2) {
|
||||
<button
|
||||
type="button"
|
||||
class="bkd-x"
|
||||
(click)="removeBucket($index)"
|
||||
matTooltip="Remove this bucket"
|
||||
aria-label="Remove bucket"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (rangeEdit) {
|
||||
<!-- A bucket the rules did not have yesterday: add it here, it lands in date order -->
|
||||
<div class="bkd-addbucket">
|
||||
<span class="bkd-addbucket-title">+ Add a bucket ending at:</span>
|
||||
<span class="bkd-span-edit">
|
||||
<input type="number" min="1" [(ngModel)]="newEnd.n" aria-label="End of the new bucket" />
|
||||
<select [(ngModel)]="newEnd.unit" aria-label="Unit of the new bucket">
|
||||
<option value="day">d</option>
|
||||
<option value="month">m</option>
|
||||
<option value="year">y</option>
|
||||
</select>
|
||||
</span>
|
||||
<button type="button" class="bkd-btn bkd-btn-sm" (click)="addBucket()">Add</button>
|
||||
<span class="bkd-b-hint">
|
||||
It is placed in the right position automatically and splits the bucket it falls inside into two.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bkd-calc">
|
||||
<label>
|
||||
<span>Check a maturity date</span>
|
||||
<input type="date" [(ngModel)]="maturityIso" aria-label="Maturity date" />
|
||||
</label>
|
||||
@if (dayCheck; as c) {
|
||||
<div class="bkd-calc-out">
|
||||
@if (c.due) {
|
||||
<strong>Already due</strong> ({{ -c.days }} days ago) → <strong>{{ c.bucket }}</strong>
|
||||
} @else {
|
||||
<strong>{{ c.days }} days left</strong> → <strong>{{ c.bucket }}</strong>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="bkd-calc-out bkd-none">Pick a maturity date and its bucket will show here.</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ The configuration table, laid out like the filed statement ═══ -->
|
||||
@for (sec of sections; track sec.side) {
|
||||
<div class="bkd-sheet-wrap">
|
||||
<table class="bkd-sheet">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="3" rowspan="2" class="s-title">{{ sec.title }}</th>
|
||||
<th [attr.colspan]="cfg.buckets.length" class="s-span">RESIDUAL MATURITY</th>
|
||||
<th rowspan="2" class="s-total-h">Total</th>
|
||||
@if (editMode) {
|
||||
<th rowspan="2" class="s-tools-h">Line</th>
|
||||
}
|
||||
</tr>
|
||||
<tr>
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<th class="s-bucket">{{ b.label }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of itemsOf(sec.side); track item.id) {
|
||||
@if (item.header) {
|
||||
<tr class="s-head">
|
||||
<td class="s-ref">
|
||||
@if (editMode) {
|
||||
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
|
||||
} @else {
|
||||
{{ item.ref }}
|
||||
}
|
||||
</td>
|
||||
<td colspan="2" class="s-name">
|
||||
@if (editMode) {
|
||||
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
|
||||
} @else {
|
||||
{{ item.name }}
|
||||
}
|
||||
</td>
|
||||
<td [attr.colspan]="cfg.buckets.length + 1" class="s-head-fill"></td>
|
||||
@if (editMode) {
|
||||
<td class="s-tools">
|
||||
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
} @else {
|
||||
<tr [class.s-off]="item.included === false">
|
||||
<td class="s-ref">
|
||||
@if (editMode) {
|
||||
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
|
||||
} @else {
|
||||
{{ item.ref }}
|
||||
}
|
||||
</td>
|
||||
<td
|
||||
class="s-name"
|
||||
[class.s-sub]="item.level === 1"
|
||||
[matTooltip]="item.reason"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
@if (editMode) {
|
||||
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
|
||||
} @else {
|
||||
{{ item.name }}
|
||||
}
|
||||
</td>
|
||||
<td class="s-rule">
|
||||
<select
|
||||
[ngModel]="item.rule"
|
||||
(ngModelChange)="ruleChanged(item, $event)"
|
||||
aria-label="Rule"
|
||||
[matTooltip]="item.reason"
|
||||
>
|
||||
@for (r of ruleOptions; track r.value) {
|
||||
<option [value]="r.value">{{ r.label }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (item.rule === 'maturity' || item.rule === 'behavioural') {
|
||||
<!-- Which accounts fill a due-date line, or give a percentage-split line its total -->
|
||||
<select
|
||||
class="s-src"
|
||||
[(ngModel)]="item.source"
|
||||
(ngModelChange)="changed()"
|
||||
aria-label="Which accounts the amount comes from"
|
||||
matTooltip="Which accounts the amount of this line comes from"
|
||||
>
|
||||
@for (o of sourceOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</td>
|
||||
|
||||
@if (item.rule === 'maturity') {
|
||||
@if (autoAmounts(item); as amounts) {
|
||||
<!-- Filled by the accounts themselves, each in the bucket of its own date -->
|
||||
@for (v of amounts; track $index) {
|
||||
<td class="s-auto" [class.s-auto-zero]="!v">{{ amountText(v) }}</td>
|
||||
}
|
||||
<td class="s-tot s-auto-total" [matTooltip]="autoAccounts(item) + ' accounts'">
|
||||
{{ amountText(autoTotal(amounts)) }}
|
||||
</td>
|
||||
} @else {
|
||||
<td [attr.colspan]="cfg.buckets.length" class="s-due">
|
||||
@if (!item.source) {
|
||||
This line has to be filled by hand: it has no account source yet
|
||||
} @else if (loading) {
|
||||
Data aa raha hai…
|
||||
} @else {
|
||||
No data came from the accounts
|
||||
}
|
||||
</td>
|
||||
<td class="s-tot">—</td>
|
||||
}
|
||||
} @else if (item.rule === 'fixed') {
|
||||
<!-- A fixed line sits in one bucket: click the bucket it should move to -->
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<td
|
||||
class="s-cell s-pick"
|
||||
[class.s-cell-on]="share(item, $index) !== null"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'Put it in ' + b.label"
|
||||
[matTooltip]="share(item, $index) !== null ? b.label : 'Yahan le jaayein: ' + b.label"
|
||||
(click)="setFixed(item, $index)"
|
||||
(keydown.enter)="setFixed(item, $index)"
|
||||
(keydown.space)="setFixed(item, $index); $event.preventDefault()"
|
||||
>
|
||||
{{ share(item, $index) !== null ? '100%' : '' }}
|
||||
</td>
|
||||
}
|
||||
} @else {
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<td class="s-cell s-cell-input" [class.s-cell-on]="share(item, $index) !== null">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
placeholder="%"
|
||||
[ngModel]="share(item, $index)"
|
||||
(ngModelChange)="setShare(item, $index, $event)"
|
||||
[attr.aria-label]="b.label"
|
||||
/>
|
||||
</td>
|
||||
}
|
||||
<td
|
||||
class="s-tot"
|
||||
[class.s-tot-ok]="rowState(item) === 'ok'"
|
||||
[class.s-tot-bad]="rowState(item) === 'bad'"
|
||||
[class.s-tot-todo]="rowState(item) === 'todo'"
|
||||
>
|
||||
{{ rowState(item) === 'todo' ? 'fill in' : rowTotal(item) + '%' }}
|
||||
</td>
|
||||
}
|
||||
|
||||
@if (editMode) {
|
||||
<td class="s-tools">
|
||||
<label class="s-incl" matTooltip="Show in report">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="item.included !== false"
|
||||
(ngModelChange)="item.included = $event; changed()"
|
||||
aria-label="In report"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (editMode) {
|
||||
<button type="button" class="bkd-btn bkd-btn-sm s-add" (click)="addLine(sec.side)">
|
||||
+ Add {{ sec.title === 'OUTFLOWS' ? 'an Outflow' : 'an Inflow' }} line
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (editMode) {
|
||||
<div class="bkd-bucket-edit">
|
||||
<div class="bkd-editor-sub">Bucket names and day span</div>
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<div class="bkd-bucket-edit-row">
|
||||
<span class="bkd-step-n">{{ $index + 1 }}</span>
|
||||
<input type="text" [(ngModel)]="b.label" (ngModelChange)="changed()" aria-label="Bucket name" />
|
||||
<input type="text" [(ngModel)]="b.hint" (ngModelChange)="changed()" aria-label="Bucket description" />
|
||||
<div class="bkd-span">
|
||||
@if (b.upTo) {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[(ngModel)]="b.upTo.n"
|
||||
(ngModelChange)="bucketsChanged()"
|
||||
aria-label="How far it reaches"
|
||||
/>
|
||||
<select [(ngModel)]="b.upTo.unit" (ngModelChange)="bucketsChanged()" aria-label="Unit">
|
||||
<option value="day">d</option>
|
||||
<option value="month">m</option>
|
||||
<option value="year">y</option>
|
||||
</select>
|
||||
} @else {
|
||||
<span class="bkd-none">No end</span>
|
||||
}
|
||||
<label class="bkd-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="!b.upTo"
|
||||
(ngModelChange)="setOpenEnded(b, $event)"
|
||||
aria-label="No end"
|
||||
/>
|
||||
No end
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (cfg.overdue; as o) {
|
||||
<div class="bkd-editor-sub bkd-overdue-title">Overdue (due but not received): which bucket</div>
|
||||
<div class="bkd-overdue">
|
||||
<label>
|
||||
<span>Overdue less than a month</span>
|
||||
<select [(ngModel)]="o.recent" (ngModelChange)="bucketsChanged()" aria-label="Recent overdue bucket">
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<option [ngValue]="$index">{{ b.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Overdue more than a month</span>
|
||||
<select [(ngModel)]="o.old" (ngModelChange)="bucketsChanged()" aria-label="Old overdue bucket">
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<option [ngValue]="$index">{{ b.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- What the accounts could not put in a date bucket, so nothing is silently left out -->
|
||||
@if (breakdown; as b) {
|
||||
<section class="bkd-apart-box">
|
||||
<h4 class="bkd-h4">Shown separately (not slotted by date)</h4>
|
||||
<ul class="bkd-apart">
|
||||
@for (n of b.notSlotted; track n.key) {
|
||||
<li>
|
||||
<strong>{{ n.label }}</strong> — {{ n.accounts }} loan, outstanding
|
||||
{{ amountText(n.outstanding) }}
|
||||
<div class="bkd-b-hint">{{ n.why }}</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<ul class="bkd-notes">
|
||||
@for (note of b.notes; track note) {
|
||||
<li>{{ note }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- ═══ What the report will show ═══ -->
|
||||
<section class="bkd-report">
|
||||
<h3 class="bkd-h3">What the report will show</h3>
|
||||
<div class="bkd-report-card">
|
||||
<div class="r-title">Statement of Structural Liquidity as on {{ asOnIso }}</div>
|
||||
<div class="r-sub">( Amounts in {{ unitWord }} )</div>
|
||||
</div>
|
||||
<ul class="bkd-report-list">
|
||||
<li>
|
||||
Two tables, <strong>OUTFLOWS</strong> and <strong>INFLOWS</strong>, with the lines above. Each line’s amount
|
||||
sits in its bucket, with a <strong>Total</strong> in the last column.
|
||||
</li>
|
||||
<li><strong>A. TOTAL OUTFLOWS</strong> — the sum of all outflow lines, bucket by bucket.</li>
|
||||
<li><strong>B. TOTAL INFLOWS</strong> — the sum of all inflow lines, bucket by bucket.</li>
|
||||
<li><strong>C. MISMATCH (B − A)</strong> — inflow minus outflow, in each bucket.</li>
|
||||
<li>
|
||||
<strong>D. CUMULATIVE MISMATCH</strong> — running total of C, from the first bucket up to that bucket.
|
||||
</li>
|
||||
<li><strong>E. C as % to A</strong> — mismatch divided by outflow, times 100.</li>
|
||||
@for (l of cfg.limits; track $index) {
|
||||
<li class="bkd-limit">
|
||||
<strong>{{ cfg.buckets[l.bucket]?.label }}</strong
|
||||
>: negative mismatch must not exceed {{ l.pct }}%. The report shows it in red when it does.
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="bkd-days-note">
|
||||
Where the amount comes from: <strong>By due date</strong> lines take their data from FD, RD and Loan accounts.
|
||||
<strong>Fixed</strong>, <strong>Split</strong> and <strong>By study</strong> lines take the ledger (GL)
|
||||
balance and apply the % above. Lines with no register have to be filled in by hand.
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
+1347
File diff suppressed because it is too large
Load Diff
+962
@@ -0,0 +1,962 @@
|
||||
/**
|
||||
* 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, DestroyRef, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Subject, catchError, debounceTime, of, switchMap } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { businessTodayIso } from 'app/core/utils/business-today';
|
||||
import {
|
||||
AlmBucket,
|
||||
AlmConfig,
|
||||
AlmItem,
|
||||
AlmRule,
|
||||
AlmSide,
|
||||
AlmPlacement,
|
||||
AlmSource,
|
||||
AlmSpan,
|
||||
AlmUnit,
|
||||
RULE_OPTIONS,
|
||||
SHARE_SOURCES,
|
||||
SOURCE_OPTIONS,
|
||||
defaultConfig
|
||||
} from './alm-bucket-breakdown.config';
|
||||
import {
|
||||
BucketWindow,
|
||||
addSpan,
|
||||
bucketIndexFor,
|
||||
bucketLabel,
|
||||
bucketWindows,
|
||||
dayRangeText,
|
||||
daysBetween,
|
||||
formatDate,
|
||||
fromSpanOf,
|
||||
parseIsoDate,
|
||||
setFromSpan
|
||||
} from './alm-bucket.util';
|
||||
import { AccountHit, AccountRow, AlmMaturityService, MaturityBreakdown, SelectedReport } from './alm-maturity.service';
|
||||
|
||||
type AmountUnit = 'rupee' | 'lakh' | 'crore';
|
||||
|
||||
/** How a line's shares stand: whether the bank still has something to fill in. */
|
||||
type RowState = 'spread' | 'ok' | 'todo' | 'bad';
|
||||
|
||||
/** Which of the server's account groups each source adds together. */
|
||||
const SOURCE_GROUPS: Record<'FD_RD' | 'FD' | 'RD' | 'LOAN', string[]> = {
|
||||
FD_RD: [
|
||||
'FD',
|
||||
'RD'
|
||||
],
|
||||
FD: ['FD'],
|
||||
RD: ['RD'],
|
||||
LOAN: ['LOAN']
|
||||
};
|
||||
|
||||
/** The two tables of the statement, in the order it prints them. */
|
||||
const SECTIONS: { side: AlmSide; title: string }[] = [
|
||||
{ side: 'liability', title: 'OUTFLOWS' },
|
||||
{ side: 'asset', title: 'INFLOWS' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Where the browser keeps the bank's edits. Same arrangement as the OSS GL mapping: there is no
|
||||
* backend table for this yet, so the setting belongs to the browser it was made in. The suffix is
|
||||
* the shape of what is stored; a different shape starts again from RBI's defaults.
|
||||
*/
|
||||
const STORAGE_KEY = 'alm_bucket_breakdown_config_v2';
|
||||
|
||||
/**
|
||||
* ALM Time-Bucket Breakdown — the configuration of the Structural Liquidity Statement.
|
||||
*
|
||||
* One table laid out like the statement the bank files: the same numbered Outflow and Inflow lines
|
||||
* down the side, the same residual-maturity buckets across the top. Each line says how its balance
|
||||
* is slotted — wholly in one bucket, by the due date of each account, or by percentages the bank
|
||||
* sets. The statement itself is computed from this configuration.
|
||||
*
|
||||
* Rules start from RBI's Urban Co-operative Bank defaults but none is fixed: every line, bucket and
|
||||
* percentage can be edited, and "Reset" returns to RBI's.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-alm-bucket-breakdown-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './alm-bucket-breakdown-dialog.component.html',
|
||||
styleUrls: ['./alm-bucket-breakdown-dialog.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatTooltipModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class AlmBucketBreakdownDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<AlmBucketBreakdownDialogComponent>);
|
||||
private service = inject(AlmMaturityService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly ruleOptions = RULE_OPTIONS;
|
||||
readonly sourceOptions = SOURCE_OPTIONS;
|
||||
readonly sections = SECTIONS;
|
||||
|
||||
cfg: AlmConfig = this.load();
|
||||
/** Which tab is open: the day breakdown of one product, or the statement's configuration table. */
|
||||
view: 'days' | 'config' = 'days';
|
||||
/** Lines can be renamed, added, removed and left out of the report only while this is on. */
|
||||
editMode = false;
|
||||
|
||||
/** The date the buckets are counted from — the business date, not the browser's clock. */
|
||||
asOnIso = businessTodayIso();
|
||||
/** Maturity typed into the day calculator. */
|
||||
maturityIso = '';
|
||||
|
||||
/** Amounts come back in rupees; the bank reads them in lakhs unless told otherwise. */
|
||||
readonly units: { value: AmountUnit; label: string; divisor: number }[] = [
|
||||
{ value: 'rupee', label: '₹ (rupaye)', divisor: 1 },
|
||||
{ value: 'lakh', label: 'Lakh', divisor: 100_000 },
|
||||
{ value: 'crore', label: 'Crore', divisor: 10_000_000 }
|
||||
];
|
||||
unit: AmountUnit = 'lakh';
|
||||
|
||||
/** FD / RD / loan totals per bucket for the as-on date, from the server. */
|
||||
breakdown: MaturityBreakdown | null = null;
|
||||
loading = false;
|
||||
loadError = '';
|
||||
/** Only the newest request's answer may land; a slow earlier one must not overwrite it. */
|
||||
private loadSeq = 0;
|
||||
|
||||
constructor() {
|
||||
this.watchSearch();
|
||||
this.loadBreakdown();
|
||||
}
|
||||
|
||||
// ── The configuration table ──
|
||||
|
||||
/** The lines of one table, headings included, in statement order. */
|
||||
itemsOf(side: AlmSide): AlmItem[] {
|
||||
return this.cfg.items.filter((i) => i.side === side);
|
||||
}
|
||||
|
||||
/** Columns across a table: reference, line, rule, every bucket, total, and the row tools while editing. */
|
||||
get columnCount(): number {
|
||||
return 3 + this.cfg.buckets.length + 1 + (this.editMode ? 1 : 0);
|
||||
}
|
||||
|
||||
/** Share of a line's balance that goes to one bucket, or null when none does. */
|
||||
share(item: AlmItem, bucket: number): number | null {
|
||||
const placed = item.placements.filter((p) => p.bucket === bucket);
|
||||
if (!placed.length) {
|
||||
return null;
|
||||
}
|
||||
const explicit = placed.filter((p) => p.pct !== null && p.pct !== undefined && `${p.pct}` !== '');
|
||||
if (explicit.length) {
|
||||
return explicit.reduce((sum, p) => sum + Number(p.pct), 0);
|
||||
}
|
||||
// No share written down: a line with one bucket puts the whole balance there.
|
||||
return item.placements.length === 1 ? 100 : null;
|
||||
}
|
||||
|
||||
/** Set the share going to one bucket; empty or zero takes the bucket out of the line. */
|
||||
setShare(item: AlmItem, bucket: number, raw: number | string | null): void {
|
||||
const value = raw === null || raw === '' ? 0 : Math.max(0, Math.min(100, Number(raw)));
|
||||
const rest = item.placements.filter((p) => p.bucket !== bucket);
|
||||
if (value > 0 && !Number.isNaN(value)) {
|
||||
const kept = item.placements.find((p) => p.bucket === bucket);
|
||||
rest.push({ bucket, pct: value, label: kept?.label });
|
||||
}
|
||||
item.placements = rest.sort((a, b) => a.bucket - b.bucket);
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* The amounts a due-date line gets from its accounts, in rupees; null while there is nothing to
|
||||
* show (no source chosen, not loaded yet, or the server could not answer).
|
||||
*/
|
||||
autoAmounts(item: AlmItem): number[] | null {
|
||||
return item.rule === 'maturity' ? this.itemAmounts(item) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a line has in each bucket, in rupees. Deposits and term loans come bucket by bucket from
|
||||
* their own dates; cash-credit and NPA loans come as one outstanding figure that the line's own
|
||||
* percentages divide. Null when the line has no accounts behind it, or nothing has loaded yet.
|
||||
*/
|
||||
itemAmounts(item: AlmItem | null): number[] | null {
|
||||
return item?.source ? this.amountsOf(item.source, item) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one kind of account has in each bucket. For cash-credit and NPA loans the percentages come
|
||||
* from the statement line that names that kind, and are zero until that line has them.
|
||||
*/
|
||||
private amountsOf(source: AlmSource, line: AlmItem | null): number[] | null {
|
||||
if (!this.breakdown) {
|
||||
return null;
|
||||
}
|
||||
if (SHARE_SOURCES.includes(source)) {
|
||||
const outstanding = this.apartLine(source)?.outstanding ?? 0;
|
||||
return this.cfg.buckets.map((_b, i) => (outstanding * (line ? (this.share(line, i) ?? 0) : 0)) / 100);
|
||||
}
|
||||
const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS];
|
||||
const groups = this.breakdown.groups.filter((g) => keys?.includes(g.key));
|
||||
if (!groups.length) {
|
||||
return null;
|
||||
}
|
||||
return this.cfg.buckets.map((_b, i) => groups.reduce((sum, g) => sum + (g.buckets[i] ?? 0), 0));
|
||||
}
|
||||
|
||||
/** The loans the server reports apart from the date buckets: CC/OD, or NPA. */
|
||||
private apartLine(source: AlmSource): { outstanding: number; accounts: number } | undefined {
|
||||
const key = source === 'CCOD' ? 'REVOLVING' : 'NPA';
|
||||
return this.breakdown?.notSlotted.find((n) => n.key === key);
|
||||
}
|
||||
|
||||
/** Accounts behind a line, for the tooltip and the day view's total. */
|
||||
autoAccounts(item: AlmItem | null): number {
|
||||
const source = item?.source;
|
||||
if (!source || !this.breakdown) {
|
||||
return 0;
|
||||
}
|
||||
if (SHARE_SOURCES.includes(source)) {
|
||||
return this.apartLine(source)?.accounts ?? 0;
|
||||
}
|
||||
const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS] ?? [];
|
||||
return this.breakdown.groups.filter((g) => keys.includes(g.key)).reduce((sum, g) => sum + g.accounts, 0);
|
||||
}
|
||||
|
||||
autoTotal(amounts: number[]): number {
|
||||
return amounts.reduce((sum, v) => sum + v, 0);
|
||||
}
|
||||
|
||||
// ── Picking kinds of account ──
|
||||
|
||||
/** Whether the day-wise breakdown is by kind of account, or for individual accounts found by search. */
|
||||
selectBy: 'types' | 'accounts' = 'types';
|
||||
|
||||
/** The kinds of account that can be picked, on the liability and the asset side. */
|
||||
readonly pickTypes: { value: AlmSource; label: string; side: AlmSide }[] = [
|
||||
{ value: 'FD', label: 'Fixed Deposits (FD)', side: 'liability' },
|
||||
{ value: 'RD', label: 'Recurring Deposits (RD)', side: 'liability' },
|
||||
{ value: 'LOAN', label: 'Term Loans', side: 'asset' },
|
||||
{ value: 'CCOD', label: 'CC / OD Loans', side: 'asset' },
|
||||
{ value: 'NPA', label: 'NPA Loans', side: 'asset' }
|
||||
];
|
||||
|
||||
/** The kinds picked; any number of them, from either side. */
|
||||
pickedTypes: AlmSource[] = [
|
||||
'FD',
|
||||
'RD'
|
||||
];
|
||||
|
||||
get pickGroups(): { side: AlmSide; title: string; items: { value: AlmSource; label: string; side: AlmSide }[] }[] {
|
||||
return [
|
||||
{
|
||||
side: 'liability',
|
||||
title: 'Liabilities: deposits (customers)',
|
||||
items: this.pickTypes.filter((t) => t.side === 'liability')
|
||||
},
|
||||
{ side: 'asset', title: 'Assets: loans (customers)', items: this.pickTypes.filter((t) => t.side === 'asset') }
|
||||
];
|
||||
}
|
||||
|
||||
setSelectBy(mode: 'types' | 'accounts'): void {
|
||||
this.selectBy = mode;
|
||||
this.reportShown = false;
|
||||
}
|
||||
|
||||
isTypePicked(value: AlmSource): boolean {
|
||||
return this.pickedTypes.includes(value);
|
||||
}
|
||||
|
||||
/** Tick or untick a kind. Any number can be ticked together; a report already open is put away. */
|
||||
toggleType(value: AlmSource): void {
|
||||
this.pickedTypes = this.isTypePicked(value) ? this.pickedTypes.filter((v) => v !== value) : [
|
||||
...this.pickedTypes,
|
||||
value
|
||||
];
|
||||
this.reportShown = false;
|
||||
}
|
||||
|
||||
selectAllTypes(): void {
|
||||
this.pickedTypes = this.pickTypes.map((t) => t.value);
|
||||
this.reportShown = false;
|
||||
}
|
||||
|
||||
clearTypes(): void {
|
||||
this.pickedTypes = [];
|
||||
this.reportShown = false;
|
||||
}
|
||||
|
||||
/** One column per kind picked: what it has in every bucket, and a note where something is still to be filled in. */
|
||||
get typeColumns(): {
|
||||
value: AlmSource;
|
||||
label: string;
|
||||
side: AlmSide;
|
||||
amounts: number[];
|
||||
total: number;
|
||||
notice: string;
|
||||
}[] {
|
||||
return this.pickTypes
|
||||
.filter((t) => this.isTypePicked(t.value))
|
||||
.map((t) => {
|
||||
const amounts =
|
||||
this.amountsOf(t.value, this.cfg.items.find((i) => !i.header && i.source === t.value) ?? null) ??
|
||||
this.cfg.buckets.map(() => 0);
|
||||
const total = this.autoTotal(amounts);
|
||||
const outstanding = SHARE_SOURCES.includes(t.value) ? (this.apartLine(t.value)?.outstanding ?? 0) : 0;
|
||||
const notice =
|
||||
outstanding > 0 && total === 0
|
||||
? `The bucket-wise % for this kind are not filled in yet, so nothing is divided. Fill them in the Configuration table (outstanding is ${this.amountText(outstanding)}).`
|
||||
: '';
|
||||
return { value: t.value, label: t.label, side: t.side, amounts, total, notice };
|
||||
});
|
||||
}
|
||||
|
||||
/** One row per bucket across the picked kinds, with the liability and asset sides added up separately. */
|
||||
get typeMatrix(): { label: string; days: string; dates: string; cells: number[]; liab: number; asset: number }[] {
|
||||
const cols = this.typeColumns;
|
||||
return this.windows.map((w, i) => {
|
||||
const cells = cols.map((c) => c.amounts[i] ?? 0);
|
||||
return {
|
||||
label: this.cfg.buckets[i]?.label ?? w.label,
|
||||
days: dayRangeText(w),
|
||||
dates: this.dateRange(w),
|
||||
cells,
|
||||
liab: cols.reduce((sum, c, k) => sum + (c.side === 'liability' ? cells[k] : 0), 0),
|
||||
asset: cols.reduce((sum, c, k) => sum + (c.side === 'asset' ? cells[k] : 0), 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get liabilityGrandTotal(): number {
|
||||
return this.typeColumns.reduce((sum, c) => sum + (c.side === 'liability' ? c.total : 0), 0);
|
||||
}
|
||||
|
||||
get assetGrandTotal(): number {
|
||||
return this.typeColumns.reduce((sum, c) => sum + (c.side === 'asset' ? c.total : 0), 0);
|
||||
}
|
||||
|
||||
get typeHasLiability(): boolean {
|
||||
return this.typeColumns.some((c) => c.side === 'liability');
|
||||
}
|
||||
|
||||
get typeHasAsset(): boolean {
|
||||
return this.typeColumns.some((c) => c.side === 'asset');
|
||||
}
|
||||
|
||||
// ── Picking accounts one by one ──
|
||||
|
||||
/** What is typed in the search box. */
|
||||
searchText = '';
|
||||
/** Which kind of account the search looks in: all, or one kind. */
|
||||
searchKind: '' | 'FD' | 'RD' | 'LOAN' = '';
|
||||
searchResults: AccountHit[] = [];
|
||||
searching = false;
|
||||
searchError = '';
|
||||
/** The accounts picked so far. */
|
||||
picked: AccountHit[] = [];
|
||||
private searchTyped = new Subject<void>();
|
||||
|
||||
/** Whether the report of the picked accounts is on screen: only after Submit, and until the picks or the date change. */
|
||||
reportShown = false;
|
||||
report: SelectedReport | null = null;
|
||||
reportLoading = false;
|
||||
reportError = '';
|
||||
/** Only the newest request's answer may land; a slow earlier one must not overwrite it. */
|
||||
private reportSeq = 0;
|
||||
|
||||
/** Set up the search so it waits for typing to pause, and a slow answer to an old search never replaces a newer one. */
|
||||
private watchSearch(): void {
|
||||
this.searchTyped
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
switchMap(() =>
|
||||
this.service.searchAccounts(this.searchText.trim(), this.searchKind).pipe(
|
||||
catchError((err) => {
|
||||
this.searchError =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
'Search failed. The backend needs a restart with the new endpoint.';
|
||||
return of({ results: [] as AccountHit[] });
|
||||
})
|
||||
)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe((found) => {
|
||||
this.searching = false;
|
||||
this.searchResults = found.results;
|
||||
});
|
||||
}
|
||||
|
||||
/** Typing or changing the kind: search again once typing pauses. */
|
||||
onSearchInput(): void {
|
||||
this.searchError = '';
|
||||
if (this.searchText.trim().length < 2) {
|
||||
this.searchResults = [];
|
||||
this.searching = false;
|
||||
return;
|
||||
}
|
||||
this.searching = true;
|
||||
this.searchTyped.next();
|
||||
}
|
||||
|
||||
/** Whether an account is already picked. */
|
||||
isPicked(hit: AccountHit): boolean {
|
||||
return this.picked.some((p) => p.kind === hit.kind && p.id === hit.id);
|
||||
}
|
||||
|
||||
pick(hit: AccountHit): void {
|
||||
if (!this.isPicked(hit)) {
|
||||
this.picked = [
|
||||
...this.picked,
|
||||
hit
|
||||
];
|
||||
this.reportShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
unpick(hit: AccountHit): void {
|
||||
this.picked = this.picked.filter((p) => !(p.kind === hit.kind && p.id === hit.id));
|
||||
this.reportShown = false;
|
||||
}
|
||||
|
||||
clearPicked(): void {
|
||||
this.picked = [];
|
||||
this.reportShown = false;
|
||||
}
|
||||
|
||||
/** Show the day-wise breakdown of the picked accounts for the chosen date. */
|
||||
submitReport(): void {
|
||||
if (this.selectBy === 'types') {
|
||||
if (this.pickedTypes.length) {
|
||||
this.reportShown = true;
|
||||
this.loadBreakdown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.picked.length) {
|
||||
return;
|
||||
}
|
||||
this.reportShown = true;
|
||||
this.loadReport();
|
||||
}
|
||||
|
||||
/** Whether Submit has anything to submit. */
|
||||
get canSubmit(): boolean {
|
||||
return this.selectBy === 'types' ? this.pickedTypes.length > 0 : this.picked.length > 0;
|
||||
}
|
||||
|
||||
/** Fetch the report of the picked accounts; also called again when the bucket setting changes under an open report. */
|
||||
private loadReport(): void {
|
||||
const seq = ++this.reportSeq;
|
||||
this.reportError = '';
|
||||
if (!parseIsoDate(this.asOnIso)) {
|
||||
this.report = null;
|
||||
this.reportError = 'The as-on date is not valid.';
|
||||
return;
|
||||
}
|
||||
if (this.bucketSpecInvalid) {
|
||||
this.report = null;
|
||||
this.reportError = 'Only the last bucket may have no end; every other bucket needs an end.';
|
||||
return;
|
||||
}
|
||||
this.reportLoading = true;
|
||||
this.service
|
||||
.getSelected(this.picked.map((p) => `${p.kind}:${p.id}`).join(','), {
|
||||
asOnDate: this.asOnIso,
|
||||
buckets: this.edgeSpec,
|
||||
overdueRecentBucket: this.cfg.overdue?.recent ?? 0,
|
||||
overdueOldBucket: this.cfg.overdue?.old ?? 2
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
if (seq === this.reportSeq) {
|
||||
this.reportLoading = false;
|
||||
this.report = result;
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
if (seq === this.reportSeq) {
|
||||
this.reportLoading = false;
|
||||
this.report = null;
|
||||
this.reportError =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
'No data came back. The backend needs a restart with the new endpoint.';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** A new as-on date means a new report: put the old one away until Submit is pressed again. */
|
||||
onAsOnChange(): void {
|
||||
this.reportShown = false;
|
||||
this.loadBreakdown();
|
||||
}
|
||||
|
||||
/** The report by bucket: what the picked accounts put in each one, with the days and dates of the bucket. */
|
||||
get reportBuckets(): { label: string; days: string; dates: string; amount: number; count: number; pct: number }[] {
|
||||
const rows = this.report?.rows ?? [];
|
||||
const total = rows.reduce((sum, r) => sum + r.amount, 0);
|
||||
return this.windows.map((w, i) => {
|
||||
const inBucket = rows.filter((r) => r.bucket === i);
|
||||
const amount = inBucket.reduce((sum, r) => sum + r.amount, 0);
|
||||
return {
|
||||
label: this.cfg.buckets[i]?.label ?? w.label,
|
||||
days: dayRangeText(w),
|
||||
dates: this.dateRange(w),
|
||||
amount,
|
||||
count: inBucket.length,
|
||||
pct: total ? (amount / total) * 100 : 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
bucketName(index: number | null): string {
|
||||
return index === null ? '' : (this.cfg.buckets[index]?.label ?? '');
|
||||
}
|
||||
|
||||
// ── The bucket ranges, as a From / To table ──
|
||||
|
||||
/** Where a bucket starts, as the table writes it; null for the first, which always starts on day 1. */
|
||||
fromOf(index: number): AlmSpan | null {
|
||||
return fromSpanOf(this.cfg.buckets, index);
|
||||
}
|
||||
|
||||
/** Change where a bucket starts. That is where the one before it ends, so that one moves too. */
|
||||
setFrom(index: number, change: { n?: number | string; unit?: AlmUnit }): void {
|
||||
const current = this.fromOf(index);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const n = change.n !== undefined ? Number(change.n) : current.n;
|
||||
this.applyRange(index - 1, () =>
|
||||
setFromSpan(this.cfg.buckets, index, { n: n > 0 ? n : 1, unit: change.unit ?? current.unit })
|
||||
);
|
||||
}
|
||||
|
||||
/** Change where a bucket ends. */
|
||||
setTo(index: number, change: { n?: number | string; unit?: AlmUnit }): void {
|
||||
const current = this.cfg.buckets[index]?.upTo;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const n = change.n !== undefined ? Number(change.n) : current.n;
|
||||
this.applyRange(index, () => {
|
||||
this.cfg.buckets[index].upTo = { n: n > 0 ? n : 1, unit: change.unit ?? current.unit };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a change to the end of one bucket, and keep the headings of the two buckets it touches in
|
||||
* step — but only a heading still worded the way the table would word it; one the bank has typed
|
||||
* over is left alone.
|
||||
*/
|
||||
private applyRange(endingIndex: number, apply: () => void): void {
|
||||
const touched = [
|
||||
endingIndex,
|
||||
endingIndex + 1
|
||||
].filter((i) => i >= 0 && i < this.cfg.buckets.length);
|
||||
const wasGenerated = touched.map((i) => this.cfg.buckets[i].label === bucketLabel(this.cfg.buckets, i));
|
||||
apply();
|
||||
touched.forEach((i, k) => {
|
||||
if (wasGenerated[k]) {
|
||||
this.cfg.buckets[i].label = bucketLabel(this.cfg.buckets, i);
|
||||
}
|
||||
});
|
||||
this.bucketsChanged();
|
||||
}
|
||||
|
||||
/** The end typed into the "add a bucket" form. */
|
||||
newEnd: { n: number; unit: AlmUnit } = { n: 7, unit: 'day' };
|
||||
|
||||
/** Start, End, adding and removing buckets work only while this is on, so a stray click cannot change them. */
|
||||
rangeEdit = false;
|
||||
|
||||
toggleRangeEdit(): void {
|
||||
this.rangeEdit = !this.rangeEdit;
|
||||
}
|
||||
|
||||
/** 'd', 'm' or 'y' — a unit the way the range table writes it. */
|
||||
unitLetter(unit: AlmUnit): string {
|
||||
return unit === 'day' ? 'd' : unit === 'month' ? 'm' : 'y';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a bucket ending where the form says. It lands in date order and takes the front part of the
|
||||
* bucket it falls inside, so what was placed there stays with the later part; lines, the overdue
|
||||
* rule and limits keep pointing at the buckets they pointed at.
|
||||
*/
|
||||
addBucket(): void {
|
||||
const end: AlmSpan = { n: Math.max(1, Number(this.newEnd.n) || 1), unit: this.newEnd.unit };
|
||||
const buckets = this.cfg.buckets;
|
||||
const reference = parseIsoDate(this.asOnIso) ?? new Date();
|
||||
const endTime = addSpan(reference, end).getTime();
|
||||
if (buckets.some((b) => b.upTo && addSpan(reference, b.upTo).getTime() === endTime)) {
|
||||
window.alert('A bucket already ends there.');
|
||||
return;
|
||||
}
|
||||
let position = buckets.findIndex((b) => !b.upTo || addSpan(reference, b.upTo).getTime() > endTime);
|
||||
if (position < 0) {
|
||||
position = buckets.length;
|
||||
}
|
||||
const laterWasGenerated = position < buckets.length && buckets[position].label === bucketLabel(buckets, position);
|
||||
this.shiftBucketRefs((i) => (i >= position ? i + 1 : i));
|
||||
// A bucket put in front becomes the first and starts on day 1; the old first now starts where it ends.
|
||||
if (position === 0 && buckets[0]) {
|
||||
buckets[0].startSpan = undefined;
|
||||
}
|
||||
buckets.splice(position, 0, { label: '', hint: '', upTo: end });
|
||||
buckets[position].label = bucketLabel(buckets, position);
|
||||
if (laterWasGenerated && position + 1 < buckets.length) {
|
||||
buckets[position + 1].label = bucketLabel(buckets, position + 1);
|
||||
}
|
||||
this.bucketsChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a bucket. Whatever was placed in it moves to the next one (to the one before, if it was
|
||||
* the last, which then runs on without end), so no line loses its money.
|
||||
*/
|
||||
removeBucket(index: number): void {
|
||||
const buckets = this.cfg.buckets;
|
||||
if (buckets.length <= 2) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Remove the "${buckets[index].label}" bucket? Its money will move to the next bucket.`)) {
|
||||
return;
|
||||
}
|
||||
const last = index === buckets.length - 1;
|
||||
const target = last ? index - 1 : index + 1;
|
||||
const targetWasGenerated = buckets[target].label === bucketLabel(buckets, target);
|
||||
this.shiftBucketRefs((i) => {
|
||||
const moved = i === index ? target : i;
|
||||
return moved > index ? moved - 1 : moved;
|
||||
});
|
||||
const removedStart = buckets[index].startSpan;
|
||||
buckets.splice(index, 1);
|
||||
// Removing the first bucket makes the next one the first, which keeps the statement's start.
|
||||
if (index === 0 && removedStart) {
|
||||
buckets[0].startSpan = removedStart;
|
||||
}
|
||||
if (last) {
|
||||
buckets[index - 1].upTo = null;
|
||||
}
|
||||
const now = last ? index - 1 : index;
|
||||
if (targetWasGenerated) {
|
||||
buckets[now].label = bucketLabel(buckets, now);
|
||||
}
|
||||
this.mergeShares();
|
||||
this.bucketsChanged();
|
||||
}
|
||||
|
||||
/** Re-point everything that names a bucket by its position: lines, the overdue rule, the limits. */
|
||||
private shiftBucketRefs(map: (index: number) => number): void {
|
||||
this.cfg.items.forEach((item) => item.placements.forEach((p) => (p.bucket = map(p.bucket))));
|
||||
if (this.cfg.overdue) {
|
||||
this.cfg.overdue.recent = map(this.cfg.overdue.recent);
|
||||
this.cfg.overdue.old = map(this.cfg.overdue.old);
|
||||
}
|
||||
this.cfg.limits.forEach((l) => (l.bucket = map(l.bucket)));
|
||||
}
|
||||
|
||||
/** After two buckets became one, a line's two shares there become one share, and a limit is kept once. */
|
||||
private mergeShares(): void {
|
||||
this.cfg.items.forEach((item) => {
|
||||
const merged = new Map<number, AlmPlacement>();
|
||||
for (const p of item.placements) {
|
||||
const have = merged.get(p.bucket);
|
||||
if (!have) {
|
||||
merged.set(p.bucket, { ...p });
|
||||
} else if (have.pct != null || p.pct != null) {
|
||||
have.pct = (Number(have.pct) || 0) + (Number(p.pct) || 0);
|
||||
}
|
||||
}
|
||||
item.placements = [...merged.values()].sort((a, b) => a.bucket - b.bucket);
|
||||
});
|
||||
const seen = new Set<number>();
|
||||
this.cfg.limits = this.cfg.limits.filter((l) => !seen.has(l.bucket) && !!seen.add(l.bucket));
|
||||
}
|
||||
|
||||
/** A bucket that ends before it starts, or no later than the one before. Empty when the range is fine. */
|
||||
rangeProblem(index: number): string {
|
||||
const w = this.windows[index];
|
||||
if (!w || !w.to) {
|
||||
return '';
|
||||
}
|
||||
return w.to < w.from ? 'End is before Start: keep it later than the end of the previous bucket.' : '';
|
||||
}
|
||||
|
||||
/** Move a whole line to one bucket: its balance all goes there and nowhere else. */
|
||||
setFixed(item: AlmItem, bucket: number): void {
|
||||
const label = item.placements.find((p) => p.bucket === bucket)?.label;
|
||||
item.placements = [{ bucket, pct: 100, label }];
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** What a line's shares add up to, or null for a line that follows due dates. */
|
||||
rowTotal(item: AlmItem): number | null {
|
||||
if (item.rule === 'maturity') {
|
||||
return null;
|
||||
}
|
||||
return this.cfg.buckets.reduce((sum, _b, i) => sum + (this.share(item, i) ?? 0), 0);
|
||||
}
|
||||
|
||||
rowState(item: AlmItem): RowState {
|
||||
const total = this.rowTotal(item);
|
||||
if (total === null) {
|
||||
return 'spread';
|
||||
}
|
||||
if (total === 0) {
|
||||
return 'todo';
|
||||
}
|
||||
return Math.abs(total - 100) < 0.005 ? 'ok' : 'bad';
|
||||
}
|
||||
|
||||
/** Shares that do not make up the whole balance, over every line that is in the report. */
|
||||
get problemCount(): number {
|
||||
return this.cfg.items.filter(
|
||||
(i) => !i.header && i.included !== false && [
|
||||
'bad',
|
||||
'todo'
|
||||
].includes(this.rowState(i))
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Changing a line's rule: due-date lines follow each account's date, the rest carry shares. */
|
||||
ruleChanged(item: AlmItem, rule: AlmRule): void {
|
||||
item.rule = rule;
|
||||
item.spreads = rule === 'maturity';
|
||||
if (rule === 'fixed') {
|
||||
// A fixed line sits in one bucket: keep the one that held the most, or start in the first.
|
||||
const biggest = [...item.placements].sort((a, b) => (Number(b.pct) || 0) - (Number(a.pct) || 0))[0];
|
||||
item.placements = [{ bucket: biggest?.bucket ?? 0, pct: 100, label: biggest?.label }];
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
|
||||
// ── Editing the lines themselves ──
|
||||
|
||||
toggleEdit(): void {
|
||||
this.editMode = !this.editMode;
|
||||
}
|
||||
|
||||
addLine(side: AlmSide): void {
|
||||
const items = this.itemsOf(side);
|
||||
const last = items[items.length - 1];
|
||||
this.cfg.items.splice(this.cfg.items.indexOf(last) + 1, 0, {
|
||||
id: `u${Date.now()}`,
|
||||
side,
|
||||
ref: '',
|
||||
level: 0,
|
||||
name: side === 'liability' ? 'Nayi liability' : 'Nayi asset',
|
||||
rule: 'fixed',
|
||||
reason: '',
|
||||
spreads: false,
|
||||
placements: [{ bucket: 0, pct: 100 }]
|
||||
});
|
||||
this.changed();
|
||||
}
|
||||
|
||||
removeLine(item: AlmItem): void {
|
||||
if (!window.confirm(`"${item.name}" hata dein?`)) {
|
||||
return;
|
||||
}
|
||||
this.cfg.items = this.cfg.items.filter((i) => i.id !== item.id);
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** Open-ended means "and beyond" — only the last bucket should be. */
|
||||
setOpenEnded(bucket: AlmBucket, open: boolean): void {
|
||||
bucket.upTo = open ? null : { n: 5, unit: 'year' };
|
||||
this.bucketsChanged();
|
||||
}
|
||||
|
||||
/** Called after any edit — the change is kept right away, there is no separate save step. */
|
||||
changed(): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.cfg));
|
||||
} catch {
|
||||
/* private window or blocked storage — the edit still holds for this session */
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (!window.confirm('All settings will go back to the RBI defaults and your changes will be lost. Continue?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* nothing stored to remove */
|
||||
}
|
||||
this.cfg = defaultConfig();
|
||||
this.loadBreakdown();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
// ── Days from the as-on date ──
|
||||
|
||||
/** Every bucket as dates, for the chosen as-on date. */
|
||||
get windows(): BucketWindow[] {
|
||||
const asOn = parseIsoDate(this.asOnIso);
|
||||
return asOn ? bucketWindows(asOn, this.cfg.buckets) : [];
|
||||
}
|
||||
|
||||
/** Day-by-day text for a window: 'Din 1 se 14'. */
|
||||
dayRange(w: BucketWindow): string {
|
||||
return dayRangeText(w);
|
||||
}
|
||||
|
||||
dateRange(w: BucketWindow): string {
|
||||
return w.to ? `${formatDate(w.from)} to ${formatDate(w.to)}` : `${formatDate(w.from)} onwards`;
|
||||
}
|
||||
|
||||
/** What the day calculator says for the maturity typed in; null until both dates are valid. */
|
||||
get dayCheck(): { days: number; bucket: string; due: boolean } | null {
|
||||
const asOn = parseIsoDate(this.asOnIso);
|
||||
const maturity = parseIsoDate(this.maturityIso);
|
||||
if (!asOn || !maturity) {
|
||||
return null;
|
||||
}
|
||||
const days = daysBetween(asOn, maturity);
|
||||
return {
|
||||
days,
|
||||
due: days <= 0,
|
||||
bucket: this.cfg.buckets[bucketIndexFor(asOn, maturity, this.cfg.buckets)]?.label ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
// ── Live FD / RD / loan totals for the as-on date ──
|
||||
|
||||
/** The bank's bucket edges as the server reads them: '14d,28d,3m,6m,1y,3y,5y'. */
|
||||
private get edgeSpec(): string {
|
||||
const letter = { day: 'd', month: 'm', year: 'y' };
|
||||
return this.cfg.buckets
|
||||
.filter((b) => b.upTo)
|
||||
.map((b) => `${b.upTo?.n}${letter[b.upTo?.unit ?? 'day']}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/** Only the last bucket may be open-ended, and every other one needs an end. */
|
||||
private get bucketSpecInvalid(): boolean {
|
||||
const last = this.cfg.buckets.length - 1;
|
||||
return this.cfg.buckets.some((b, i) => !b.upTo && i < last) || !!this.cfg.buckets[last]?.upTo;
|
||||
}
|
||||
|
||||
/** Fetch the fixed-term breakdown for the chosen as-on date and the current bucket setting. */
|
||||
loadBreakdown(): void {
|
||||
const seq = ++this.loadSeq;
|
||||
this.loadError = '';
|
||||
if (!parseIsoDate(this.asOnIso)) {
|
||||
this.breakdown = null;
|
||||
this.loadError = 'The as-on date is not valid.';
|
||||
return;
|
||||
}
|
||||
if (this.bucketSpecInvalid) {
|
||||
this.breakdown = null;
|
||||
this.loadError = 'Only the last bucket may have no end; every other bucket needs an end.';
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getBreakdown({
|
||||
asOnDate: this.asOnIso,
|
||||
buckets: this.edgeSpec,
|
||||
overdueRecentBucket: this.cfg.overdue?.recent ?? 0,
|
||||
overdueOldBucket: this.cfg.overdue?.old ?? 2
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
if (seq !== this.loadSeq) {
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
this.breakdown = result;
|
||||
},
|
||||
error: (err) => {
|
||||
if (seq !== this.loadSeq) {
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
this.breakdown = null;
|
||||
this.loadError =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
'No data came back. The backend needs a restart with the new endpoint.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** A bucket edge or the overdue rule changed: the server must slot everything again. */
|
||||
bucketsChanged(): void {
|
||||
this.changed();
|
||||
this.loadBreakdown();
|
||||
if (this.reportShown && this.selectBy === 'accounts') {
|
||||
this.loadReport();
|
||||
}
|
||||
}
|
||||
|
||||
/** The unit as the statement's heading words it: 'Lakhs of Rupees'. */
|
||||
get unitWord(): string {
|
||||
return { rupee: 'Rupees', lakh: 'Lakhs of Rupees', crore: 'Crores of Rupees' }[this.unit];
|
||||
}
|
||||
|
||||
/** An amount in rupees as the chosen unit; a dash when there is nothing in it. */
|
||||
amountText(rupees: number): string {
|
||||
if (!rupees) {
|
||||
return '–';
|
||||
}
|
||||
const divisor = this.units.find((u) => u.value === this.unit)?.divisor ?? 1;
|
||||
return (rupees / divisor).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** The saved edits if they are intact, otherwise RBI's defaults. */
|
||||
private load(): AlmConfig {
|
||||
const fallback = defaultConfig();
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') as AlmConfig | null;
|
||||
if (
|
||||
saved &&
|
||||
Array.isArray(saved.buckets) &&
|
||||
saved.buckets.length >= 2 &&
|
||||
Array.isArray(saved.items) &&
|
||||
Array.isArray(saved.limits)
|
||||
) {
|
||||
const overdue = (saved.overdue ??= fallback.overdue ?? { recent: 0, old: 2 });
|
||||
// Buckets can be added and removed, so anything pointing past the last one is cut back.
|
||||
const last = saved.buckets.length - 1;
|
||||
overdue.recent = Math.min(overdue.recent, last);
|
||||
overdue.old = Math.min(overdue.old, last);
|
||||
saved.limits = saved.limits.filter((l) => l.bucket <= last);
|
||||
saved.items.forEach((item) => {
|
||||
item.placements = (item.placements ?? []).filter((p) => p.bucket <= last);
|
||||
});
|
||||
// Lines saved before due-date lines could name their accounts take the default's, by id.
|
||||
saved.items.forEach((item) => {
|
||||
if (item.source === undefined) {
|
||||
item.source = fallback.items.find((d) => d.id === item.id)?.source ?? '';
|
||||
}
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
} catch {
|
||||
/* unreadable — start from the defaults */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Structural Liquidity Statement of an Urban Co-operative Bank (non-scheduled — 8 residual-maturity
|
||||
* buckets, 1-14 days not split), laid out row for row like the statement the bank files: the same
|
||||
* numbered Outflow and Inflow lines, the same buckets.
|
||||
*
|
||||
* Everything in this file is only the DEFAULT. It follows RBI's Urban Co-operative Banks (Asset
|
||||
* Liability Management) circular of 17 September 2008 and the 2025 Directions, and where the bank's
|
||||
* own filed statement slots a line differently (Interest Payable, Provisions, Other Liabilities) it
|
||||
* follows the bank's statement. The screen works on a copy the bank can edit and falls back to these
|
||||
* values on "Reset"; percentages in particular are a benchmark that a bank may replace with its own
|
||||
* behavioural study approved by the Board / ALCO.
|
||||
*/
|
||||
|
||||
export type AlmSide = 'liability' | 'asset';
|
||||
|
||||
/**
|
||||
* How the bucket is decided.
|
||||
* - fixed: always the same bucket(s).
|
||||
* - maturity: follows the due date of each account/instalment, so it spreads over the buckets.
|
||||
* - split: one balance divided between buckets by share.
|
||||
* - behavioural: the bank's own past-data study decides.
|
||||
*/
|
||||
export type AlmRule = 'fixed' | 'maturity' | 'split' | 'behavioural';
|
||||
|
||||
/**
|
||||
* Which accounts feed a due-date line on their own. Empty means nothing does yet and the line has to
|
||||
* be filled by hand. FD_RD is the two deposit kinds added together, as RBI's "Term Deposits" head is.
|
||||
*/
|
||||
export type AlmSource = 'FD' | 'RD' | 'FD_RD' | 'LOAN' | 'CCOD' | 'NPA' | '';
|
||||
|
||||
export const SOURCE_OPTIONS: { value: AlmSource; label: string }[] = [
|
||||
{ value: '', label: 'None (fill by hand)' },
|
||||
{ value: 'FD_RD', label: 'FD + RD accounts (by maturity)' },
|
||||
{ value: 'FD', label: 'FD accounts only (by maturity)' },
|
||||
{ value: 'RD', label: 'RD accounts only (by maturity)' },
|
||||
{ value: 'LOAN', label: 'Term Loan accounts (by instalment)' },
|
||||
{ value: 'CCOD', label: 'CC / OD loan accounts (outstanding × %)' },
|
||||
{ value: 'NPA', label: 'NPA loan accounts (outstanding × %)' }
|
||||
];
|
||||
|
||||
/** Sources whose accounts give one outstanding figure that the line's own percentages then divide. */
|
||||
export const SHARE_SOURCES: AlmSource[] = [
|
||||
'CCOD',
|
||||
'NPA'
|
||||
];
|
||||
|
||||
export type AlmUnit = 'day' | 'month' | 'year';
|
||||
|
||||
/** How far out a bucket reaches, counted from the as-on date. */
|
||||
export interface AlmSpan {
|
||||
n: number;
|
||||
unit: AlmUnit;
|
||||
}
|
||||
|
||||
export interface AlmBucket {
|
||||
/** The column heading, as the statement prints it. */
|
||||
label: string;
|
||||
/** One line a branch user can follow. */
|
||||
hint: string;
|
||||
/**
|
||||
* Last day the bucket takes, counted from the as-on date. Calendar months and years are used
|
||||
* as they are, so '3 months' from 30 June is 30 September. Null means no upper end.
|
||||
*/
|
||||
upTo: AlmSpan | null;
|
||||
/**
|
||||
* Only on the first bucket: where it starts, when that is not day 1. Every later bucket starts where
|
||||
* the one before it ends. Money due before this start still counts in the first bucket, since
|
||||
* nothing may fall outside the statement.
|
||||
*/
|
||||
startSpan?: AlmSpan;
|
||||
}
|
||||
|
||||
export interface AlmPlacement {
|
||||
/** Index into the bucket list. */
|
||||
bucket: number;
|
||||
/** Share of the balance that goes to this bucket, 0-100; blank when the whole line goes there. */
|
||||
pct?: number | null;
|
||||
/** Short name for the portion, e.g. 'Core'. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AlmItem {
|
||||
id: string;
|
||||
side: AlmSide;
|
||||
/** The statement's own numbering: '1.', '3.', 'I)'. */
|
||||
ref: string;
|
||||
/** 0 for a numbered head, 1 for a line under a head. */
|
||||
level: 0 | 1;
|
||||
/** A heading only, such as '3. Deposits': it carries no figure of its own. */
|
||||
header?: boolean;
|
||||
/** Left out of the report when false; shown when absent or true. */
|
||||
included?: boolean;
|
||||
name: string;
|
||||
rule: AlmRule;
|
||||
/** Plain-language reason, in the words a branch manager would use. */
|
||||
reason: string;
|
||||
/** True when each account falls in the bucket of its own due date. */
|
||||
spreads: boolean;
|
||||
/** For a due-date line: the accounts whose maturities fill it. */
|
||||
source?: AlmSource;
|
||||
placements: AlmPlacement[];
|
||||
}
|
||||
|
||||
/** Prudential cap on the net cumulative negative mismatch, as a % of cumulative outflow in that bucket. */
|
||||
export interface AlmLimit {
|
||||
bucket: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an instalment or receivable that is already due and unpaid is slotted — the bank's Overdue
|
||||
* Receivables rule. Both are bucket positions: 'recent' for one that fell due less than a month ago,
|
||||
* 'old' for anything older.
|
||||
*/
|
||||
export interface AlmOverdueRule {
|
||||
recent: number;
|
||||
old: number;
|
||||
}
|
||||
|
||||
export interface AlmConfig {
|
||||
buckets: AlmBucket[];
|
||||
items: AlmItem[];
|
||||
limits: AlmLimit[];
|
||||
overdue?: AlmOverdueRule;
|
||||
}
|
||||
|
||||
export const RULE_OPTIONS: { value: AlmRule; label: string; help: string }[] = [
|
||||
{ value: 'fixed', label: 'Fixed', help: 'Always in the same bucket' },
|
||||
{ value: 'maturity', label: 'By due date', help: 'Each account by its own maturity' },
|
||||
{ value: 'split', label: 'Split', help: 'Balance divided by percentages' },
|
||||
{ value: 'behavioural', label: 'By study', help: 'Percentages from the bank’s own study' }
|
||||
];
|
||||
|
||||
/** Column headings as the filed statement prints them, with the span each one covers. */
|
||||
export const DEFAULT_BUCKETS: AlmBucket[] = [
|
||||
{ label: '1 to 14 days', hint: 'Needed straight away', upTo: { n: 14, unit: 'day' } },
|
||||
{ label: '15 to 28 days', hint: 'After the next two weeks', upTo: { n: 28, unit: 'day' } },
|
||||
{ label: '29 days and upto 3 months', hint: 'Within this quarter', upTo: { n: 3, unit: 'month' } },
|
||||
{ label: 'Over 3 months and upto 6 months', hint: 'Next quarter', upTo: { n: 6, unit: 'month' } },
|
||||
{ label: 'Over 6 months and upto 1 year', hint: 'Within the year', upTo: { n: 1, unit: 'year' } },
|
||||
{ label: 'Over 1 year and upto 3 years', hint: 'Medium term', upTo: { n: 3, unit: 'year' } },
|
||||
{ label: 'Over 3 years and upto 5 years', hint: 'Long term', upTo: { n: 5, unit: 'year' } },
|
||||
{ label: 'Over 5 years', hint: 'Permanent items', upTo: null }
|
||||
];
|
||||
|
||||
/** Non-scheduled UCB: negative mismatch in 1-14 and 15-28 days must stay within 20% of outflows. */
|
||||
export const DEFAULT_LIMITS: AlmLimit[] = [
|
||||
{ bucket: 0, pct: 20 },
|
||||
{ bucket: 1, pct: 20 }
|
||||
];
|
||||
|
||||
/** Buckets by position, so the lines below read as the bucket they mean. */
|
||||
const B = { d14: 0, d28: 1, m3: 2, m6: 3, y1: 4, y3: 5, y5: 6, over5: 7 };
|
||||
|
||||
const whole = (bucket: number): AlmPlacement[] => [{ bucket, pct: 100 }];
|
||||
|
||||
type Seed = Omit<AlmItem, 'id' | 'spreads' | 'level'> & { level?: 0 | 1; spreads?: boolean };
|
||||
|
||||
const head = (side: AlmSide, ref: string, name: string): Seed => ({
|
||||
side,
|
||||
ref,
|
||||
level: 0,
|
||||
header: true,
|
||||
name,
|
||||
rule: 'fixed',
|
||||
reason: '',
|
||||
placements: []
|
||||
});
|
||||
|
||||
const line = (
|
||||
side: AlmSide,
|
||||
ref: string,
|
||||
name: string,
|
||||
rule: AlmRule,
|
||||
reason: string,
|
||||
placements: AlmPlacement[] = [],
|
||||
level: 0 | 1 = 1,
|
||||
source: AlmSource = ''
|
||||
): Seed => ({ side, ref, level, name, rule, reason, placements, spreads: rule === 'maturity', source });
|
||||
|
||||
const SEED: Seed[] = [
|
||||
// ── OUTFLOWS (liabilities) ──
|
||||
line(
|
||||
'liability',
|
||||
'1.',
|
||||
'Capital',
|
||||
'fixed',
|
||||
'This is the bank’s own money and is never paid back, so it goes in the farthest bucket.',
|
||||
whole(B.over5),
|
||||
0
|
||||
),
|
||||
line(
|
||||
'liability',
|
||||
'2.',
|
||||
'Reserves & Surplus',
|
||||
'fixed',
|
||||
'The bank’s own money, never paid back. Farthest bucket.',
|
||||
whole(B.over5),
|
||||
0
|
||||
),
|
||||
head('liability', '3.', 'Deposits'),
|
||||
line(
|
||||
'liability',
|
||||
'I)',
|
||||
'Current Deposits',
|
||||
'split',
|
||||
'A part can be withdrawn at any time (volatile), so it goes in 1-14 days. The rest is stable (core), so it goes in 1-3 years. Change the % from the bank’s own study.',
|
||||
[
|
||||
{ bucket: B.d14, pct: 10, label: 'Volatile' },
|
||||
{ bucket: B.y3, pct: 90, label: 'Core' }
|
||||
]
|
||||
),
|
||||
line(
|
||||
'liability',
|
||||
'II)',
|
||||
'Saving Bank Deposits',
|
||||
'split',
|
||||
'Same as Current. RBI’s benchmark is about 10% volatile. Change the % from the bank’s own study.',
|
||||
[
|
||||
{ bucket: B.d14, pct: 10, label: 'Volatile' },
|
||||
{ bucket: B.y3, pct: 90, label: 'Core' }
|
||||
]
|
||||
),
|
||||
line(
|
||||
'liability',
|
||||
'III)',
|
||||
'Term Deposits (FD and RD)',
|
||||
'maturity',
|
||||
'The maturity date of each FD and RD decides its bucket. An FD that is already due goes in the first bucket.',
|
||||
[],
|
||||
1,
|
||||
'FD_RD'
|
||||
),
|
||||
line('liability', 'IV)', 'Certificates of Deposit', 'maturity', 'By maturity date.'),
|
||||
head('liability', '4.', 'Borrowings'),
|
||||
line('liability', 'I.', 'Call and short Notice', 'maturity', 'The bucket of the day it has to be repaid.'),
|
||||
line('liability', 'II.', 'Inter-Bank (Term)', 'maturity', 'The bucket of the day it has to be repaid.'),
|
||||
line('liability', 'III.', 'Refinances', 'maturity', 'The bucket of the day it has to be repaid.'),
|
||||
line('liability', 'IV.', 'Others (specify)', 'maturity', 'The bucket of the day it has to be repaid.'),
|
||||
head('liability', '5.', 'Other Liabilities & Provisions'),
|
||||
line('liability', 'I.', 'DD / Bills Payable', 'fixed', 'These are paid quickly, so 1-14 days.', whole(B.d14)),
|
||||
line('liability', 'II.', 'Branch Adjustments', 'fixed', 'Settled between branches, usually quickly.', whole(B.d14)),
|
||||
line(
|
||||
'liability',
|
||||
'III.',
|
||||
'Provisions',
|
||||
'fixed',
|
||||
'In your filed statement provisions are in 1-14 days. Change it according to the purpose of the provision.',
|
||||
whole(B.d14)
|
||||
),
|
||||
line(
|
||||
'liability',
|
||||
'IV.',
|
||||
'Other Liabilities',
|
||||
'fixed',
|
||||
'Amounts that are not to be paid out (like fees received in advance) go in the farthest bucket. Your filed statement has them in over 5 years.',
|
||||
whole(B.over5)
|
||||
),
|
||||
line(
|
||||
'liability',
|
||||
'6.',
|
||||
'Unavailed portion of Cash Credit / Overdraft / Demand Loan component of Working Capital',
|
||||
'behavioural',
|
||||
'How much of the limit customers will use comes from the bank’s past history. RBI: fill the % in the buckets within 12 months.',
|
||||
[],
|
||||
0
|
||||
),
|
||||
line(
|
||||
'liability',
|
||||
'7.',
|
||||
'Letters of Credit / Guarantees',
|
||||
'behavioural',
|
||||
'How much of the LC / guarantee will devolve comes from the bank’s history, net of margin. Fill in the %.',
|
||||
[],
|
||||
0
|
||||
),
|
||||
line('liability', '8.', 'Repos', 'maturity', 'The bucket of the day it has to be repaid.', [], 0),
|
||||
line('liability', '9.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0),
|
||||
line('liability', '10.', 'Swaps (Sell/Buy/Maturing forwards)', 'maturity', 'By maturity date.', [], 0),
|
||||
line(
|
||||
'liability',
|
||||
'11.',
|
||||
'Interest Payable',
|
||||
'fixed',
|
||||
'Interest still to be paid as per the books. Your filed statement has it in 1-14 days. RBI: the bucket of the date it is due.',
|
||||
whole(B.d14),
|
||||
0
|
||||
),
|
||||
line(
|
||||
'liability',
|
||||
'12.',
|
||||
'Others (specify)',
|
||||
'fixed',
|
||||
'Anything else. Your filed statement has it in over 5 years.',
|
||||
whole(B.over5),
|
||||
0
|
||||
),
|
||||
|
||||
// ── INFLOWS (assets) ──
|
||||
line(
|
||||
'asset',
|
||||
'1.',
|
||||
'Cash',
|
||||
'fixed',
|
||||
'Cash is with the bank right now, so it is available straight away.',
|
||||
whole(B.d14),
|
||||
0
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'2.',
|
||||
'Balances with RBI',
|
||||
'behavioural',
|
||||
'Balance above the CRR/SLR requirement is in 1-14 days. The required part follows the maturity of the bank’s deposits (DTL). Fill in the %.',
|
||||
[],
|
||||
0
|
||||
),
|
||||
head('asset', '3.', 'Balances with other Banks'),
|
||||
line(
|
||||
'asset',
|
||||
'I)',
|
||||
'Current Account',
|
||||
'fixed',
|
||||
'What can be withdrawn goes in 1-14 days. RBI: the minimum balance that has to be kept goes in 1-3 years; split it if you want.',
|
||||
whole(B.d14)
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'II)',
|
||||
'Money at Call and short Notice, Term Deposits and other placements with other banks including DCCBs and SCBs',
|
||||
'maturity',
|
||||
'By the maturity date of the FD / call money placed with the bank.'
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'4.',
|
||||
'Investments (including those under Repos but excluding Reverse Repos)',
|
||||
'maturity',
|
||||
'By the maturity of the bond / G-Sec, leaving out the part needed to maintain SLR.',
|
||||
[],
|
||||
0
|
||||
),
|
||||
head('asset', '5.', 'Advances (Performing)'),
|
||||
line(
|
||||
'asset',
|
||||
'I.',
|
||||
'Bills Purchased and Discounted (including bills under DUPN)',
|
||||
'maturity',
|
||||
'By the due date of the bill.'
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'II.',
|
||||
'Cash Credits, Overdrafts and Loans repayable on demand',
|
||||
'behavioural',
|
||||
'The part that comes back quickly goes in a short bucket; the part that always stays (core) goes in 1-3 years. Fill in the % from the bank’s study.',
|
||||
[],
|
||||
1,
|
||||
'CCOD'
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'III.',
|
||||
'Term Loans',
|
||||
'maturity',
|
||||
'Each EMI goes in the bucket of its own due date, not the whole loan at once.',
|
||||
[],
|
||||
1,
|
||||
'LOAN'
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'6.',
|
||||
'NPAs (Advances and Investments)',
|
||||
'behavioural',
|
||||
'Sub-standard NPA in 3-5 years; doubtful and loss in the farthest bucket (net of provision). Fill in the % as the bank sees fit.',
|
||||
[],
|
||||
0,
|
||||
'NPA'
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'7.',
|
||||
'Fixed Assets',
|
||||
'fixed',
|
||||
'Things like buildings and furniture, which do not bring in money quickly.',
|
||||
whole(B.over5),
|
||||
0
|
||||
),
|
||||
head('asset', '8.', 'Other Assets'),
|
||||
line(
|
||||
'asset',
|
||||
'I.',
|
||||
'Branch Adjustments',
|
||||
'fixed',
|
||||
'Debit balance of branch adjustments, usually settled quickly.',
|
||||
whole(B.d14)
|
||||
),
|
||||
line('asset', 'II.', 'Leased Assets', 'maturity', 'By the date of the lease’s interim cash flows.'),
|
||||
line(
|
||||
'asset',
|
||||
'III.',
|
||||
'Others',
|
||||
'fixed',
|
||||
'Things that are not real money (intangibles, non-cash) go in the farthest bucket. Your filed statement has them in over 5 years.',
|
||||
whole(B.over5)
|
||||
),
|
||||
line('asset', '9.', 'Reverse Repos', 'maturity', 'The bucket of the day the money comes back.', [], 0),
|
||||
line('asset', '10.', 'Swaps (Buy/Sell)/Maturing forwards', 'maturity', 'By maturity date.', [], 0),
|
||||
line('asset', '11.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0),
|
||||
line(
|
||||
'asset',
|
||||
'12.',
|
||||
'Interest Receivable',
|
||||
'fixed',
|
||||
'Interest still to be received. Your filed statement has it in over 5 years. RBI: the bucket of the date it is due.',
|
||||
whole(B.over5),
|
||||
0
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'13.',
|
||||
'Export Refinance from RBI',
|
||||
'fixed',
|
||||
'Limit that has not been used yet can be drawn straight away.',
|
||||
whole(B.d14),
|
||||
0
|
||||
),
|
||||
line(
|
||||
'asset',
|
||||
'14.',
|
||||
'Others (specify)',
|
||||
'behavioural',
|
||||
'Anything else. Fill in the % as the bank’s study gives.',
|
||||
[],
|
||||
0
|
||||
)
|
||||
];
|
||||
|
||||
/** A fresh, independent copy of the RBI defaults — callers are free to mutate it. */
|
||||
export function defaultConfig(): AlmConfig {
|
||||
return {
|
||||
buckets: DEFAULT_BUCKETS.map((b) => ({ ...b })),
|
||||
limits: DEFAULT_LIMITS.map((l) => ({ ...l })),
|
||||
// RBI (2008 UCB circular): under a month overdue is expected within 14 days; older goes to 29 days-3 months.
|
||||
overdue: { recent: 0, old: 2 },
|
||||
items: SEED.map((s, i) => ({
|
||||
...s,
|
||||
level: s.level ?? 1,
|
||||
id: `d${i + 1}`,
|
||||
spreads: s.spreads ?? false,
|
||||
placements: s.placements.map((p) => ({ ...p }))
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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 { AlmBucket, AlmSpan } from './alm-bucket-breakdown.config';
|
||||
|
||||
/**
|
||||
* The day arithmetic behind the Structural Liquidity Statement: how many days a maturity is from the
|
||||
* as-on date, and which bucket that lands it in. The statement and this screen both use it, so what
|
||||
* the screen shows is what the return will do.
|
||||
*
|
||||
* Dates are plain calendar days ('yyyy-MM-dd'), never timestamps, so the browser's time zone and
|
||||
* daylight saving cannot move a maturity into the next bucket.
|
||||
*/
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** Where the first bucket starts unless told otherwise: the day after the as-on date. */
|
||||
const DAY_ONE: AlmSpan = { n: 1, unit: 'day' };
|
||||
|
||||
const MONTHS = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
|
||||
/** One bucket laid over a calendar, for a given as-on date. */
|
||||
export interface BucketWindow {
|
||||
label: string;
|
||||
/** First day the bucket takes. */
|
||||
from: Date;
|
||||
/** Last day the bucket takes, or null when it runs on without end. */
|
||||
to: Date | null;
|
||||
/** Days from the as-on date to the first / last day. */
|
||||
fromDays: number;
|
||||
toDays: number | null;
|
||||
}
|
||||
|
||||
/** 'yyyy-MM-dd' to a local calendar date; null when it is not a real date. */
|
||||
export function parseIsoDate(iso: string | null | undefined): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? '');
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
return date.getFullYear() === Number(m[1]) && date.getMonth() === Number(m[2]) - 1 && date.getDate() === Number(m[3])
|
||||
? date
|
||||
: null;
|
||||
}
|
||||
|
||||
/** '30-Jun-2026'. */
|
||||
export function formatDate(date: Date): string {
|
||||
return `${String(date.getDate()).padStart(2, '0')}-${MONTHS[date.getMonth()]}-${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** Whole days from one calendar date to another; negative when `to` is earlier. */
|
||||
export function daysBetween(from: Date, to: Date): number {
|
||||
const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
|
||||
const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
|
||||
return Math.round((b - a) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* The date a span reaches. Months and years are calendar months and years, and a day that does not
|
||||
* exist in the target month falls back to that month's last day (31 Jan + 1 month = 28/29 Feb).
|
||||
*/
|
||||
export function addSpan(from: Date, span: AlmSpan): Date {
|
||||
if (span.unit === 'day') {
|
||||
return new Date(from.getFullYear(), from.getMonth(), from.getDate() + span.n);
|
||||
}
|
||||
const months = span.unit === 'year' ? span.n * 12 : span.n;
|
||||
const target = new Date(from.getFullYear(), from.getMonth() + months, 1);
|
||||
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
||||
return new Date(target.getFullYear(), target.getMonth(), Math.min(from.getDate(), lastDay));
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the bucket a maturity falls in. A maturity on or before the as-on date is already due, so
|
||||
* it is slotted in the first bucket rather than falling out of the statement.
|
||||
*/
|
||||
export function bucketIndexFor(asOn: Date, maturity: Date, buckets: AlmBucket[]): number {
|
||||
if (!buckets.length || maturity <= asOn) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const upTo = buckets[i].upTo;
|
||||
if (!upTo || maturity <= addSpan(asOn, upTo)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return buckets.length - 1;
|
||||
}
|
||||
|
||||
/** Each bucket as a run of calendar days from the as-on date, in bucket order. */
|
||||
export function bucketWindows(asOn: Date, buckets: AlmBucket[]): BucketWindow[] {
|
||||
let from = addSpan(asOn, fromSpanOf(buckets, 0) ?? DAY_ONE);
|
||||
return buckets.map((b) => {
|
||||
const to = b.upTo ? addSpan(asOn, b.upTo) : null;
|
||||
const window: BucketWindow = {
|
||||
label: b.label,
|
||||
from,
|
||||
to,
|
||||
fromDays: daysBetween(asOn, from),
|
||||
toDays: to ? daysBetween(asOn, to) : null
|
||||
};
|
||||
if (to) {
|
||||
from = new Date(to.getFullYear(), to.getMonth(), to.getDate() + 1);
|
||||
}
|
||||
return window;
|
||||
});
|
||||
}
|
||||
|
||||
/** 'Days 1 to 14', 'Days 29 to 92', 'Day 1827 onwards'. */
|
||||
export function dayRangeText(w: BucketWindow): string {
|
||||
return w.toDays === null ? `Day ${w.fromDays} onwards` : `Days ${w.fromDays} to ${w.toDays}`;
|
||||
}
|
||||
|
||||
// ── The bucket ranges as a From / To table ──
|
||||
|
||||
/** '14 d', '3 m', '1 y' — how a span is written in the From / To table. */
|
||||
export function spanText(span: AlmSpan): string {
|
||||
return `${span.n} ${span.unit === 'day' ? 'd' : span.unit === 'month' ? 'm' : 'y'}`;
|
||||
}
|
||||
|
||||
/** '14 days', '1 month', '5 years' — a span in words, as the statement's headings put it. */
|
||||
function spanWords(span: AlmSpan): string {
|
||||
const word = span.unit === 'day' ? 'day' : span.unit === 'month' ? 'month' : 'year';
|
||||
return `${span.n} ${word}${span.n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where bucket `index` starts, as it is written in the table: the first starts on day 1, a bucket
|
||||
* after one that ends in days starts the day after (14 d then 15 d), and one after a month or year
|
||||
* starts "over" it (3 m then 3 m). Null for the first bucket, whose start is fixed.
|
||||
*/
|
||||
export function fromSpanOf(buckets: AlmBucket[], index: number): AlmSpan | null {
|
||||
if (index === 0) {
|
||||
return { ...(buckets[0]?.startSpan ?? DAY_ONE) };
|
||||
}
|
||||
const previous = buckets[index - 1].upTo;
|
||||
if (!previous) {
|
||||
return null;
|
||||
}
|
||||
return previous.unit === 'day' ? { n: previous.n + 1, unit: 'day' } : { ...previous };
|
||||
}
|
||||
|
||||
/** Move the start of bucket `index`, which is the end of the one before it. */
|
||||
export function setFromSpan(buckets: AlmBucket[], index: number, from: AlmSpan): void {
|
||||
const n = Number(from.n) || 1;
|
||||
if (index === 0) {
|
||||
// Day 1 is the ordinary start, so it is stored as no override at all.
|
||||
buckets[0].startSpan = n === 1 && from.unit === 'day' ? undefined : { n, unit: from.unit };
|
||||
return;
|
||||
}
|
||||
buckets[index - 1].upTo = from.unit === 'day' ? { n: Math.max(1, n - 1), unit: 'day' } : { n, unit: from.unit };
|
||||
}
|
||||
|
||||
/**
|
||||
* The column heading a range deserves, worded the way the filed statement words them:
|
||||
* '1 to 14 days', '29 days and upto 3 months', 'Over 3 months and upto 6 months', 'Over 5 years'.
|
||||
*/
|
||||
export function bucketLabel(buckets: AlmBucket[], index: number): string {
|
||||
const to = buckets[index]?.upTo ?? null;
|
||||
const previous = index > 0 ? buckets[index - 1].upTo : null;
|
||||
if (index === 0) {
|
||||
const start = buckets[0].startSpan;
|
||||
if (start) {
|
||||
if (start.unit === 'day') {
|
||||
return !to
|
||||
? `Over ${spanWords(start)}`
|
||||
: to.unit === 'day'
|
||||
? `${start.n} to ${to.n} days`
|
||||
: `${start.n} days and upto ${spanWords(to)}`;
|
||||
}
|
||||
return to ? `Over ${spanWords(start)} and upto ${spanWords(to)}` : `Over ${spanWords(start)}`;
|
||||
}
|
||||
if (to?.unit === 'day' && to.n === 1) {
|
||||
return 'Next day';
|
||||
}
|
||||
return !to ? 'Over 0 days' : to.unit === 'day' ? `1 to ${to.n} days` : `1 day and upto ${spanWords(to)}`;
|
||||
}
|
||||
if (!previous) {
|
||||
return buckets[index].label;
|
||||
}
|
||||
if (previous.unit === 'day') {
|
||||
const from = previous.n + 1;
|
||||
if (!to) {
|
||||
return `Over ${spanWords(previous)}`;
|
||||
}
|
||||
if (to.unit === 'day' && to.n === from) {
|
||||
return `Day ${from}`;
|
||||
}
|
||||
return to.unit === 'day' ? `${from} to ${to.n} days` : `${from} days and upto ${spanWords(to)}`;
|
||||
}
|
||||
return to ? `Over ${spanWords(previous)} and upto ${spanWords(to)}` : `Over ${spanWords(previous)}`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** One product's total, split by bucket, in rupees. */
|
||||
export interface MaturityGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
side: 'liability' | 'asset';
|
||||
accounts: number;
|
||||
total: number;
|
||||
buckets: number[];
|
||||
}
|
||||
|
||||
/** Loans reported apart from the date buckets, with the reason they are not slotted by date. */
|
||||
export interface NotSlotted {
|
||||
key: string;
|
||||
label: string;
|
||||
accounts: number;
|
||||
outstanding: number;
|
||||
why: string;
|
||||
}
|
||||
|
||||
export interface MaturityBreakdown {
|
||||
asOnDate: string;
|
||||
bucketCount: number;
|
||||
groups: MaturityGroup[];
|
||||
notSlotted: NotSlotted[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** What the screen asks the server for: the date, the bank's own bucket edges and its overdue rule. */
|
||||
export interface MaturityQuery {
|
||||
asOnDate: string;
|
||||
/** Edges like '14d,28d,3m,6m,1y,3y,5y'. */
|
||||
buckets: string;
|
||||
overdueRecentBucket: number;
|
||||
overdueOldBucket: number;
|
||||
}
|
||||
|
||||
/** One account found by a search, to be picked. */
|
||||
export interface AccountHit {
|
||||
/** FD, RD or LOAN. */
|
||||
kind: 'FD' | 'RD' | 'LOAN';
|
||||
id: number;
|
||||
accountNo: string;
|
||||
customer: string | null;
|
||||
product: string;
|
||||
/** Maturity date of a deposit; null for a loan. */
|
||||
date: string | null;
|
||||
/** Deposit balance, or loan principal outstanding. */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** One deposit, or one unpaid loan instalment, of the accounts picked. */
|
||||
export interface AccountRow {
|
||||
id: number;
|
||||
accountNo: string;
|
||||
customer: string | null;
|
||||
product: string;
|
||||
kind: string;
|
||||
/** Loan instalment number, for term loans. */
|
||||
installment?: number;
|
||||
/** Maturity date of a deposit, or due date of an instalment. */
|
||||
date: string | null;
|
||||
/** Days from the as-on date to that date; negative once it has passed. */
|
||||
days: number | null;
|
||||
status: string;
|
||||
/** Position of the bucket it falls in. */
|
||||
bucket: number | null;
|
||||
amount: number;
|
||||
/** Why the row needs a second look, for an NPA or a cash-credit / overdraft loan. */
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface SelectedReport {
|
||||
asOnDate: string;
|
||||
bucketCount: number;
|
||||
count: number;
|
||||
total: number;
|
||||
rows: AccountRow[];
|
||||
}
|
||||
|
||||
/** Fixed-term deposits and loans of the bank's customers, slotted into residual-maturity buckets. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AlmMaturityService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getBreakdown(query: MaturityQuery): Observable<MaturityBreakdown> {
|
||||
const params = new HttpParams()
|
||||
.set('asOnDate', query.asOnDate)
|
||||
.set('buckets', query.buckets)
|
||||
.set('overdueRecentBucket', query.overdueRecentBucket)
|
||||
.set('overdueOldBucket', query.overdueOldBucket);
|
||||
return this.http.get<MaturityBreakdown>('/almreturn/maturity-breakdown', { params });
|
||||
}
|
||||
|
||||
/** Find accounts by account number or customer name; at least two characters. A kind of FD, RD or LOAN narrows it. */
|
||||
searchAccounts(q: string, kind: string): Observable<{ results: AccountHit[] }> {
|
||||
let params = new HttpParams().set('q', q);
|
||||
if (kind) {
|
||||
params = params.set('kind', kind);
|
||||
}
|
||||
return this.http.get<{ results: AccountHit[] }>('/almreturn/maturity-breakdown/search', { params });
|
||||
}
|
||||
|
||||
/** The day-wise breakdown of the picked accounts, given as KIND:id pairs like FD:12,RD:5,LOAN:33. */
|
||||
getSelected(accounts: string, query: MaturityQuery): Observable<SelectedReport> {
|
||||
const params = new HttpParams()
|
||||
.set('accounts', accounts)
|
||||
.set('asOnDate', query.asOnDate)
|
||||
.set('buckets', query.buckets)
|
||||
.set('overdueRecentBucket', query.overdueRecentBucket)
|
||||
.set('overdueOldBucket', query.overdueOldBucket);
|
||||
return this.http.get<SelectedReport>('/almreturn/maturity-breakdown/selected', { params });
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
RBI Liabilities Register
|
||||
</div>
|
||||
<h2 class="dialog-title">Demand & Time Liabilities</h2>
|
||||
<p class="dialog-sub">NDTL · CRR/SLR Computation Base · As per RBI Master Circular</p>
|
||||
<p class="dialog-sub">What the bank owes, and what it must hold against it</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
|
||||
@@ -45,36 +45,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<!-- What the liabilities are, and what they come to. Four cards rather than five: the CRR
|
||||
requirement that used to sit here is on the compliance bar below, beside what is held
|
||||
against it, where it answers a question rather than repeating one. -->
|
||||
<div class="summary-row">
|
||||
<div class="summary-card sc-demand">
|
||||
<div class="sc-label">Total Demand Liabilities</div>
|
||||
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
|
||||
<div class="sc-sub">Payable on demand</div>
|
||||
<div class="sc-label">Demand liabilities</div>
|
||||
<div class="sc-value" [title]="money(totalDemand)">{{ tile(totalDemand) }}</div>
|
||||
<div class="sc-sub">Repayable on demand</div>
|
||||
</div>
|
||||
<div class="summary-card sc-time">
|
||||
<div class="sc-label">Total Time Liabilities</div>
|
||||
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
|
||||
<div class="sc-sub">Payable on maturity</div>
|
||||
<div class="sc-label">Time liabilities</div>
|
||||
<div class="sc-value" [title]="money(totalTime)">{{ tile(totalTime) }}</div>
|
||||
<div class="sc-sub">Repayable on maturity</div>
|
||||
</div>
|
||||
<div class="summary-card sc-asset">
|
||||
<div class="sc-label">Assets with Banking System</div>
|
||||
<div class="sc-value">
|
||||
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
|
||||
<div class="sc-label">Held with other banks</div>
|
||||
<!-- Scaled on the tile, exact on hover: the card is read for order of magnitude, but the
|
||||
figure behind it should never be more than a pointer away. -->
|
||||
<div class="sc-value" [title]="money(totalBankingAssets)">
|
||||
{{ tile(totalBankingAssets) }}
|
||||
</div>
|
||||
<!-- These assets net against what the bank OWES other banks, not against the whole base, and
|
||||
the netting floors at zero — so where they exceed what is owed, nothing comes off at all.
|
||||
Said plainly, because "deducted from NDTL" was being printed on a card where no
|
||||
deduction had been made. -->
|
||||
<div class="sc-sub" [class.dn]="bankingAssetsNet">
|
||||
{{ bankingAssetsNet ? 'Reduces the base below' : 'More than we owe banks — base unchanged' }}
|
||||
</div>
|
||||
<div class="sc-change dn">↓ Deducted from NDTL</div>
|
||||
</div>
|
||||
<div class="summary-card sc-ndtl">
|
||||
<div class="sc-label">Net Demand & Time Liabilities (NDTL)</div>
|
||||
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
|
||||
<div class="sc-sub">After deducting banking assets</div>
|
||||
</div>
|
||||
<div class="summary-card sc-crr">
|
||||
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
|
||||
(3%)" while Admin holds 4% states a rate the bank is not on. -->
|
||||
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
|
||||
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
|
||||
<div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
|
||||
<div class="sc-label">NDTL — the base</div>
|
||||
<div class="sc-value" [title]="money(ndtl)">{{ tile(ndtl) }}</div>
|
||||
<div class="sc-sub">Demand + time, net of bank balances</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,26 +97,33 @@
|
||||
<span class="tab-pill pill-time">02</span> Time Liabilities
|
||||
</button>
|
||||
<button class="tab-btn" [class.active]="activeTab === 'assets'" (click)="activeTab = 'assets'">
|
||||
<span class="tab-pill pill-asset">03</span> Assets with Banking System
|
||||
<span class="tab-pill pill-asset">03</span> Held with other banks
|
||||
</button>
|
||||
<button class="tab-btn" [class.active]="activeTab === 'slr'" (click)="activeTab = 'slr'">
|
||||
<span class="tab-pill pill-slr">04</span> SLR — Liquid Assets
|
||||
<span class="tab-pill pill-slr">04</span> SLR assets
|
||||
</button>
|
||||
<!-- Where the reserve is actually held, and what it holds TODAY. The four tabs above report the
|
||||
fortnight close the return is made up to; this one answers the question a treasury desk
|
||||
asks every morning — as things stand now, are we short, and which account do we cover it
|
||||
into. A close two weeks old cannot say that, because the balances have since moved. -->
|
||||
<button class="tab-btn" [class.active]="activeTab === 'held'" (click)="activeTab = 'held'">
|
||||
<span class="tab-pill pill-held">05</span> Our accounts today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Demand Liabilities Table -->
|
||||
@if (activeTab === 'demand') {
|
||||
<div class="section-desc">Amounts payable on demand — RBI Schedule II</div>
|
||||
<div class="section-desc">Money the bank must repay whenever a customer asks for it.</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Liability Item</th>
|
||||
<th>Account Code</th>
|
||||
<th class="right">Opening (₹ Cr)</th>
|
||||
<th class="right">Current (₹ Cr)</th>
|
||||
<th class="right">Change (₹ Cr)</th>
|
||||
<th>Item</th>
|
||||
<th class="right">Opening</th>
|
||||
<th class="right">Current</th>
|
||||
<th class="right">Change</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -167,17 +177,17 @@
|
||||
|
||||
<!-- Time Liabilities Table -->
|
||||
@if (activeTab === 'time') {
|
||||
<div class="section-desc">Amounts payable after a specified period — RBI Schedule III</div>
|
||||
<div class="section-desc">Money the bank repays only when the deposit matures.</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Liability Item</th>
|
||||
<th>Account Code</th>
|
||||
<th class="right">Opening (₹ Cr)</th>
|
||||
<th class="right">Current (₹ Cr)</th>
|
||||
<th class="right">Change (₹ Cr)</th>
|
||||
<th>Item</th>
|
||||
<th class="right">Opening</th>
|
||||
<th class="right">Current</th>
|
||||
<th class="right">Change</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -231,17 +241,20 @@
|
||||
|
||||
<!-- Assets with Banking System Table -->
|
||||
@if (activeTab === 'assets') {
|
||||
<div class="section-desc">Deducted from gross liabilities to arrive at NDTL — RBI Master Circular</div>
|
||||
<div class="section-desc">
|
||||
What the bank keeps with other banks. This comes off what it owes them — and only off that, so where it is the
|
||||
larger of the two the base is left as it is.
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Asset Item</th>
|
||||
<th>Account Code</th>
|
||||
<th class="right">Opening (₹ Cr)</th>
|
||||
<th class="right">Current (₹ Cr)</th>
|
||||
<th class="right">Change (₹ Cr)</th>
|
||||
<th>Item</th>
|
||||
<th class="right">Opening</th>
|
||||
<th class="right">Current</th>
|
||||
<th class="right">Change</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -275,7 +288,12 @@
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
|
||||
<strong>Total Assets with Banking System</strong>
|
||||
<span class="deduct-tag">{{
|
||||
bankingAssetsNet
|
||||
? '( Deducted from NDTL )'
|
||||
: '( Netted against item I, which is smaller — no deduction )'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
|
||||
<td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
|
||||
@@ -289,20 +307,18 @@
|
||||
|
||||
<!-- SLR - Liquid Assets Table -->
|
||||
@if (activeTab === 'slr') {
|
||||
<div class="section-desc">
|
||||
SLR = (Liquid Assets / NDTL) × 100 — RBI Sec 24(2A) of Banking Regulation Act, 1949
|
||||
</div>
|
||||
<div class="section-desc">Cash, bank balances and securities the bank holds against its SLR requirement.</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Liquid Asset</th>
|
||||
<th>Account Code</th>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th class="right">Opening (₹ Cr)</th>
|
||||
<th class="right">Current (₹ Cr)</th>
|
||||
<th class="right">Change (₹ Cr)</th>
|
||||
<th class="right">Opening</th>
|
||||
<th class="right">Current</th>
|
||||
<th class="right">Change</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -348,119 +364,141 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- SLR Formula -->
|
||||
<!-- How the ratio is struck. The "× 100" box that stood here was a term whose value was 100 and
|
||||
whose label was 100 — it took a quarter of the row to say that a fraction is written as a
|
||||
percentage. -->
|
||||
<div class="formula-block slr-formula">
|
||||
<div class="formula-title">SLR Computation — (Liquid Assets / NDTL) × 100</div>
|
||||
<div class="formula-title">How the SLR ratio is worked out</div>
|
||||
<div class="formula-row">
|
||||
<div class="formula-term ft-slr">
|
||||
<div class="formula-term-label">Liquid Assets</div>
|
||||
<div class="formula-term-val">₹13,238.80</div>
|
||||
<div class="formula-term-sub">6 asset categories</div>
|
||||
<div class="formula-term-label">Assets we hold</div>
|
||||
<div class="formula-term-val">{{ money(totalLiquidAssets) }}</div>
|
||||
<div class="formula-term-sub">{{ liquidAssets.length }} categories</div>
|
||||
</div>
|
||||
<div class="formula-op">÷</div>
|
||||
<div class="formula-term ft-ndtl">
|
||||
<div class="formula-term-label">NDTL</div>
|
||||
<div class="formula-term-val">₹69,221.30</div>
|
||||
<div class="formula-term-sub">Net Demand & Time</div>
|
||||
</div>
|
||||
<div class="formula-op">×</div>
|
||||
<div class="formula-term" style="border-color: #e2e8f0; background: #f8fafc">
|
||||
<div class="formula-term-label" style="color: #64748b">100</div>
|
||||
<div class="formula-term-val" style="color: #334155">100</div>
|
||||
<div class="formula-term-sub"> </div>
|
||||
<div class="formula-term-label">NDTL base</div>
|
||||
<div class="formula-term-val">{{ money(baseNdtl) }}</div>
|
||||
<div class="formula-term-sub">as at {{ baseDateStr || '—' }}</div>
|
||||
</div>
|
||||
<div class="formula-op equals">=</div>
|
||||
<div class="formula-term ft-slr-result">
|
||||
<div class="formula-term-label">Actual SLR</div>
|
||||
<div class="formula-term-label">SLR held</div>
|
||||
<div class="formula-term-val">{{ slrActualPercent }}%</div>
|
||||
<div class="formula-term-sub">Min required: {{ slrRate }}%</div>
|
||||
<div class="formula-term-sub">minimum {{ slrRate }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLR Status Bar -->
|
||||
<div class="slr-status-bar">
|
||||
<div class="slr-status-left">
|
||||
<div class="slr-status-title">SLR Compliance Status</div>
|
||||
<div class="slr-status-desc">
|
||||
Actual: {{ slrActualPercent }}% · Required: {{ slrRate }}.00% · NDTL: ₹{{
|
||||
ndtl | number: '1.2-2'
|
||||
}}
|
||||
Cr
|
||||
</div>
|
||||
</div>
|
||||
<div class="slr-status-calcs">
|
||||
<div class="slr-calc">
|
||||
<div class="slr-calc-label">SLR Required ({{ slrRate }}%)</div>
|
||||
<div class="slr-calc-val">₹{{ slrRequired | number: '1.2-2' }} Cr</div>
|
||||
</div>
|
||||
<div class="slr-calc">
|
||||
<div class="slr-calc-label">Liquid Assets Held</div>
|
||||
<div class="slr-calc-val">₹{{ totalLiquidAssets | number: '1.2-2' }} Cr</div>
|
||||
</div>
|
||||
<div class="slr-calc">
|
||||
<div class="slr-calc-label">Surplus</div>
|
||||
<div class="slr-calc-val surplus">₹{{ slrSurplus | number: '1.2-2' }} Cr</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The SLR compliance bar that used to sit here said the same three figures as the row above
|
||||
it and the bar below it — required, held, surplus, three times on one screen. It is gone;
|
||||
the compliance bar at the foot carries both ratios for every tab. -->
|
||||
}
|
||||
|
||||
<!-- NDTL Formula Block -->
|
||||
<div class="formula-block">
|
||||
<div class="formula-title">
|
||||
NDTL Computation — Formula Breakdown · Data as of {{ reportingDateStr }}
|
||||
<!-- Required against held, for both ratios. Always on screen, under whichever tab is open:
|
||||
whether the bank is short is the one thing a reader must not have to go looking for. -->
|
||||
<div class="crr-bar">
|
||||
<div class="crr-text">
|
||||
<div class="crr-title">Are we meeting the requirement?</div>
|
||||
<div class="crr-desc">
|
||||
Charged on the NDTL of {{ money(baseNdtl) }} as at {{ baseDateStr || reportingDateStr }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-row">
|
||||
<div class="formula-term ft-demand">
|
||||
<div class="formula-term-label">Demand Liabilities</div>
|
||||
<div class="formula-term-val">₹{{ totalDemand | number: '1.2-2' }}</div>
|
||||
<div class="formula-term-sub">16 line items</div>
|
||||
</div>
|
||||
<div class="formula-op">+</div>
|
||||
<div class="formula-term ft-time">
|
||||
<div class="formula-term-label">Time Liabilities</div>
|
||||
<div class="formula-term-val">₹{{ totalTime | number: '1.2-2' }}</div>
|
||||
<div class="formula-term-sub">13 line items</div>
|
||||
</div>
|
||||
<div class="formula-op minus">−</div>
|
||||
<div class="formula-term ft-asset">
|
||||
<div class="formula-term-label">Assets with Banking</div>
|
||||
<div class="formula-term-val">₹{{ totalBankingAssets | number: '1.2-2' }}</div>
|
||||
<div class="formula-term-sub">3 line items</div>
|
||||
</div>
|
||||
<div class="formula-op equals">=</div>
|
||||
<div class="formula-term ft-ndtl">
|
||||
<div class="formula-term-label">NDTL</div>
|
||||
<div class="formula-term-val">₹{{ ndtl | number: '1.2-2' }}</div>
|
||||
<div class="formula-term-sub">CRR & SLR base</div>
|
||||
<!-- Required AND held, side by side. The requirement alone answers half the question: a screen
|
||||
headed "Reserve Requirements" that states ₹2.10 Cr without saying what is against it sends
|
||||
a reader to another tab to find out whether the bank is short — which is the one thing they
|
||||
opened this bar to learn. -->
|
||||
<div class="crr-calcs">
|
||||
@for (r of positionRatios(); track r.name) {
|
||||
<div class="crr-calc" [class.crr-calc--short]="!r.met">
|
||||
<!-- A ratio with no rate on record is said to have none. It used to print "SLR @ null%" and
|
||||
a surplus of nothing, which reads as a requirement met rather than one not set. -->
|
||||
<div class="crr-calc-label">{{ r.name }}{{ r.rate === null ? ' — rate not set' : ' @ ' + r.rate + '%' }}</div>
|
||||
<div class="crr-calc-val">{{ money(r.required) }}</div>
|
||||
<div class="crr-calc-held">
|
||||
held {{ money(r.maintained) }}
|
||||
<span [class.crr-short]="!r.met">
|
||||
· {{ r.met ? '+' + money(r.surplus) : 'short ' + money(r.shortfall) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="crr-calc">
|
||||
<div class="crr-calc-label">Total Reserve</div>
|
||||
<div class="crr-calc-val">{{ money(totalReserve) }}</div>
|
||||
<div class="crr-calc-held">CRR + SLR required</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CRR/SLR Bar -->
|
||||
<div class="crr-bar">
|
||||
<div class="crr-text">
|
||||
<div class="crr-title">CRR & SLR Reserve Requirements — RBI Computation on NDTL</div>
|
||||
<div class="crr-desc">
|
||||
Based on NDTL of ₹{{ ndtl | number: '1.2-2' }} Cr · Data as of {{ reportingDateStr }} (45-day lag)
|
||||
</div>
|
||||
<!-- Reserve accounts, as they stand today -->
|
||||
@if (activeTab === 'held') {
|
||||
<div class="section-desc">
|
||||
Where the cash reserve is held, with each account's balance on the business date
|
||||
<strong>{{ position?.onDate || '—' }}</strong
|
||||
>. The requirement below is charged on <strong>{{ position?.baseDate || '—' }}</strong
|
||||
>, a fortnight back — RBI's lag is a rule, so today's own liabilities settle nothing yet.
|
||||
</div>
|
||||
<div class="crr-calcs">
|
||||
<div class="crr-calc">
|
||||
<div class="crr-calc-label">CRR @ {{ crrRate }}.00%</div>
|
||||
<div class="crr-calc-val">₹{{ crrRequired | number: '1.2-2' }} Cr</div>
|
||||
|
||||
@if (position) {
|
||||
<!-- Required against held, for each ratio. Stated before the accounts because a desk reads
|
||||
the gap first and only then asks which account to move money into. -->
|
||||
<div class="held-ratios">
|
||||
@for (r of positionRatios(); track r.name) {
|
||||
<div class="held-ratio" [class.held-ratio--short]="!r.met">
|
||||
<div class="held-ratio__name">{{ r.name }}{{ r.rate === null ? '' : ' @ ' + r.rate + '%' }}</div>
|
||||
<div class="held-ratio__grid">
|
||||
<span>Required</span><strong>{{ money(r.required) }}</strong> <span>Maintained</span
|
||||
><strong>{{ money(r.maintained) }}</strong>
|
||||
<span>{{ r.met ? 'Surplus' : 'Shortfall' }}</span>
|
||||
<strong [class.held-short]="!r.met">{{ money(r.met ? r.surplus : r.shortfall) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="crr-calc">
|
||||
<div class="crr-calc-label">SLR @ {{ slrRate }}.00%</div>
|
||||
<div class="crr-calc-val">₹{{ slrRequired | number: '1.2-2' }} Cr</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Account</th>
|
||||
<th>Form 1 head</th>
|
||||
<th class="right">Balance today</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of position.accounts; track a.glAccountId) {
|
||||
<tr>
|
||||
<td class="sl">{{ $index + 1 }}</td>
|
||||
<td>
|
||||
<div class="item-name">{{ a.name }}</div>
|
||||
<div class="item-sub mono">{{ a.glCode }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ a.item }} · {{ a.head }}
|
||||
<!-- An account the return does not read is named as such. It can still be moved
|
||||
into — the money is real — but adding it to what is maintained would have
|
||||
this screen claim a compliance the filed form does not. -->
|
||||
@if (!a.countsTowardMaintained) {
|
||||
<span class="held-chip">not counted on the return</span>
|
||||
}
|
||||
</td>
|
||||
<td class="right amt">{{ money(a.balance) }}</td>
|
||||
<td class="right">
|
||||
<button class="held-move" (click)="openTransfer(a)">
|
||||
<fa-icon icon="exchange-alt"></fa-icon>
|
||||
Transfer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="crr-calc">
|
||||
<div class="crr-calc-label">Total Reserve</div>
|
||||
<div class="crr-calc-val">₹{{ totalReserve | number: '1.2-2' }} Cr</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="dialog-footer">
|
||||
|
||||
@@ -158,7 +158,10 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
||||
// ===== Summary Cards =====
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
// Five cards only while each can still hold its figure. A seventy-crore NDTL printed to the
|
||||
// paise needs room, and fixed fifths cut it off at the card edge — the figures were there and
|
||||
// unreadable. Below that width the row wraps to fewer, wider cards instead.
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 14px;
|
||||
padding: 20px 28px;
|
||||
}
|
||||
@@ -167,8 +170,12 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
||||
background: $surface;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
// 18px of side padding on a fifth of the row left the figure barely more than half the card.
|
||||
padding: 16px 14px;
|
||||
position: relative;
|
||||
// A grid item will not shrink below its content by default, which is how a long figure pushed
|
||||
// its own card wider than its column and off the edge of the dialog.
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
@@ -210,16 +217,29 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: $ink3;
|
||||
font-family: $mono;
|
||||
letter-spacing: 1px;
|
||||
// Tightened from 1px: on a five-up row the tracking alone pushed "NET DEMAND & TIME LIABILITIES
|
||||
// (NDTL)" onto a third line and squeezed the figure beneath it.
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
// Wraps rather than clipping — a heading that reads "ASSETS WITH BANKIN" names nothing.
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sc-value {
|
||||
font-size: 20px;
|
||||
// Sized to the card rather than fixed: a figure that outgrows its box is worse than a smaller
|
||||
// one, because the digits that fall off the right are the ones that distinguish it. Floors at
|
||||
// 14px so a long figure shrinks rather than wraps mid-number.
|
||||
font-size: clamp(14px, 1.45vw, 20px);
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
font-family: $mono;
|
||||
// Figures are read by lining their digits up; a proportional fallback would stagger them.
|
||||
font-variant-numeric: tabular-nums;
|
||||
// A rupee figure never breaks across lines — "₹6,35,05," over "967.50" reads as two numbers.
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cr {
|
||||
@@ -232,12 +252,16 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
||||
.sc-change {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sc-sub {
|
||||
font-size: 11px;
|
||||
color: $ink3;
|
||||
margin-top: 4px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.up {
|
||||
@@ -869,3 +893,112 @@ $slr-b: #bae6fd;
|
||||
line-height: 1.6;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* ── Reserve accounts, as they stand today ── */
|
||||
.pill-held {
|
||||
background: #0b5394;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Required against held, per ratio — read before the accounts, because a desk asks how big the
|
||||
gap is before asking which account to cover it from. */
|
||||
.held-ratios {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px 20px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.held-ratio {
|
||||
flex: 1 1 260px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #137333;
|
||||
border-radius: 8px;
|
||||
background: #f8faf9;
|
||||
}
|
||||
|
||||
/* A ratio that is not met is the thing this tab exists to surface. */
|
||||
.held-ratio--short {
|
||||
border-left-color: #c5221f;
|
||||
background: #fdf6f6;
|
||||
}
|
||||
|
||||
.held-ratio__name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #5f6368;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.held-ratio__grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 14px;
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
strong {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.held-short {
|
||||
color: #c5221f;
|
||||
}
|
||||
|
||||
/* An account the filed return does not read. Named rather than hidden: the money is real and can
|
||||
be moved, but it settles nothing on the form. */
|
||||
.held-chip {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
background: #fff4e5;
|
||||
color: #b06000;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.held-move {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #0b5394;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #0b5394;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: #0b5394;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* What is held against each requirement, under the requirement itself. */
|
||||
.crr-calc-held {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: $ink3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A ratio that is not met — on a compliance bar this is the one thing that must not be missed. */
|
||||
.crr-calc--short {
|
||||
border-color: $red;
|
||||
}
|
||||
|
||||
.crr-short,
|
||||
.sc-short {
|
||||
color: $red;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule, DecimalPipe } from '@angular/common';
|
||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatDialog, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
|
||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||
import { ReserveTransferDialogComponent } from './reserve-transfer-dialog.component';
|
||||
|
||||
/** One account under a head, at the base date and the reporting date. */
|
||||
interface CrrSlrRow {
|
||||
@@ -31,6 +33,47 @@ interface CrrSlrRow {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** One reserve account, with what it holds on the business date. */
|
||||
interface ReserveAccount {
|
||||
glAccountId: number;
|
||||
glCode: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
/** The Form 1 item the account answers — V, VI(a), VI(b), VI(c). */
|
||||
item: string;
|
||||
head: string;
|
||||
/**
|
||||
* Whether the filed return reads this balance.
|
||||
*
|
||||
* An account the bank holds but has not mapped is still somewhere money can be moved, so it is
|
||||
* listed — but counting it toward what is maintained would have this screen claim a compliance
|
||||
* the return does not.
|
||||
*/
|
||||
countsTowardMaintained: boolean;
|
||||
}
|
||||
|
||||
/** One ratio's standing: what it needs, what it has, and which way the gap runs. */
|
||||
interface ReserveRatio {
|
||||
required: number;
|
||||
maintained: number;
|
||||
surplus: number;
|
||||
/** Positive — a shortfall is an amount to cover, not a negative surplus. */
|
||||
shortfall: number;
|
||||
met: boolean;
|
||||
}
|
||||
|
||||
/** The reserve position as it stands on the business date. */
|
||||
interface ReservePosition {
|
||||
onDate: string;
|
||||
/** The close the requirement is charged on — a fortnight back, by RBI's lag rule. */
|
||||
baseDate: string;
|
||||
crrPercentage: number | null;
|
||||
slrPercentage: number | null;
|
||||
crr: ReserveRatio;
|
||||
slr: ReserveRatio;
|
||||
accounts: ReserveAccount[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-crr-slr-dialog',
|
||||
standalone: true,
|
||||
@@ -49,8 +92,18 @@ interface CrrSlrRow {
|
||||
export class CrrSlrDialogComponent implements OnInit {
|
||||
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
|
||||
private rbi = inject(RbiCircularsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
|
||||
activeTab: 'demand' | 'time' | 'assets' | 'slr' | 'held' = 'demand';
|
||||
|
||||
/**
|
||||
* The reserve position on the business date — null until it loads, or if it cannot be read.
|
||||
*
|
||||
* Separate from the return the other tabs show: those report a fortnight close, this reports
|
||||
* where the money is now.
|
||||
*/
|
||||
position: ReservePosition | null = null;
|
||||
|
||||
/** The date the figures were struck at, as the return itself reports it. */
|
||||
reportingDateStr = '';
|
||||
@@ -82,7 +135,23 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
totalDemand: number | null = null;
|
||||
totalTime: number | null = null;
|
||||
totalBankingAssets: number | null = null;
|
||||
/**
|
||||
* Whether item III was actually deducted from the base.
|
||||
*
|
||||
* It nets against item I rather than against the whole base, and the netting floors at zero — so
|
||||
* a bank holding more with other banks than it owes them has nothing deducted at all.
|
||||
*/
|
||||
bankingAssetsNet = false;
|
||||
/** Item IV(a) — the bank's own net liabilities on the date being reported. */
|
||||
ndtl: number | null = null;
|
||||
/**
|
||||
* Item IV(b) — the base a fortnight back, which both ratios are actually charged on.
|
||||
*
|
||||
* A separate figure from `ndtl` above and not interchangeable with it: the requirement is struck
|
||||
* on this one, so a ratio or a tile that quotes the other states a percentage of something the
|
||||
* requirement was never computed from.
|
||||
*/
|
||||
baseNdtl: number | null = null;
|
||||
crrRate: number | null = null;
|
||||
slrRate: number | null = null;
|
||||
crrRequired: number | null = null;
|
||||
@@ -98,7 +167,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
|
||||
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
|
||||
*/
|
||||
crore(value: number | null): string {
|
||||
rupees(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—';
|
||||
}
|
||||
@@ -106,9 +175,19 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
|
||||
const toCrore = (v: number | null | undefined): number | null =>
|
||||
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
|
||||
// Today's position, alongside the return below. The two answer different questions and are
|
||||
// fetched separately: the return reports a fortnight close, this reports the balances as they
|
||||
// stand on the business date, which is what a desk can still act on.
|
||||
this.rbi.getReservePosition().subscribe({
|
||||
next: (data: ReservePosition) => (this.position = data),
|
||||
// A position that cannot be read leaves the other four tabs working. The tab simply shows
|
||||
// nothing rather than taking the whole dialog down with it.
|
||||
error: () => (this.position = null)
|
||||
});
|
||||
|
||||
// Figures stay in rupees. They were rounded to crore until now, which on a bank this size threw
|
||||
// away everything that tells one fortnight from the next — a seventy-crore NDTL and the one a
|
||||
// fortnight later both read "₹70 Cr".
|
||||
|
||||
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
|
||||
// at the month end — and this screen should show the close the bank last filed, not a position
|
||||
@@ -139,27 +218,66 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
|
||||
// than recomputed, so this screen and the filed form cannot come to disagree.
|
||||
const lines: any[] = data?.partA?.lines ?? [];
|
||||
// Matched without regard to case: the form prints its heads in capitals — "(A) DEMAND
|
||||
// LIABILITIES :" — and matching them as written left every tile reading a dash while the
|
||||
// figures sat in the response.
|
||||
// Matched without regard to case, and only against lines that actually carry figures. The
|
||||
// form heads item I with "(a) Demand Liabilities:" — a caption with no values — directly
|
||||
// above item II's "(A) DEMAND LIABILITIES :", which holds the figure. Matching on text
|
||||
// alone found the caption first and read a dash off it.
|
||||
const at = (startsWith: string): number | null => {
|
||||
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
|
||||
const want = startsWith.toUpperCase();
|
||||
const line = lines.find(
|
||||
(l: any) =>
|
||||
Array.isArray(l?.values) &&
|
||||
String(l?.label ?? '')
|
||||
.toUpperCase()
|
||||
.startsWith(want)
|
||||
);
|
||||
return line?.values?.[latest] ?? null;
|
||||
};
|
||||
|
||||
this.totalDemand = toCrore(at('(A) Demand liabilities'));
|
||||
this.totalTime = toCrore(at('(B) Time liabilities'));
|
||||
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
|
||||
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
|
||||
// not the bank's liabilities on the day being reported.
|
||||
this.ndtl = toCrore(at('IV. b)'));
|
||||
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
|
||||
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
|
||||
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
|
||||
// Item II's own two lines, matched including the trailing colon the form prints. Item I
|
||||
// carries a "(b) Time Liabilities to the banking system" of its own — a different figure,
|
||||
// and the one a looser match found first.
|
||||
this.totalDemand = at('(A) DEMAND LIABILITIES :');
|
||||
this.totalTime = at('(B) TIME LIABILITIES :');
|
||||
this.totalBankingAssets = at('TOTAL OF III');
|
||||
// Whether item III actually came off the base. The form nets it against item I — the
|
||||
// liabilities OWED TO the banking system — and floors that netting at zero: where the
|
||||
// bank's assets with other banks exceed what it owes them, the excess is NOT deducted and
|
||||
// the base is item II alone. Stating "deducted from NDTL" in that case names a subtraction
|
||||
// the return did not make, and a reader checking the arithmetic finds it does not work.
|
||||
const liabilitiesToBanks = at('TOTAL OF I');
|
||||
this.bankingAssetsNet =
|
||||
liabilitiesToBanks !== null &&
|
||||
this.totalBankingAssets !== null &&
|
||||
liabilitiesToBanks > this.totalBankingAssets;
|
||||
// Two different figures, and the screen needs both.
|
||||
//
|
||||
// IV(a) is the bank's OWN net liabilities on the reporting date — what the demand, time and
|
||||
// banking-asset tiles add up to, so it is the one the formula row must end at. IV(b) is the
|
||||
// base a fortnight back, which is what the requirement is charged on.
|
||||
//
|
||||
// Showing IV(b) as the result of the formula was arithmetic that did not work: three terms
|
||||
// struck on one date reaching a figure struck on another, off by whatever the liabilities
|
||||
// had moved in between.
|
||||
this.ndtl = at('IV. a)');
|
||||
this.baseNdtl = at('IV. b)');
|
||||
this.crrRequired = data?.partB?.required?.[latest] ?? null;
|
||||
this.slrRequired = data?.partC?.required?.[latest] ?? null;
|
||||
this.totalLiquidAssets = data?.partC?.maintained?.[latest] ?? null;
|
||||
|
||||
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
|
||||
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
|
||||
}
|
||||
if (this.ndtl) {
|
||||
// Struck on the base, not on today's own liabilities: the requirement below it is charged
|
||||
// on the base, and a ratio measured against a different denominator from the one the
|
||||
// requirement uses cannot be compared with it — "33.62% against a minimum of 18%" would be
|
||||
// two percentages of two different things.
|
||||
if (this.baseNdtl) {
|
||||
this.slrActualPercent =
|
||||
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
|
||||
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.baseNdtl) * 100).toFixed(2);
|
||||
}
|
||||
if (this.crrRequired !== null && this.slrRequired !== null) {
|
||||
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
|
||||
@@ -209,13 +327,20 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
// The form's own wording, with its numbering trimmed off the front — the table has a column
|
||||
// of its own for that.
|
||||
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
|
||||
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
|
||||
code: line.configKey ?? '—',
|
||||
opening: this.crore(opening),
|
||||
current: this.crore(current),
|
||||
// The form's own numbering — I(a)(ii), II(A), VII(c) — rather than the configuration key
|
||||
// behind it. A reader checking this screen against the filed return looks for the item
|
||||
// number; FORM1_I_A_II_OTHER_DEMAND_BS is the name of a database row and means nothing to
|
||||
// them.
|
||||
sub: '',
|
||||
code: (label.match(/^\s*\(?([IVXivx]+|[A-Za-z0-9]+)\)?[.)]?/) ?? [
|
||||
,
|
||||
'—'
|
||||
])[1] ?? '—',
|
||||
opening: this.rupees(opening),
|
||||
current: this.rupees(current),
|
||||
openingValue: opening,
|
||||
currentValue: current,
|
||||
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
|
||||
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`,
|
||||
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
|
||||
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
|
||||
status: line.configured === false ? 'Not mapped' : 'Active'
|
||||
@@ -235,7 +360,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
*/
|
||||
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
|
||||
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
|
||||
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
|
||||
return values.length ? this.rupees(values.reduce((a, b) => a + b, 0)) : '—';
|
||||
}
|
||||
|
||||
/** The movement across a tab, between the two closes the form reports. */
|
||||
@@ -246,7 +371,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
return '—';
|
||||
}
|
||||
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
|
||||
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
|
||||
return `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`;
|
||||
}
|
||||
|
||||
/** Fills the four tabs from one reading of the form. */
|
||||
@@ -257,16 +382,148 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
};
|
||||
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
|
||||
// maps a new account to a head gets it on the right tab without this screen being changed.
|
||||
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
|
||||
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
|
||||
// III — what the banking system owes this bank, which nets off the liability base.
|
||||
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
|
||||
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
|
||||
// Item II only — what the bank owes its own depositors. Item I's lines are worded the same way
|
||||
// but are owed to the banking system, and the form nets them against item III rather than
|
||||
// adding them here, so a tab headed "payable on demand" that carried both would double-count
|
||||
// the one and misstate the other.
|
||||
const toBankingSystem = (l: string): boolean => has(l, 'to the banking system', 'banking system');
|
||||
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit') && !toBankingSystem(l));
|
||||
this.timeLiabilities = this.rowsFrom(
|
||||
lines,
|
||||
latest,
|
||||
(l) => has(l, 'time liabilit') && !has(l, 'demand and time') && !toBankingSystem(l)
|
||||
);
|
||||
// V, VI and VII — the cash, balances and securities the two ratios are met in.
|
||||
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
|
||||
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
|
||||
// III — what the banking system owes this bank, which nets off the liability base. Matched on
|
||||
// the words the form actually prints: its item III(a) reads "TOTAL OF CREDIT BALANCES IN
|
||||
// CURRENT ACCOUNT maintained with S.B.I...", which the shorter phrases this looked for never
|
||||
// appeared in, so the tab came out empty while its total showed a figure.
|
||||
this.bankingAssets = this.rowsFrom(
|
||||
lines,
|
||||
latest,
|
||||
(l) =>
|
||||
has(
|
||||
l,
|
||||
'credit balances in current account',
|
||||
'other assets with the banking system',
|
||||
'balances in all accounts',
|
||||
'money at call',
|
||||
'advances & any other'
|
||||
) &&
|
||||
// Item I(a)(i) is worded almost identically but is the opposite side of the ledger: what
|
||||
// the banking system holds WITH this bank, a liability. On a tab headed "deducted from
|
||||
// gross liabilities" it would be read as an asset and netted the wrong way.
|
||||
!has(l, 'maintained with the co-operative bank by state')
|
||||
);
|
||||
// V, VI and VII — the cash, balances and securities the two ratios are met in. Item I's own
|
||||
// SBI line is excluded: it is a liability TO the banking system, and listing it among the
|
||||
// assets held against a ratio reads as something the bank can count toward it.
|
||||
this.liquidAssets = this.rowsFrom(
|
||||
lines,
|
||||
latest,
|
||||
(l) =>
|
||||
has(l, 'cash on hand', 'reserve bank of india', 'co.op.bank', 'co-operative bank', 'govt. security') &&
|
||||
!has(l, 'maintained with the co-operative bank by state')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two ratios as rows, so the template states them once rather than twice over.
|
||||
*/
|
||||
positionRatios(): {
|
||||
name: string;
|
||||
rate: number | null;
|
||||
required: number;
|
||||
maintained: number;
|
||||
surplus: number;
|
||||
shortfall: number;
|
||||
met: boolean;
|
||||
}[] {
|
||||
if (!this.position) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ name: 'CRR', rate: this.position.crrPercentage, ...this.position.crr },
|
||||
{ name: 'SLR', rate: this.position.slrPercentage, ...this.position.slr }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A reserve figure in rupees, to the paisa.
|
||||
*
|
||||
* Not rounded like the table cells above: a desk covering a shortfall transfers an exact amount, and
|
||||
* "₹0.37 Cr" is not an amount anyone can send.
|
||||
*/
|
||||
money(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A figure for a summary tile, scaled so it fits however large the bank grows.
|
||||
*
|
||||
* Tiles are read for order of magnitude, so they carry whole rupees up to a crore and switch to
|
||||
* crore above it: ₹70,10,45,062 is nineteen characters and overflows its card, where ₹70.10 Cr
|
||||
* is seven and says the same thing to anyone glancing at it. The exact amount, to the paise, is
|
||||
* on the tables below and on the reserve-accounts tab — which is where anyone acting on a figure
|
||||
* rather than scanning it goes.
|
||||
*
|
||||
* Scaled rather than merely shrunk, because a fixed format only ever fits the balances the bank
|
||||
* happens to hold today. This one holds at four or five characters whether the figure is lakhs
|
||||
* or lakh-crore, so a bank that grows ten-fold does not come back to a row of clipped tiles.
|
||||
*/
|
||||
tile(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
const abs = Math.abs(value);
|
||||
const sign = value < 0 ? '-' : '';
|
||||
// Indian scale: a crore is 1e7, a lakh 1e5. Below a lakh the whole rupee figure is already
|
||||
// short enough that scaling it would hide detail for nothing.
|
||||
if (abs >= 1e7) {
|
||||
return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`;
|
||||
}
|
||||
if (abs >= 1e5) {
|
||||
return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`;
|
||||
}
|
||||
return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move money into or out of a reserve account.
|
||||
*
|
||||
* The entry posts through the platform's own journal command, so it goes to maker-checker like
|
||||
* any other entry against the ledger — the money is real, and a transfer that settles a statutory
|
||||
* requirement is not a thing one person should be able to post alone.
|
||||
*/
|
||||
openTransfer(account: ReserveAccount): void {
|
||||
const ref = this.dialog.open(ReserveTransferDialogComponent, {
|
||||
data: {
|
||||
from: account,
|
||||
// Only the reserve accounts. A transfer out of the cash reserve into something that does
|
||||
// not count toward it would quietly reduce what is maintained, and this screen exists to
|
||||
// keep that figure up.
|
||||
accounts: this.position?.accounts ?? [],
|
||||
// The entry posts against the user's own office — the one whose books hold these accounts.
|
||||
// Falls back to head office, which Fineract seeds as 1, rather than failing to open.
|
||||
officeId: this.auth.getCredentials()?.officeId ?? 1,
|
||||
currencyCode: 'INR'
|
||||
},
|
||||
autoFocus: false
|
||||
});
|
||||
|
||||
ref.afterClosed().subscribe((posted: boolean) => {
|
||||
if (posted) {
|
||||
// The balances on screen are now a step behind the ledger. Read again rather than adjusting
|
||||
// them here: the entry may have gone to maker-checker and not yet moved anything, and a
|
||||
// screen that assumed it had would show a position the bank is not in.
|
||||
this.rbi.getReservePosition().subscribe({
|
||||
next: (data: ReservePosition) => (this.position = data),
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<!--
|
||||
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="rt">
|
||||
<div class="rt-head">
|
||||
<h2 class="rt-title">Transfer between reserve accounts</h2>
|
||||
<button class="rt-x" (click)="close()" aria-label="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The two accounts, the way a desk thinks about them: money leaves one and arrives in the
|
||||
other. The ledger's debit and credit are worked out when the entry posts, because asking a
|
||||
treasury user which side to credit in order to move their own money is asking them to do the
|
||||
ledger's job. -->
|
||||
<div class="rt-from">
|
||||
<div class="rt-field-label">From</div>
|
||||
<div class="rt-acct">
|
||||
<div class="rt-acct-name">{{ from.name }}</div>
|
||||
<div class="rt-acct-sub">{{ from.glCode }} · {{ from.item }} {{ from.head }}</div>
|
||||
</div>
|
||||
<div class="rt-bal">
|
||||
<span class="rt-bal-label">Balance</span>
|
||||
<span class="rt-bal-val">{{ money(from.balance) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rt-arrow"><fa-icon icon="arrow-down"></fa-icon></div>
|
||||
|
||||
<div class="rt-row">
|
||||
<label class="rt-field-label" for="rt-to">To</label>
|
||||
<select id="rt-to" class="rt-input" [(ngModel)]="toAccountId">
|
||||
<option [ngValue]="null">Choose the account to receive it</option>
|
||||
@for (a of destinations; track a.glAccountId) {
|
||||
<option [ngValue]="a.glAccountId">{{ a.glCode }} · {{ a.name }} — {{ money(a.balance) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="rt-row">
|
||||
<label class="rt-field-label" for="rt-amt">Amount to transfer</label>
|
||||
<div class="rt-amount-wrap">
|
||||
<span class="rt-rupee">₹</span>
|
||||
<input
|
||||
id="rt-amt"
|
||||
class="rt-input rt-amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
[(ngModel)]="amount"
|
||||
/>
|
||||
</div>
|
||||
<!-- An account cannot send more than it holds: the ledger would carry a negative balance on a
|
||||
bank account, which is not a position that exists. Said as the amount is typed rather than
|
||||
on submit, so the figure is corrected before anyone commits to it. -->
|
||||
@if (overdrawn) {
|
||||
<div class="rt-warn">More than {{ from.name }} holds. It has {{ money(from.balance) }}.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- What each account would hold once this posts. A transfer is made to reach a balance, so the
|
||||
balance it reaches is the thing worth showing before it is sent. -->
|
||||
@if (to && (amount ?? 0) > 0 && !overdrawn) {
|
||||
<div class="rt-after">
|
||||
<div class="rt-after-row">
|
||||
<span>{{ from.name }}</span>
|
||||
<span
|
||||
>{{ money(from.balance) }} → <strong>{{ money(fromAfter) }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<div class="rt-after-row">
|
||||
<span>{{ to.name }}</span>
|
||||
<span
|
||||
>{{ money(to.balance) }} → <strong>{{ money(toAfter) }}</strong></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rt-row">
|
||||
<label class="rt-field-label" for="rt-ref">Reference <span class="rt-opt">optional</span></label>
|
||||
<input id="rt-ref" class="rt-input" type="text" placeholder="NEFT / RTGS reference" [(ngModel)]="reference" />
|
||||
</div>
|
||||
|
||||
<div class="rt-row">
|
||||
<label class="rt-field-label" for="rt-note">Note <span class="rt-opt">optional</span></label>
|
||||
<input
|
||||
id="rt-note"
|
||||
class="rt-input"
|
||||
type="text"
|
||||
placeholder="Why the money is being moved"
|
||||
[(ngModel)]="comments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- What this does and does not do. The entry is the bank's record of the movement; the money
|
||||
reaches the other bank when the desk sends the NEFT or RTGS. A screen that posted silently
|
||||
would leave someone believing the funds had moved. -->
|
||||
<div class="rt-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
This records the movement in the ledger and goes for approval. Send the NEFT or RTGS separately — the money
|
||||
does not move until you do.
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="rt-error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rt-actions">
|
||||
<button mat-button (click)="close()">Cancel</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
|
||||
{{ submitting ? 'Posting…' : 'Post transfer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$ink: #202124;
|
||||
$ink2: #3c4043;
|
||||
$ink3: #5f6368;
|
||||
$border: #e2e8f0;
|
||||
$primary: #0b5394;
|
||||
$red: #c5221f;
|
||||
$amber: #b06000;
|
||||
$surface: #fff;
|
||||
$mono: 'Roboto Mono', 'Courier New', monospace;
|
||||
|
||||
.rt {
|
||||
padding: 20px 24px 16px;
|
||||
min-width: 420px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.rt-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.rt-title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
}
|
||||
|
||||
.rt-x {
|
||||
border: none;
|
||||
background: none;
|
||||
color: $ink3;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
|
||||
&:hover {
|
||||
color: $ink;
|
||||
}
|
||||
}
|
||||
|
||||
.rt-field-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: $ink3;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rt-opt {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: $ink3;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// The source account — fixed, since the dialog is opened from its row, so it reads as a statement
|
||||
// rather than as another field to fill in.
|
||||
.rt-from {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.rt-acct-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
}
|
||||
|
||||
.rt-acct-sub {
|
||||
font-size: 11px;
|
||||
color: $ink3;
|
||||
font-family: $mono;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rt-bal {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed $border;
|
||||
}
|
||||
|
||||
.rt-bal-label {
|
||||
font-size: 11px;
|
||||
color: $ink3;
|
||||
}
|
||||
|
||||
.rt-bal-val {
|
||||
font-family: $mono;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rt-arrow {
|
||||
text-align: center;
|
||||
color: $primary;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.rt-row {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rt-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: $ink2;
|
||||
background: $surface;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
// The amount carries the rupee inside the field, so the figure is read as money without the sign
|
||||
// being something anyone has to type.
|
||||
.rt-amount-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rt-rupee {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $ink3;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rt-amount {
|
||||
padding-left: 26px;
|
||||
font-family: $mono;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rt-warn {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
// What each account would hold once this posts — a transfer is made to reach a balance, so the
|
||||
// balance it reaches belongs on screen before it is sent.
|
||||
.rt-after {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #cfe3d4;
|
||||
border-radius: 8px;
|
||||
background: #f1f8f3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rt-after-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: $ink2;
|
||||
|
||||
& + & {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-family: $mono;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
// What the entry does and does not do: the money moves when the desk sends the NEFT, not when this
|
||||
// posts, and a dialog that stayed silent would leave someone believing otherwise.
|
||||
.rt-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff8e6;
|
||||
color: $amber;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.rt-error {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fdecea;
|
||||
color: $red;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.rt-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
|
||||
|
||||
/** A reserve account as the position screen lists it. */
|
||||
export interface TransferAccount {
|
||||
glAccountId: number;
|
||||
glCode: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
item: string;
|
||||
head: string;
|
||||
}
|
||||
|
||||
/** What the dialog is opened with: the account clicked, and everything it may move to. */
|
||||
export interface ReserveTransferData {
|
||||
from: TransferAccount;
|
||||
accounts: TransferAccount[];
|
||||
/** The office the entry posts against, and the date it posts on. */
|
||||
officeId: number;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves money between the accounts the cash reserve is held in.
|
||||
*
|
||||
* The entry posts through the platform's own journal command, so it goes to maker-checker like any
|
||||
* other entry against the ledger. That is deliberate: the amounts settle a statutory requirement,
|
||||
* and a transfer that changes whether the bank is compliant is not something one person should be
|
||||
* able to post alone.
|
||||
*
|
||||
* It records the movement; it does not make it. The money reaches the other bank when the treasury
|
||||
* desk sends the NEFT or RTGS, and this entry is the bank's own record of having done so — which is
|
||||
* why the dialog says as much before it posts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-reserve-transfer-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './reserve-transfer-dialog.component.html',
|
||||
styleUrls: ['./reserve-transfer-dialog.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ReserveTransferDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
|
||||
private rbi = inject(RbiCircularsService);
|
||||
|
||||
/** The account money leaves. Fixed — the dialog is opened from its row. */
|
||||
readonly from: TransferAccount;
|
||||
/** Everything it can move to: the other reserve accounts, never itself. */
|
||||
readonly destinations: TransferAccount[];
|
||||
|
||||
toAccountId: number | null = null;
|
||||
amount: number | null = null;
|
||||
reference = '';
|
||||
comments = '';
|
||||
|
||||
submitting = false;
|
||||
error = '';
|
||||
|
||||
readonly data = inject<ReserveTransferData>(MAT_DIALOG_DATA);
|
||||
|
||||
constructor() {
|
||||
this.from = this.data.from;
|
||||
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
|
||||
}
|
||||
|
||||
/** The account chosen to receive the money, while one is chosen. */
|
||||
get to(): TransferAccount | null {
|
||||
return this.destinations.find((a) => a.glAccountId === this.toAccountId) ?? null;
|
||||
}
|
||||
|
||||
/** What the source would hold once this posts — the figure that decides whether it may. */
|
||||
get fromAfter(): number {
|
||||
return this.from.balance - (this.amount ?? 0);
|
||||
}
|
||||
|
||||
get toAfter(): number {
|
||||
return (this.to?.balance ?? 0) + (this.amount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the amount can actually be sent.
|
||||
*
|
||||
* An account cannot send more than it holds: the ledger would carry a negative balance on a cash
|
||||
* or bank account, which is not a position that exists.
|
||||
*/
|
||||
get overdrawn(): boolean {
|
||||
return this.amount !== null && this.amount > this.from.balance;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return !this.submitting && this.toAccountId !== null && (this.amount ?? 0) > 0 && !this.overdrawn;
|
||||
}
|
||||
|
||||
money(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the transfer as a single journal entry.
|
||||
*
|
||||
* These are asset accounts, so the one money LEAVES is credited and the one it arrives in is
|
||||
* debited — the opposite of how the screen words it, and deliberately so: a treasury user thinks
|
||||
* "send from here, receive there", and making them think in debits and credits to move their own
|
||||
* money would be asking them to do the ledger's job.
|
||||
*/
|
||||
submit(): void {
|
||||
if (!this.canSubmit || !this.to) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.error = '';
|
||||
|
||||
// The reserve endpoint, not the manual journal entry one: that is turned off at the HTTP edge
|
||||
// on this installation. The sides are named from and to, as a desk thinks of them; the debit
|
||||
// and credit are worked out on the server, where the accounts can also be checked.
|
||||
const transfer = {
|
||||
fromGlAccountId: this.from.glAccountId,
|
||||
toGlAccountId: this.to.glAccountId,
|
||||
amount: this.amount as number,
|
||||
officeId: this.data.officeId,
|
||||
reference: this.reference || undefined,
|
||||
comments:
|
||||
this.comments || `Reserve transfer: ${this.from.glCode} ${this.from.name} to ${this.to.glCode} ${this.to.name}`
|
||||
};
|
||||
|
||||
this.rbi.transferReserve(transfer).subscribe({
|
||||
next: () => this.dialogRef.close(true),
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
// The platform's own message, not a rewritten one: it names the rule that stopped the
|
||||
// entry — a closed accounting period, a disabled account — and a generic "could not post"
|
||||
// sends the user to look for a fault that has already been explained.
|
||||
this.error =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
'The transfer could not be posted.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,18 @@
|
||||
>
|
||||
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
|
||||
</div>
|
||||
@if (crrSlr.crrGap) {
|
||||
<div class="metric-gap" [class.metric-gap--short]="crrSlr.crrGap.short">{{ crrSlr.crrGap.text }}</div>
|
||||
}
|
||||
<div class="metric-row">
|
||||
<span class="metric-label"
|
||||
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
|
||||
>
|
||||
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
|
||||
</div>
|
||||
@if (crrSlr.slrGap) {
|
||||
<div class="metric-gap" [class.metric-gap--short]="crrSlr.slrGap.short">{{ crrSlr.slrGap.text }}</div>
|
||||
}
|
||||
</div>
|
||||
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
|
||||
says so. Where it cannot compute, the card says that instead of passing the bank. -->
|
||||
|
||||
@@ -899,3 +899,17 @@ $purple: #7c3aed;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// What is held against a requirement, under the requirement itself. Surplus is quiet; a shortfall is
|
||||
// red, because on a compliance card it is the one line that must not be read past.
|
||||
.metric-gap {
|
||||
margin: -2px 0 8px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #137333;
|
||||
}
|
||||
|
||||
.metric-gap--short {
|
||||
color: #c5221f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,19 @@ export class DashboardComponent implements OnInit {
|
||||
slrRate: number | null;
|
||||
crrRequired: string;
|
||||
slrRequired: string;
|
||||
/** Surplus, or the shortfall, per ratio. A shortfall is its own amount, not a negative surplus. */
|
||||
crrGap: { text: string; short: boolean } | null;
|
||||
slrGap: { text: string; short: boolean } | null;
|
||||
status: 'ok' | 'short' | 'unknown';
|
||||
} = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' };
|
||||
} = {
|
||||
crrRate: null,
|
||||
slrRate: null,
|
||||
crrRequired: '—',
|
||||
slrRequired: '—',
|
||||
crrGap: null,
|
||||
slrGap: null,
|
||||
status: 'unknown'
|
||||
};
|
||||
|
||||
/** Array of all user activities */
|
||||
userActivity: string[];
|
||||
@@ -93,8 +104,27 @@ export class DashboardComponent implements OnInit {
|
||||
|
||||
/** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
|
||||
private loadCrrSlr(): void {
|
||||
const crore = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`;
|
||||
// Rupees, not crore. Rounding to crore on a bank this size collapsed the figures to "₹2 Cr"
|
||||
// and "₹13 Cr" — two requirements a crore apart both read the same, and a tile a treasury desk
|
||||
// glances at said nothing it could act on.
|
||||
// Scaled to the Indian crore/lakh so the tile holds its figure however large the bank grows.
|
||||
// Plain rupees ran to nineteen characters on a seventy-crore requirement and overflowed the
|
||||
// card; rounding to crore outright collapsed two requirements a crore apart to the same "₹2
|
||||
// Cr". This keeps four or five characters and two decimals of precision at any scale.
|
||||
const money = (v: number | null | undefined): string => {
|
||||
if (v === null || v === undefined) {
|
||||
return '—';
|
||||
}
|
||||
const abs = Math.abs(v);
|
||||
const sign = v < 0 ? '-' : '';
|
||||
if (abs >= 1e7) {
|
||||
return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`;
|
||||
}
|
||||
if (abs >= 1e5) {
|
||||
return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`;
|
||||
}
|
||||
return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`;
|
||||
};
|
||||
|
||||
// The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
|
||||
// the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
|
||||
@@ -107,8 +137,8 @@ export class DashboardComponent implements OnInit {
|
||||
}
|
||||
this.crrSlr.crrRate = data?.crrPercentage ?? null;
|
||||
this.crrSlr.slrRate = data?.slrPercentage ?? null;
|
||||
this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]);
|
||||
this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]);
|
||||
this.crrSlr.crrRequired = money(data?.partB?.required?.[latest]);
|
||||
this.crrSlr.slrRequired = money(data?.partC?.required?.[latest]);
|
||||
|
||||
// Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
|
||||
// bank short on SLR is not within limits because its cash reserve happens to be ample.
|
||||
@@ -116,6 +146,19 @@ export class DashboardComponent implements OnInit {
|
||||
const crrNeed = data?.partB?.required?.[latest];
|
||||
const slrHeld = data?.partC?.maintained?.[latest];
|
||||
const slrNeed = data?.partC?.required?.[latest];
|
||||
// Each ratio's own standing, under its requirement: surplus where it is met, and the
|
||||
// shortfall — in red, named as one — where it is not. A single "Shortfall" tag said the bank
|
||||
// was short without saying which ratio or by how much, which is the first thing asked.
|
||||
const gap = (held: number | null | undefined, need: number | null | undefined) => {
|
||||
if (held === null || held === undefined || need === null || need === undefined) {
|
||||
return null;
|
||||
}
|
||||
const short = held < need;
|
||||
return { text: `${short ? 'Short' : 'Surplus'} ${money(Math.abs(held - need))}`, short };
|
||||
};
|
||||
this.crrSlr.crrGap = gap(crrHeld, crrNeed);
|
||||
this.crrSlr.slrGap = gap(slrHeld, slrNeed);
|
||||
|
||||
if ([
|
||||
crrHeld,
|
||||
crrNeed,
|
||||
|
||||
Reference in New Issue
Block a user