feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -20,8 +20,6 @@ import { CashierPositionComponent } from './cashier-position/cashier-position.co
|
||||
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
|
||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||
import { DayBookComponent } from './day-book/day-book.component';
|
||||
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
|
||||
import { VoucherViewComponent } from './voucher-view/voucher-view.component';
|
||||
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
||||
import { BranchDayComponent } from './branch-day/branch-day.component';
|
||||
import { VaultPositionComponent } from './vault-position/vault-position.component';
|
||||
@@ -226,20 +224,23 @@ const routes: Routes = [
|
||||
component: DayBookComponent,
|
||||
data: { title: 'Day Book', breadcrumb: 'Day Book' }
|
||||
},
|
||||
// The register is read one side at a time: Received is the CR side, Payment the DR side.
|
||||
// Same component both times — the side it shows comes from `data.side`.
|
||||
//
|
||||
// `cashOnly` is what makes these the cashier's own registers rather than the whole voucher
|
||||
// book: a cashier deals in notes and coins, so only vouchers that actually moved cash
|
||||
// belong here, and the two reports then reconcile against the drawer and the closing
|
||||
// report. A back-office screen wanting every voucher points at the same component without
|
||||
// the flag.
|
||||
{
|
||||
path: 'voucher-exceptions',
|
||||
component: VoucherExceptionsComponent,
|
||||
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
|
||||
},
|
||||
{
|
||||
path: 'voucher',
|
||||
component: VoucherViewComponent,
|
||||
data: { title: 'Voucher', breadcrumb: 'Voucher' }
|
||||
},
|
||||
{
|
||||
path: 'voucher-report',
|
||||
path: 'received-report',
|
||||
component: VoucherReportComponent,
|
||||
data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' }
|
||||
data: { title: 'Received Report', breadcrumb: 'Received Report', side: 'CR', cashOnly: true }
|
||||
},
|
||||
{
|
||||
path: 'payment-report',
|
||||
component: VoucherReportComponent,
|
||||
data: { title: 'Payment Report', breadcrumb: 'Payment Report', side: 'DR', cashOnly: true }
|
||||
},
|
||||
{
|
||||
path: 'closing-report',
|
||||
|
||||
@@ -312,6 +312,47 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
// Names the part of a total that has no row in the table below, so the figures do not read as
|
||||
// failing to add up.
|
||||
&__note {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// The drawer strip sits under the counter totals and is the figure a physical count is checked
|
||||
// against, so it is marked out as the one to read rather than a second row of the same thing.
|
||||
&--balance {
|
||||
margin-top: 10px;
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.closing-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// Says what a row without a denomination is, next to its chip.
|
||||
.closing-row__hint {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// A totals row, marked off from the rows it sums. Defined here rather than only in the day book's
|
||||
// own stylesheet, which the other cashier screens do not load.
|
||||
.pg-table tfoot td {
|
||||
background: #f8fafc;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.pg-step {
|
||||
|
||||
@@ -126,66 +126,6 @@ export class CashierService {
|
||||
return this.http.get('/cashreports/day-book', { params });
|
||||
}
|
||||
|
||||
// ── Voucher physical verification & attachments ──
|
||||
// The voucher number always travels in the body or a query parameter, never as a path segment:
|
||||
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
|
||||
// encoded slashes with a 400 before the request reaches the resource.
|
||||
|
||||
/** Records the outcome of checking a voucher against the paper slip. */
|
||||
verifyVoucher(
|
||||
voucherNo: string,
|
||||
status: 'VERIFIED' | 'EXCEPTION',
|
||||
slipAmount?: number,
|
||||
remarks?: string
|
||||
): Observable<any> {
|
||||
return this.http.post('/vouchers/verify', {
|
||||
voucherNo,
|
||||
status,
|
||||
slipAmount: slipAmount != null ? String(slipAmount) : null,
|
||||
remarks: remarks ?? null,
|
||||
locale: 'en'
|
||||
});
|
||||
}
|
||||
|
||||
uploadVoucherAttachment(
|
||||
voucherNo: string,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
fileData: string,
|
||||
description?: string
|
||||
): Observable<any> {
|
||||
return this.http.post('/vouchers/attachments', {
|
||||
voucherNo,
|
||||
fileName,
|
||||
contentType,
|
||||
fileData,
|
||||
description: description ?? null
|
||||
});
|
||||
}
|
||||
|
||||
listVoucherAttachments(voucherNo: string): Observable<any> {
|
||||
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||
return this.http.get('/vouchers/attachments', { params });
|
||||
}
|
||||
|
||||
deleteVoucherAttachment(id: number): Observable<any> {
|
||||
return this.http.delete(`/vouchers/attachments/${id}`);
|
||||
}
|
||||
|
||||
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
|
||||
downloadVoucherAttachment(id: number): Observable<Blob> {
|
||||
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
|
||||
* raised on Friday still needs answering on Monday.
|
||||
*/
|
||||
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
|
||||
const params = new HttpParams().set('status', status);
|
||||
return this.http.get('/vouchers/exceptions', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* The vouchers raised over a period, one row each, with what was debited and what credited.
|
||||
*
|
||||
@@ -193,7 +133,13 @@ export class CashierService {
|
||||
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
|
||||
* way to choose whose report this is.
|
||||
*/
|
||||
voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable<any> {
|
||||
voucherReport(
|
||||
from: string,
|
||||
to: string,
|
||||
username?: string,
|
||||
voucherType?: string,
|
||||
cashOnly?: boolean
|
||||
): Observable<any> {
|
||||
let params = new HttpParams().set('from', from).set('to', to);
|
||||
if (username) {
|
||||
params = params.set('username', username);
|
||||
@@ -201,25 +147,14 @@ export class CashierService {
|
||||
if (voucherType) {
|
||||
params = params.set('voucherType', voucherType);
|
||||
}
|
||||
// Only sent when asked for: the server treats a missing flag as the full register, so the
|
||||
// cashier screens opt in to cash and the back-office ones keep seeing everything.
|
||||
if (cashOnly) {
|
||||
params = params.set('cashOnly', 'true');
|
||||
}
|
||||
return this.http.get('/vouchers/report', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
|
||||
*
|
||||
* The voucher number travels as a query parameter, not a path segment: numbers are formatted
|
||||
* CR/2708/0001 and the server rejects the encoded slashes in a path.
|
||||
*/
|
||||
voucherDetail(voucherNo: string): Observable<any> {
|
||||
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||
return this.http.get('/vouchers/detail', { params });
|
||||
}
|
||||
|
||||
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
|
||||
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
|
||||
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||
* stood then, and what it holds now.
|
||||
|
||||
@@ -27,12 +27,23 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!showGl && rows.length > 0) {
|
||||
<!-- Shown whenever the day has any cash at all, not only when a denomination breakdown exists:
|
||||
a cash Demand Draft is recorded without one, and gating on rows hid its money entirely. -->
|
||||
@if (!showGl && (rows.length > 0 || ddCashTotal > 0)) {
|
||||
<!-- Summary totals bar -->
|
||||
<div class="closing-summary">
|
||||
<div class="closing-summary__item closing-summary__item--in">
|
||||
<span class="closing-summary__label">Total Deposits (IN)</span>
|
||||
<span class="closing-summary__value">₹{{ depositTotal | number: '1.2-2' }}</span>
|
||||
<span class="closing-summary__value">₹{{ depositTotalWithDd | number: '1.2-2' }}</span>
|
||||
@if (ddCashTotal > 0) {
|
||||
<!-- Named on the tile as well as in the table: this part of the total carries no
|
||||
denomination, so without saying so the figures read as failing to add up. -->
|
||||
<span class="closing-summary__note">
|
||||
incl. ₹{{ ddCashTotal | number: '1.2-2' }} not itemised ({{ ddCashCount }} cash DD{{
|
||||
ddCashCount === 1 ? '' : 's'
|
||||
}})
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="closing-summary__sep">−</div>
|
||||
<div class="closing-summary__item closing-summary__item--out">
|
||||
@@ -45,15 +56,57 @@
|
||||
[class.closing-summary__item--net-pos]="netTotal >= 0"
|
||||
[class.closing-summary__item--net-neg]="netTotal < 0"
|
||||
>
|
||||
<span class="closing-summary__label">Net Closing Cash</span>
|
||||
<!-- Named for what it is: the counter's own movement. It was labelled "Net Closing Cash",
|
||||
which it is not -- it carries nothing forward from yesterday and leaves out vault and
|
||||
inter-cashier legs, so it can differ from the drawer by a large amount. The real
|
||||
closing figure is in the balance strip below. -->
|
||||
<span class="closing-summary__label">Net Counter Movement</span>
|
||||
<span class="closing-summary__value">₹{{ netTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Opening → closing, the figure a physical count is checked against. Shown only for a
|
||||
cashier-scoped report; a branch-wide one has no single drawer to open and close. -->
|
||||
@if (!showGl && scopedToCashier && closingBalance !== null) {
|
||||
<div class="closing-summary closing-summary--balance">
|
||||
<div class="closing-summary__item">
|
||||
<span class="closing-summary__label">Opening Balance</span>
|
||||
<span class="closing-summary__value">₹{{ openingBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">+</div>
|
||||
<div
|
||||
class="closing-summary__item"
|
||||
[class.closing-summary__item--in]="(cashMovement ?? 0) >= 0"
|
||||
[class.closing-summary__item--out]="(cashMovement ?? 0) < 0"
|
||||
>
|
||||
<span class="closing-summary__label">Cash Movement</span>
|
||||
<span class="closing-summary__value">₹{{ cashMovement | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">=</div>
|
||||
<div
|
||||
class="closing-summary__item"
|
||||
[class.closing-summary__item--net-pos]="(closingBalance ?? 0) >= 0"
|
||||
[class.closing-summary__item--net-neg]="(closingBalance ?? 0) < 0"
|
||||
>
|
||||
<span class="closing-summary__label">Closing Balance</span>
|
||||
<span class="closing-summary__value">₹{{ closingBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (cashMovement !== null && netTotal !== cashMovement) {
|
||||
<!-- The gap between the two is not an error: it is the vault, inter-cashier and exchange
|
||||
traffic that never crossed the counter. Said plainly, because two different "net"
|
||||
figures on one screen otherwise read as a bug. -->
|
||||
<p class="closing-note">
|
||||
Cash movement differs from counter movement by ₹{{ cashMovement - netTotal | number: '1.2-2' }} — vault,
|
||||
inter-cashier and exchange legs that did not pass over the counter.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!showGl) {
|
||||
<div class="pg-card">
|
||||
@if (noteRows.length > 0) {
|
||||
@if (noteRows.length > 0 || ddCashTotal > 0) {
|
||||
<div class="pos-section-label">Notes</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
@@ -93,7 +146,42 @@
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<!-- Cash taken for a Demand Draft, on the deposit side where it belongs. It has no
|
||||
denomination to sit against -- the notes were never itemised -- so the piece
|
||||
counts show a dash rather than a zero, which would read as "no notes". -->
|
||||
@if (ddCashTotal > 0) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">Not itemised</span>
|
||||
<span class="closing-row__hint"> {{ ddCashCount }} cash DD{{ ddCashCount === 1 ? '' : 's' }} </span>
|
||||
</td>
|
||||
<td class="num col-in">—</td>
|
||||
<td class="num col-in">₹{{ ddCashTotal | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">—</td>
|
||||
<td class="num col-out">₹0.00</td>
|
||||
<td class="num col-net">—</td>
|
||||
<td class="num col-net" style="color: #16a34a">+₹{{ ddCashTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<!-- Totals, so the table reconciles against the summary above rather than having to be
|
||||
added up by hand — and so the deposit column visibly accounts for the DD row. -->
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td class="num col-in">{{ noteDepositCount }}</td>
|
||||
<td class="num col-in">₹{{ noteDepositAmount | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">{{ noteWithdrawalCount }}</td>
|
||||
<td class="num col-out">₹{{ noteWithdrawalAmount | number: '1.2-2' }}</td>
|
||||
<td class="num col-net"></td>
|
||||
<td class="num col-net">
|
||||
{{ noteNetAmount >= 0 ? '+' : '−' }}₹{{
|
||||
(noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2'
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
}
|
||||
|
||||
@@ -141,8 +229,20 @@
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (loaded && rows.length === 0 && exchangeRows.length === 0) {
|
||||
<div class="pg-empty">No cash denomination data for this date.</div>
|
||||
<!-- Not shown when a Demand Draft row is on the table: there is something to read then, and
|
||||
an "empty" notice under it contradicts it. -->
|
||||
@if (loaded && rows.length === 0 && exchangeRows.length === 0 && ddCashTotal === 0) {
|
||||
<!-- A day with no counter work is not an empty report: the cashier is still holding
|
||||
whatever they held yesterday, and that balance is shown in the strip above. Saying
|
||||
only "no data" here read as though the drawer were empty. -->
|
||||
<div class="pg-empty">
|
||||
@if (scopedToCashier && closingBalance !== null) {
|
||||
No cash crossed the counter on this date. The drawer still holds ₹{{ closingBalance | number: '1.2-2' }},
|
||||
carried forward.
|
||||
} @else {
|
||||
No cash denomination data for this date.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -38,6 +38,38 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
/**
|
||||
* Cash taken at the counter for a Demand Draft.
|
||||
*
|
||||
* A cash DD asks for a denomination breakdown but does not require one, and this report is built
|
||||
* entirely from breakdowns — so that cash was missing from the drawer figure even though the
|
||||
* ledger held it and the cashier held the notes. It is counted from the request itself and shown
|
||||
* on its own line, because the denomination rows below cannot account for it.
|
||||
*/
|
||||
ddCashTotal = 0;
|
||||
ddCashCount = 0;
|
||||
/** Deposits including the DD cash — the figure the drawer is actually checked against. */
|
||||
depositTotalWithDd = 0;
|
||||
|
||||
/**
|
||||
* The drawer, opening to closing.
|
||||
*
|
||||
* `netTotal` above is only the day's counter movement — deposits less withdrawals. It is not what
|
||||
* the cashier is holding, because it carries nothing forward from yesterday and ignores vault
|
||||
* allocations and inter-cashier legs. The closing balance is the figure that has to match a
|
||||
* physical count at the end of the day, so it is reported separately rather than inferred from
|
||||
* the counter totals.
|
||||
*
|
||||
* Null when the server did not scope the report to one cashier: a branch-wide opening balance is
|
||||
* the branch day register's business, not this report's.
|
||||
*/
|
||||
openingBalance: number | null = null;
|
||||
closingBalance: number | null = null;
|
||||
/** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */
|
||||
cashMovement: number | null = null;
|
||||
/** Whether the figures above are one cashier's drawer or the whole branch's counter work. */
|
||||
scopedToCashier = false;
|
||||
|
||||
// GL View
|
||||
showGl = false;
|
||||
glRows: any[] = [];
|
||||
@@ -66,6 +98,33 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
get noteRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind !== 'coin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Totals for the notes table, including the un-itemised Demand Draft cash.
|
||||
*
|
||||
* The DD row carries an amount but no piece count, so pieces and amounts are summed separately
|
||||
* rather than one being derived from the other — a denomination times a count would not reach the
|
||||
* DD's figure, and adding a fictional count to make it balance would misstate the drawer.
|
||||
*/
|
||||
get noteDepositCount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.depositCount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteDepositAmount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.depositAmount ?? 0), 0) + this.ddCashTotal;
|
||||
}
|
||||
|
||||
get noteWithdrawalCount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.withdrawalCount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteWithdrawalAmount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteNetAmount(): number {
|
||||
return this.noteDepositAmount - this.noteWithdrawalAmount;
|
||||
}
|
||||
get coinRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind === 'coin');
|
||||
}
|
||||
@@ -92,6 +151,15 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
this.depositTotal = Number(res?.depositTotal || 0);
|
||||
this.withdrawalTotal = Number(res?.withdrawalTotal || 0);
|
||||
this.netTotal = Number(res?.netTotal || 0);
|
||||
this.ddCashTotal = Number(res?.ddCashTotal || 0);
|
||||
this.ddCashCount = Number(res?.ddCashCount || 0);
|
||||
this.depositTotalWithDd = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
|
||||
this.scopedToCashier = res?.scopedToCashier === true;
|
||||
// Sent only for a cashier-scoped call, and read as absent rather than zero when missing —
|
||||
// a zero opening balance and no opening balance at all are different statements.
|
||||
this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance);
|
||||
this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance);
|
||||
this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
@@ -101,6 +169,13 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
this.depositTotal = 0;
|
||||
this.withdrawalTotal = 0;
|
||||
this.netTotal = 0;
|
||||
this.ddCashTotal = 0;
|
||||
this.ddCashCount = 0;
|
||||
this.depositTotalWithDd = 0;
|
||||
this.scopedToCashier = false;
|
||||
this.openingBalance = null;
|
||||
this.closingBalance = null;
|
||||
this.cashMovement = null;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
@@ -65,42 +65,6 @@
|
||||
@if (errorMsg) {
|
||||
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||
}
|
||||
|
||||
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
|
||||
flag raised for investigation is useless if someone has to scroll to find it. -->
|
||||
@if (!loading && exceptionRows.length > 0) {
|
||||
<div class="db-exceptions">
|
||||
<div class="db-exceptions__head">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<strong>
|
||||
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
|
||||
</strong>
|
||||
@if (resolvedExceptionCount > 0) {
|
||||
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
|
||||
}
|
||||
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
|
||||
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
|
||||
</button>
|
||||
</div>
|
||||
@for (e of exceptionRows; track e.voucherNo) {
|
||||
<div class="db-exceptions__item">
|
||||
<span class="pg-chip">{{ e.voucherNo }}</span>
|
||||
<span class="db-exceptions__amt">
|
||||
System ₹{{ e.amount | number: '1.2-2' }}
|
||||
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
|
||||
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
|
||||
<strong class="db-exceptions__diff"
|
||||
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
|
||||
>
|
||||
}
|
||||
</span>
|
||||
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
|
||||
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
@@ -139,252 +103,87 @@
|
||||
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
|
||||
</p>
|
||||
|
||||
<div class="pg-card">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th>Type</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Slip Check</th>
|
||||
<th>Time</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
|
||||
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||||
{{ r.side }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ r.kind }}</td>
|
||||
<td>
|
||||
{{ r.accountNo ?? '—' }}
|
||||
@if (r.accountType) {
|
||||
<span class="db-muted">· {{ r.accountType }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.party ?? '—' }}</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.handledBy ?? '—' }}</td>
|
||||
<td>
|
||||
{{ r.approvedBy ?? '—' }}
|
||||
@if (r.approvedBy && r.actionedAt) {
|
||||
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (r.verifyStatus === 'VERIFIED') {
|
||||
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
|
||||
>✓ MATCHED</span
|
||||
>
|
||||
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
|
||||
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
|
||||
<span
|
||||
class="db-badge db-badge--ok"
|
||||
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
|
||||
>
|
||||
✓ RESOLVED
|
||||
</span>
|
||||
} @else if (r.verifyStatus === 'EXCEPTION') {
|
||||
<span
|
||||
class="db-badge db-badge--rejected"
|
||||
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
|
||||
>
|
||||
⚠ EXCEPTION
|
||||
</span>
|
||||
} @else {
|
||||
<span class="db-muted">Not checked</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ timeOf(r.submittedAt) }}</td>
|
||||
<td class="db-actions">
|
||||
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
</button>
|
||||
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
|
||||
<mat-icon>attach_file</mat-icon>
|
||||
@if (r.attachmentCount > 0) {
|
||||
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
|
||||
}
|
||||
</button>
|
||||
</td>
|
||||
<!-- One table per source register rather than one long list. The two read differently: the
|
||||
cashier's rows are cash that actually crossed the counter, the clerk's are requests nobody
|
||||
has actioned yet. Mixed together they invited the wrong sum. -->
|
||||
@for (s of sections; track s.key) {
|
||||
<div class="pg-card db-section">
|
||||
<div class="db-section__head">
|
||||
<strong>{{ s.label }}</strong>
|
||||
<span class="db-muted">{{ s.hint }}</span>
|
||||
<span class="db-section__count">{{ s.rows.length }} row{{ s.rows.length === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th>Type</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
|
||||
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
|
||||
enough: it cannot be printed, exported, or read without a mouse. -->
|
||||
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
|
||||
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
|
||||
<td colspan="13">
|
||||
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
|
||||
<strong>Exception</strong>
|
||||
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
|
||||
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
|
||||
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
|
||||
}
|
||||
— {{ r.verifyRemarks }}
|
||||
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
|
||||
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
|
||||
@if (r.resolutionStatus) {
|
||||
<div class="db-exc-row__resolution">
|
||||
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
|
||||
{{ r.resolutionNotes }}
|
||||
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of s.rows; track r.voucherNo ?? r.section + r.txnId) {
|
||||
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||||
{{ r.side }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ r.kind }}</td>
|
||||
<td>
|
||||
{{ r.accountNo ?? '—' }}
|
||||
@if (r.accountType) {
|
||||
<span class="db-muted">· {{ r.accountType }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
|
||||
next to the row they describe. -->
|
||||
@if (verifyRow === r) {
|
||||
<tr class="db-panel-row">
|
||||
<td colspan="13">
|
||||
<div class="db-panel">
|
||||
<div class="db-panel__head">
|
||||
<strong>Slip check — {{ r.voucherNo }}</strong>
|
||||
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="db-panel__body">
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">System recorded</span>
|
||||
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<label class="pg-field">
|
||||
<span>Amount written on slip</span>
|
||||
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
|
||||
</label>
|
||||
@if (verifySlipBlank) {
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">Difference</span>
|
||||
<span class="db-cmp__value">—</span>
|
||||
</div>
|
||||
} @else if (verifyMismatch) {
|
||||
<div class="db-cmp db-cmp--bad">
|
||||
<span class="db-cmp__label">Difference</span>
|
||||
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="db-cmp db-cmp--good">
|
||||
<span class="db-cmp__label">Match</span>
|
||||
<span class="db-cmp__value">Amounts agree</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<label class="pg-field db-panel__remarks">
|
||||
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="verifyRemarks"
|
||||
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
|
||||
/>
|
||||
</label>
|
||||
@if (verifyMismatch) {
|
||||
<p class="db-panel__warn">
|
||||
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
|
||||
change the posted amount.
|
||||
</p>
|
||||
}
|
||||
<div class="db-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
(click)="saveVerify()"
|
||||
[disabled]="verifySaving || verifySlipBlank"
|
||||
>
|
||||
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
|
||||
</button>
|
||||
<!-- Raising an exception on an already-matched voucher stays available: a
|
||||
slip cleared in haste, or a difference noticed later, has to be
|
||||
recordable without erasing the earlier verdict. -->
|
||||
@if (!verifyMismatch) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--warn"
|
||||
(click)="raiseException()"
|
||||
[disabled]="verifySaving"
|
||||
>
|
||||
Raise Exception
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<td>{{ r.party ?? '—' }}</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<!-- Attachment panel for this voucher. -->
|
||||
@if (attachRow === r) {
|
||||
<tr class="db-panel-row">
|
||||
<td colspan="13">
|
||||
<div class="db-panel">
|
||||
<div class="db-panel__head">
|
||||
<strong>Attachments — {{ r.voucherNo }}</strong>
|
||||
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<label class="db-upload">
|
||||
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
|
||||
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
|
||||
</label>
|
||||
@if (attachUploading) {
|
||||
<p class="db-muted">Uploading…</p>
|
||||
}
|
||||
@if (attachLoading) {
|
||||
<p class="db-muted">Loading…</p>
|
||||
}
|
||||
@if (!attachLoading && attachments.length === 0) {
|
||||
<p class="db-muted">Nothing attached to this voucher yet.</p>
|
||||
}
|
||||
@for (a of attachments; track a.id) {
|
||||
<div class="db-file">
|
||||
<mat-icon>description</mat-icon>
|
||||
<span class="db-file__name">{{ a.fileName }}</span>
|
||||
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
|
||||
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<td>{{ r.handledBy ?? '—' }}</td>
|
||||
<td>
|
||||
{{ r.approvedBy ?? '—' }}
|
||||
@if (r.approvedBy && r.actionedAt) {
|
||||
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ timeOf(r.submittedAt) }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
|
||||
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">{{ s.label }} subtotal</td>
|
||||
<td class="num">
|
||||
@if (s.credit > 0 && s.debit > 0) {
|
||||
₹{{ s.credit | number: '1.2-2' }} CR · ₹{{ s.debit | number: '1.2-2' }} DR
|
||||
} @else if (s.credit > 0) {
|
||||
₹{{ s.credit | number: '1.2-2' }} CR
|
||||
} @else if (s.debit > 0) {
|
||||
₹{{ s.debit | number: '1.2-2' }} DR
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
|
||||
|
||||
@@ -32,6 +32,34 @@
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* One card per source register. The heading says which register, and why its rows read differently
|
||||
from the others, so the three are not added up as though they were one list. */
|
||||
.db-section {
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
|
||||
.db-side {
|
||||
display: inline-block;
|
||||
@@ -169,41 +197,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The reason spelled out under its own flagged row. */
|
||||
.db-exc-row td {
|
||||
background: #fef2f2 !important;
|
||||
border-bottom: 1px solid #fecaca !important;
|
||||
color: #7f1d1d;
|
||||
font-size: 12px;
|
||||
padding: 8px 14px !important;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
vertical-align: -3px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
|
||||
something anyone has to act on. */
|
||||
.db-exc-row--resolved td {
|
||||
background: #f0fdf4 !important;
|
||||
border-bottom: 1px solid #bbf7d0 !important;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.db-exc-row__resolution {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px dotted #bbf7d0;
|
||||
}
|
||||
|
||||
.db-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Raising an exception is a deliberate, consequential act — styled to read that way without
|
||||
looking destructive, since nothing is deleted. */
|
||||
.pg-btn--warn {
|
||||
@@ -352,38 +345,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.db-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.db-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-table tfoot td {
|
||||
background: #f8fafc;
|
||||
font-weight: 800;
|
||||
|
||||
@@ -13,8 +13,18 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/**
|
||||
* Which part of the day's work a row came from. Set by the server, one per source register.
|
||||
*
|
||||
* Only counter business appears in the day book. Cash moved between the vault and the drawers is
|
||||
* the bank shifting its own money, not a customer transaction, and it is read on the Cashier
|
||||
* Position and Closing Cash Report where the drawer itself is the subject.
|
||||
*/
|
||||
type DayBookSection = 'CASHIER' | 'CLERK';
|
||||
|
||||
interface DayBookRow {
|
||||
voucherNo: string | null;
|
||||
section: DayBookSection;
|
||||
side: 'CR' | 'DR';
|
||||
kind: string;
|
||||
accountNo: string | null;
|
||||
@@ -29,18 +39,6 @@ interface DayBookRow {
|
||||
txnId: string | null;
|
||||
submittedAt: string | null;
|
||||
actionedAt: string | null;
|
||||
/** null until someone has physically checked the slip. */
|
||||
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
|
||||
slipAmount: number | null;
|
||||
verifyRemarks: string | null;
|
||||
verifiedByName: string | null;
|
||||
verifiedAt: string | null;
|
||||
/** Set once a clerk or manager has closed the exception; null while it is still open. */
|
||||
resolutionStatus: string | null;
|
||||
resolutionNotes: string | null;
|
||||
resolvedByName: string | null;
|
||||
resolvedAt: string | null;
|
||||
attachmentCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,48 +126,47 @@ export class DayBookComponent implements OnInit {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** Show only the flagged vouchers — set from the exception banner. */
|
||||
exceptionsOnly = false;
|
||||
|
||||
/**
|
||||
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
|
||||
* visible on its own row as history, but it is no longer something anyone has to act on.
|
||||
* The day's rows split by where they came from, in the order they are read.
|
||||
*
|
||||
* Cashier work first — that is the counter — then the clerk's outstanding requests. An empty
|
||||
* section is dropped rather than shown as a heading over nothing.
|
||||
*
|
||||
* A section is not "who typed it": a clerk raises a withdrawal that a cashier pays out, so the
|
||||
* heading says which register the entry belongs to, and the By column still names the person.
|
||||
*/
|
||||
get exceptionRows(): DayBookRow[] {
|
||||
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
|
||||
}
|
||||
|
||||
/** Closed exceptions, kept for the record rather than for action. */
|
||||
get resolvedExceptionCount(): number {
|
||||
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
|
||||
}
|
||||
|
||||
/** Human label for a resolution code. */
|
||||
resolutionLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'RESOLVED_SYSTEM_ERROR':
|
||||
return 'System entry was wrong';
|
||||
case 'RESOLVED_SLIP_ERROR':
|
||||
return 'Slip was written wrong';
|
||||
case 'RESOLVED_NO_ACTION':
|
||||
return 'No action needed';
|
||||
default:
|
||||
return status ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Vouchers nobody has checked against a slip yet. */
|
||||
get uncheckedCount(): number {
|
||||
return this.rows.filter((r) => !r.verifyStatus).length;
|
||||
}
|
||||
|
||||
toggleExceptionsOnly(): void {
|
||||
this.exceptionsOnly = !this.exceptionsOnly;
|
||||
get sections(): {
|
||||
key: DayBookSection;
|
||||
label: string;
|
||||
hint: string;
|
||||
rows: DayBookRow[];
|
||||
credit: number;
|
||||
debit: number;
|
||||
}[] {
|
||||
const order: { key: DayBookSection; label: string; hint: string }[] = [
|
||||
{ key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' },
|
||||
{ key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' }
|
||||
];
|
||||
return order
|
||||
.map((s) => {
|
||||
const rows = this.filteredRows.filter((r) => r.section === s.key);
|
||||
return {
|
||||
...s,
|
||||
rows,
|
||||
credit: rows
|
||||
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
|
||||
.reduce((t, r) => t + Number(r.amount ?? 0), 0),
|
||||
debit: rows
|
||||
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
|
||||
.reduce((t, r) => t + Number(r.amount ?? 0), 0)
|
||||
};
|
||||
})
|
||||
.filter((s) => s.rows.length > 0);
|
||||
}
|
||||
|
||||
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
|
||||
get filteredRows(): DayBookRow[] {
|
||||
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
|
||||
const base = this.rows;
|
||||
const q = this.search.trim().toLowerCase();
|
||||
if (!q) return base;
|
||||
return base.filter((r) =>
|
||||
@@ -231,236 +228,9 @@ export class DayBookComponent implements OnInit {
|
||||
return t ? t.slice(0, 5) : '—';
|
||||
}
|
||||
|
||||
// ── Physical slip verification ────────────────────────────────────────────
|
||||
|
||||
/** Row whose verification panel is open, or null when none is. */
|
||||
verifyRow: DayBookRow | null = null;
|
||||
verifySlipAmount: number | null = null;
|
||||
verifyRemarks = '';
|
||||
verifySaving = false;
|
||||
|
||||
openVerify(r: DayBookRow): void {
|
||||
this.verifyRow = r;
|
||||
// Pre-filled with the system's own figure: the common case is that the two agree, and when
|
||||
// they don't the cashier only has to change the digits that actually differ.
|
||||
this.verifySlipAmount = Number(r.amount ?? 0);
|
||||
this.verifyRemarks = r.verifyRemarks ?? '';
|
||||
this.attachRow = null;
|
||||
}
|
||||
|
||||
closeVerify(): void {
|
||||
this.verifyRow = null;
|
||||
this.verifySlipAmount = null;
|
||||
this.verifyRemarks = '';
|
||||
}
|
||||
|
||||
/** True when the amount typed off the slip differs from what the system recorded. */
|
||||
get verifyMismatch(): boolean {
|
||||
if (!this.verifyRow || this.verifySlipAmount == null) return false;
|
||||
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
|
||||
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
|
||||
*/
|
||||
get verifySlipBlank(): boolean {
|
||||
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
|
||||
}
|
||||
|
||||
get verifyDifference(): number {
|
||||
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
|
||||
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the verdict. The status is derived from the figures rather than picked by the user —
|
||||
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
|
||||
*/
|
||||
saveVerify(): void {
|
||||
const row = this.verifyRow;
|
||||
if (!row?.voucherNo) return;
|
||||
|
||||
const isException = this.verifyMismatch;
|
||||
if (isException && !this.verifyRemarks.trim()) {
|
||||
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifySaving = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.verifyVoucher(
|
||||
row.voucherNo,
|
||||
isException ? 'EXCEPTION' : 'VERIFIED',
|
||||
isException ? Number(this.verifySlipAmount) : undefined,
|
||||
this.verifyRemarks.trim() || undefined
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.verifySaving = false;
|
||||
if (res) {
|
||||
this.closeVerify();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
|
||||
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
|
||||
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
|
||||
* is exactly what an investigation needs.
|
||||
*/
|
||||
raiseException(): void {
|
||||
const row = this.verifyRow;
|
||||
if (!row?.voucherNo) return;
|
||||
|
||||
if (!this.verifyRemarks.trim()) {
|
||||
this.errorMsg = 'Say what the problem is before raising an exception.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifySaving = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
|
||||
// both the slip and the system said at the time the exception was raised.
|
||||
.verifyVoucher(
|
||||
row.voucherNo,
|
||||
'EXCEPTION',
|
||||
Number(this.verifySlipAmount ?? row.amount ?? 0),
|
||||
this.verifyRemarks.trim()
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.verifySaving = false;
|
||||
if (res) {
|
||||
this.closeVerify();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Attachments ───────────────────────────────────────────────────────────
|
||||
|
||||
attachRow: DayBookRow | null = null;
|
||||
attachments: any[] = [];
|
||||
attachLoading = false;
|
||||
attachUploading = false;
|
||||
|
||||
openAttach(r: DayBookRow): void {
|
||||
this.attachRow = r;
|
||||
this.verifyRow = null;
|
||||
this.attachments = [];
|
||||
if (!r.voucherNo) return;
|
||||
this.attachLoading = true;
|
||||
this.service
|
||||
.listVoucherAttachments(r.voucherNo)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.attachLoading = false;
|
||||
this.attachments = res?.attachments ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
closeAttach(): void {
|
||||
this.attachRow = null;
|
||||
this.attachments = [];
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
const row = this.attachRow;
|
||||
if (!file || !row?.voucherNo) return;
|
||||
|
||||
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
|
||||
// only to have it refused.
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.attachUploading = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.uploadVoucherAttachment(
|
||||
row.voucherNo!,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
String(reader.result)
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.attachUploading = false;
|
||||
input.value = '';
|
||||
if (res) {
|
||||
this.openAttach(row);
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
|
||||
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
|
||||
* then handed to the browser as a normal save.
|
||||
*/
|
||||
downloadAttachment(a: any): void {
|
||||
this.service
|
||||
.downloadVoucherAttachment(a.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
this.errorMsg = 'Could not download that file.';
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = a.fileName ?? 'attachment';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
deleteAttachment(a: any): void {
|
||||
const row = this.attachRow;
|
||||
this.service
|
||||
.deleteVoucherAttachment(a.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe(() => {
|
||||
if (row) this.openAttach(row);
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const header = [
|
||||
'Section',
|
||||
'Voucher No',
|
||||
'Side',
|
||||
'Type',
|
||||
@@ -471,18 +241,11 @@ export class DayBookComponent implements OnInit {
|
||||
'Status',
|
||||
'Handled By',
|
||||
'Approved By',
|
||||
'Slip Check',
|
||||
'Slip Amount',
|
||||
'Remarks',
|
||||
'Checked By',
|
||||
'Resolution',
|
||||
'Resolution Notes',
|
||||
'Closed By',
|
||||
'Attachments',
|
||||
'Time'
|
||||
];
|
||||
const lines = this.filteredRows.map((r) =>
|
||||
[
|
||||
r.section,
|
||||
r.voucherNo ?? '',
|
||||
r.side,
|
||||
r.kind,
|
||||
@@ -493,14 +256,6 @@ export class DayBookComponent implements OnInit {
|
||||
r.status,
|
||||
r.handledBy ?? '',
|
||||
r.approvedBy ?? '',
|
||||
r.verifyStatus ?? 'NOT CHECKED',
|
||||
r.slipAmount != null ? String(r.slipAmount) : '',
|
||||
r.verifyRemarks ?? '',
|
||||
r.verifiedByName ?? '',
|
||||
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
|
||||
r.resolutionNotes ?? '',
|
||||
r.resolvedByName ?? '',
|
||||
String(r.attachmentCount ?? 0),
|
||||
this.timeOf(r.submittedAt)
|
||||
]
|
||||
// Quote every field and double any embedded quote: names and narrations routinely
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
export interface CashierIssueDdDialogData {
|
||||
leaflet: any;
|
||||
branchName: string;
|
||||
sponsorBankName: string;
|
||||
faceValue: number;
|
||||
chargesAmount: number;
|
||||
gstPercentage: number;
|
||||
gstAmount: number;
|
||||
totalCashPayable: number;
|
||||
/**
|
||||
* How the draft is being paid for. Cash is a walk-in purchaser the bank may know nothing about,
|
||||
* so their name, address and Aadhaar have to be captured here. An account debit is a member whose
|
||||
* account is already on screen — those fields are not asked for, and must not be sent, because
|
||||
* the identity is the account holder's rather than a walk-in's.
|
||||
*/
|
||||
paymentMethod?: 'CASH' | 'ACCOUNT_DEBIT';
|
||||
/** The account being debited, shown so the clerk can see what is funding the draft. */
|
||||
accountNo?: string;
|
||||
/** The account holder, for the same reason. */
|
||||
accountHolderName?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-cashier-issue-dd-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
template: `
|
||||
<div class="dd-dialog-shell">
|
||||
<div class="dd-dialog-header">
|
||||
<div class="dd-dialog-header__title">
|
||||
<mat-icon class="dd-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
|
||||
<p>
|
||||
Next available DD leaflet auto-selected ·
|
||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-icon-button type="button" (click)="onClose()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="dd-dialog-body">
|
||||
<!-- Draft & Financials Summary Card -->
|
||||
<div class="dd-summary-card">
|
||||
<div class="summary-col">
|
||||
<span class="label">Auto-Selected Leaflet</span>
|
||||
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
|
||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">Face Value</span>
|
||||
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">GST ({{ data.gstPercentage }}%)</span>
|
||||
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">on charges only</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col highlight-col">
|
||||
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
|
||||
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">
|
||||
@if (isAccountDebit) {
|
||||
{{ data.accountNo || 'Account' }}
|
||||
} @else {
|
||||
Denominations verified
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The account funding the draft, stated before the form.
|
||||
|
||||
A clerk reaching this dialog has an account loaded on the workstation behind it, and the
|
||||
debit is irreversible once a checker approves. Naming the account and its holder here is
|
||||
what makes a wrong account visible while it can still be cancelled.
|
||||
-->
|
||||
@if (isAccountDebit) {
|
||||
<div class="dd-funding-note">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
<div>
|
||||
<strong>Debiting {{ data.accountNo }}</strong>
|
||||
@if (data.accountHolderName) {
|
||||
· {{ data.accountHolderName }}
|
||||
}
|
||||
<div class="dd-funding-sub">
|
||||
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
||||
approves. No cash is taken at the counter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="form" class="dd-form">
|
||||
<!--
|
||||
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
|
||||
account debit the purchaser IS the account holder, already identified and KYC'd, and
|
||||
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
|
||||
-->
|
||||
@if (!isAccountDebit) {
|
||||
<div class="form-section-title">Walk-in Purchaser Details</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Walk-in Purchaser Name *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserName"
|
||||
placeholder="e.g. RAMESH CHANDRA"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
|
||||
>Purchaser name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
|
||||
>Must be at least 3 characters</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>12-Digit Aadhaar Number *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAadhaar"
|
||||
placeholder="12-digit Aadhaar"
|
||||
maxlength="12"
|
||||
/>
|
||||
<mat-hint>Masked to last 4 digits before storage</mat-hint>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
|
||||
>Aadhaar number is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
|
||||
>Must be exactly 12 numeric digits</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Purchaser Address / Location *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAddress"
|
||||
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
|
||||
>Address is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Beneficiary Section -->
|
||||
<div class="form-section-title">Beneficiary & Payment Details</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="beneficiaryName"
|
||||
placeholder="e.g. UNIVERSITY OF MUMBAI"
|
||||
maxlength="120"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
|
||||
>Beneficiary name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - Branch *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtBranch"
|
||||
placeholder="e.g. FORT BRANCH"
|
||||
maxlength="150"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
|
||||
>Payable Branch is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - City *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtCity"
|
||||
placeholder="e.g. MUMBAI"
|
||||
maxlength="100"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
||||
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
|
||||
Submit for Manager Approval
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dd-dialog-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
.dd-dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
.dd-dialog-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.dd-dialog-header__title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dd-dialog-header__title p {
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.dd-icon {
|
||||
color: #38bdf8;
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.close-btn {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
.dd-dialog-body {
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dd-summary-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.summary-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.summary-col.highlight-col {
|
||||
background: #ecfdf5;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.summary-divider {
|
||||
width: 1px;
|
||||
height: 36px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.summary-col .label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dd-number-highlight {
|
||||
font-size: 16px;
|
||||
color: #0369a1;
|
||||
font-family: monospace;
|
||||
}
|
||||
.summary-col .amount {
|
||||
font-size: 15px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.summary-col .total-amount {
|
||||
font-size: 16px;
|
||||
color: #065f46;
|
||||
}
|
||||
.summary-col .sublabel {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.form-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
margin: 16px 0 10px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.form-section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.dd-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.form-field-flex {
|
||||
flex: 1;
|
||||
}
|
||||
.dd-dialog-actions {
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
/* The account about to be debited — read before the form, so a wrong one is caught here. */
|
||||
.dd-funding-note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin: 14px 0 4px;
|
||||
padding: 11px 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 3px solid #1d4ed8;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
.dd-funding-note mat-icon {
|
||||
color: #1d4ed8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dd-funding-sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class CashierIssueDdDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private dialogRef = inject(MatDialogRef<CashierIssueDdDialogComponent>);
|
||||
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
|
||||
get isAccountDebit(): boolean {
|
||||
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Walk-in identity is required for cash and rejected outright for an account debit — the server
|
||||
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
|
||||
// Registering them unconditionally and merely hiding them would hold the form invalid on a
|
||||
// required field the clerk cannot see.
|
||||
this.form = this.fb.group(
|
||||
this.isAccountDebit
|
||||
? {
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
payableAtBranch: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
payableAtCity: [
|
||||
'',
|
||||
[Validators.required]
|
||||
]
|
||||
}
|
||||
: {
|
||||
walkinPurchaserName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(3)
|
||||
]
|
||||
],
|
||||
walkinPurchaserAddress: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
walkinPurchaserAadhaar: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^[0-9]{12}$/)
|
||||
]
|
||||
],
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
payableAtBranch: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
payableAtCity: [
|
||||
'',
|
||||
[Validators.required]
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const val = this.form.value;
|
||||
const beneficiary = {
|
||||
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
||||
payableAtBranch: (val.payableAtBranch || '').trim().toUpperCase(),
|
||||
payableAtCity: (val.payableAtCity || '').trim().toUpperCase()
|
||||
};
|
||||
// Walk-in fields are left off entirely on an account debit rather than sent empty: the server
|
||||
// rejects a purchaser name arriving alongside an account, and '' would be sent as one.
|
||||
this.dialogRef.close(
|
||||
this.isAccountDebit
|
||||
? beneficiary
|
||||
: {
|
||||
walkinPurchaserName: (val.walkinPurchaserName || '').trim().toUpperCase(),
|
||||
walkinPurchaserAddress: (val.walkinPurchaserAddress || '').trim().toUpperCase(),
|
||||
walkinPurchaserAadhaar: (val.walkinPurchaserAadhaar || '').trim(),
|
||||
...beneficiary
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,23 @@
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.sponsorBankId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@if (sponsorBanks.length === 0) {
|
||||
<option [ngValue]="null">-- Select Bank --</option>
|
||||
}
|
||||
@for (sb of sponsorBanks; track sb.id) {
|
||||
<option [ngValue]="sb.id">{{ sb.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Transaction Type">TXN TP.</label>
|
||||
<select
|
||||
@@ -182,7 +199,22 @@
|
||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||
(click)="$event.stopPropagation()"
|
||||
(blur)="onAcNoBlur(row, i)"
|
||||
placeholder="Account number"
|
||||
[placeholder]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Account to debit'
|
||||
: row.typ === 'DD'
|
||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||
: 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--ecsno">
|
||||
@@ -340,7 +372,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
@if (isDdRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
@@ -375,7 +407,7 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@@ -387,6 +419,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
|
||||
<button
|
||||
type="button"
|
||||
class="te-dd-issue-btn"
|
||||
[disabled]="saving"
|
||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
||||
<span>Fill DD Details & Send for Approval</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,6 +447,18 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── DD GL UNCONFIGURED WARNING BANNER ────────────────────────── -->
|
||||
@if (selectedRow?.typ === 'DD' && !isDdGlMappingConfigured) {
|
||||
<div class="ws-dd-unconfigured-banner">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div class="ws-dd-unconfigured-banner__text">
|
||||
<strong>DD GL Mapping Required:</strong> Demand Draft GL accounts have not been configured yet. Please ask an
|
||||
administrator to set up the mapping under <em>Admin > Sponsor DD GST / GL Settings</em> before issuing
|
||||
drafts.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── APPROVED WITHDRAWAL BANNER (cashier disburse flow) ─────────── -->
|
||||
@if (!isClerk && loadingWithdrawal) {
|
||||
<div class="ws-withdrawal-banner ws-withdrawal-banner--loading">
|
||||
@@ -1404,7 +1465,12 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--type">
|
||||
<label class="ws-f__lbl">TYPE</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.typ"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onTypChange(row)"
|
||||
>
|
||||
@for (t of typOptions; track t.value) {
|
||||
<option [value]="t.value">{{ t.label }}</option>
|
||||
}
|
||||
@@ -1412,18 +1478,36 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br">
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.sponsorBankId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@if (sponsorBanks.length === 0) {
|
||||
<option [ngValue]="null">-- Select Bank --</option>
|
||||
}
|
||||
@for (sb of sponsorBanks; track sb.id) {
|
||||
<option [ngValue]="sb.id">{{ sb.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.paymentTypeId"
|
||||
(click)="$event.stopPropagation()"
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
@@ -1436,6 +1520,7 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
@@ -1462,7 +1547,14 @@
|
||||
[value]="row.acNo"
|
||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||
(blur)="onAcNoBlur(row, i)"
|
||||
placeholder="Account number"
|
||||
[placeholder]="
|
||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD'"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
||||
[title]="
|
||||
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--ecsno">
|
||||
@@ -1591,14 +1683,19 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--desc">
|
||||
<label class="ws-f__lbl">DESCRIPTION</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.description"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="Narration / remarks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
@if (isDdRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
@@ -1612,6 +1709,7 @@
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
@@ -1632,7 +1730,7 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@@ -1644,6 +1742,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
|
||||
<button
|
||||
type="button"
|
||||
class="te-dd-issue-btn"
|
||||
[disabled]="saving"
|
||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
||||
<span>Fill DD Details & Send for Approval</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3384,3 +3384,67 @@ select.te-cell {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ws-dd-unconfigured-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 10px 16px;
|
||||
padding: 12px 16px;
|
||||
background: #fffbeb;
|
||||
border: 1.5px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
|
||||
strong {
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.te-dd-issue-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #0284c7, #0ea5e9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgb(2 132 199 / 35%);
|
||||
transition: all 0.15s ease-in-out;
|
||||
letter-spacing: 0.2px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #0369a1, #0284c7);
|
||||
box-shadow: 0 3px 8px rgb(2 132 199 / 50%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #cbd5e1;
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||
import { OrganizationService } from '../../organization/organization.service';
|
||||
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
|
||||
|
||||
interface TxnRow {
|
||||
typ: string;
|
||||
@@ -51,6 +52,7 @@ interface TxnRow {
|
||||
routingCode: string;
|
||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||
ddCharges: number | null;
|
||||
sponsorBankId?: number | null;
|
||||
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||
resolutionNumber?: string;
|
||||
@@ -1523,7 +1525,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||
*/
|
||||
paymentTypesForRow(row: TxnRow): any[] {
|
||||
const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined);
|
||||
// Sponsor-bank payment types are excluded. They were added for the Demand Draft module, which
|
||||
// picks its bank from m_sponsor_bank on the DD row itself, and they are not counter instruments
|
||||
// — a clerk choosing "SBI" as the TXN TP. of a withdrawal says nothing about how the money
|
||||
// moves. They are recognised by their description rather than by name so that adding another
|
||||
// sponsor bank does not put it back on this list.
|
||||
const tellerFacing = this.paymentTypes.filter(
|
||||
(pt: any) => !pt.isSystemDefined && !/sponsor bank/i.test(pt.description ?? '')
|
||||
);
|
||||
if (!this.isClerk) {
|
||||
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
|
||||
// clerk/back-office approval flows, never straight through the cash drawer.
|
||||
@@ -1541,6 +1550,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
if (isShareSettlementCredit) {
|
||||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
// A withdrawal slip is a cash instruction and nothing else: the clerk raises it, the cashier
|
||||
// counts the notes out against the token. There is no non-cash way to settle one, so cash is
|
||||
// the only choice offered rather than the default among several.
|
||||
if (row.typ === 'Withdrawal Slip') {
|
||||
return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
|
||||
}
|
||||
// A clerk's Demand Draft is never funded by cash they take themselves. Cash for a draft is
|
||||
// counted at the counter, so it comes either from the cashier's own DD screen or from an
|
||||
// approved cashier deposit request the clerk points the draft at; the clerk's own route is to
|
||||
// debit the member's account. Offering cash here let a draft be raised against notes no drawer
|
||||
// had counted, which is exactly how the six cash DDs with no denomination breakdown arose.
|
||||
if (row.typ === 'DD') {
|
||||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
// Everything else can go either way, so both are offered and the clerk says which. A cheque or
|
||||
// a bank transfer normally settles through the instrument, a disbursement or fund transfer
|
||||
// between accounts — the type alone does not fix it to one or the other.
|
||||
return tellerFacing;
|
||||
}
|
||||
|
||||
@@ -1664,9 +1690,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// wrong for crediting a GL amount into a savings/current account for any other reason (e.g.
|
||||
// funding a newly-activated account's opening balance from a GL source).
|
||||
{ value: 'Fund Transfer', label: 'Fund Transfer' },
|
||||
// NOTE: the exact same TxnRow.typ string is used at every dispatch/default-setting site below —
|
||||
// search for 'Disbursement' whenever this needs to change again, since 'Fund Transfer' always
|
||||
// rides alongside it in every one of those checks rather than having its own separate case.
|
||||
{ value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
|
||||
{ value: 'DD', label: 'DD (Demand Draft)' }
|
||||
];
|
||||
@@ -1692,6 +1715,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
|
||||
}
|
||||
|
||||
sponsorBanks: any[] = [];
|
||||
ddGlMapping: any = null;
|
||||
|
||||
// ── Denomination ──────────────────────────────────────────────────────────
|
||||
notes: Denomination[] = [];
|
||||
coins: Denomination[] = [];
|
||||
@@ -1752,6 +1778,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.loadCashVaultGl();
|
||||
this.loadDdGstPercentage();
|
||||
this.loadSponsorBanks();
|
||||
|
||||
if (this.reviewMode) {
|
||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||
@@ -1842,12 +1869,85 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.getGlMappings()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((mappings: any[]) => {
|
||||
const mapping = mappings && mappings.length ? mappings[0] : null;
|
||||
this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
|
||||
this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
|
||||
this.ddGlMapping = mappings && mappings.length ? mappings[0] : null;
|
||||
this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18);
|
||||
this.ddLimitAmount = this.ddGlMapping?.ddLimitAmount != null ? Number(this.ddGlMapping.ddLimitAmount) : null;
|
||||
if (this.ddGlMapping?.ddPayableGlAccountId) {
|
||||
if (this.ddGlMapping.ddPayableGlCode) {
|
||||
this.applyDdGlMappingToDdRows();
|
||||
} else {
|
||||
this.http
|
||||
.get<any>(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((glAcc: any) => {
|
||||
if (glAcc) {
|
||||
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
|
||||
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
|
||||
}
|
||||
this.applyDdGlMappingToDdRows();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyDdGlMappingToRow(row: TxnRow): void {
|
||||
if (row.typ !== 'DD' || !this.ddGlMapping) return;
|
||||
// Leaves an account-funded DD alone: its A/C NO. is the member's account the clerk typed, and
|
||||
// overwriting it with the DD Payable GL would silently turn the row back into a cash draft.
|
||||
if (this.isDdAccountDebitRow(row)) return;
|
||||
row.acTyp = 'GL';
|
||||
row.dbCr = 'Cr';
|
||||
|
||||
if (this.ddGlMapping.ddPayableGlCode) {
|
||||
row.acNo = this.ddGlMapping.ddPayableGlCode;
|
||||
this.selectedRow = row;
|
||||
this.loadAccountDetails(row.acNo, 'GL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ddGlMapping.ddPayableGlAccountId) {
|
||||
this.http
|
||||
.get<any>(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((glAcc: any) => {
|
||||
if (glAcc) {
|
||||
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
|
||||
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
|
||||
row.acNo = this.ddGlMapping.ddPayableGlCode;
|
||||
this.selectedRow = row;
|
||||
this.loadAccountDetails(row.acNo, 'GL');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applyDdGlMappingToDdRows(): void {
|
||||
for (const row of this.rows) {
|
||||
if (row.typ === 'DD') {
|
||||
this.applyDdGlMappingToRow(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadSponsorBanks(): void {
|
||||
this.sponsorDDService
|
||||
.getSponsorBanks()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((banks: any[]) => {
|
||||
this.sponsorBanks = banks || [];
|
||||
for (const row of this.rows) {
|
||||
if (row.typ === 'DD' && !row.sponsorBankId && this.sponsorBanks.length > 0) {
|
||||
row.sponsorBankId = this.sponsorBanks[0].id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isDdGlMappingConfigured(): boolean {
|
||||
return !!(this.ddGlMapping && this.ddGlMapping.ddPayableGlAccountId && this.ddGlMapping.cashGlAccountId);
|
||||
}
|
||||
|
||||
private loadDayStatus(): void {
|
||||
this.cashierService
|
||||
.getCashierDayStatus()
|
||||
@@ -2093,9 +2193,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
break;
|
||||
// A cheque clears through the bank rather than the drawer, so it opens on the transfer
|
||||
// instrument. Cash is still on its dropdown — a cheque can be cashed over the counter — but
|
||||
// it is the exception, not what the field should start on.
|
||||
case 'Cheque':
|
||||
row.dbCr = 'Dr';
|
||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||
break;
|
||||
case 'Disbursement':
|
||||
case 'Fund Transfer':
|
||||
@@ -2121,16 +2224,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
break;
|
||||
// A DD is bought with cash: the customer hands over Face Value + Charges + GST, so it is money
|
||||
// coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment type and
|
||||
// AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST panel never
|
||||
// appears and the row would post as an ordinary entry with the charges silently dropped.
|
||||
// A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is
|
||||
// money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment
|
||||
// type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST
|
||||
// panel never appears and the row would post as an ordinary entry with the charges silently
|
||||
// dropped.
|
||||
//
|
||||
// A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no
|
||||
// longer offers cash, so defaulting to it would open the field on a value it cannot show.
|
||||
// isDdCashGlRow() still holds for them through AC.TP = GL.
|
||||
case 'DD':
|
||||
row.dbCr = 'Cr';
|
||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||
row.acTyp = 'GL';
|
||||
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
if (this.sponsorBanks.length > 0 && !row.sponsorBankId) {
|
||||
row.sponsorBankId = this.sponsorBanks[0].id;
|
||||
}
|
||||
this.applyDdGlMappingToRow(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2145,10 +2257,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return pt?.id ?? null;
|
||||
}
|
||||
|
||||
/** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
|
||||
/** TYPE=DD + TXN TP=Cash */
|
||||
isDdCashGlRow(row: TxnRow): boolean {
|
||||
if (this.isDdAccountDebitRow(row)) return false;
|
||||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
|
||||
return row.typ === 'DD' && (row.acTyp === 'GL' || !row.acTyp || !!pt?.isCashPayment);
|
||||
}
|
||||
|
||||
/**
|
||||
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
|
||||
*
|
||||
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
|
||||
* would let a draft be raised against an account with no cash to reconcile it against — and the
|
||||
* clerk flow is the one that carries a maker-checker step over the member's balance.
|
||||
*/
|
||||
isDdAccountDebitRow(row: TxnRow): boolean {
|
||||
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
|
||||
}
|
||||
|
||||
/**
|
||||
* Any DD row the charges/GST panel applies to.
|
||||
*
|
||||
* Charges and GST come from the admin's DD configuration and are collected whichever way the
|
||||
* draft is paid for — the difference is only where the money comes from, so the panel is the same
|
||||
* for both and the label on the total changes rather than the arithmetic.
|
||||
*/
|
||||
isDdRow(row: TxnRow): boolean {
|
||||
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
|
||||
}
|
||||
|
||||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||
@@ -2179,6 +2314,258 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||
}
|
||||
|
||||
branchNameFor(branchId: number): string {
|
||||
const map: Record<number, string> = {
|
||||
1: '001 · Main',
|
||||
2: '002 · M.G. Road',
|
||||
14: '014 · Station Rd',
|
||||
27: '027 · Industrial'
|
||||
};
|
||||
return map[branchId] || `Branch ${branchId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashier Walk-in DD creation flow:
|
||||
* 1. Validates one-time persistent GL configuration
|
||||
* 2. Auto-selects next available leaflet
|
||||
* 3. Opens dialog to capture purchaser/beneficiary details
|
||||
* 4. Submits issue request directly with physical denominations for manager approval
|
||||
*/
|
||||
openIssueWalkinDd(row: TxnRow): void {
|
||||
if (!this.isDdGlMappingConfigured) {
|
||||
this.showAlert('error', 'DD GL Mapping Not Configured', [
|
||||
'DD GL mapping is not configured.',
|
||||
'Please ask an administrator to set up the GL accounts in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const faceValue = Number(row.amount);
|
||||
if (!faceValue || faceValue <= 0) {
|
||||
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
|
||||
return;
|
||||
}
|
||||
const chargesAmount = Number(row.ddCharges) || 0;
|
||||
const gstAmount = this.ddGstAmountFor(row);
|
||||
const totalCashPayable = this.ddTotalPayableFor(row);
|
||||
const accountDebit = this.isDdAccountDebitRow(row);
|
||||
|
||||
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
|
||||
// the server takes a savings/current account id and a client id, not the typed number. Both are
|
||||
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
|
||||
let fundingAccountId: number | null = null;
|
||||
let fundingClientId: number | null = null;
|
||||
if (accountDebit) {
|
||||
const acNo = (row.acNo || '').trim();
|
||||
if (!acNo) {
|
||||
this.showAlert('error', 'Account Required', [
|
||||
'Enter the account number the Demand Draft is funded from.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
fundingAccountId = this.rowAccountIds[acNo] ?? null;
|
||||
fundingClientId = this.rowClientIds[acNo] ?? null;
|
||||
if (!fundingAccountId || !fundingClientId) {
|
||||
this.showAlert('error', 'Account Not Found', [
|
||||
`Account ${acNo} could not be resolved.`,
|
||||
'Check the account number and wait for its details to load before raising the draft.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
|
||||
// denomination count left over from an earlier row must not be checked against it.
|
||||
if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
|
||||
this.showAlert('error', 'Denomination Mismatch', [
|
||||
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
|
||||
'Please verify and match the physical cash denomination count.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.getNextAvailableLeaflet(row.br, row.sponsorBankId ?? undefined).subscribe({
|
||||
next: (leaflet) => {
|
||||
this.saving = false;
|
||||
if (!leaflet) {
|
||||
this.showAlert('error', 'No Available Leaflets', [
|
||||
'No available Demand Draft leaflets found for this branch and sponsor bank.',
|
||||
'Please register a DD inventory series first.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
|
||||
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
|
||||
|
||||
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
|
||||
width: '600px',
|
||||
disableClose: true,
|
||||
data: {
|
||||
leaflet,
|
||||
branchName,
|
||||
sponsorBankName: selectedBank?.name || 'Sponsor Bank',
|
||||
faceValue,
|
||||
chargesAmount,
|
||||
gstPercentage: this.ddGstPercentage,
|
||||
gstAmount,
|
||||
totalCashPayable,
|
||||
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
|
||||
accountNo: accountDebit ? row.acNo : undefined,
|
||||
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (!result) return;
|
||||
if (accountDebit) {
|
||||
this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
|
||||
} else {
|
||||
this.submitWalkinCashDd(row, leaflet, result);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'Error Auto-Selecting DD Leaflet', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a Demand Draft funded by debiting a member's savings or current account.
|
||||
*
|
||||
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
|
||||
* account is only touched when a checker approves, which is also when the balance is re-checked —
|
||||
* so a draft raised against a sufficient balance can still be refused if the balance has since
|
||||
* gone. No denominations are sent, because no cash is taken.
|
||||
*
|
||||
* The account goes in savingsAccountId or currentAccountId, never both: the server insists on
|
||||
* exactly one, since which table the account lives in decides which control GL is debited.
|
||||
*/
|
||||
private submitAccountDebitDd(
|
||||
row: TxnRow,
|
||||
leaflet: any,
|
||||
beneficiaryData: any,
|
||||
fundingAccountId: number,
|
||||
fundingClientId: number
|
||||
): void {
|
||||
const d = new Date();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||
|
||||
const payload = {
|
||||
clientId: fundingClientId,
|
||||
beneficiaryName: beneficiaryData.beneficiaryName,
|
||||
payableAtBranch: beneficiaryData.payableAtBranch,
|
||||
payableAtCity: beneficiaryData.payableAtCity,
|
||||
faceValue: Number(row.amount),
|
||||
chargesAmount: Number(row.ddCharges) || 0,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: 'ACCOUNT_DEBIT' as const,
|
||||
savingsAccountId: isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: isCurrent ? fundingAccountId : undefined
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.showAlert('success', 'DD Issuance Request Submitted', [
|
||||
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}) submitted successfully!`,
|
||||
`₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} on approval.`,
|
||||
'Sent to Manager / Head Cashier for maker-checker approval.'
|
||||
]);
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
row.description = '';
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
|
||||
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
|
||||
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
|
||||
// vanished from the closing report -- the notes were counted on screen and then dropped.
|
||||
const denominationMap: { [key: string]: number } = {};
|
||||
for (const d of [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
]) {
|
||||
const count = this.countFor(d);
|
||||
if (count > 0) {
|
||||
denominationMap[this.denomKey(d)] = count;
|
||||
}
|
||||
}
|
||||
|
||||
// The breakdown is the only record of which notes were taken, and the server insists it add up
|
||||
// to the cash collected. Checked here so the cashier is told which figure is wrong while the
|
||||
// counts are still on screen, rather than getting a rejection after the fact.
|
||||
//
|
||||
// ddTotalPayableFor rounds up to the whole rupee actually collected, which is what the notes
|
||||
// can add to; the exact figure with paise is accepted too, for a draft that lands on a rupee.
|
||||
const totalCollected = Number(this.ddTotalPayableFor(row));
|
||||
const exactPayable = Number(row.amount || 0) + (Number(row.ddCharges) || 0) + this.ddGstAmountFor(row);
|
||||
const countedTotal = Object.entries(denominationMap).reduce(
|
||||
(sum, [
|
||||
key,
|
||||
count
|
||||
]) => sum + Number(key.split(':')[1]) * count,
|
||||
0
|
||||
);
|
||||
if (countedTotal !== totalCollected && countedTotal !== exactPayable) {
|
||||
this.showAlert('error', 'Denomination Entry Does Not Match', [
|
||||
countedTotal === 0
|
||||
? 'Enter the notes and coins taken from the purchaser before submitting.'
|
||||
: `The notes and coins entered come to ₹${countedTotal.toFixed(2)}, but ₹${totalCollected.toFixed(2)} is collected (face value + charges + GST).`,
|
||||
'The breakdown must equal the cash taken — it is what the closing report and the drawer are checked against.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const d = new Date();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const payload = {
|
||||
walkinPurchaserName: purchaserData.walkinPurchaserName,
|
||||
walkinPurchaserAddress: purchaserData.walkinPurchaserAddress,
|
||||
walkinPurchaserAadhaar: purchaserData.walkinPurchaserAadhaar,
|
||||
beneficiaryName: purchaserData.beneficiaryName,
|
||||
payableAtBranch: purchaserData.payableAtBranch,
|
||||
payableAtCity: purchaserData.payableAtCity,
|
||||
faceValue: Number(row.amount),
|
||||
chargesAmount: Number(row.ddCharges) || 0,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: 'CASH' as const,
|
||||
denominationData: Object.keys(denominationMap).length > 0 ? JSON.stringify(denominationMap) : undefined
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.showAlert('success', 'DD Issuance Request Submitted', [
|
||||
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}, Total Cash: ₹${this.ddTotalPayableFor(row)}) submitted successfully!`,
|
||||
'Sent to Manager / Head Cashier for maker-checker approval.'
|
||||
]);
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
row.description = '';
|
||||
this.clearDenominations();
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||||
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
||||
@@ -2811,16 +3198,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const f = cfg.matchField;
|
||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||||
// gated on "exactly one row came back" — it never would be for those types.
|
||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||
const partialMatch = !exactMatch
|
||||
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||
? (allItems.find((x: any) => String(x[f] ?? '').endsWith(acNo)) ??
|
||||
allItems.find((x: any) => String(x[f] ?? '').includes(acNo)))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? null;
|
||||
const idMatch =
|
||||
!exactMatch && !partialMatch && acTyp === 'GL'
|
||||
? allItems.find((x: any) => String(x.id) === String(acNo))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
|
||||
if (match?.id) {
|
||||
const matchedGlCode = match[f] ?? acNo;
|
||||
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
|
||||
this.selectedRow.acNo = match.glCode;
|
||||
}
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch);
|
||||
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = false;
|
||||
@@ -3048,7 +3442,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
const returnedId = acc.glCode ?? acc.accountNo;
|
||||
if (returnedId && returnedId !== acNo) {
|
||||
if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) {
|
||||
this.loadingAccount = false;
|
||||
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
|
||||
return false;
|
||||
@@ -3294,7 +3688,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
{ label: 'NEFT Facility', active: !!f.neftFacility },
|
||||
{ label: 'RTGS Facility', active: !!f.rtgsFacility },
|
||||
{ label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility },
|
||||
{ label: 'Withdrawal', active: !!f.withdrawalFacility },
|
||||
{ label: 'Deposit (KYC)', active: !!f.depositFacilityKyc },
|
||||
{ label: 'Virtual Debit Card', active: !!f.virtualDebitCard },
|
||||
{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }
|
||||
@@ -4579,6 +4972,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
const row = this.selectedRow!;
|
||||
if (row.typ === 'DD') {
|
||||
this.openIssueWalkinDd(row);
|
||||
return;
|
||||
}
|
||||
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
const command = isDeposit ? 'deposit' : 'withdrawal';
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · EXCEPTIONS</div>
|
||||
<h1 class="pg-title">Voucher Exceptions</h1>
|
||||
<p class="pg-sub">
|
||||
Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
|
||||
wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
|
||||
document.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
<span>Show</span>
|
||||
<select [(ngModel)]="filter" (change)="load()">
|
||||
<option value="OPEN">Open</option>
|
||||
<option value="RESOLVED">Resolved</option>
|
||||
<option value="ALL">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Reload
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||
}
|
||||
|
||||
@if (!canResolve && loaded) {
|
||||
<div class="ve-note">
|
||||
<mat-icon>info</mat-icon>
|
||||
You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
|
||||
that whoever raised the flag is not the one who rules on it.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
|
||||
@if (!loading && loaded && exceptions.length === 0) {
|
||||
<div class="pg-empty">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (e of exceptions; track e.voucherNo) {
|
||||
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
|
||||
<div class="ve-card__head">
|
||||
<span class="pg-chip">{{ e.voucherNo }}</span>
|
||||
@if (e.resolutionStatus) {
|
||||
<span class="db-badge db-badge--ok">RESOLVED</span>
|
||||
} @else {
|
||||
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
|
||||
}
|
||||
<span class="db-muted">
|
||||
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
|
||||
</span>
|
||||
@if (!e.resolutionStatus && canResolve) {
|
||||
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
|
||||
Review & Close
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ve-card__amts">
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">System recorded</span>
|
||||
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (!amountsAgree(e)) {
|
||||
<div class="db-cmp db-cmp--bad">
|
||||
<span class="db-cmp__label">Written on slip</span>
|
||||
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="db-cmp db-cmp--bad">
|
||||
<span class="db-cmp__label">Difference</span>
|
||||
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">Amounts</span>
|
||||
<span class="db-cmp__value">Agree — flagged for another reason</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
|
||||
|
||||
@if (e.resolutionStatus) {
|
||||
<div class="ve-card__resolution">
|
||||
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
|
||||
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Resolution panel, opened under the exception it closes. -->
|
||||
@if (resolveRow === e) {
|
||||
<div class="db-panel ve-panel">
|
||||
<div class="db-panel__head">
|
||||
<strong>Close exception — {{ e.voucherNo }}</strong>
|
||||
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="pg-field ve-panel__field">
|
||||
<span>What was wrong?</span>
|
||||
<select [(ngModel)]="resolutionStatus">
|
||||
<option value="">— Select —</option>
|
||||
@for (o of resolutionOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="pg-field ve-panel__field">
|
||||
<span>What was found, and what was done about it?</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="resolutionNotes"
|
||||
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="ve-panel__docs">
|
||||
<strong>Supporting document</strong>
|
||||
<p class="db-muted">
|
||||
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
|
||||
whatever the decision rests on.
|
||||
</p>
|
||||
<label class="db-upload">
|
||||
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
|
||||
<span class="db-muted">Max 10 MB</span>
|
||||
</label>
|
||||
@if (attachUploading) {
|
||||
<p class="db-muted">Uploading…</p>
|
||||
}
|
||||
@if (attachLoading) {
|
||||
<p class="db-muted">Loading…</p>
|
||||
}
|
||||
@if (!attachLoading && attachments.length === 0) {
|
||||
<p class="ve-panel__warn">No document attached yet — required before closing.</p>
|
||||
}
|
||||
@for (a of attachments; track a.id) {
|
||||
<div class="db-file">
|
||||
<mat-icon>description</mat-icon>
|
||||
<span class="db-file__name">{{ a.fileName }}</span>
|
||||
<span class="db-muted">{{ a.uploadedByName }}</span>
|
||||
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="db-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
(click)="saveResolution()"
|
||||
[disabled]="!canSubmitResolution"
|
||||
>
|
||||
Close Exception
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
|
||||
shared with the day book. Only the exception card itself is defined here. */
|
||||
|
||||
.ve-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
color: #1e40af;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
|
||||
reads at a glance without opening anything. */
|
||||
.ve-card {
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 8px;
|
||||
|
||||
&--resolved {
|
||||
border-left-color: #16a34a;
|
||||
background: #fafffb;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Pushed right so the identity of the voucher reads first. */
|
||||
&__act {
|
||||
margin-left: auto;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__amts {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__why {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__resolution {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #14532d;
|
||||
}
|
||||
}
|
||||
|
||||
.ve-panel {
|
||||
margin-top: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
|
||||
&__field {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
&__docs {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__warn {
|
||||
padding: 6px 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 4px;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
interface VoucherException {
|
||||
voucherNo: string;
|
||||
systemAmount: number | null;
|
||||
slipAmount: number | null;
|
||||
remarks: string | null;
|
||||
raisedByName: string | null;
|
||||
raisedAt: string | null;
|
||||
accountNo: string | null;
|
||||
party: string | null;
|
||||
raisedAgainst: string | null;
|
||||
attachmentCount: number;
|
||||
resolutionStatus: string | null;
|
||||
resolutionNotes: string | null;
|
||||
resolvedByName: string | null;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
|
||||
*
|
||||
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
|
||||
* or manager sits down with them, records what was actually wrong, and attaches the paper that
|
||||
* supports the decision.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-exceptions',
|
||||
templateUrl: './voucher-exceptions.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'../day-book/day-book.component.scss',
|
||||
'./voucher-exceptions.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class VoucherExceptionsComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
|
||||
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
|
||||
exceptions: VoucherException[] = [];
|
||||
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
|
||||
canResolve = false;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
errorMsg = '';
|
||||
|
||||
// Resolution panel
|
||||
resolveRow: VoucherException | null = null;
|
||||
resolutionStatus = '';
|
||||
resolutionNotes = '';
|
||||
resolveSaving = false;
|
||||
|
||||
// Attachments for the voucher being resolved
|
||||
attachments: any[] = [];
|
||||
attachLoading = false;
|
||||
attachUploading = false;
|
||||
|
||||
readonly resolutionOptions = [
|
||||
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
|
||||
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
|
||||
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.voucherExceptions(this.filter)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
this.exceptions = res?.exceptions ?? [];
|
||||
this.canResolve = !!res?.canResolve;
|
||||
});
|
||||
}
|
||||
|
||||
difference(e: VoucherException): number {
|
||||
if (e.slipAmount == null) return 0;
|
||||
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
|
||||
}
|
||||
|
||||
/** True when the flag was about something other than the amount. */
|
||||
amountsAgree(e: VoucherException): boolean {
|
||||
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
|
||||
}
|
||||
|
||||
openResolve(e: VoucherException): void {
|
||||
this.resolveRow = e;
|
||||
this.resolutionStatus = '';
|
||||
this.resolutionNotes = '';
|
||||
this.loadAttachments(e);
|
||||
}
|
||||
|
||||
closeResolve(): void {
|
||||
this.resolveRow = null;
|
||||
this.attachments = [];
|
||||
}
|
||||
|
||||
private loadAttachments(e: VoucherException): void {
|
||||
this.attachments = [];
|
||||
this.attachLoading = true;
|
||||
this.service
|
||||
.listVoucherAttachments(e.voucherNo)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.attachLoading = false;
|
||||
this.attachments = res?.attachments ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
const row = this.resolveRow;
|
||||
if (!file || !row) return;
|
||||
|
||||
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.attachUploading = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.uploadVoucherAttachment(
|
||||
row.voucherNo,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
String(reader.result),
|
||||
'Supporting document for exception resolution'
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.attachUploading = false;
|
||||
input.value = '';
|
||||
if (res) {
|
||||
this.loadAttachments(row);
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
downloadAttachment(a: any): void {
|
||||
this.service
|
||||
.downloadVoucherAttachment(a.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
this.errorMsg = 'Could not download that file.';
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = a.fileName ?? 'attachment';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
|
||||
get canSubmitResolution(): boolean {
|
||||
return (
|
||||
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
|
||||
);
|
||||
}
|
||||
|
||||
saveResolution(): void {
|
||||
const row = this.resolveRow;
|
||||
if (!row) return;
|
||||
|
||||
this.resolveSaving = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.resolveSaving = false;
|
||||
if (res) {
|
||||
this.closeResolve();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolutionLabel(status: string | null): string {
|
||||
if (!status) return '';
|
||||
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
|
||||
}
|
||||
|
||||
/** Date and time — an exception queue spans days, so the day matters here. */
|
||||
stamp(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const [
|
||||
d,
|
||||
t
|
||||
] = iso.split('T');
|
||||
return t ? `${d} ${t.slice(0, 5)}` : d;
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,8 @@
|
||||
<header class="pg-head vr-no-print">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · REPORT</div>
|
||||
<h1 class="pg-title">Voucher Report</h1>
|
||||
<p class="pg-sub">
|
||||
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
|
||||
</p>
|
||||
<h1 class="pg-title">{{ title }}</h1>
|
||||
<p class="pg-sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
@@ -42,10 +40,8 @@
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Run
|
||||
</button>
|
||||
@if (report && report.count > 0) {
|
||||
<button type="button" class="pg-btn" (click)="print()">
|
||||
<mat-icon>print</mat-icon> Print
|
||||
</button>
|
||||
@if (report && count > 0) {
|
||||
<button type="button" class="pg-btn" (click)="print()"><mat-icon>print</mat-icon> Print</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
@@ -64,8 +60,7 @@
|
||||
@if (report.from !== report.to) {
|
||||
— {{ report.to | date: 'dd MMM yyyy' }}
|
||||
}
|
||||
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
|
||||
· ₹{{ report.totalAmount | number: '1.2-2' }}
|
||||
· {{ count }} {{ noun }}{{ count === 1 ? '' : 's' }} · ₹{{ totalAmount | number: '1.2-2' }}
|
||||
@if (report.username) {
|
||||
· {{ report.username }}
|
||||
}
|
||||
@@ -74,10 +69,46 @@
|
||||
@if (!report.canSeeAllUsers) {
|
||||
<span class="vr-scope-note">Your own vouchers</span>
|
||||
}
|
||||
<!-- The total on a cash register means something narrower than the whole voucher book, so the
|
||||
screen says which one it is instead of leaving the figure ambiguous. -->
|
||||
@if (cashOnly) {
|
||||
<span class="vr-scope-note">Cash only</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (report.count === 0) {
|
||||
<div class="pg-empty">No vouchers were raised in this period.</div>
|
||||
<!-- A voucher with no CR/DR prefix belongs to neither report. Say so, so the two reports not
|
||||
adding up to the day's total is explained rather than left to be puzzled over. -->
|
||||
@if (unclassifiedCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ unclassifiedCount }} voucher{{ unclassifiedCount === 1 ? '' : 's' }} in this period
|
||||
{{ unclassifiedCount === 1 ? 'carries' : 'carry' }} no CR or DR prefix, so
|
||||
{{ unclassifiedCount === 1 ? 'it appears' : 'they appear' }} on neither the Received nor the Payment report.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Which part of the total has notes and coins counted behind it. Shown on the cash register
|
||||
as well as the full one: a voucher with no breakdown is still real money, so it stays in
|
||||
the total, and what the drawer can be checked against is stated instead of assumed. -->
|
||||
@if (uncountedCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ uncountedCount }} of these {{ noun }}s {{ uncountedCount === 1 ? 'has' : 'have' }} no denomination breakdown
|
||||
recorded, totalling ₹{{ uncountedTotal | number: '1.2-2' }}. Some registers pay out without counting notes
|
||||
against the voucher, so these are still cash — ₹{{ countedTotal | number: '1.2-2' }} of the total has a
|
||||
breakdown behind it.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Explains the blank Debit/Credit cells instead of leaving them looking like lost data. -->
|
||||
@if (missingSidesCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ missingSidesCount }} of these {{ noun }}s {{ missingSidesCount === 1 ? 'has' : 'have' }} no debit or credit
|
||||
recorded. The sides are attached when a request is approved, so vouchers raised before that step was added keep
|
||||
their number and amount but show no accounts. Newly raised {{ noun }}s carry both sides.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (count === 0) {
|
||||
<div class="pg-empty">No {{ noun }}s were raised in this period.</div>
|
||||
} @else {
|
||||
<div class="vr-summaries">
|
||||
<div class="pg-card">
|
||||
@@ -129,7 +160,7 @@
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Vouchers</div>
|
||||
<div class="pos-section-label">{{ isReceived ? 'Receipts' : 'Payments' }}</div>
|
||||
<table class="pg-table vr-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -143,33 +174,39 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of report.vouchers; track row.voucherNo) {
|
||||
@for (row of rows; track row.voucherNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
|
||||
{{ row.voucherNo }}
|
||||
</a>
|
||||
{{ row.voucherNo }}
|
||||
@if (row.verificationStatus === 'EXCEPTION') {
|
||||
<mat-icon class="vr-flag" title="An exception was raised against this voucher">error_outline</mat-icon>
|
||||
<mat-icon class="vr-flag" title="An exception was raised against this voucher"
|
||||
>error_outline</mat-icon
|
||||
>
|
||||
}
|
||||
</td>
|
||||
<td>{{ row.voucherType || '—' }}</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||
@if (row.debitAccount) {
|
||||
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||
@if (row.creditAccount) {
|
||||
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||
}
|
||||
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
||||
@if (row.extraLines > 0) {
|
||||
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
||||
}
|
||||
</td>
|
||||
<!-- A voucher raised before its sides were recorded has neither. Say that once,
|
||||
across both cells, rather than showing two dashes that read as "nowhere". -->
|
||||
@if (!hasSides(row)) {
|
||||
<td colspan="2" class="vr-nosides">Sides not recorded</td>
|
||||
} @else {
|
||||
<td>
|
||||
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||
@if (row.debitAccount) {
|
||||
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||
@if (row.creditAccount) {
|
||||
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||
}
|
||||
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
||||
@if (row.extraLines > 0) {
|
||||
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ row.username || '—' }}</td>
|
||||
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
|
||||
|
||||
@@ -36,16 +36,6 @@
|
||||
}
|
||||
|
||||
.vr-rows {
|
||||
.vr-link {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.vr-flag {
|
||||
margin-left: 4px;
|
||||
font-size: 15px;
|
||||
@@ -65,6 +55,13 @@
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// Spans the Debit and Credit cells for a voucher raised before its sides were recorded.
|
||||
.vr-nosides {
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing a report should put the register on paper — the filters are how you reached it. */
|
||||
@@ -72,9 +69,4 @@
|
||||
.vr-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vr-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,19 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/**
|
||||
* Which side of the register a screen shows.
|
||||
*
|
||||
* 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route,
|
||||
* so the two screens are the same component pointed at opposite sides.
|
||||
*/
|
||||
export type VoucherSide = 'CR' | 'DR';
|
||||
|
||||
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||
interface VoucherRow {
|
||||
voucherNo: string;
|
||||
@@ -30,6 +38,13 @@ interface VoucherRow {
|
||||
creditAccount: string | null;
|
||||
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
|
||||
extraLines: number;
|
||||
/**
|
||||
* Whether a denomination breakdown was recorded against this voucher.
|
||||
*
|
||||
* True proves cash was counted. False does not prove the opposite: several registers pay real
|
||||
* money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash.
|
||||
*/
|
||||
denominationCounted: boolean;
|
||||
}
|
||||
|
||||
interface VoucherReport {
|
||||
@@ -38,6 +53,8 @@ interface VoucherReport {
|
||||
username: string | null;
|
||||
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
|
||||
canSeeAllUsers: boolean;
|
||||
/** Whether the server restricted the register to cash movements. */
|
||||
cashOnly: boolean;
|
||||
vouchers: VoucherRow[];
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
@@ -60,12 +77,12 @@ interface VoucherReport {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class VoucherReportComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
/** Day is the working default; a month is what you ask for at a close. */
|
||||
period: 'DAY' | 'MONTH' = 'DAY';
|
||||
@@ -74,21 +91,67 @@ export class VoucherReportComponent implements OnInit {
|
||||
usernameFilter = '';
|
||||
typeFilter = '';
|
||||
|
||||
/** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */
|
||||
side: VoucherSide = 'CR';
|
||||
|
||||
/**
|
||||
* Whether this screen is the cash register rather than the whole voucher register.
|
||||
*
|
||||
* A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a
|
||||
* clearing settlement, a cheque book fee — are left out, and everything that could have crossed
|
||||
* the counter stays in. The filter errs towards keeping a row: a book entry on the register can
|
||||
* be seen and recognised, whereas cash that never appears is invisible until it turns up as an
|
||||
* unexplained difference at the end of the day.
|
||||
*/
|
||||
cashOnly = false;
|
||||
|
||||
report: VoucherReport | null = null;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
|
||||
this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
|
||||
this.load();
|
||||
}
|
||||
|
||||
get isReceived(): boolean {
|
||||
return this.side === 'CR';
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.isReceived ? 'Received Report' : 'Payment Report';
|
||||
}
|
||||
|
||||
get subtitle(): string {
|
||||
if (this.cashOnly) {
|
||||
return this.isReceived
|
||||
? 'Cash taken in over a day or a month, and who took it — book entries left out.'
|
||||
: 'Cash paid out over a day or a month, and who paid it — book entries left out.';
|
||||
}
|
||||
return this.isReceived
|
||||
? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.'
|
||||
: 'Every payment voucher raised over a day or a month — money paid out, and who paid it.';
|
||||
}
|
||||
|
||||
/** The word for one row on this side, for counts and the empty state. */
|
||||
get noun(): string {
|
||||
return this.isReceived ? 'receipt' : 'payment';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const { from, to } = this.range();
|
||||
this.service
|
||||
.voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
|
||||
.voucherReport(
|
||||
from,
|
||||
to,
|
||||
this.usernameFilter.trim() || undefined,
|
||||
this.typeFilter.trim() || undefined,
|
||||
this.cashOnly
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.error =
|
||||
@@ -125,9 +188,95 @@ export class VoucherReportComponent implements OnInit {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows belonging to this screen's side.
|
||||
*
|
||||
* The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the
|
||||
* server stamps when it raises the voucher: a deposit credits the member and gets CR, everything
|
||||
* that takes money out gets DR. That prefix is the authoritative record of the side, so it is
|
||||
* matched directly rather than re-deriving the side from the voucher type here — a new type added
|
||||
* on the server then lands on the correct report with no change needed on this screen.
|
||||
*/
|
||||
get rows(): VoucherRow[] {
|
||||
const all = this.report?.vouchers ?? [];
|
||||
return all.filter((row) => this.sideOf(row) === this.side);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a voucher has its two sides recorded against it.
|
||||
*
|
||||
* The registers raise a voucher when the request is made and only attach its sides once it is
|
||||
* approved and posted, so a voucher raised before that step existed carries a number but no
|
||||
* lines. Those rows are labelled rather than shown as an empty dash, which reads like the money
|
||||
* went nowhere.
|
||||
*/
|
||||
hasSides(row: VoucherRow): boolean {
|
||||
return !!(row.debitParty || row.creditParty);
|
||||
}
|
||||
|
||||
/** How many rows on this side are missing their debit/credit lines. */
|
||||
get missingSidesCount(): number {
|
||||
return this.rows.filter((row) => !this.hasSides(row)).length;
|
||||
}
|
||||
|
||||
/** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */
|
||||
private sideOf(row: VoucherRow): VoucherSide | null {
|
||||
const no = (row.voucherNo ?? '').toUpperCase();
|
||||
if (no.startsWith('CR/')) {
|
||||
return 'CR';
|
||||
}
|
||||
if (no.startsWith('DR/')) {
|
||||
return 'DR';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** How many rows on this side — not the server's count, which spans both. */
|
||||
get count(): number {
|
||||
return this.rows.length;
|
||||
}
|
||||
|
||||
/** Total for this side only. */
|
||||
get totalAmount(): number {
|
||||
return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of this side's total whose notes and coins were actually counted.
|
||||
*
|
||||
* Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown,
|
||||
* because the register that raised it records none. So this is the confirmed part of the cash,
|
||||
* not the whole of it, and the gap is reported rather than treated as book entries.
|
||||
*/
|
||||
get countedTotal(): number {
|
||||
return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
/** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */
|
||||
get uncountedCount(): number {
|
||||
return this.rows.filter((row) => !row.denominationCounted).length;
|
||||
}
|
||||
|
||||
/** How much of this side's total has no note-and-coin breakdown behind it. */
|
||||
get uncountedTotal(): number {
|
||||
return this.totalAmount - this.countedTotal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vouchers whose number carries neither prefix, so they appear on neither report.
|
||||
*
|
||||
* Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the
|
||||
* register is exactly the kind of thing a cashier must be told about, not left to discover when
|
||||
* the two reports do not add up to the day.
|
||||
*/
|
||||
get unclassifiedCount(): number {
|
||||
const all = this.report?.vouchers ?? [];
|
||||
return all.filter((row) => this.sideOf(row) === null).length;
|
||||
}
|
||||
|
||||
/** Totals per user, for the summary a manager reads before the rows. */
|
||||
get byUser(): { username: string; count: number; total: number }[] {
|
||||
const rows = this.report?.vouchers ?? [];
|
||||
const rows = this.rows;
|
||||
const tally = new Map<string, { username: string; count: number; total: number }>();
|
||||
for (const row of rows) {
|
||||
const key = row.username ?? '—';
|
||||
@@ -141,7 +290,7 @@ export class VoucherReportComponent implements OnInit {
|
||||
|
||||
/** Totals per voucher type — deposits against withdrawals against journals. */
|
||||
get byType(): { type: string; count: number; total: number }[] {
|
||||
const rows = this.report?.vouchers ?? [];
|
||||
const rows = this.rows;
|
||||
const tally = new Map<string, { type: string; count: number; total: number }>();
|
||||
for (const row of rows) {
|
||||
const key = row.voucherType ?? '—';
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<header class="pg-head vv-no-print">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · VOUCHER</div>
|
||||
<h1 class="pg-title">Voucher</h1>
|
||||
<p class="pg-sub">
|
||||
Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
|
||||
and for how much.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
<span>Voucher No</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="voucherNo"
|
||||
(keyup.enter)="search()"
|
||||
placeholder="CR/2708/0001"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="search()" [disabled]="loading">
|
||||
<mat-icon>search</mat-icon> Find
|
||||
</button>
|
||||
@if (voucher) {
|
||||
<button type="button" class="pg-btn" (click)="print()">
|
||||
<mat-icon>print</mat-icon> Print
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="pg-alert pg-alert--error vv-no-print">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty vv-no-print">Loading…</div>
|
||||
}
|
||||
|
||||
@if (voucher && !loading) {
|
||||
<div class="pg-card vv-slip">
|
||||
<div class="vv-slip-head">
|
||||
<div>
|
||||
<div class="vv-no">{{ voucher.voucherNo }}</div>
|
||||
<div class="vv-meta">
|
||||
{{ voucher.voucherType || '—' }}
|
||||
@if (voucher.branchName) {
|
||||
· {{ voucher.branchName }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vv-slip-head-right">
|
||||
<div class="vv-amount">₹{{ voucher.amount | number: '1.2-2' }}</div>
|
||||
<div class="vv-meta">
|
||||
@if (voucher.createdAt) {
|
||||
{{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }}
|
||||
}
|
||||
@if (voucher.createdBy) {
|
||||
· {{ voucher.createdBy }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (voucher.verificationStatus) {
|
||||
<div class="vv-status" [class.vv-status--exception]="voucher.verificationStatus === 'EXCEPTION'">
|
||||
<mat-icon>{{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }}</mat-icon>
|
||||
{{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- A voucher raised before its sides were recorded: the number is valid, the posting behind it
|
||||
simply was never captured, so it is shown as an older record rather than as an error. -->
|
||||
@if (hasNoLines) {
|
||||
<div class="pg-empty">
|
||||
No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept.
|
||||
</div>
|
||||
} @else {
|
||||
<table class="pg-table vv-lines">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Particulars</th>
|
||||
<th>Account</th>
|
||||
<th class="num">Debit</th>
|
||||
<th class="num">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of debitLines; track line.lineNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="vv-party">{{ line.partyName || '—' }}</div>
|
||||
@if (line.description) {
|
||||
<div class="vv-desc">{{ line.description }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
|
||||
<td class="num">—</td>
|
||||
</tr>
|
||||
}
|
||||
@for (line of creditLines; track line.lineNo) {
|
||||
<tr>
|
||||
<td class="vv-indent">
|
||||
<div class="vv-party">{{ line.partyName || '—' }}</div>
|
||||
@if (line.description) {
|
||||
<div class="vv-desc">{{ line.description }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">—</td>
|
||||
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2">Total</td>
|
||||
<td class="num">{{ voucher.totalDebit | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ voucher.totalCredit | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<!-- Stated rather than assumed: a voucher whose sides were written before the balance check
|
||||
existed, or repaired by hand, should read as unbalanced rather than pass as a valid slip. -->
|
||||
@if (!voucher.balanced) {
|
||||
<div class="pg-alert pg-alert--error">
|
||||
This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Page chrome and the table come from cashier-shared.scss. Only the slip itself is defined here. */
|
||||
|
||||
.vv-slip {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.vv-slip-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.vv-slip-head-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vv-no {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.vv-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 14px;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 8px;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */
|
||||
&.vv-status--exception {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.vv-lines {
|
||||
/* Credits sit under their debits the way they do on a written slip, so the indent carries the
|
||||
relationship that the DR/CR columns alone would leave to the reader to work out. */
|
||||
.vv-indent {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.vv-party {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are
|
||||
how you reached it, not part of it. */
|
||||
@media print {
|
||||
.vv-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vv-slip {
|
||||
max-width: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* 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 { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/** One side of a voucher: a debit or a credit against a single account. */
|
||||
interface VoucherLine {
|
||||
lineNo: number;
|
||||
entryType: 'DR' | 'CR';
|
||||
glAccountId: number | null;
|
||||
savingsAccountId: number | null;
|
||||
loanAccountId: number | null;
|
||||
partyName: string | null;
|
||||
accountNo: string | null;
|
||||
amount: number;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface Voucher {
|
||||
voucherNo: string;
|
||||
voucherType: string | null;
|
||||
referenceType: string | null;
|
||||
referenceId: number | null;
|
||||
amount: number | null;
|
||||
branchOfficeId: number | null;
|
||||
branchName: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
verificationStatus: string | null;
|
||||
lines: VoucherLine[];
|
||||
totalDebit: number;
|
||||
totalCredit: number;
|
||||
balanced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One voucher, as a slip: which accounts were debited, which credited, and for how much.
|
||||
*
|
||||
* Looked up by voucher number rather than listed, because that is how a voucher is reached — from a
|
||||
* paper slip in someone's hand, or from a number quoted on a statement.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-view',
|
||||
templateUrl: './voucher-view.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./voucher-view.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class VoucherViewComponent {
|
||||
private service = inject(CashierService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
voucherNo = '';
|
||||
voucher: Voucher | null = null;
|
||||
loading = false;
|
||||
/** Shown in place of the slip — a bad number and a server error read differently to the user. */
|
||||
error: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// A voucher number in the URL means someone arrived from a link or a statement rather than by
|
||||
// typing, so the slip is fetched straight away instead of showing them an empty search box.
|
||||
const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo');
|
||||
if (fromUrl) {
|
||||
this.voucherNo = fromUrl;
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
|
||||
search(): void {
|
||||
const trimmed = (this.voucherNo || '').trim();
|
||||
if (!trimmed) {
|
||||
this.error = 'Enter a voucher number.';
|
||||
this.voucher = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// Kept in the URL so the slip can be linked to and survives a refresh.
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { voucherNo: trimmed },
|
||||
replaceUrl: true
|
||||
});
|
||||
|
||||
this.service
|
||||
.voucherDetail(trimmed)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.error =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
`Voucher ${trimmed} could not be loaded.`;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: Voucher | null) => {
|
||||
this.loading = false;
|
||||
this.voucher = res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Debits first, then credits — the order a slip is read in. */
|
||||
get debitLines(): VoucherLine[] {
|
||||
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR');
|
||||
}
|
||||
|
||||
get creditLines(): VoucherLine[] {
|
||||
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR');
|
||||
}
|
||||
|
||||
/**
|
||||
* A voucher raised before its sides were recorded. The voucher is real and its number valid — only
|
||||
* the posting behind it was never captured — so this reads as an older record, not a broken one.
|
||||
*/
|
||||
get hasNoLines(): boolean {
|
||||
return !!this.voucher && this.voucher.lines.length === 0;
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
@@ -39,5 +39,4 @@ export class DividendDashboardService {
|
||||
const params = status && status !== 'ALL' ? { status } : {};
|
||||
return this.http.get('/clerk/dividend-dashboard/locks', { params });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@
|
||||
flex: 0 0 auto;
|
||||
padding: 9px 14px;
|
||||
border: 0;
|
||||
|
||||
/* The active marker is this underline, so every tab reserves the space for it — otherwise the
|
||||
strip would shift by 2px each time the selection moved. */
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
@@ -102,7 +102,6 @@ export class DividendDashboardComponent implements OnInit {
|
||||
lockYears: string[] = [];
|
||||
lockYearFilter = 'ALL';
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSummary();
|
||||
this.loadDeclarations();
|
||||
|
||||
@@ -1043,4 +1043,56 @@ export class ClientsService {
|
||||
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clients whose periodic re-KYC due date has passed, oldest first.
|
||||
*
|
||||
* Drives the expiry worklist. A client drops out of this list the moment their re-KYC is
|
||||
* completed, because renewing pushes the expiry date into the future.
|
||||
*/
|
||||
getLapsedKyc(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/kyc/exceptions/lapsed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Savings accounts the dormancy job has marked.
|
||||
*
|
||||
* @param subStatus 100 inactive, 200 dormant, 300 escheat; omit for all three
|
||||
*/
|
||||
getDormantAccounts(subStatus?: number): Observable<any[]> {
|
||||
const params = subStatus ? new HttpParams().set('subStatus', String(subStatus)) : undefined;
|
||||
return this.http.get<any[]>('/savingsaccounts/dormant', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the escheated balances of the given accounts were remitted to RBI's DEAF.
|
||||
*
|
||||
* The transfer itself happens outside this system; this only writes down that it was made, which
|
||||
* is what lets the escheat report tell remitted money from money still sitting with the bank.
|
||||
* Accounts that are not escheated, or already recorded, are skipped server-side — the result
|
||||
* carries `recorded` and `skipped` counts.
|
||||
*/
|
||||
recordDeafRemittance(data: {
|
||||
savingsAccountIds: number[];
|
||||
remittedOn: string;
|
||||
deafReference?: string;
|
||||
remarks?: string;
|
||||
locale?: string;
|
||||
dateFormat?: string;
|
||||
}): Observable<any> {
|
||||
return this.http.post('/savingsaccounts/deaf-remittance', {
|
||||
locale: 'en',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
...data
|
||||
});
|
||||
}
|
||||
|
||||
/** Records that a customer claimed a remitted balance back and the bank recovered it from DEAF. */
|
||||
refundDeafRemittance(savingsAccountId: number, refundedOn: string): Observable<any> {
|
||||
return this.http.post(`/savingsaccounts/deaf-remittance/${savingsAccountId}/refund`, {
|
||||
locale: 'en',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
refundedOn
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,47 +184,6 @@
|
||||
Today's Activity
|
||||
</a>
|
||||
}
|
||||
<!-- Outside the clerkAccountLoaded block on purpose: an open exception has to be
|
||||
reachable whether or not the clerk currently has an account loaded. -->
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-exceptions')"
|
||||
[routerLink]="['/cashier/voucher-exceptions']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
Exceptions
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.split('?')[0] === '/cashier/voucher'"
|
||||
[routerLink]="['/cashier/voucher']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1z" />
|
||||
<line x1="8" y1="9" x2="16" y2="9" />
|
||||
<line x1="8" y1="13" x2="14" y2="13" />
|
||||
</svg>
|
||||
Voucher
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-report')"
|
||||
[routerLink]="['/cashier/voucher-report']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
Voucher Report
|
||||
</a>
|
||||
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
||||
</nav>
|
||||
} @else {
|
||||
@@ -288,27 +247,17 @@
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/day-book']"
|
||||
[routerLink]="['/cashier/received-report']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="book"></fa-icon> Day Book
|
||||
<fa-icon icon="list"></fa-icon> Received
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/voucher-exceptions']"
|
||||
[routerLink]="['/cashier/payment-report']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
|
||||
</a>
|
||||
<a class="cashier-nav__item" [routerLink]="['/cashier/voucher']" routerLinkActive="cashier-nav__item--active">
|
||||
<fa-icon icon="receipt"></fa-icon> Voucher
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/voucher-report']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="list"></fa-icon> Voucher Report
|
||||
<fa-icon icon="list"></fa-icon> Payment
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
@@ -685,6 +634,15 @@
|
||||
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
|
||||
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry & Outreach
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
|
||||
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant & DEAF
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
|
||||
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
|
||||
</button>
|
||||
<!--
|
||||
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
|
||||
so it never shows up under the report categories above and had no nav entry at all.
|
||||
@@ -1092,15 +1050,6 @@
|
||||
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
|
||||
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
|
||||
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher']">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
|
||||
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
|
||||
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
|
||||
</button>
|
||||
@@ -1152,15 +1101,6 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
|
||||
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
|
||||
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher']">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
|
||||
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
|
||||
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
|
||||
</button>
|
||||
@@ -1253,12 +1193,6 @@
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd/issue-approvals']" tabindex="0">
|
||||
<fa-icon icon="check-double" class="menu-item-icon"></fa-icon> DD Issuance Approvals
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd/reprint-approvals']" tabindex="0">
|
||||
<fa-icon icon="print" class="menu-item-icon"></fa-icon> DD Reprint Approvals
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
|
||||
|
||||
+8
-5
@@ -367,15 +367,18 @@
|
||||
}
|
||||
|
||||
<!-- Compliance / AML Action Triage -->
|
||||
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
|
||||
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
|
||||
<div
|
||||
class="compliance-triage"
|
||||
style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px"
|
||||
>
|
||||
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px">
|
||||
Compliance & AML Triage
|
||||
</h4>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{ standalone: true }">
|
||||
Flag as suspicious (escalate to STR cases under RBI compliance)
|
||||
</mat-checkbox>
|
||||
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{standalone: true}">
|
||||
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{ standalone: true }">
|
||||
Mark as whitelisted (prevent future AML alerts for this account)
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -145,7 +145,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
cases = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const newRef = 101 + cases.length;
|
||||
const newCase: any = {
|
||||
reportType: 'STR',
|
||||
@@ -171,10 +171,10 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`,
|
||||
status: 'po-review'
|
||||
};
|
||||
|
||||
|
||||
cases.unshift(newCase);
|
||||
localStorage.setItem('fiu_str_cases', JSON.stringify(cases));
|
||||
|
||||
|
||||
const storedCtr = localStorage.getItem('fiu_ctr_txns');
|
||||
if (storedCtr) {
|
||||
try {
|
||||
@@ -201,7 +201,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
whitelisted.push(account.toString());
|
||||
localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted));
|
||||
}
|
||||
|
||||
|
||||
const storedCtr = localStorage.getItem('fiu_ctr_txns');
|
||||
if (storedCtr) {
|
||||
try {
|
||||
|
||||
+5
-1
@@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { CurrentAccountsService } from '../../current-accounts.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,7 @@ export class AddChargeCurrentAccountComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private dateUtils = inject(Dates);
|
||||
private currentAccountsService = inject(CurrentAccountsService);
|
||||
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
/** Minimum Due Date allowed. */
|
||||
@@ -66,7 +68,9 @@ export class AddChargeCurrentAccountComponent implements OnInit {
|
||||
*/
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { currentAccountActionData: any }) => {
|
||||
this.currentAccountChargeOptions = data.currentAccountActionData.chargeOptions;
|
||||
this.facilityChargeFilter
|
||||
.excludeFacilityCharges(data.currentAccountActionData.chargeOptions)
|
||||
.subscribe((charges: any[]) => (this.currentAccountChargeOptions = charges));
|
||||
});
|
||||
this.currentAccountId = this.route.snapshot.params['currentAccountId'];
|
||||
}
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
|
||||
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||
import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
||||
@@ -73,6 +74,7 @@ export class CurrentAccountChargesStepComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private dateUtils = inject(Dates);
|
||||
private translateService = inject(TranslateService);
|
||||
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||
|
||||
/** Current Account Product Template */
|
||||
private _currentAccountProductTemplate: any;
|
||||
@@ -80,7 +82,9 @@ export class CurrentAccountChargesStepComponent implements OnInit {
|
||||
set currentAccountProductTemplate(value: any) {
|
||||
this._currentAccountProductTemplate = value;
|
||||
if (value) {
|
||||
this.chargeData = value.chargeOptions || [];
|
||||
this.facilityChargeFilter
|
||||
.excludeFacilityCharges(value.chargeOptions)
|
||||
.subscribe((charges: any[]) => (this.chargeData = charges));
|
||||
// Only pre-populate from product template for new accounts
|
||||
if (!this.currentAccountTemplate?.id) {
|
||||
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
|
||||
|
||||
-12
@@ -174,18 +174,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dig-card" [class.dig-card--on]="facilityConfig.withdrawalFacility">
|
||||
<div class="dig-card__icon"><i class="fas fa-arrow-right-arrow-left"></i></div>
|
||||
<div class="dig-card__body">
|
||||
<span class="dig-card__label">Withdrawal Facility</span>
|
||||
<span class="dig-card__status">{{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
<label class="dig-toggle">
|
||||
<input type="checkbox" [(ngModel)]="facilityConfig.withdrawalFacility" />
|
||||
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dig-card" [class.dig-card--on]="facilityConfig.depositFacilityKyc">
|
||||
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
|
||||
<div class="dig-card__body">
|
||||
|
||||
-1
@@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = {
|
||||
neftFacility: false,
|
||||
rtgsFacility: false,
|
||||
interBankTotalCapFacility: false,
|
||||
withdrawalFacility: false,
|
||||
depositFacilityKyc: false,
|
||||
virtualDebitCard: false,
|
||||
internetBanking: false,
|
||||
|
||||
-1
@@ -76,7 +76,6 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
|
||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' },
|
||||
{ key: 'internetBanking', label: 'Internet Banking' },
|
||||
|
||||
-2
@@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
|
||||
neftFacility: false,
|
||||
rtgsFacility: false,
|
||||
interBankTotalCapFacility: false,
|
||||
withdrawalFacility: false,
|
||||
depositFacilityKyc: false,
|
||||
virtualDebitCard: false,
|
||||
internetBanking: false,
|
||||
@@ -66,7 +65,6 @@ const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [
|
||||
{ key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' },
|
||||
{ key: 'internetBanking', label: 'Internet Banking', icon: 'fa-globe' },
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<!--
|
||||
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="dr">
|
||||
<!-- Header -->
|
||||
<div class="dr-head">
|
||||
<div>
|
||||
<h2 class="dr-head-t"><fa-icon icon="clock"></fa-icon> Inactive, Dormant & DEAF Reports</h2>
|
||||
<div class="dr-head-s">
|
||||
Accounts with no customer-induced transaction, at each of the three marks RBI sets. Interest the bank posts does
|
||||
not reset the clock — only a deposit or withdrawal the customer makes.
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-head-a">
|
||||
<button class="dr-btn dr-btn--slim" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="undo"></fa-icon> Reload
|
||||
</button>
|
||||
<button class="dr-btn dr-btn--primary" (click)="exportExcel()" [disabled]="loading">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export all three
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="dr-alert">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>{{ errorMessage }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- The money that has left customers' accounts but not reached RBI -->
|
||||
@if (summary.pendingRemittanceCount > 0) {
|
||||
<div class="dr-alert">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>₹{{ summary.pendingRemittance | number: '1.0-0' }}</strong> from
|
||||
<strong>{{ summary.pendingRemittanceCount }}</strong> escheated
|
||||
{{ summary.pendingRemittanceCount === 1 ? 'account has' : 'accounts have' }} not been recorded as remitted to
|
||||
RBI's DEAF. The balance has already left the customer's account, so until the remittance is recorded the money
|
||||
sits with the bank against no fund.
|
||||
<button class="dr-link" (click)="showTab('ESCHEAT'); pendingRemittanceOnly = true">Show these accounts</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Entry: filters that apply to all three reports -->
|
||||
<div class="dr-entry">
|
||||
<div class="dr-entry-h"><fa-icon icon="filter"></fa-icon> Report criteria</div>
|
||||
<div class="dr-entry-b">
|
||||
<div class="dr-field">
|
||||
<label>As on</label>
|
||||
<!-- The idle period is worked out server-side against the business date, so this states the
|
||||
day the figures are for rather than offering to recompute them for another. -->
|
||||
<input class="dr-inp dr-inp--ro" type="date" [value]="today" disabled />
|
||||
</div>
|
||||
|
||||
<div class="dr-field">
|
||||
<label>Branch</label>
|
||||
<select class="dr-inp" [(ngModel)]="branchCode">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branches; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="dr-field">
|
||||
<label>Minimum balance</label>
|
||||
<input class="dr-inp" type="number" [(ngModel)]="minBalance" placeholder="Any" min="0" />
|
||||
</div>
|
||||
|
||||
<div class="dr-field dr-field--wide">
|
||||
<label>Search</label>
|
||||
<input class="dr-inp" type="text" [(ngModel)]="search" placeholder="Customer name, ID or account number" />
|
||||
</div>
|
||||
|
||||
<div class="dr-field dr-field--btn">
|
||||
<button class="dr-btn dr-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The three reports, side by side -->
|
||||
<div class="dr-tabs">
|
||||
@for (t of tabs; track t) {
|
||||
<button
|
||||
class="dr-tab"
|
||||
[class]="'dr-tab dr-tab--' + stageMeta[t].tone"
|
||||
[class.dr-tab--on]="tab === t"
|
||||
(click)="showTab(t)"
|
||||
>
|
||||
<div class="dr-tab-h">
|
||||
<span class="dr-tab-l">{{ stageMeta[t].label }}</span>
|
||||
<span class="dr-tab-n">{{ countFor(t) }}</span>
|
||||
</div>
|
||||
<div class="dr-tab-d">After {{ stageMeta[t].afterDays }} days idle</div>
|
||||
<div class="dr-tab-e">{{ stageMeta[t].effect }}</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- What this stage means, and how an account comes back from it -->
|
||||
<div class="dr-context">
|
||||
<div class="dr-context-i">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>{{ stageMeta[tab].label }}.</strong> {{ stageMeta[tab].effect }}
|
||||
<div class="dr-context-r"><strong>Revival:</strong> {{ stageMeta[tab].revival }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-context-t">
|
||||
<span class="dr-context-c">{{ visibleRows.length }}</span> accounts ·
|
||||
<span class="dr-context-c">₹{{ visibleBalance | number: '1.0-0' }}</span> total balance
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<div class="dr-bar">
|
||||
<label class="dr-chk">
|
||||
<input type="checkbox" [(ngModel)]="pendingRemittanceOnly" /> Not yet remitted to RBI
|
||||
</label>
|
||||
<span class="dr-money">
|
||||
<span class="dr-money-i dr-money-i--bad">
|
||||
₹{{ summary.pendingRemittance | number: '1.0-0' }} not remitted
|
||||
</span>
|
||||
<span class="dr-money-i dr-money-i--ok"> ₹{{ summary.remittedBalance | number: '1.0-0' }} with the fund </span>
|
||||
@if (summary.claimedBackCount > 0) {
|
||||
<span class="dr-money-i"> ₹{{ summary.claimedBackBalance | number: '1.0-0' }} claimed back </span>
|
||||
}
|
||||
</span>
|
||||
<span class="dr-spacer"></span>
|
||||
@if (selected.size > 0) {
|
||||
<span class="dr-selinfo"> {{ selected.size }} selected · ₹{{ selectedTotal | number: '1.0-0' }} </span>
|
||||
<button class="dr-btn dr-btn--slim" (click)="clearSelection()">Clear</button>
|
||||
}
|
||||
<button class="dr-btn dr-btn--primary" (click)="openRemit()" [disabled]="selected.size === 0">
|
||||
<fa-icon icon="check"></fa-icon> Record remittance to RBI
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Table -->
|
||||
@if (loading) {
|
||||
<div class="dr-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading…</div>
|
||||
} @else if (visibleRows.length === 0) {
|
||||
<div class="dr-empty">No accounts at this stage match the criteria above.</div>
|
||||
} @else {
|
||||
<div class="dr-tablewrap">
|
||||
<table class="dr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<th class="dr-tick">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected"
|
||||
(change)="toggleSelectAll()"
|
||||
title="Select every account not yet remitted"
|
||||
/>
|
||||
</th>
|
||||
}
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Branch</th>
|
||||
<th>Product</th>
|
||||
<th class="num">{{ tab === 'ESCHEAT' ? 'Balance Escheated' : 'Balance' }}</th>
|
||||
<th>Last Customer Txn</th>
|
||||
<th>Idle For</th>
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<th>Escheated On</th>
|
||||
<th>Remittance to RBI</th>
|
||||
} @else {
|
||||
<th>Next Stage</th>
|
||||
}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of visibleRows; track r.accountId) {
|
||||
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="selected.has(r.accountId)">
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<td class="dr-tick">
|
||||
<!-- An already-remitted balance cannot be remitted again. -->
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selected.has(r.accountId)"
|
||||
[disabled]="r.remittance === 'REMITTED'"
|
||||
(click)="toggleSelect(r, $event)"
|
||||
/>
|
||||
</td>
|
||||
}
|
||||
<td class="mono">{{ r.accountNo }}</td>
|
||||
|
||||
<td>
|
||||
<strong>{{ r.clientName }}</strong>
|
||||
<div class="dr-sub mono">{{ r.clientId }} · {{ r.mobile }}</div>
|
||||
</td>
|
||||
|
||||
<td>{{ branchName(r.branchCode) }}</td>
|
||||
|
||||
<td class="dr-prod">{{ r.productName }}</td>
|
||||
|
||||
<td class="num strong">{{ r.balance | number: '1.0-0' }}</td>
|
||||
|
||||
<td class="mono">{{ r.lastCustomerTxnDate }}</td>
|
||||
|
||||
<td>
|
||||
<span [class]="toneClass(r.stage)">{{ idleText(r.daysIdle) }}</span>
|
||||
</td>
|
||||
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<td class="mono">{{ r.escheatedOn || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="remittanceClass(r.remittance)">
|
||||
{{ r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted' }}
|
||||
</span>
|
||||
@if (r.remittedOn) {
|
||||
<div class="dr-sub mono">{{ r.remittedOn }}</div>
|
||||
}
|
||||
@if (r.deafReference) {
|
||||
<div class="dr-sub mono">{{ r.deafReference }}</div>
|
||||
}
|
||||
@if (r.refundedOn) {
|
||||
<div class="dr-claimed"><fa-icon icon="undo"></fa-icon> Claimed back {{ r.refundedOn }}</div>
|
||||
}
|
||||
</td>
|
||||
} @else {
|
||||
<td>
|
||||
<div class="dr-next">{{ r.nextStageLabel }}</div>
|
||||
<div class="dr-sub mono" [class.dr-sub--red]="isNearingEscheat(r)">
|
||||
{{ r.nextStageDate }}
|
||||
@if (isNearingEscheat(r)) {
|
||||
· {{ daysToNext(r) }} days
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
|
||||
<td class="dr-act">
|
||||
<button class="dr-mini" (click)="openClient(r, $event)">
|
||||
<fa-icon icon="user"></fa-icon> Customer
|
||||
</button>
|
||||
<!-- A remitted balance can still be claimed, so long as it has not been already. -->
|
||||
@if (tab === 'ESCHEAT' && r.remittance === 'REMITTED' && !r.refundedOn) {
|
||||
<button
|
||||
class="dr-mini"
|
||||
title="The customer claimed this balance back — the bank paid them and recovers it from DEAF"
|
||||
(click)="openRefund(r, $event)"
|
||||
>
|
||||
<fa-icon icon="undo"></fa-icon> Claimed back
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Where the money in the escheat report has to go -->
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<div class="dr-foot">
|
||||
<fa-icon icon="gavel"></fa-icon>
|
||||
<div>
|
||||
<strong>Depositor Education and Awareness Fund.</strong> A balance unclaimed for ten years is transferred to
|
||||
RBI's DEAF. The transfer itself is made outside this system, so an account shows as escheated here from the day
|
||||
the balance leaves it — the remittance column records that the money actually reached RBI, and stays "not
|
||||
remitted" until it does. A customer can still claim the amount at any time; the bank pays them and recovers it
|
||||
from the fund, which shows here as claimed back.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Record that money was sent to RBI -->
|
||||
@if (remitOpen) {
|
||||
<div class="dr-modal-bg" (click)="remitOpen = false">
|
||||
<div class="dr-modal" (click)="$event.stopPropagation()">
|
||||
<div class="dr-modal-h">
|
||||
<h3>Record remittance to RBI's DEAF</h3>
|
||||
<button class="dr-x" (click)="remitOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-b">
|
||||
<div class="dr-modal-sum">
|
||||
<div>
|
||||
<span class="dr-modal-n">{{ selected.size }}</span>
|
||||
<span class="dr-modal-l">accounts</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="dr-modal-n">₹{{ selectedTotal | number: '1.0-0' }}</span>
|
||||
<span class="dr-modal-l">total remitted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dr-note-box">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
This records a transfer that has already been made to RBI. It does not move any money — it marks these
|
||||
balances as accounted for, so they stop showing as outstanding.
|
||||
</div>
|
||||
|
||||
<label class="dr-lbl">Date sent to RBI <span class="dr-req">*</span></label>
|
||||
<input class="dr-inp dr-inp--full" type="date" [(ngModel)]="remitDate" />
|
||||
|
||||
<label class="dr-lbl">RBI / DEAF reference</label>
|
||||
<input
|
||||
class="dr-inp dr-inp--full"
|
||||
type="text"
|
||||
[(ngModel)]="remitReference"
|
||||
placeholder="e.g. DEAF/2026/Q2/004913 — as printed on RBI's advice"
|
||||
/>
|
||||
|
||||
<label class="dr-lbl">Remarks</label>
|
||||
<textarea class="dr-ta" rows="3" [(ngModel)]="remitRemarks" placeholder="Optional"></textarea>
|
||||
|
||||
@if (remitError) {
|
||||
<div class="dr-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ remitError }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-f">
|
||||
<button class="dr-btn" (click)="remitOpen = false">Cancel</button>
|
||||
<button class="dr-btn dr-btn--primary" (click)="saveRemittance()" [disabled]="remitSaving">
|
||||
@if (remitSaving) {
|
||||
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
|
||||
} @else {
|
||||
Record remittance
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Customer claimed a remitted balance back -->
|
||||
@if (refundOpen && refundRow) {
|
||||
<div class="dr-modal-bg" (click)="refundOpen = false">
|
||||
<div class="dr-modal dr-modal--sm" (click)="$event.stopPropagation()">
|
||||
<div class="dr-modal-h">
|
||||
<h3>Record a claim against DEAF</h3>
|
||||
<button class="dr-x" (click)="refundOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-b">
|
||||
<div class="dr-modal-who">
|
||||
<strong>{{ refundRow.clientName }}</strong>
|
||||
<span class="mono">{{ refundRow.accountNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-sum">
|
||||
<div>
|
||||
<span class="dr-modal-n">₹{{ refundRow.balance | number: '1.0-0' }}</span>
|
||||
<span class="dr-modal-l">claimed</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="dr-modal-n mono">{{ refundRow.remittedOn }}</span>
|
||||
<span class="dr-modal-l">remitted to RBI</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dr-note-box">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
A depositor can claim an unclaimed balance at any time, however long it has been with the fund. The bank pays
|
||||
the customer and recovers the amount from RBI. This records that, so the money is no longer counted as sitting
|
||||
with the fund.
|
||||
</div>
|
||||
|
||||
<label class="dr-lbl">Date the customer was paid <span class="dr-req">*</span></label>
|
||||
<input class="dr-inp dr-inp--full" type="date" [(ngModel)]="refundDate" />
|
||||
|
||||
@if (refundError) {
|
||||
<div class="dr-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ refundError }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-f">
|
||||
<button class="dr-btn" (click)="refundOpen = false">Cancel</button>
|
||||
<button class="dr-btn dr-btn--primary" (click)="saveRefund()" [disabled]="refundSaving">
|
||||
@if (refundSaving) {
|
||||
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
|
||||
} @else {
|
||||
Record claim
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (toastVisible) {
|
||||
<div class="dr-toast">{{ toastMsg }}</div>
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--d-navy: #1a237e;
|
||||
--d-blue: #1d4ed8;
|
||||
--d-blue-l: #eff6ff;
|
||||
--d-green: #15803d;
|
||||
--d-green-l: #dcfce7;
|
||||
--d-amber: #b45309;
|
||||
--d-amber-l: #fef3c7;
|
||||
--d-red: #c0392b;
|
||||
--d-red-l: #fdecea;
|
||||
--d-crit: #7f1d1d;
|
||||
--d-crit-l: #fee2e2;
|
||||
--d-g50: #f8fafc;
|
||||
--d-g100: #f1f5f9;
|
||||
--d-g200: #e2e8f0;
|
||||
--d-g300: #cbd5e1;
|
||||
--d-g500: #64748b;
|
||||
--d-g700: #334155;
|
||||
--d-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--d-g900);
|
||||
background: var(--d-g50);
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dr-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--d-navy);
|
||||
}
|
||||
|
||||
.dr-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--d-g500);
|
||||
max-width: 780px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dr-head-a {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The unremitted-money warning: the one number on this screen that is a live exposure. */
|
||||
.dr-alert {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--d-red-l);
|
||||
border: 1px solid #f5b7b1;
|
||||
border-left: 4px solid var(--d-red);
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--d-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--d-red);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-link {
|
||||
display: inline;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: var(--d-red);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Entry / criteria ───────────────────────────────────────────────────── */
|
||||
|
||||
.dr-entry {
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dr-entry-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
background: var(--d-g100);
|
||||
border-bottom: 1px solid var(--d-g200);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-entry-b {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.dr-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 160px;
|
||||
|
||||
label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
&--wide {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
&--btn {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-inp {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--d-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dr-chk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--d-g700);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── The three reports, side by side ────────────────────────────────────── */
|
||||
|
||||
.dr-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(255px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dr-tab {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-top: 3px solid var(--d-g300);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.12s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-top-color: var(--d-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-top-color: var(--d-amber);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-top-color: var(--d-crit);
|
||||
}
|
||||
|
||||
/* The report on display stays obvious while its rows are being read. */
|
||||
&--on {
|
||||
background: var(--d-g100);
|
||||
box-shadow: inset 0 0 0 1px var(--d-g300);
|
||||
}
|
||||
}
|
||||
|
||||
.dr-tab-h {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dr-tab-l {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dr-tab-n {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dr-tab-d {
|
||||
margin-top: 2px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-tab-e {
|
||||
margin-top: 5px;
|
||||
font-size: 10.5px;
|
||||
color: var(--d-g500);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── Context strip ──────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--d-blue-l);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-context-i {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
|
||||
fa-icon {
|
||||
color: var(--d-blue);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-context-r {
|
||||
margin-top: 3px;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-context-t {
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-context-c {
|
||||
font-weight: 700;
|
||||
color: var(--d-g900);
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--d-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--d-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--d-blue);
|
||||
color: var(--d-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--slim {
|
||||
padding: 6px 11px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--d-blue);
|
||||
border-color: var(--d-blue);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dr-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--d-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--d-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--d-blue);
|
||||
color: var(--d-blue);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-tablewrap {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.dr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
background: var(--d-g100);
|
||||
border-bottom: 1px solid var(--d-g200);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--d-g500);
|
||||
white-space: nowrap;
|
||||
|
||||
&.num {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--d-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--d-g50);
|
||||
}
|
||||
}
|
||||
|
||||
/* A dormant account inside a year of the DEAF mark is the one to act on now. */
|
||||
.dr-row--warn {
|
||||
background: var(--d-amber-l);
|
||||
|
||||
&:hover {
|
||||
background: #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--d-g500);
|
||||
margin-top: 2px;
|
||||
|
||||
&--red {
|
||||
color: var(--d-amber);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-prod {
|
||||
max-width: 220px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.dr-next {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-ref {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.dr-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--info {
|
||||
background: var(--d-blue-l);
|
||||
color: var(--d-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--d-amber-l);
|
||||
color: var(--d-amber);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: var(--d-crit-l);
|
||||
color: var(--d-crit);
|
||||
}
|
||||
}
|
||||
|
||||
.dr-rem {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
|
||||
&--ok {
|
||||
background: var(--d-green-l);
|
||||
color: var(--d-green);
|
||||
}
|
||||
|
||||
/* Money out of the customer's account but not yet with RBI — never shown as settled. */
|
||||
&--pending {
|
||||
background: var(--d-red-l);
|
||||
color: var(--d-red);
|
||||
}
|
||||
}
|
||||
|
||||
.dr-empty {
|
||||
padding: 34px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--d-g500);
|
||||
background: #fff;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.dr-foot {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--d-g500);
|
||||
|
||||
fa-icon {
|
||||
color: var(--d-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Selection & remittance bar ─────────────────────────────────────────── */
|
||||
|
||||
.dr-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dr-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dr-selinfo {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-tick {
|
||||
width: 34px;
|
||||
text-align: center;
|
||||
|
||||
input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* An already-remitted row cannot be picked again; the cursor should say so. */
|
||||
input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-row--sel {
|
||||
background: var(--d-blue-l);
|
||||
}
|
||||
|
||||
.dr-act {
|
||||
white-space: nowrap;
|
||||
|
||||
.dr-mini + .dr-mini {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-modal-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(15 23 42 / 45%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dr-modal {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 18px 50px rgb(15 23 42 / 26%);
|
||||
|
||||
&--sm {
|
||||
max-width: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-modal-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 17px;
|
||||
border-bottom: 1px solid var(--d-g200);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--d-navy);
|
||||
}
|
||||
}
|
||||
|
||||
.dr-x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--d-g500);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dr-modal-b {
|
||||
padding: 15px 17px;
|
||||
}
|
||||
|
||||
.dr-modal-f {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 17px;
|
||||
border-top: 1px solid var(--d-g200);
|
||||
}
|
||||
|
||||
.dr-modal-sum {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--d-g100);
|
||||
}
|
||||
|
||||
.dr-modal-n {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.dr-modal-l {
|
||||
font-size: 10.5px;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-modal-who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 11px;
|
||||
margin-bottom: 11px;
|
||||
border-bottom: 1px solid var(--d-g100);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.dr-modal-p {
|
||||
margin: 12px 0 5px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-modal-ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
/* Marking an account exempt is a decision on record, not a convenience. */
|
||||
.dr-modal-warn {
|
||||
margin: 12px 0 0;
|
||||
padding: 8px 11px;
|
||||
background: var(--d-amber-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--d-amber);
|
||||
}
|
||||
|
||||
.dr-lbl {
|
||||
display: block;
|
||||
margin: 12px 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-req {
|
||||
color: var(--d-red);
|
||||
}
|
||||
|
||||
.dr-inp--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dr-ta {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--d-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dr-note-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
background: var(--d-blue-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--d-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--d-blue);
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-err {
|
||||
margin-top: 10px;
|
||||
padding: 7px 10px;
|
||||
background: var(--d-red-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11.5px;
|
||||
color: var(--d-red);
|
||||
}
|
||||
|
||||
.dr-toast {
|
||||
position: fixed;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
background: var(--d-g900);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
box-shadow: 0 8px 22px rgb(15 23 42 / 30%);
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
/* Where the escheated money currently stands, read at a glance above the table. */
|
||||
.dr-money {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.dr-money-i {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g500);
|
||||
|
||||
&--bad {
|
||||
color: var(--d-red);
|
||||
}
|
||||
|
||||
&--ok {
|
||||
color: var(--d-green);
|
||||
}
|
||||
}
|
||||
|
||||
/* A balance the customer has taken back is no longer with the fund. */
|
||||
.dr-claimed {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--d-blue);
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients/clients.service';
|
||||
|
||||
/** Shared report helpers */
|
||||
import { exportWorkbook } from '../cashier/cashier-export';
|
||||
|
||||
/** Report data + computation */
|
||||
import {
|
||||
DORMANCY_POLICY,
|
||||
DORMANCY_STAGES,
|
||||
DormancyRow,
|
||||
DormancyStage,
|
||||
DormancySummary,
|
||||
daysBetween,
|
||||
dormancySummary,
|
||||
nextStage,
|
||||
sortByIdle,
|
||||
stageFromSubStatus
|
||||
} from './dormancy-report.compute';
|
||||
|
||||
/**
|
||||
* The three reports on accounts that have gone quiet.
|
||||
*
|
||||
* They are one screen rather than three because they are one progression: an account moves from
|
||||
* inactive to dormant to escheat as the same silence lengthens, and an officer reading one usually
|
||||
* wants to see what is coming behind it. The filters at the top therefore apply to all three, and
|
||||
* the tabs only choose which stage is on display.
|
||||
*
|
||||
* The escheat tab carries the figure that matters most — money taken out of customers' accounts
|
||||
* that has not yet reached RBI's DEAF. Nothing in this system performs that remittance, so without
|
||||
* a report on it the gap is invisible.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-dormancy-report',
|
||||
templateUrl: './dormancy-report.component.html',
|
||||
styleUrls: ['./dormancy-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class DormancyReportComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
rows: DormancyRow[] = [];
|
||||
today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
/** Which of the three reports is on display. */
|
||||
tab: DormancyStage = 'DORMANT';
|
||||
|
||||
/** Filters, shared by all three tabs. */
|
||||
branchCode = '';
|
||||
search = '';
|
||||
minBalance: number | null = null;
|
||||
pendingRemittanceOnly = false;
|
||||
|
||||
/**
|
||||
* Branch options come from the rows themselves rather than a fixed list: the server returns the
|
||||
* office name it holds, and a dropdown built from anywhere else would offer branches that match
|
||||
* nothing and omit ones that do.
|
||||
*/
|
||||
get branches(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.branchCode).filter((b) => !!b))].sort();
|
||||
}
|
||||
readonly policy = DORMANCY_POLICY;
|
||||
readonly stageMeta = DORMANCY_STAGES;
|
||||
readonly tabs: DormancyStage[] = [
|
||||
'INACTIVE',
|
||||
'DORMANT',
|
||||
'ESCHEAT'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the accounts the dormancy job has marked.
|
||||
*
|
||||
* The stage is taken from the sub-status the job wrote rather than recomputed from the idle
|
||||
* period. The sub-status is what actually restricts the account, so a report that worked it out
|
||||
* independently could disagree with the account's real state and send officers after the wrong
|
||||
* ones.
|
||||
*/
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.clientsService.getDormantAccounts().subscribe({
|
||||
next: (accounts: any[]) => {
|
||||
this.rows = sortByIdle(
|
||||
(accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null)
|
||||
);
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the dormancy reports.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recording a DEAF remittance ────────────────────────────────────────────
|
||||
|
||||
/** Accounts ticked on the escheat tab, by account id. */
|
||||
selected = new Set<number>();
|
||||
|
||||
remitOpen = false;
|
||||
remitDate = new Date().toISOString().slice(0, 10);
|
||||
remitReference = '';
|
||||
remitRemarks = '';
|
||||
remitSaving = false;
|
||||
remitError = '';
|
||||
|
||||
toggleSelect(row: DormancyRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (this.selected.has(row.accountId)) {
|
||||
this.selected.delete(row.accountId);
|
||||
} else {
|
||||
this.selected.add(row.accountId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ticks every unremitted row on display, or clears them if all are already ticked. */
|
||||
toggleSelectAll(): void {
|
||||
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED');
|
||||
if (selectable.every((r) => this.selected.has(r.accountId))) {
|
||||
selectable.forEach((r) => this.selected.delete(r.accountId));
|
||||
} else {
|
||||
selectable.forEach((r) => this.selected.add(r.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the whole selection.
|
||||
*
|
||||
* A method rather than `selected.clear()` in the template — Angular's expression syntax does not
|
||||
* allow calling built-in methods on a Set.
|
||||
*/
|
||||
clearSelection(): void {
|
||||
this.selected.clear();
|
||||
}
|
||||
|
||||
get allSelected(): boolean {
|
||||
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED');
|
||||
return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId));
|
||||
}
|
||||
|
||||
get selectedTotal(): number {
|
||||
return this.rows.filter((r) => this.selected.has(r.accountId)).reduce((s, r) => s + r.balance, 0);
|
||||
}
|
||||
|
||||
openRemit(): void {
|
||||
this.remitDate = new Date().toISOString().slice(0, 10);
|
||||
this.remitReference = '';
|
||||
this.remitRemarks = '';
|
||||
this.remitError = '';
|
||||
this.remitOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the remittance against every ticked account.
|
||||
*
|
||||
* The server skips anything not escheated or already recorded, so the result is reported back as
|
||||
* counts rather than assumed — a batch that silently recorded fewer rows than were ticked would
|
||||
* leave the officer believing money was accounted for when it was not.
|
||||
*/
|
||||
saveRemittance(): void {
|
||||
if (this.selected.size === 0) {
|
||||
this.remitError = 'Select at least one account.';
|
||||
return;
|
||||
}
|
||||
if (!this.remitDate) {
|
||||
this.remitError = 'Enter the date the money was sent to RBI.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.remitSaving = true;
|
||||
this.remitError = '';
|
||||
|
||||
this.clientsService
|
||||
.recordDeafRemittance({
|
||||
savingsAccountIds: [...this.selected],
|
||||
remittedOn: this.remitDate,
|
||||
deafReference: this.remitReference.trim() || undefined,
|
||||
remarks: this.remitRemarks.trim() || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.remitSaving = false;
|
||||
this.remitOpen = false;
|
||||
const recorded = res?.changes?.recorded ?? res?.recorded ?? this.selected.size;
|
||||
const skipped = res?.changes?.skipped ?? res?.skipped ?? 0;
|
||||
this.selected.clear();
|
||||
this.toast(
|
||||
skipped > 0
|
||||
? `${recorded} remittance(s) recorded, ${skipped} skipped — already recorded or not escheated.`
|
||||
: `${recorded} remittance(s) recorded.`
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.remitSaving = false;
|
||||
this.remitError =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the remittance.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Customer claiming a remitted balance back ──────────────────────────────
|
||||
|
||||
refundOpen = false;
|
||||
refundRow: DormancyRow | null = null;
|
||||
refundDate = new Date().toISOString().slice(0, 10);
|
||||
refundSaving = false;
|
||||
refundError = '';
|
||||
|
||||
/**
|
||||
* Opens the claim dialog for a balance already sent to the fund.
|
||||
*
|
||||
* A customer can claim an unclaimed deposit at any time, however long it has been with DEAF. The
|
||||
* bank pays them and recovers the amount from RBI, so recording it here keeps the remitted total
|
||||
* honest rather than leaving money counted as still sitting in the fund.
|
||||
*/
|
||||
openRefund(row: DormancyRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.refundRow = row;
|
||||
this.refundDate = new Date().toISOString().slice(0, 10);
|
||||
this.refundError = '';
|
||||
this.refundOpen = true;
|
||||
}
|
||||
|
||||
saveRefund(): void {
|
||||
if (!this.refundRow) {
|
||||
return;
|
||||
}
|
||||
if (!this.refundDate) {
|
||||
this.refundError = 'Enter the date the customer was paid.';
|
||||
return;
|
||||
}
|
||||
|
||||
const row = this.refundRow;
|
||||
this.refundSaving = true;
|
||||
this.refundError = '';
|
||||
|
||||
this.clientsService.refundDeafRemittance(row.accountId, this.refundDate).subscribe({
|
||||
next: () => {
|
||||
this.refundSaving = false;
|
||||
this.refundOpen = false;
|
||||
this.toast(`Claim recorded for ${row.clientName}.`);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.refundSaving = false;
|
||||
this.refundError =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the claim.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
clearTimeout(this.toastTimer);
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000);
|
||||
}
|
||||
|
||||
/** Maps one DormantAccountData row onto what the three reports render. */
|
||||
private mapAccount(a: any): DormancyRow | null {
|
||||
const stage = stageFromSubStatus(Number(a?.subStatus));
|
||||
if (!stage) {
|
||||
// Only the three dormancy states belong here; anything else is not on this ladder.
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastTxn = (a?.lastCustomerTransactionDate || '').toString().slice(0, 10);
|
||||
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
|
||||
|
||||
return {
|
||||
accountId: a?.accountId,
|
||||
accountNo: a?.accountNo || '—',
|
||||
clientId: a?.clientId,
|
||||
clientName: a?.clientName || '—',
|
||||
branchCode: a?.officeName || '',
|
||||
productName: a?.productName || '—',
|
||||
balance: Number(a?.balance ?? 0),
|
||||
lastCustomerTxnDate: lastTxn,
|
||||
daysIdle: Number(a?.daysIdle ?? 0),
|
||||
stage,
|
||||
subStatus: Number(a?.subStatus ?? 0),
|
||||
nextStageDate: next.date,
|
||||
nextStageLabel: next.label,
|
||||
// An escheated account counts as remitted only when a remittance has actually been recorded
|
||||
// against it. Absence of that row is the whole point of the report: the money has left the
|
||||
// customer's account but is not yet accounted for as having reached RBI.
|
||||
remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined,
|
||||
escheatedOn: (a?.closedOnDate || '').toString().slice(0, 10) || undefined,
|
||||
remittedOn: (a?.deafRemittedOn || '').toString().slice(0, 10) || undefined,
|
||||
deafReference: a?.deafReference || undefined,
|
||||
refundedOn: (a?.deafRefundedOn || '').toString().slice(0, 10) || undefined,
|
||||
mobile: a?.mobileNo || ''
|
||||
};
|
||||
}
|
||||
|
||||
// ── Filtering ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Rows on the tab in view, after the shared filters. */
|
||||
get visibleRows(): DormancyRow[] {
|
||||
return this.rowsForStage(this.tab);
|
||||
}
|
||||
|
||||
private rowsForStage(stage: DormancyStage): DormancyRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (r.stage !== stage) {
|
||||
return false;
|
||||
}
|
||||
if (this.branchCode && r.branchCode !== this.branchCode) {
|
||||
return false;
|
||||
}
|
||||
if (this.minBalance != null && r.balance < this.minBalance) {
|
||||
return false;
|
||||
}
|
||||
// Only meaningful on the escheat tab, where a row can be awaiting remittance.
|
||||
if (this.pendingRemittanceOnly && r.remittance !== 'PENDING') {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Tab counts respect the filters, so the badge matches what the tab will show. */
|
||||
countFor(stage: DormancyStage): number {
|
||||
return this.rowsForStage(stage).length;
|
||||
}
|
||||
|
||||
get summary(): DormancySummary {
|
||||
return dormancySummary(this.rows, this.today);
|
||||
}
|
||||
|
||||
get visibleBalance(): number {
|
||||
return this.visibleRows.reduce((s, r) => s + r.balance, 0);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.branchCode = '';
|
||||
this.search = '';
|
||||
this.minBalance = null;
|
||||
this.pendingRemittanceOnly = false;
|
||||
}
|
||||
|
||||
showTab(stage: DormancyStage): void {
|
||||
this.tab = stage;
|
||||
// The remittance filter only exists on the escheat tab; carrying it elsewhere would silently
|
||||
// empty a tab that has no remittance state at all.
|
||||
if (stage !== 'ESCHEAT') {
|
||||
this.pendingRemittanceOnly = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The server already returns the office by name, so there is nothing to look up. */
|
||||
branchName(office: string): string {
|
||||
return office || '—';
|
||||
}
|
||||
|
||||
toneClass(stage: DormancyStage): string {
|
||||
return `dr-badge dr-badge--${DORMANCY_STAGES[stage].tone}`;
|
||||
}
|
||||
|
||||
/** "3 years 2 months" reads better than a four-digit day count. */
|
||||
idleText(days: number): string {
|
||||
const years = Math.floor(days / 365);
|
||||
const months = Math.floor((days % 365) / 30);
|
||||
if (years === 0) {
|
||||
return `${months} month${months === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (months === 0) {
|
||||
return `${years} year${years === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${years}y ${months}m`;
|
||||
}
|
||||
|
||||
/** Days until the next mark, for the accounts approaching one. */
|
||||
daysToNext(row: DormancyRow): number | null {
|
||||
return row.nextStageDate ? daysBetween(this.today, row.nextStageDate) : null;
|
||||
}
|
||||
|
||||
/** Flags a dormant account within a year of the ten-year mark. */
|
||||
isNearingEscheat(row: DormancyRow): boolean {
|
||||
const days = this.daysToNext(row);
|
||||
return row.stage === 'DORMANT' && days !== null && days <= 365;
|
||||
}
|
||||
|
||||
remittanceClass(state?: string): string {
|
||||
return state === 'REMITTED' ? 'dr-rem dr-rem--ok' : 'dr-rem dr-rem--pending';
|
||||
}
|
||||
|
||||
openClient(row: DormancyRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Exports all three reports, not just the tab in view.
|
||||
*
|
||||
* They are read together — an auditor asking about escheat invariably asks what is behind it —
|
||||
* and a workbook holding one of the three would only have to be produced three times.
|
||||
*/
|
||||
exportExcel(): void {
|
||||
const stamp = `As on ${this.today}${this.branchCode ? ` · ${this.branchName(this.branchCode)}` : ' · All branches'}`;
|
||||
|
||||
exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Inactive',
|
||||
title: `Inactive Accounts — idle ${this.policy.inactiveDays}+ days`,
|
||||
subtitle: stamp,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Customer ID',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Balance',
|
||||
'Last Customer Txn',
|
||||
'Idle',
|
||||
'Becomes Dormant On'
|
||||
],
|
||||
widths: [
|
||||
18,
|
||||
28,
|
||||
12,
|
||||
22,
|
||||
30,
|
||||
14,
|
||||
18,
|
||||
12,
|
||||
20
|
||||
],
|
||||
rows: this.rowsForStage('INACTIVE').map((r) => [
|
||||
r.accountNo,
|
||||
r.clientName,
|
||||
String(r.clientId),
|
||||
this.branchName(r.branchCode),
|
||||
r.productName,
|
||||
r.balance,
|
||||
r.lastCustomerTxnDate,
|
||||
this.idleText(r.daysIdle),
|
||||
r.nextStageDate
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Dormant',
|
||||
title: `Dormant / Inoperative Accounts — idle ${this.policy.dormantDays}+ days`,
|
||||
subtitle: stamp,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Customer ID',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Balance',
|
||||
'Last Customer Txn',
|
||||
'Idle',
|
||||
'Due to DEAF On',
|
||||
'Nearing DEAF'
|
||||
],
|
||||
widths: [
|
||||
18,
|
||||
28,
|
||||
12,
|
||||
22,
|
||||
30,
|
||||
14,
|
||||
18,
|
||||
12,
|
||||
18,
|
||||
14
|
||||
],
|
||||
rows: this.rowsForStage('DORMANT').map((r) => [
|
||||
r.accountNo,
|
||||
r.clientName,
|
||||
String(r.clientId),
|
||||
this.branchName(r.branchCode),
|
||||
r.productName,
|
||||
r.balance,
|
||||
r.lastCustomerTxnDate,
|
||||
this.idleText(r.daysIdle),
|
||||
r.nextStageDate,
|
||||
this.isNearingEscheat(r) ? 'Within 1 year' : ''
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Escheat & DEAF',
|
||||
title: `Escheated Accounts — idle ${this.policy.escheatDays}+ days`,
|
||||
subtitle: `${stamp} · Not yet remitted to RBI: ₹${this.summary.pendingRemittance.toLocaleString('en-IN')}`,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Customer ID',
|
||||
'Branch',
|
||||
'Balance Escheated',
|
||||
'Last Customer Txn',
|
||||
'Idle',
|
||||
'Escheated On',
|
||||
'Remittance to RBI',
|
||||
'Remitted On',
|
||||
'DEAF Reference',
|
||||
'Claimed Back On'
|
||||
],
|
||||
widths: [
|
||||
18,
|
||||
28,
|
||||
12,
|
||||
22,
|
||||
18,
|
||||
18,
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
14,
|
||||
24,
|
||||
16
|
||||
],
|
||||
rows: this.rowsForStage('ESCHEAT').map((r) => [
|
||||
r.accountNo,
|
||||
r.clientName,
|
||||
String(r.clientId),
|
||||
this.branchName(r.branchCode),
|
||||
r.balance,
|
||||
r.lastCustomerTxnDate,
|
||||
this.idleText(r.daysIdle),
|
||||
r.escheatedOn || '',
|
||||
r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted',
|
||||
r.remittedOn || '',
|
||||
r.deafReference || '',
|
||||
r.refundedOn || ''
|
||||
])
|
||||
}
|
||||
],
|
||||
`dormancy-reports-${this.today}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accounts that have gone quiet, and what has happened to them.
|
||||
*
|
||||
* RBI counts the silence from the last *customer-induced* transaction — a deposit or a withdrawal
|
||||
* the customer made. Interest the bank posts and charges it recovers are the bank operating the
|
||||
* account, not the customer using it, so they do not reset the clock. An account crosses three
|
||||
* marks: inactive at a year, inoperative at two, and due to the Depositor Education and Awareness
|
||||
* Fund at ten.
|
||||
*
|
||||
* The escheat stage is the one that needs watching. Sending the balance to DEAF is a transfer to
|
||||
* RBI that happens outside this system, so an account can sit closed here with its money not yet
|
||||
* remitted. That gap is what the third report exists to show.
|
||||
*/
|
||||
|
||||
/** The three marks an idle account passes. */
|
||||
export type DormancyStage = 'INACTIVE' | 'DORMANT' | 'ESCHEAT';
|
||||
|
||||
/** Sub-status codes as stored on the savings account. */
|
||||
export const SUB_STATUS = {
|
||||
NONE: 0,
|
||||
INACTIVE: 100,
|
||||
DORMANT: 200,
|
||||
ESCHEAT: 300
|
||||
};
|
||||
|
||||
/** RBI periods, in days from the last customer transaction. */
|
||||
export const DORMANCY_POLICY = {
|
||||
inactiveDays: 365,
|
||||
dormantDays: 730,
|
||||
escheatDays: 3650
|
||||
};
|
||||
|
||||
export interface DormancyStageMeta {
|
||||
stage: DormancyStage;
|
||||
label: string;
|
||||
tone: 'info' | 'warn' | 'critical';
|
||||
afterDays: number;
|
||||
/** What the customer can still do. */
|
||||
effect: string;
|
||||
/** How the account comes back, in the officer's words. */
|
||||
revival: string;
|
||||
}
|
||||
|
||||
export const DORMANCY_STAGES: Record<DormancyStage, DormancyStageMeta> = {
|
||||
INACTIVE: {
|
||||
stage: 'INACTIVE',
|
||||
label: 'Inactive',
|
||||
tone: 'info',
|
||||
afterDays: DORMANCY_POLICY.inactiveDays,
|
||||
effect: 'Nothing is restricted. The account is flagged internally so the branch can reach out.',
|
||||
revival: 'Any customer deposit or withdrawal clears the flag at once.'
|
||||
},
|
||||
DORMANT: {
|
||||
stage: 'DORMANT',
|
||||
label: 'Dormant / Inoperative',
|
||||
tone: 'warn',
|
||||
afterDays: DORMANCY_POLICY.dormantDays,
|
||||
effect: 'Debits are stopped. Credits still land, so pension and benefit transfers are unaffected.',
|
||||
revival: 'The customer completes KYC at the branch and makes one transaction — no reactivation charge.'
|
||||
},
|
||||
ESCHEAT: {
|
||||
stage: 'ESCHEAT',
|
||||
label: 'Escheat — due to DEAF',
|
||||
tone: 'critical',
|
||||
afterDays: DORMANCY_POLICY.escheatDays,
|
||||
effect: 'The account is closed and the balance is taken out, to be remitted to RBI’s DEAF.',
|
||||
revival: 'The customer can still claim the money from the bank, which then recovers it from DEAF.'
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether the balance taken out at escheat has actually reached RBI. */
|
||||
export type DeafRemittanceState = 'PENDING' | 'REMITTED';
|
||||
|
||||
export interface DormancyRow {
|
||||
accountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
branchCode: string;
|
||||
productName: string;
|
||||
balance: number;
|
||||
/** Last transaction the customer themselves made. */
|
||||
lastCustomerTxnDate: string;
|
||||
daysIdle: number;
|
||||
stage: DormancyStage;
|
||||
/** Sub-status currently on the account. */
|
||||
subStatus: number;
|
||||
/** The day the next mark is crossed; blank once escheated. */
|
||||
nextStageDate: string;
|
||||
nextStageLabel: string;
|
||||
/** Escheat only — whether the money has gone to RBI. */
|
||||
remittance?: DeafRemittanceState;
|
||||
/** Escheat only — the day the balance was taken out of the account. */
|
||||
escheatedOn?: string;
|
||||
/** Escheat only — the day it was remitted, once it has been. */
|
||||
remittedOn?: string;
|
||||
/** Escheat only — RBI's reference for the remittance. */
|
||||
deafReference?: string;
|
||||
/** Escheat only — set if the customer claimed the balance back after it went to the fund. */
|
||||
refundedOn?: string;
|
||||
mobile: string;
|
||||
}
|
||||
|
||||
function addDays(date: string, days: number): string {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function daysBetween(earlier: string, later: string): number {
|
||||
const a = new Date(`${earlier}T00:00:00Z`).getTime();
|
||||
const b = new Date(`${later}T00:00:00Z`).getTime();
|
||||
return Math.round((b - a) / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage an account is at, from the sub-status the dormancy job wrote on it.
|
||||
*
|
||||
* Preferred over deriving it from the idle period, because the sub-status is what actually
|
||||
* restricts the account. Anything outside the three dormancy codes — a manual block, an active
|
||||
* account — is not on this ladder and returns null.
|
||||
*/
|
||||
export function stageFromSubStatus(subStatus: number): DormancyStage | null {
|
||||
if (subStatus === SUB_STATUS.INACTIVE) {
|
||||
return 'INACTIVE';
|
||||
}
|
||||
if (subStatus === SUB_STATUS.DORMANT) {
|
||||
return 'DORMANT';
|
||||
}
|
||||
if (subStatus === SUB_STATUS.ESCHEAT) {
|
||||
return 'ESCHEAT';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Which mark an account has passed, from how long it has been idle. */
|
||||
export function dormancyStage(daysIdle: number): DormancyStage | null {
|
||||
if (daysIdle >= DORMANCY_POLICY.escheatDays) {
|
||||
return 'ESCHEAT';
|
||||
}
|
||||
if (daysIdle >= DORMANCY_POLICY.dormantDays) {
|
||||
return 'DORMANT';
|
||||
}
|
||||
if (daysIdle >= DORMANCY_POLICY.inactiveDays) {
|
||||
return 'INACTIVE';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The next mark and the day it falls due. */
|
||||
export function nextStage(lastTxnDate: string, stage: DormancyStage): { date: string; label: string } {
|
||||
if (stage === 'INACTIVE') {
|
||||
return { date: addDays(lastTxnDate, DORMANCY_POLICY.dormantDays), label: 'Becomes dormant' };
|
||||
}
|
||||
if (stage === 'DORMANT') {
|
||||
return { date: addDays(lastTxnDate, DORMANCY_POLICY.escheatDays), label: 'Balance due to DEAF' };
|
||||
}
|
||||
// Escheat is the last mark — nothing further happens to the account on its own.
|
||||
return { date: '', label: 'Awaiting DEAF remittance' };
|
||||
}
|
||||
|
||||
export interface DormancySummary {
|
||||
inactive: number;
|
||||
dormant: number;
|
||||
escheat: number;
|
||||
inactiveBalance: number;
|
||||
dormantBalance: number;
|
||||
escheatBalance: number;
|
||||
/** Escheated money not yet sent to RBI — the figure that must not be left to drift. */
|
||||
pendingRemittance: number;
|
||||
pendingRemittanceCount: number;
|
||||
/** Balances that reached the fund and are still with it. */
|
||||
remittedBalance: number;
|
||||
/** Balances since claimed back by the customer and recovered from the fund. */
|
||||
claimedBackBalance: number;
|
||||
claimedBackCount: number;
|
||||
/** Dormant accounts approaching the ten-year mark within a year. */
|
||||
nearingEscheat: number;
|
||||
}
|
||||
|
||||
export function dormancySummary(rows: DormancyRow[], today: string): DormancySummary {
|
||||
const inactive = rows.filter((r) => r.stage === 'INACTIVE');
|
||||
const dormant = rows.filter((r) => r.stage === 'DORMANT');
|
||||
const escheat = rows.filter((r) => r.stage === 'ESCHEAT');
|
||||
const pending = escheat.filter((r) => r.remittance === 'PENDING');
|
||||
const claimedBack = escheat.filter((r) => r.refundedOn);
|
||||
// A balance the customer has since claimed back is no longer with the fund, so it is counted
|
||||
// separately rather than left in the remitted total.
|
||||
const stillWithFund = escheat.filter((r) => r.remittance === 'REMITTED' && !r.refundedOn);
|
||||
const sum = (list: DormancyRow[]) => list.reduce((s, r) => s + r.balance, 0);
|
||||
|
||||
return {
|
||||
inactive: inactive.length,
|
||||
dormant: dormant.length,
|
||||
escheat: escheat.length,
|
||||
inactiveBalance: sum(inactive),
|
||||
dormantBalance: sum(dormant),
|
||||
escheatBalance: sum(escheat),
|
||||
pendingRemittance: sum(pending),
|
||||
pendingRemittanceCount: pending.length,
|
||||
remittedBalance: sum(stillWithFund),
|
||||
claimedBackBalance: sum(claimedBack),
|
||||
claimedBackCount: claimedBack.length,
|
||||
nearingEscheat: dormant.filter((r) => r.nextStageDate && daysBetween(today, r.nextStageDate) <= 365).length
|
||||
};
|
||||
}
|
||||
|
||||
/** Idle longest first — the accounts closest to the next mark need attention first. */
|
||||
export function sortByIdle(rows: DormancyRow[]): DormancyRow[] {
|
||||
return [...rows].sort((a, b) => b.daysIdle - a.daysIdle || b.balance - a.balance);
|
||||
}
|
||||
+6
-4
@@ -441,16 +441,18 @@
|
||||
<tfoot>
|
||||
<tr class="row-total expense">
|
||||
<td colspan="2">
|
||||
<strong>Total Contingent Liabilities: ₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong>
|
||||
<strong
|
||||
>Total Contingent Liabilities: ₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p class="acr-note">
|
||||
<fa-icon icon="info-circle"></fa-icon> Individual item-wise figures for this schedule are not broken
|
||||
out here — only the audited total is shown, to avoid presenting a sub-item split that could not be
|
||||
independently verified against the source statement.
|
||||
<fa-icon icon="info-circle"></fa-icon> Individual item-wise figures for this schedule are not broken out
|
||||
here — only the audited total is shown, to avoid presenting a sub-item split that could not be independently
|
||||
verified against the source statement.
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
+14
-2
@@ -150,7 +150,13 @@ export class AccountingReportsDialogComponent {
|
||||
|
||||
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
|
||||
pnl: PnlLine[] = [
|
||||
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances & Bills', amount: 102641.73 },
|
||||
{
|
||||
code: 'I-01',
|
||||
group: 'INCOME',
|
||||
subgroup: 'Interest Earned',
|
||||
label: 'Interest on Advances & Bills',
|
||||
amount: 102641.73
|
||||
},
|
||||
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 },
|
||||
{
|
||||
code: 'I-03',
|
||||
@@ -167,7 +173,13 @@ export class AccountingReportsDialogComponent {
|
||||
label: 'Net Income on Sale of Fixed Assets',
|
||||
amount: 0.3
|
||||
},
|
||||
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Other Income (ATM/POS/UPI/Rental/Misc)', amount: 280 },
|
||||
{
|
||||
code: 'I-06',
|
||||
group: 'INCOME',
|
||||
subgroup: 'Other Income',
|
||||
label: 'Other Income (ATM/POS/UPI/Rental/Misc)',
|
||||
amount: 280
|
||||
},
|
||||
{
|
||||
code: 'E-01',
|
||||
group: 'EXPENDITURE',
|
||||
|
||||
@@ -659,7 +659,7 @@ export class CimsXbrlDialogComponent {
|
||||
name: 'OSS-4',
|
||||
fullName: 'Connected Lending & Large Exposure Statement',
|
||||
description:
|
||||
'Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank\'s own capital/advances scale for consistency.',
|
||||
"Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank's own capital/advances scale for consistency.",
|
||||
cimsCode: 'CIMS_OSS_004',
|
||||
taxonomy: 'rbi-cims-oss4-2024-04-01',
|
||||
periodicity: 'Quarterly',
|
||||
@@ -729,7 +729,7 @@ export class CimsXbrlDialogComponent {
|
||||
name: 'OSS-5',
|
||||
fullName: 'Institutional & Membership Profile',
|
||||
description:
|
||||
'Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank\'s real paid-up capital figure.',
|
||||
"Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank's real paid-up capital figure.",
|
||||
cimsCode: 'CIMS_OSS_005',
|
||||
taxonomy: 'rbi-cims-oss5-2024-04-01',
|
||||
periodicity: 'Annual',
|
||||
|
||||
@@ -27,7 +27,15 @@ interface DemoClient {
|
||||
standalone: true,
|
||||
templateUrl: './fd-rd-entry-dialog.component.html',
|
||||
styleUrls: ['./fd-rd-entry-dialog.component.scss'],
|
||||
imports: [CommonModule, DecimalPipe, FormsModule, MatDialogModule, MatButtonModule, MatTooltipModule, FaIconComponent]
|
||||
imports: [
|
||||
CommonModule,
|
||||
DecimalPipe,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class FdRdEntryDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<FdRdEntryDialogComponent>);
|
||||
@@ -53,8 +61,16 @@ export class FdRdEntryDialogComponent {
|
||||
{ id: 'C-10689', name: 'Vikram Traders' }
|
||||
];
|
||||
|
||||
payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c'];
|
||||
interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout'];
|
||||
payModes = [
|
||||
'Cash',
|
||||
'Cheque',
|
||||
'Transfer from Savings A/c'
|
||||
];
|
||||
interestPayoutOptions = [
|
||||
'Cumulative (Paid on Maturity)',
|
||||
'Monthly Payout',
|
||||
'Quarterly Payout'
|
||||
];
|
||||
maturityInstructions = [
|
||||
'Renew Principal & Interest',
|
||||
'Renew Principal Only, Payout Interest',
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<!--
|
||||
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="ir">
|
||||
<!-- Header -->
|
||||
<div class="ir-head">
|
||||
<div>
|
||||
<h2 class="ir-head-t"><fa-icon icon="percentage"></fa-icon> Savings Interest Posted</h2>
|
||||
<div class="ir-head-s">
|
||||
Interest actually credited to members, grouped by the rhythm each product posts on. An account appears under its
|
||||
own product's posting period and nowhere else.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ir-head-a">
|
||||
<button class="ir-btn ir-btn--slim" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="undo"></fa-icon> Reload
|
||||
</button>
|
||||
<button class="ir-btn ir-btn--primary" (click)="exportExcel()" [disabled]="loading">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export all periods
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="ir-alert"><fa-icon icon="exclamation-triangle"></fa-icon> {{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
A product paid on more dates than its rhythm allows was paid by something other than this
|
||||
ledger — stock Fineract's savings interest job writes backdated postings a day at a time and
|
||||
ignores the product's posting period. The postings are real and stay on the report; this says
|
||||
so, so a total is not read as the rhythm the product is meant to keep.
|
||||
-->
|
||||
@if (rhythmWarnings.length > 0) {
|
||||
<div class="ir-warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Postings do not follow the product's rhythm.</strong>
|
||||
@for (w of rhythmWarnings; track w.product) {
|
||||
<div class="ir-warn-row">
|
||||
<strong>{{ w.product }}</strong> is configured {{ w.label }} ({{ w.expected }}) but has paid on
|
||||
<strong>{{ w.dates }} different dates</strong>.
|
||||
</div>
|
||||
}
|
||||
<div class="ir-warn-note">
|
||||
These were written by the platform's own savings interest job, which posts a day at a time regardless of the
|
||||
product's period. Every posting below is real money credited to a member — the figures are correct, but a
|
||||
period total will not match the rhythm the product is set to.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Filters, shared by every period -->
|
||||
<div class="ir-filters">
|
||||
<div class="ir-field">
|
||||
<label>From</label>
|
||||
<input class="ir-inp" type="date" [(ngModel)]="fromDate" (ngModelChange)="load()" />
|
||||
</div>
|
||||
<div class="ir-field">
|
||||
<label>To</label>
|
||||
<input class="ir-inp" type="date" [(ngModel)]="toDate" (ngModelChange)="load()" />
|
||||
</div>
|
||||
<div class="ir-field ir-field--wide">
|
||||
<label>Search</label>
|
||||
<input class="ir-inp" type="text" [(ngModel)]="search" placeholder="Account number, customer or product" />
|
||||
</div>
|
||||
<div class="ir-field ir-field--btn">
|
||||
<button class="ir-btn ir-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One card per posting rhythm; each is its own report -->
|
||||
<div class="ir-tabs">
|
||||
@for (p of periods; track p.enumValue) {
|
||||
<button class="ir-tab" [class.ir-tab--on]="activePeriod === p.enumValue" (click)="showPeriod(p.enumValue)">
|
||||
<div class="ir-tab-h">
|
||||
<span class="ir-tab-l">{{ p.label }}</span>
|
||||
<span class="ir-tab-n">{{ countFor(p.enumValue) }}</span>
|
||||
</div>
|
||||
<div class="ir-tab-d">{{ p.cadence }}</div>
|
||||
<div class="ir-tab-t">₹{{ totalFor(p.enumValue) | number: '1.2-2' }}</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The period control for the rhythm in view.
|
||||
|
||||
Each rhythm gets its own, at its own granularity: a day for Daily, a month for the rest. One
|
||||
shared control would make the quarterly report filter by a single date and hide the rest of the
|
||||
quarter.
|
||||
-->
|
||||
<div class="ir-pick">
|
||||
<label class="ir-pick-l">{{ activeMeta.pickLabel }}</label>
|
||||
<!--
|
||||
A dropdown of the dates this rhythm actually posted on, not a calendar: a quarterly product
|
||||
posts four times a year, and a date picker would offer every other day of it holding nothing.
|
||||
The account count rides in the option, so "how many accounts at this quarter end" is answered
|
||||
before the filter is applied.
|
||||
-->
|
||||
<select class="ir-inp ir-inp--pick" [(ngModel)]="periodPick[activePeriod]">
|
||||
<option value="">All {{ activeMeta.pickUnit }}s</option>
|
||||
@for (p of availablePicks(activePeriod); track p.date) {
|
||||
<option [value]="p.date">
|
||||
{{ formatDate(p.date) }} — {{ p.accounts }} account{{ p.accounts === 1 ? '' : 's' }}, ₹{{
|
||||
p.total | number: '1.2-2'
|
||||
}}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
@if (periodPick[activePeriod]) {
|
||||
<button class="ir-btn ir-btn--slim" (click)="clearPick(activePeriod)">
|
||||
<fa-icon icon="undo"></fa-icon> Show all
|
||||
</button>
|
||||
}
|
||||
<span class="ir-spacer"></span>
|
||||
<span class="ir-pick-hint">
|
||||
{{ availablePicks(activePeriod).length }} {{ activeMeta.pickUnit
|
||||
}}{{ availablePicks(activePeriod).length === 1 ? '' : 's' }} with postings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- What the tab in view holds -->
|
||||
<div class="ir-context">
|
||||
<div class="ir-context-i">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>{{ activeMeta.label }}.</strong> {{ activeMeta.cadence }} — every account on a product configured to
|
||||
post {{ activeMeta.label.toLowerCase() }}.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ir-context-t">
|
||||
<span class="ir-context-c">{{ visibleAccounts }}</span> accounts ·
|
||||
<span class="ir-context-c">{{ visibleRows.length }}</span> postings ·
|
||||
<span class="ir-context-c">₹{{ visibleTotal | number: '1.2-2' }}</span> paid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Postings, grouped by the day they were paid -->
|
||||
@if (loading) {
|
||||
<div class="ir-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading…</div>
|
||||
} @else if (visibleRows.length === 0) {
|
||||
<div class="ir-empty">
|
||||
No {{ activeMeta.label.toLowerCase() }} interest posted in this period.
|
||||
@if (activePeriod !== 1) {
|
||||
A cycle that has not ended yet has nothing to show.
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@for (group of groupedByDate; track group.date) {
|
||||
<div class="ir-group">
|
||||
<div class="ir-group-h">
|
||||
<span class="ir-group-d"><fa-icon icon="calendar"></fa-icon> {{ formatDate(group.date) }}</span>
|
||||
<span class="ir-group-m">
|
||||
{{ group.accounts }} account{{ group.accounts === 1 ? '' : 's' }} ·
|
||||
<strong>₹{{ group.total | number: '1.2-2' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ir-tablewrap">
|
||||
<table class="ir-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Product</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Balance Before</th>
|
||||
<th class="num">Interest Posted</th>
|
||||
<th class="num">Balance After</th>
|
||||
<th>Branch</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of group.rows; track r.transaction_id) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.account_no }}</td>
|
||||
<td>{{ r.client_name || '—' }}</td>
|
||||
<td class="ir-prod">{{ r.product_name }}</td>
|
||||
<td class="num">{{ r.interest_rate | number: '1.2-2' }}%</td>
|
||||
<td class="num">{{ r.balance_before | number: '1.2-2' }}</td>
|
||||
<td class="num strong ir-amt">{{ r.interest_amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.balance_after | number: '1.2-2' }}</td>
|
||||
<td>{{ r.office_name || '—' }}</td>
|
||||
<td>
|
||||
<button class="ir-mini" (click)="openAccount(r)" [disabled]="r.client_id == null">
|
||||
<fa-icon icon="list"></fa-icon> Transactions
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--i-navy: #1a237e;
|
||||
--i-blue: #1d4ed8;
|
||||
--i-blue-l: #eff6ff;
|
||||
--i-green: #15803d;
|
||||
--i-green-l: #dcfce7;
|
||||
--i-amber: #b45309;
|
||||
--i-amber-l: #fef3c7;
|
||||
--i-red: #c0392b;
|
||||
--i-red-l: #fdecea;
|
||||
--i-g50: #f8fafc;
|
||||
--i-g100: #f1f5f9;
|
||||
--i-g200: #e2e8f0;
|
||||
--i-g300: #cbd5e1;
|
||||
--i-g500: #64748b;
|
||||
--i-g700: #334155;
|
||||
--i-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--i-g900);
|
||||
background: var(--i-g50);
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ir-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--i-navy);
|
||||
}
|
||||
|
||||
.ir-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--i-g500);
|
||||
max-width: 760px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ir-head-a {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ir-alert {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--i-red-l);
|
||||
border: 1px solid #f5b7b1;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--i-red);
|
||||
}
|
||||
|
||||
/* ── Filters ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.ir-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 150px;
|
||||
|
||||
label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--i-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
&--wide {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
&--btn {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-inp {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--i-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--i-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--i-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--i-blue);
|
||||
color: var(--i-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--slim {
|
||||
padding: 6px 11px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--i-blue);
|
||||
border-color: var(--i-blue);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ir-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--i-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--i-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--i-blue);
|
||||
color: var(--i-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── One card per posting rhythm ────────────────────────────────────────── */
|
||||
|
||||
.ir-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ir-tab {
|
||||
text-align: left;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-top: 3px solid var(--i-g300);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.12s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
|
||||
}
|
||||
|
||||
/* The rhythm on display stays obvious while its rows are being read. */
|
||||
&--on {
|
||||
background: var(--i-blue-l);
|
||||
border-top-color: var(--i-blue);
|
||||
box-shadow: inset 0 0 0 1px #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-tab-h {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ir-tab-l {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ir-tab-n {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ir-tab-d {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
|
||||
.ir-tab-t {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--i-green);
|
||||
}
|
||||
|
||||
/* ── Context strip ──────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--i-blue-l);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--i-g700);
|
||||
}
|
||||
|
||||
.ir-context-i {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
|
||||
fa-icon {
|
||||
color: var(--i-blue);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-context-t {
|
||||
white-space: nowrap;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
|
||||
.ir-context-c {
|
||||
font-weight: 700;
|
||||
color: var(--i-g900);
|
||||
}
|
||||
|
||||
/* ── One group per posting date ─────────────────────────────────────────── */
|
||||
|
||||
.ir-group {
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The date is the unit an officer reconciles against: a run pays a whole product on one day. */
|
||||
.ir-group-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
background: var(--i-g100);
|
||||
border-bottom: 1px solid var(--i-g200);
|
||||
}
|
||||
|
||||
.ir-group-d {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--i-navy);
|
||||
}
|
||||
|
||||
.ir-group-m {
|
||||
font-size: 11.5px;
|
||||
color: var(--i-g500);
|
||||
|
||||
strong {
|
||||
color: var(--i-green);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-tablewrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ir-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--i-g200);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--i-g500);
|
||||
white-space: nowrap;
|
||||
|
||||
&.num {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--i-g100);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--i-g50);
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-prod {
|
||||
max-width: 210px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* The figure the report exists for. */
|
||||
.ir-amt {
|
||||
color: var(--i-green);
|
||||
}
|
||||
|
||||
.ir-empty {
|
||||
padding: 34px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--i-g500);
|
||||
background: #fff;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* ── Per-rhythm period control ──────────────────────────────────────────── */
|
||||
|
||||
.ir-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 9px 14px;
|
||||
margin-bottom: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.ir-pick-l {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
|
||||
.ir-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ir-pick-hint {
|
||||
font-size: 11px;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
|
||||
/* The dropdown carries a date, a count and a total, so it needs room. */
|
||||
.ir-inp--pick {
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
/* ── Rhythm mismatch warning ────────────────────────────────────────────── */
|
||||
|
||||
.ir-warn {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--i-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
border-left: 4px solid var(--i-amber);
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--i-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--i-amber);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-warn-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ir-warn-note {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import {
|
||||
SavingsDailyInterestService,
|
||||
SavingsInterestPostingRow
|
||||
} from '../savings-daily-interest/savings-daily-interest.service';
|
||||
|
||||
/** Shared report helpers */
|
||||
import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export';
|
||||
|
||||
/**
|
||||
* One posting rhythm, as the product configures it.
|
||||
*
|
||||
* The enum values are the product's own {@code interest_posting_period_enum}; they are not
|
||||
* consecutive, and 2 and 3 are unused in this platform.
|
||||
*/
|
||||
interface PostingPeriod {
|
||||
enumValue: number;
|
||||
key: string;
|
||||
label: string;
|
||||
/** What "the end of the cycle" means for this rhythm, in the officer's words. */
|
||||
cadence: string;
|
||||
/** What the period control is asking for, in the terms this rhythm is read in. */
|
||||
pickLabel: string;
|
||||
/** The unit the control counts in, for the "N with postings" hint. */
|
||||
pickUnit: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-interest-report',
|
||||
templateUrl: './interest-report.component.html',
|
||||
styleUrls: ['./interest-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class InterestReportComponent implements OnInit {
|
||||
private service = inject(SavingsDailyInterestService);
|
||||
private router = inject(Router);
|
||||
|
||||
/**
|
||||
* Each rhythm is its own report.
|
||||
*
|
||||
* A monthly product's month-end posting and a quarterly product's quarter-end posting are
|
||||
* different events on different dates; listing them together reads as one uneven series and
|
||||
* makes a month's total impossible to state. So an account appears under the rhythm its own
|
||||
* product posts on, and nowhere else.
|
||||
*/
|
||||
readonly periods: PostingPeriod[] = [
|
||||
{
|
||||
enumValue: 1,
|
||||
key: 'daily',
|
||||
label: 'Daily',
|
||||
cadence: 'Posted every day',
|
||||
pickLabel: 'Posted on',
|
||||
pickUnit: 'day'
|
||||
},
|
||||
{
|
||||
enumValue: 4,
|
||||
key: 'monthly',
|
||||
label: 'Monthly',
|
||||
cadence: 'Posted at each month end',
|
||||
pickLabel: 'Month end',
|
||||
pickUnit: 'month end'
|
||||
},
|
||||
{
|
||||
enumValue: 5,
|
||||
key: 'quarterly',
|
||||
label: 'Quarterly',
|
||||
cadence: 'Posted at each quarter end',
|
||||
pickLabel: 'Quarter end',
|
||||
pickUnit: 'quarter end'
|
||||
},
|
||||
{
|
||||
enumValue: 6,
|
||||
key: 'halfYearly',
|
||||
label: 'Half-yearly',
|
||||
cadence: 'Posted every six months',
|
||||
pickLabel: 'Half-year end',
|
||||
pickUnit: 'half-year end'
|
||||
},
|
||||
{
|
||||
enumValue: 7,
|
||||
key: 'yearly',
|
||||
label: 'Yearly',
|
||||
cadence: 'Posted at each year end',
|
||||
pickLabel: 'Year end',
|
||||
pickUnit: 'year end'
|
||||
}
|
||||
];
|
||||
|
||||
activePeriod = 4;
|
||||
|
||||
/** Every posting, across all rhythms — the tabs read from this. */
|
||||
rows: SavingsInterestPostingRow[] = [];
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
|
||||
/** Filters, shared by every rhythm. */
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
search = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every posting once and splits it by rhythm in the browser.
|
||||
*
|
||||
* One request rather than five: the tab counts have to be right before a tab is opened, and
|
||||
* asking per rhythm would leave them blank until each was visited.
|
||||
*/
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.service
|
||||
.getPostings({
|
||||
fromDate: this.fromDate || undefined,
|
||||
toDate: this.toDate || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.rows = data ?? [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the interest postings.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Per-rhythm views ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The period each rhythm is narrowed to, keyed by its enum.
|
||||
*
|
||||
* Each rhythm carries its own, because the unit an officer picks differs: a daily report is read
|
||||
* one day at a time, a monthly one a month at a time. A single shared control would force the
|
||||
* quarterly report to be filtered by a day, which selects one posting date and hides the rest of
|
||||
* the quarter.
|
||||
*/
|
||||
periodPick: Record<number, string> = {};
|
||||
|
||||
private matchesPick(row: SavingsInterestPostingRow, periodEnum: number): boolean {
|
||||
const pick = this.periodPick[periodEnum];
|
||||
return !pick || row.posting_date === pick;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cycle ends a rhythm actually posted on, each with how many accounts were paid.
|
||||
*
|
||||
* A dropdown of real posting dates rather than a free date picker: a rhythm posts on a handful of
|
||||
* dates a year, and a calendar would offer three hundred and sixty-odd days that hold nothing.
|
||||
* The account count sits in the option itself, so the answer to "how many accounts were paid at
|
||||
* this quarter end" is visible before the filter is even applied.
|
||||
*/
|
||||
availablePicks(periodEnum: number): { date: string; accounts: number; total: number }[] {
|
||||
const byDate = new Map<string, SavingsInterestPostingRow[]>();
|
||||
for (const row of this.rows) {
|
||||
if (row.posting_period_enum !== periodEnum || !row.posting_date) {
|
||||
continue;
|
||||
}
|
||||
const list = byDate.get(row.posting_date);
|
||||
if (list) {
|
||||
list.push(row);
|
||||
} else {
|
||||
byDate.set(row.posting_date, [row]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byDate.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(
|
||||
([
|
||||
date,
|
||||
rows
|
||||
]) => ({
|
||||
date,
|
||||
accounts: new Set(rows.map((r) => r.savings_account_id)).size,
|
||||
total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clearPick(periodEnum: number): void {
|
||||
delete this.periodPick[periodEnum];
|
||||
}
|
||||
|
||||
/**
|
||||
* Products whose postings do not follow the rhythm they are configured for.
|
||||
*
|
||||
* A quarterly product posts four times a year. Where one shows a hundred and thirty-five posting
|
||||
* dates, something other than this ledger has paid it — stock Fineract's own savings interest job
|
||||
* writes backdated postings a day at a time and pays no attention to the product's posting
|
||||
* period. The report cannot hide those: the money reached the members and the transactions are
|
||||
* real. What it can do is say so, so a total read off this screen is not mistaken for the rhythm
|
||||
* the product is meant to keep.
|
||||
*
|
||||
* The threshold is generous — a rhythm is only called out at more than double the dates it could
|
||||
* legitimately produce across the range on display, so a backdated correction or a re-run does
|
||||
* not raise it.
|
||||
*/
|
||||
get rhythmWarnings(): { product: string; label: string; dates: number; expected: string }[] {
|
||||
const expectedMax: Record<number, number> = { 1: 100000, 4: 24, 5: 8, 6: 4, 7: 2 };
|
||||
const expectedWord: Record<number, string> = {
|
||||
1: 'daily',
|
||||
4: 'about one a month',
|
||||
5: 'about four a year',
|
||||
6: 'two a year',
|
||||
7: 'one a year'
|
||||
};
|
||||
|
||||
const byProduct = new Map<string, { label: string; period: number; dates: Set<string> }>();
|
||||
for (const row of this.rows) {
|
||||
const entry = byProduct.get(row.product_name);
|
||||
if (entry) {
|
||||
entry.dates.add(row.posting_date);
|
||||
} else {
|
||||
byProduct.set(row.product_name, {
|
||||
label: this.periods.find((p) => p.enumValue === row.posting_period_enum)?.label ?? '—',
|
||||
period: row.posting_period_enum,
|
||||
dates: new Set([row.posting_date])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...byProduct.entries()]
|
||||
.filter(
|
||||
([
|
||||
,
|
||||
v
|
||||
]) => v.dates.size > (expectedMax[v.period] ?? 100000)
|
||||
)
|
||||
.map(
|
||||
([
|
||||
product,
|
||||
v
|
||||
]) => ({
|
||||
product,
|
||||
label: v.label,
|
||||
dates: v.dates.size,
|
||||
expected: expectedWord[v.period] ?? '—'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private rowsFor(periodEnum: number): SavingsInterestPostingRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (r.posting_period_enum !== periodEnum) {
|
||||
return false;
|
||||
}
|
||||
if (!this.matchesPick(r, periodEnum)) {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get visibleRows(): SavingsInterestPostingRow[] {
|
||||
return this.rowsFor(this.activePeriod);
|
||||
}
|
||||
|
||||
countFor(periodEnum: number): number {
|
||||
return this.rowsFor(periodEnum).length;
|
||||
}
|
||||
|
||||
totalFor(periodEnum: number): number {
|
||||
return this.rowsFor(periodEnum).reduce((s, r) => s + Number(r.interest_amount ?? 0), 0);
|
||||
}
|
||||
|
||||
get visibleTotal(): number {
|
||||
return this.totalFor(this.activePeriod);
|
||||
}
|
||||
|
||||
/** Distinct accounts paid on the tab in view — a count of rows would double-count re-postings. */
|
||||
get visibleAccounts(): number {
|
||||
return new Set(this.visibleRows.map((r) => r.savings_account_id)).size;
|
||||
}
|
||||
|
||||
get activeMeta(): PostingPeriod {
|
||||
return this.periods.find((p) => p.enumValue === this.activePeriod) ?? this.periods[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* The postings on the tab in view, grouped by the day they were paid.
|
||||
*
|
||||
* A posting run pays a whole product on one date, so the date is the unit an officer reconciles
|
||||
* against — "what went out at this month end" — rather than the individual row.
|
||||
*/
|
||||
get groupedByDate(): { date: string; rows: SavingsInterestPostingRow[]; total: number; accounts: number }[] {
|
||||
const byDate = new Map<string, SavingsInterestPostingRow[]>();
|
||||
for (const row of this.visibleRows) {
|
||||
const key = row.posting_date ?? '';
|
||||
const list = byDate.get(key);
|
||||
if (list) {
|
||||
list.push(row);
|
||||
} else {
|
||||
byDate.set(key, [row]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byDate.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(
|
||||
([
|
||||
date,
|
||||
rows
|
||||
]) => ({
|
||||
date,
|
||||
rows,
|
||||
total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0),
|
||||
accounts: new Set(rows.map((r) => r.savings_account_id)).size
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
showPeriod(periodEnum: number): void {
|
||||
this.activePeriod = periodEnum;
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.fromDate = '';
|
||||
this.toDate = '';
|
||||
this.search = '';
|
||||
this.periodPick = {};
|
||||
this.load();
|
||||
}
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Posting dates arrive as ISO yyyy-MM-dd. */
|
||||
formatDate(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value.split('-').map(Number);
|
||||
if (!y || !m || !d) {
|
||||
return value;
|
||||
}
|
||||
return new Date(y, m - 1, d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
openAccount(row: SavingsInterestPostingRow): void {
|
||||
if (row.client_id == null) {
|
||||
return;
|
||||
}
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.client_id,
|
||||
'savings-accounts',
|
||||
row.savings_account_id,
|
||||
'transactions'
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One sheet per rhythm, so the workbook mirrors the screen.
|
||||
*
|
||||
* A rhythm with nothing posted still gets its sheet: an empty Quarterly sheet says the quarter
|
||||
* has not ended yet, which a missing sheet would leave the reader to guess at.
|
||||
*/
|
||||
exportExcel(): void {
|
||||
const stamp = `${this.fromDate || 'from inception'} to ${this.toDate || 'date'}`;
|
||||
|
||||
const sheets: ExportSheetSpec[] = this.periods.map((p) => {
|
||||
const rows = this.rowsFor(p.enumValue);
|
||||
return {
|
||||
sheetName: p.label,
|
||||
title: `Savings Interest Posted — ${p.label}`,
|
||||
subtitle: `${p.cadence} · ${stamp} · ${rows.length} posting(s), total ${this.totalFor(p.enumValue).toFixed(2)}`,
|
||||
headers: [
|
||||
'Posting Date',
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Product',
|
||||
'Rate %',
|
||||
'Balance Before',
|
||||
'Interest Posted',
|
||||
'Balance After',
|
||||
'Branch'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
20,
|
||||
28,
|
||||
26,
|
||||
9,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
22
|
||||
],
|
||||
rows: rows.map((r) => [
|
||||
this.formatDate(r.posting_date),
|
||||
r.account_no,
|
||||
r.client_name || '—',
|
||||
r.product_name,
|
||||
Number(r.interest_rate ?? 0),
|
||||
Number(r.balance_before ?? 0),
|
||||
Number(r.interest_amount ?? 0),
|
||||
Number(r.balance_after ?? 0),
|
||||
r.office_name || '—'
|
||||
])
|
||||
};
|
||||
});
|
||||
|
||||
exportWorkbook(sheets, `savings-interest-posted-${new Date().toISOString().slice(0, 10)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
<!--
|
||||
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="ke">
|
||||
<!-- Header -->
|
||||
<div class="ke-head">
|
||||
<div>
|
||||
<h2 class="ke-head-t"><fa-icon icon="id-card"></fa-icon> Expired KYC & Customer Outreach</h2>
|
||||
<div class="ke-head-s">
|
||||
Customers whose periodic re-KYC has lapsed: what each restriction stops, and the record of every attempt to
|
||||
reach them. A customer drops off this list the moment their re-KYC is completed.
|
||||
</div>
|
||||
</div>
|
||||
<button class="ke-btn ke-btn--slim" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="undo"></fa-icon> Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The rule the screen runs on -->
|
||||
<div class="ke-policy">
|
||||
<fa-icon icon="gavel"></fa-icon>
|
||||
<div>
|
||||
<strong>RBI Master Direction on KYC.</strong> An account is not closed or restricted merely because periodic
|
||||
re-KYC is pending. Nothing stops for the first <strong>{{ policy.graceDays }} days</strong> past the due date;
|
||||
debits stop after that, and the account is marked inoperative after
|
||||
<strong>{{ policy.inoperativeDays }} days</strong>. Restrictions are applied to the customer's savings accounts
|
||||
overnight and lift as soon as the re-KYC is completed. These credits are never held up:
|
||||
<span class="ke-allow-list">{{ alwaysAllowed.join(' · ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="ke-warnbar"><fa-icon icon="exclamation-triangle"></fa-icon> {{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<!-- Ladder summary; each card filters the worklist -->
|
||||
<div class="ke-stats">
|
||||
@for (key of stageKeys; track key) {
|
||||
<button
|
||||
class="ke-stat"
|
||||
[class.active]="stageFilter === key"
|
||||
[class]="'ke-stat ke-stat--' + stageMeta[key].tone"
|
||||
[class.ke-stat--on]="stageFilter === key"
|
||||
(click)="toggleStage(key)"
|
||||
>
|
||||
<div class="ke-stat-n">
|
||||
@switch (key) {
|
||||
@case ('INOPERATIVE') {
|
||||
{{ summary.inoperative }}
|
||||
}
|
||||
@case ('PARTIAL_FREEZE') {
|
||||
{{ summary.partialFreeze }}
|
||||
}
|
||||
@default {
|
||||
{{ summary.grace }}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="ke-stat-l">{{ stageMeta[key].label }}</div>
|
||||
<div class="ke-stat-w">{{ stageMeta[key].summary }}</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="ke-filters">
|
||||
<input class="ke-inp" type="text" placeholder="Search name, customer ID or account" [(ngModel)]="search" />
|
||||
|
||||
<select class="ke-sel" [(ngModel)]="riskFilter">
|
||||
<option [ngValue]="0">All risk categories</option>
|
||||
<option [ngValue]="1">Low Risk</option>
|
||||
<option [ngValue]="2">Medium Risk</option>
|
||||
<option [ngValue]="3">High Risk</option>
|
||||
</select>
|
||||
|
||||
<label class="ke-chk">
|
||||
<input type="checkbox" [(ngModel)]="notContactedOnly" /> Not yet contacted
|
||||
@if (summary.notContacted > 0) {
|
||||
<span class="ke-badge">{{ summary.notContacted }}</span>
|
||||
}
|
||||
</label>
|
||||
|
||||
<button class="ke-btn ke-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
|
||||
<span class="ke-spacer"></span>
|
||||
<span class="ke-count">{{ visibleRows.length }} of {{ summary.total }} customers</span>
|
||||
</div>
|
||||
|
||||
<!-- Worklist -->
|
||||
@if (loading) {
|
||||
<div class="ke-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading worklist…</div>
|
||||
} @else if (visibleRows.length === 0) {
|
||||
<div class="ke-empty">No customers match these filters.</div>
|
||||
} @else {
|
||||
<div class="ke-tablewrap">
|
||||
<table class="ke-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Customer</th>
|
||||
<th>Account</th>
|
||||
<th>Risk</th>
|
||||
<th>KYC Expiry</th>
|
||||
<th>Position</th>
|
||||
<th>What is blocked</th>
|
||||
<th>Next step</th>
|
||||
<th>Outreach</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of visibleRows; track r.clientId) {
|
||||
<tr class="ke-row" [class.open]="expanded === r.clientId" (click)="toggle(r.clientId)">
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === r.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<strong>{{ r.clientName }}</strong>
|
||||
<div class="ke-sub mono">{{ r.clientId }}</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="mono">{{ r.accountNo || '—' }}</span>
|
||||
<div class="ke-sub">Branch {{ r.branchCode || '—' }}</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span [class]="riskClass(r.riskCategory)">{{ r.riskLabel }}</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="mono">{{ r.expiryDate }}</span>
|
||||
<div class="ke-sub" [class.ke-sub--red]="r.daysOverdue > 0">{{ overdueText(r) }}</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span [class]="stageClass(r.stage)">{{ r.meta.label }}</span>
|
||||
</td>
|
||||
|
||||
<td class="ke-block-cell">
|
||||
@if (r.meta.blocked.length === 0) {
|
||||
<span class="ke-nothing">Nothing blocked — fully operative</span>
|
||||
} @else {
|
||||
@for (b of r.meta.blocked; track b) {
|
||||
<span class="ke-blocked">{{ b }}</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
@if (r.nextActionDate) {
|
||||
<div class="ke-next">{{ r.nextActionLabel }}</div>
|
||||
<div class="ke-sub mono">{{ r.nextActionDate }}</div>
|
||||
} @else {
|
||||
<span class="ke-sub">{{ r.nextActionLabel }}</span>
|
||||
}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
@if (r.contactCount === 0) {
|
||||
<span class="ke-none">Not contacted</span>
|
||||
} @else {
|
||||
<span class="ke-times">{{ r.contactCount }} attempt{{ r.contactCount > 1 ? 's' : '' }}</span>
|
||||
<div class="ke-sub mono">last {{ r.lastContactDate }}</div>
|
||||
}
|
||||
@if (r.acknowledged) {
|
||||
<div class="ke-ack"><fa-icon icon="check"></fa-icon> Customer reached</div>
|
||||
}
|
||||
</td>
|
||||
|
||||
<td class="ke-act-cell">
|
||||
<div class="ke-acts">
|
||||
<button
|
||||
class="ke-ico"
|
||||
title="Record an email to the customer"
|
||||
[disabled]="!r.email"
|
||||
(click)="openContact(r, 'EMAIL', $event)"
|
||||
>
|
||||
<fa-icon icon="envelope"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="ke-ico"
|
||||
title="Record an SMS"
|
||||
[disabled]="!r.mobile"
|
||||
(click)="openContact(r, 'SMS', $event)"
|
||||
>
|
||||
<fa-icon icon="comment-dots"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="ke-ico"
|
||||
title="Record a phone call"
|
||||
[disabled]="!r.mobile"
|
||||
(click)="openContact(r, 'PHONE_CALL', $event)"
|
||||
>
|
||||
<fa-icon icon="phone"></fa-icon>
|
||||
</button>
|
||||
<button class="ke-ico" title="Record a visit" (click)="openContact(r, 'FIELD_VISIT', $event)">
|
||||
<fa-icon icon="user-check"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ke-acts">
|
||||
<button class="ke-mini" (click)="openAck(r, $event)" [disabled]="r.acknowledged">
|
||||
<fa-icon icon="check"></fa-icon> Mark contacted
|
||||
</button>
|
||||
<button
|
||||
class="ke-mini ke-mini--go"
|
||||
title="Complete re-KYC on the customer's KYC screen. Once it is renewed the restrictions lift and this customer drops off the list."
|
||||
(click)="openKyc(r, $event)"
|
||||
>
|
||||
<fa-icon icon="id-card"></fa-icon> Renew KYC
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded: what is allowed, and the full outreach history -->
|
||||
@if (expanded === r.clientId) {
|
||||
<tr class="ke-detail">
|
||||
<td colspan="10">
|
||||
<div class="ke-detail-grid">
|
||||
<div class="ke-panel">
|
||||
<h4><fa-icon icon="check-circle"></fa-icon> Still allowed</h4>
|
||||
<ul>
|
||||
@for (a of r.meta.allowed; track a) {
|
||||
<li>{{ a }}</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="ke-panel-note">{{ r.meta.summary }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ke-panel">
|
||||
<h4><fa-icon icon="ban"></fa-icon> Blocked</h4>
|
||||
@if (r.meta.blocked.length === 0) {
|
||||
<div class="ke-panel-note">
|
||||
Nothing is restricted at this stage. The account operates normally while the customer is given
|
||||
time to complete re-KYC.
|
||||
</div>
|
||||
} @else {
|
||||
<ul>
|
||||
@for (b of r.meta.blocked; track b) {
|
||||
<li>{{ b }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<div class="ke-panel-note ke-panel-note--allow">
|
||||
Never held up: {{ alwaysAllowed.join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ke-panel">
|
||||
<h4><fa-icon icon="address-card"></fa-icon> Contact on file</h4>
|
||||
<div class="ke-kv">
|
||||
<span>Mobile</span><strong class="mono">{{ r.mobile || 'Not on file' }}</strong>
|
||||
</div>
|
||||
<div class="ke-kv">
|
||||
<span>Email</span><strong class="mono">{{ r.email || 'Not on file' }}</strong>
|
||||
</div>
|
||||
@if (r.acknowledged) {
|
||||
<div class="ke-ack-box">
|
||||
<strong>Customer reached</strong> on {{ r.acknowledgedOn }} by {{ r.acknowledgedBy }}.
|
||||
@if (r.acknowledgedNote) {
|
||||
<div class="ke-sub">{{ r.acknowledgedNote }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="ke-hist-h"><fa-icon icon="history"></fa-icon> Outreach history</h4>
|
||||
@if (r.contacts.length === 0) {
|
||||
<div class="ke-panel-note">
|
||||
No attempt recorded yet. Use the actions on the row to log an email, SMS, call or visit.
|
||||
</div>
|
||||
} @else {
|
||||
<table class="ke-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Channel</th>
|
||||
<th>Destination</th>
|
||||
<th>Outcome</th>
|
||||
<th>Officer</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of r.contacts; track c.date + c.channel + c.notes) {
|
||||
<tr>
|
||||
<td class="mono">{{ c.date }}</td>
|
||||
<td>{{ c.channelLabel }}</td>
|
||||
<td class="mono">{{ c.destinationMasked || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="deliveryClass(c.deliveryStatus)">{{ deliveryText(c) }}</span>
|
||||
</td>
|
||||
<td>{{ c.officer }}</td>
|
||||
<td class="ke-notes">{{ c.notes }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Record an outreach attempt -->
|
||||
@if (contactOpen && contactRow) {
|
||||
<div class="ke-modal-bg" (click)="contactOpen = false">
|
||||
<div class="ke-modal" (click)="$event.stopPropagation()">
|
||||
<div class="ke-modal-h">
|
||||
<h3>Record customer contact</h3>
|
||||
<button class="ke-x" (click)="contactOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div class="ke-modal-b">
|
||||
<div class="ke-modal-who">
|
||||
<strong>{{ contactRow.clientName }}</strong>
|
||||
<span class="mono">{{ contactRow.accountNo }}</span>
|
||||
<span [class]="stageClass(contactRow.stage)">{{ contactRow.meta.label }}</span>
|
||||
</div>
|
||||
|
||||
<label class="ke-lbl">Channel</label>
|
||||
<div class="ke-chan">
|
||||
@for (c of channels; track c.key) {
|
||||
<button class="ke-chan-b" [class.active]="contactChannel === c.key" (click)="contactChannel = c.key">
|
||||
<fa-icon [icon]="c.icon"></fa-icon> {{ c.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ke-dest" [class.ke-dest--missing]="contactDestinationMissing">
|
||||
@if (contactDestinationMissing) {
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
No destination on file for this channel — the attempt will be recorded but nothing is sent.
|
||||
} @else {
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
@if (contactChannelTransmits) {
|
||||
Will be sent to <strong>{{ contactDestination }}</strong>
|
||||
} @else {
|
||||
Recorded as made <strong>{{ contactDestination }}</strong>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<label class="ke-lbl">What was said or attempted <span class="ke-req">*</span></label>
|
||||
<textarea
|
||||
class="ke-ta"
|
||||
rows="4"
|
||||
[(ngModel)]="contactNotes"
|
||||
placeholder="e.g. Called on registered mobile, customer confirmed they will visit the branch with PAN and address proof next week."
|
||||
></textarea>
|
||||
|
||||
<label class="ke-lbl">Officer (optional)</label>
|
||||
<input class="ke-inp ke-inp--full" type="text" [(ngModel)]="contactOfficer" placeholder="Name of the officer" />
|
||||
|
||||
@if (contactError) {
|
||||
<div class="ke-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ contactError }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ke-modal-f">
|
||||
<button class="ke-btn ke-btn--ghost" (click)="contactOpen = false">Cancel</button>
|
||||
<button class="ke-btn ke-btn--primary" (click)="saveContact()" [disabled]="contactSaving">
|
||||
@if (contactSaving) {
|
||||
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
|
||||
} @else {
|
||||
Record contact
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Mark the customer as reached -->
|
||||
@if (ackOpen && ackRow) {
|
||||
<div class="ke-modal-bg" (click)="ackOpen = false">
|
||||
<div class="ke-modal ke-modal--sm" (click)="$event.stopPropagation()">
|
||||
<div class="ke-modal-h">
|
||||
<h3>Mark customer as contacted</h3>
|
||||
<button class="ke-x" (click)="ackOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div class="ke-modal-b">
|
||||
<div class="ke-modal-who">
|
||||
<strong>{{ ackRow.clientName }}</strong>
|
||||
<span class="mono">{{ ackRow.accountNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ke-note-box">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
This records that the customer was reached and told what is required. It does not complete the re-KYC — the
|
||||
restriction ladder keeps running until the KYC record itself is renewed.
|
||||
</div>
|
||||
|
||||
<label class="ke-lbl">What the customer said</label>
|
||||
<textarea
|
||||
class="ke-ta"
|
||||
rows="3"
|
||||
[(ngModel)]="ackNote"
|
||||
placeholder="e.g. Customer acknowledged the debit freeze and will visit the branch on 12 Sep with documents."
|
||||
></textarea>
|
||||
|
||||
<label class="ke-lbl">Officer (optional)</label>
|
||||
<input class="ke-inp ke-inp--full" type="text" [(ngModel)]="ackOfficer" placeholder="Name of the officer" />
|
||||
</div>
|
||||
|
||||
<div class="ke-modal-f">
|
||||
<button class="ke-btn ke-btn--ghost" (click)="ackOpen = false">Cancel</button>
|
||||
<button class="ke-btn ke-btn--primary" (click)="saveAck()">Mark contacted</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (toastVisible) {
|
||||
<div class="ke-toast">{{ toastMsg }}</div>
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--k-navy: #1a237e;
|
||||
--k-blue: #1d4ed8;
|
||||
--k-blue-l: #eff6ff;
|
||||
--k-green: #15803d;
|
||||
--k-green-l: #dcfce7;
|
||||
--k-amber: #b45309;
|
||||
--k-amber-l: #fef3c7;
|
||||
--k-red: #c0392b;
|
||||
--k-red-l: #fdecea;
|
||||
--k-crit: #7f1d1d;
|
||||
--k-crit-l: #fee2e2;
|
||||
--k-g50: #f8fafc;
|
||||
--k-g100: #f1f5f9;
|
||||
--k-g200: #e2e8f0;
|
||||
--k-g300: #cbd5e1;
|
||||
--k-g500: #64748b;
|
||||
--k-g700: #334155;
|
||||
--k-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--k-g900);
|
||||
background: var(--k-g50);
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ke-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--k-navy);
|
||||
}
|
||||
|
||||
.ke-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--k-g500);
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
/* The rule the ladder implements, stated once at the top. */
|
||||
.ke-policy {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--k-blue-l);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--k-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--k-blue);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-allow-list {
|
||||
font-weight: 600;
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
.ke-warnbar {
|
||||
padding: 9px 13px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--k-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--k-amber);
|
||||
}
|
||||
|
||||
/* ── Ladder cards ───────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ke-stat {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-left: 4px solid var(--k-g300);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.12s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-left-color: var(--k-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--k-amber);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
border-left-color: var(--k-red);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-left-color: var(--k-crit);
|
||||
}
|
||||
|
||||
/* The selected rung stays obvious while its rows are being worked. */
|
||||
&--on {
|
||||
background: var(--k-g100);
|
||||
box-shadow: inset 0 0 0 1px var(--k-g300);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-stat-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.ke-stat-l {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.ke-stat-w {
|
||||
font-size: 10.5px;
|
||||
color: var(--k-g500);
|
||||
margin-top: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── Filters ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.ke-inp,
|
||||
.ke-sel {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
|
||||
&--full {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-inp {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.ke-chk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--k-g700);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ke-badge {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--k-red-l);
|
||||
color: var(--k-red);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ke-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ke-count {
|
||||
font-size: 11.5px;
|
||||
color: var(--k-g500);
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--k-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--k-blue);
|
||||
color: var(--k-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--slim {
|
||||
padding: 5px 11px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--k-blue);
|
||||
border-color: var(--k-blue);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-tablewrap {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.ke-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
background: var(--k-g100);
|
||||
border-bottom: 1px solid var(--k-g200);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--k-g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--k-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--k-g50);
|
||||
}
|
||||
|
||||
&.open {
|
||||
background: var(--k-blue-l);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--k-g500);
|
||||
margin-top: 2px;
|
||||
|
||||
&--red {
|
||||
color: var(--k-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Badges ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-stage {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--info {
|
||||
background: var(--k-blue-l);
|
||||
color: var(--k-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--k-amber-l);
|
||||
color: var(--k-amber);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: var(--k-red-l);
|
||||
color: var(--k-red);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: var(--k-crit-l);
|
||||
color: var(--k-crit);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-risk {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
|
||||
&--low {
|
||||
background: var(--k-green-l);
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
&--mid {
|
||||
background: var(--k-amber-l);
|
||||
color: var(--k-amber);
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: var(--k-red-l);
|
||||
color: var(--k-red);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-block-cell {
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
.ke-blocked {
|
||||
display: inline-block;
|
||||
margin: 0 3px 3px 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--k-red-l);
|
||||
color: var(--k-red);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Nothing restricted is good news, not an absence of information. */
|
||||
.ke-nothing {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
.ke-next {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--k-g700);
|
||||
}
|
||||
|
||||
.ke-none {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--k-red);
|
||||
}
|
||||
|
||||
.ke-times {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ke-ack {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
/* ── Row actions ────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-act-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ke-acts {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ke-ico {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: var(--k-g700);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--k-blue);
|
||||
color: var(--k-blue);
|
||||
background: var(--k-blue-l);
|
||||
}
|
||||
|
||||
/* A channel with no destination on file cannot be attempted. */
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--k-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--k-blue);
|
||||
color: var(--k-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--go {
|
||||
border-color: var(--k-blue);
|
||||
color: var(--k-blue);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expanded detail ────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-detail {
|
||||
background: var(--k-g50);
|
||||
|
||||
> td {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ke-panel {
|
||||
padding: 11px 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 6px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--k-g700);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 17px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-panel-note {
|
||||
margin-top: 7px;
|
||||
font-size: 10.5px;
|
||||
color: var(--k-g500);
|
||||
line-height: 1.5;
|
||||
|
||||
&--allow {
|
||||
color: var(--k-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 3px 0;
|
||||
font-size: 11.5px;
|
||||
|
||||
span {
|
||||
color: var(--k-g500);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-ack-box {
|
||||
margin-top: 8px;
|
||||
padding: 7px 9px;
|
||||
background: var(--k-green-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
.ke-hist-h {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--k-g700);
|
||||
}
|
||||
|
||||
.ke-subtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 7px 9px;
|
||||
background: var(--k-g100);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--k-g500);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 7px 9px;
|
||||
border-top: 1px solid var(--k-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-notes {
|
||||
max-width: 380px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ke-dlv {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
|
||||
&--ok {
|
||||
background: var(--k-green-l);
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
/* Anything that is not SENT means the customer was not reached — it must not read as success. */
|
||||
&--bad {
|
||||
background: var(--k-amber-l);
|
||||
color: var(--k-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-empty {
|
||||
padding: 34px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--k-g500);
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-modal-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(15 23 42 / 45%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ke-modal {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 18px 50px rgb(15 23 42 / 26%);
|
||||
|
||||
&--sm {
|
||||
max-width: 460px;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-modal-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 17px;
|
||||
border-bottom: 1px solid var(--k-g200);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--k-navy);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--k-g500);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ke-modal-b {
|
||||
padding: 15px 17px;
|
||||
}
|
||||
|
||||
.ke-modal-f {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 17px;
|
||||
border-top: 1px solid var(--k-g200);
|
||||
}
|
||||
|
||||
.ke-modal-who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 11px;
|
||||
margin-bottom: 11px;
|
||||
border-bottom: 1px solid var(--k-g100);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.ke-lbl {
|
||||
display: block;
|
||||
margin: 11px 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--k-g700);
|
||||
}
|
||||
|
||||
.ke-req {
|
||||
color: var(--k-red);
|
||||
}
|
||||
|
||||
.ke-chan {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ke-chan-b {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 11px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--k-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--k-blue);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--k-blue);
|
||||
border-color: var(--k-blue);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Says plainly whether anything will actually be sent. */
|
||||
.ke-dest {
|
||||
margin-top: 9px;
|
||||
padding: 7px 10px;
|
||||
background: var(--k-green-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--k-green);
|
||||
|
||||
&--missing {
|
||||
background: var(--k-amber-l);
|
||||
color: var(--k-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-ta {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ke-note-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
background: var(--k-blue-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--k-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--k-blue);
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-err {
|
||||
margin-top: 10px;
|
||||
padding: 7px 10px;
|
||||
background: var(--k-red-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11.5px;
|
||||
color: var(--k-red);
|
||||
}
|
||||
|
||||
.ke-toast {
|
||||
position: fixed;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
background: var(--k-g900);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
box-shadow: 0 8px 22px rgb(15 23 42 / 30%);
|
||||
z-index: 1100;
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients/clients.service';
|
||||
|
||||
/** Report data + computation */
|
||||
import {
|
||||
ALWAYS_ALLOWED,
|
||||
CONTACT_CHANNELS,
|
||||
KYC_EXPIRY_POLICY,
|
||||
KYC_EXPIRY_STAGES,
|
||||
KycContactRow,
|
||||
KycExpiryRow,
|
||||
KycExpiryStage,
|
||||
KycExpirySummary,
|
||||
buildExpiryRow,
|
||||
channelLabel,
|
||||
expirySummary,
|
||||
sortWorklist
|
||||
} from './kyc-expiry.compute';
|
||||
|
||||
/**
|
||||
* The re-KYC expiry worklist.
|
||||
*
|
||||
* Answers two questions an officer has about an overdue customer: what has stopped working on the
|
||||
* account, and what has been done to reach them. The restriction ladder is read-only — it is
|
||||
* derived from the expiry date, not stored — so this screen never has to be kept in step with the
|
||||
* account's real state; it says what the policy implies and leaves the account alone.
|
||||
*
|
||||
* Recording an outreach attempt does write: it posts to the contact-log API the KYC exceptions
|
||||
* screen already uses, so both screens read the same history.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-kyc-expiry',
|
||||
templateUrl: './kyc-expiry.component.html',
|
||||
styleUrls: ['./kyc-expiry.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class KycExpiryComponent implements OnInit {
|
||||
private clientsService = inject(ClientsService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
|
||||
rows: KycExpiryRow[] = [];
|
||||
expanded: number | null = null;
|
||||
|
||||
/** Filters */
|
||||
stageFilter: KycExpiryStage | '' = '';
|
||||
riskFilter = 0;
|
||||
search = '';
|
||||
notContactedOnly = false;
|
||||
|
||||
/** Contact dialog */
|
||||
contactOpen = false;
|
||||
contactRow: KycExpiryRow | null = null;
|
||||
contactChannel = 'EMAIL';
|
||||
contactNotes = '';
|
||||
contactOfficer = '';
|
||||
contactSaving = false;
|
||||
contactError = '';
|
||||
|
||||
/** Acknowledge dialog */
|
||||
ackOpen = false;
|
||||
ackRow: KycExpiryRow | null = null;
|
||||
ackNote = '';
|
||||
ackOfficer = '';
|
||||
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
readonly channels = CONTACT_CHANNELS;
|
||||
readonly policy = KYC_EXPIRY_POLICY;
|
||||
readonly alwaysAllowed = ALWAYS_ALLOWED;
|
||||
readonly stageMeta = KYC_EXPIRY_STAGES;
|
||||
/** DUE_SOON is absent by design: nothing on this screen has an unexpired KYC. */
|
||||
readonly stageKeys: KycExpiryStage[] = [
|
||||
'INOPERATIVE',
|
||||
'PARTIAL_FREEZE',
|
||||
'GRACE'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the customers whose re-KYC has lapsed, then folds in each one's outreach history.
|
||||
*
|
||||
* The server already restricts the list to lapsed records, so nothing is filtered by date here —
|
||||
* a customer whose renewal is still ahead never reaches this screen, and one who renews drops out
|
||||
* of it on the next load.
|
||||
*/
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
this.clientsService.getLapsedKyc().subscribe({
|
||||
next: (records: any[]) => {
|
||||
const clients = (records || []).map((r) => this.mapLapsed(r)).filter((c) => !!c.expiryDate);
|
||||
|
||||
if (clients.length === 0) {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
forkJoin(
|
||||
clients.map((c) =>
|
||||
this.clientsService.getKycContactLog(c.clientId).pipe(
|
||||
map((logs: any[]) => (logs || []).map((l) => this.mapContact(l))),
|
||||
// One customer's history failing must not blank the whole worklist — an overdue
|
||||
// account the branch cannot see is worse than one shown without its outreach.
|
||||
catchError(() => of([] as KycContactRow[]))
|
||||
)
|
||||
)
|
||||
).subscribe({
|
||||
next: (histories) => {
|
||||
this.rows = sortWorklist(clients.map((c, i) => buildExpiryRow(c, histories[i], today)));
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.rows = sortWorklist(clients.map((c) => buildExpiryRow(c, [], today)));
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Contact history could not be loaded. The worklist below is still accurate.';
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the lapsed KYC worklist.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Maps one ClientKycData record onto what the ladder needs. */
|
||||
private mapLapsed(r: any): {
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
riskCategory: number;
|
||||
expiryDate: string;
|
||||
mobile: string;
|
||||
email: string;
|
||||
} {
|
||||
return {
|
||||
clientId: r?.clientId,
|
||||
clientName: r?.clientDisplayName || '—',
|
||||
accountNo: r?.clientAccountNo || '',
|
||||
branchCode: r?.clientOfficeName || '',
|
||||
riskCategory: r?.riskCategory ?? 1,
|
||||
expiryDate: (r?.expiryDate || '').toString().slice(0, 10),
|
||||
// Only the masked forms come back from the server, which is what the dialog shows and all it
|
||||
// needs — the destination itself is resolved server-side when a notice is sent.
|
||||
mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''),
|
||||
email: r?.clientEmailMasked || (r?.hasEmail ? 'On file' : '')
|
||||
};
|
||||
}
|
||||
|
||||
private mapContact(log: any): KycContactRow {
|
||||
const status = log?.deliveryStatus || 'NOT_APPLICABLE';
|
||||
return {
|
||||
date: (log?.contactedOn || log?.createdOn || '').toString().slice(0, 10),
|
||||
channel: log?.channel || '',
|
||||
channelLabel: channelLabel(log?.channel || ''),
|
||||
notes: log?.notes || '',
|
||||
officer: log?.officerName || log?.loggedByUsername || '—',
|
||||
deliveryStatus: status,
|
||||
delivered: status === 'SENT',
|
||||
destinationMasked: log?.destinationMasked || ''
|
||||
};
|
||||
}
|
||||
|
||||
// ── Filtering ──────────────────────────────────────────────────────────────
|
||||
|
||||
get visibleRows(): KycExpiryRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.stageFilter && r.stage !== this.stageFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.riskFilter && r.riskCategory !== this.riskFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.notContactedOnly && r.contactCount > 0) {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get summary(): KycExpirySummary {
|
||||
return expirySummary(this.rows);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.stageFilter = '';
|
||||
this.riskFilter = 0;
|
||||
this.search = '';
|
||||
this.notContactedOnly = false;
|
||||
}
|
||||
|
||||
toggleStage(stage: KycExpiryStage): void {
|
||||
this.stageFilter = this.stageFilter === stage ? '' : stage;
|
||||
this.expanded = null;
|
||||
}
|
||||
|
||||
toggle(clientId: number): void {
|
||||
this.expanded = this.expanded === clientId ? null : clientId;
|
||||
}
|
||||
|
||||
// ── Outreach ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opens the contact dialog on a sensible channel.
|
||||
*
|
||||
* Email is the default only where the customer has one — offering a channel with no destination
|
||||
* behind it produces a log entry that looks like an attempt but reached nobody.
|
||||
*/
|
||||
openContact(row: KycExpiryRow, channel: string, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.contactRow = row;
|
||||
this.contactChannel = channel || (row.email ? 'EMAIL' : row.mobile ? 'SMS' : 'PHONE_CALL');
|
||||
this.contactNotes = '';
|
||||
this.contactOfficer = '';
|
||||
this.contactError = '';
|
||||
this.contactOpen = true;
|
||||
}
|
||||
|
||||
/** Whether the chosen channel has a destination on file. */
|
||||
get contactDestination(): string {
|
||||
if (!this.contactRow) {
|
||||
return '';
|
||||
}
|
||||
const ch = this.contactChannel;
|
||||
if (ch === 'EMAIL') {
|
||||
return this.contactRow.email;
|
||||
}
|
||||
if (ch === 'SMS' || ch === 'WHATSAPP' || ch === 'PHONE_CALL') {
|
||||
return this.contactRow.mobile;
|
||||
}
|
||||
// A visit is made in person, so there is no destination to check.
|
||||
return 'in person';
|
||||
}
|
||||
|
||||
get contactDestinationMissing(): boolean {
|
||||
return !this.contactDestination;
|
||||
}
|
||||
|
||||
get contactChannelTransmits(): boolean {
|
||||
return !!CONTACT_CHANNELS.find((c) => c.key === this.contactChannel)?.transmits;
|
||||
}
|
||||
|
||||
saveContact(): void {
|
||||
if (!this.contactRow || !this.contactNotes.trim()) {
|
||||
this.contactError = 'Enter what was said or attempted — the note is the record.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.contactSaving = true;
|
||||
this.contactError = '';
|
||||
const row = this.contactRow;
|
||||
|
||||
this.clientsService
|
||||
.saveKycContactLog(row.clientId, {
|
||||
channel: this.contactChannel,
|
||||
notes: this.contactNotes.trim(),
|
||||
officerName: this.contactOfficer.trim() || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.contactSaving = false;
|
||||
this.contactOpen = false;
|
||||
// The log row is written whether or not the message left the gateway, so the officer is
|
||||
// told which of the two happened rather than being left to assume it was delivered.
|
||||
const label = channelLabel(this.contactChannel);
|
||||
const status = res?.deliveryStatus;
|
||||
this.toast(
|
||||
status && status !== 'SENT' && status !== 'NOT_APPLICABLE'
|
||||
? `${label} recorded for ${row.clientName}, but it was not delivered — see the history.`
|
||||
: `${label} recorded for ${row.clientName}`
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.contactSaving = false;
|
||||
this.contactError =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the contact attempt.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Acknowledgement ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Marks that the customer has been reached and understands what is required.
|
||||
*
|
||||
* This is a note on the outreach, not a substitute for the re-KYC: the ladder keeps running, and
|
||||
* the account is only released once the KYC record itself is renewed on the KYC screen.
|
||||
*/
|
||||
openAck(row: KycExpiryRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.ackRow = row;
|
||||
this.ackNote = '';
|
||||
this.ackOfficer = '';
|
||||
this.ackOpen = true;
|
||||
}
|
||||
|
||||
saveAck(): void {
|
||||
if (!this.ackRow) {
|
||||
return;
|
||||
}
|
||||
const row = this.ackRow;
|
||||
// Recorded through the same contact log so the acknowledgement sits in one history with the
|
||||
// attempts that led to it, rather than in a store of its own.
|
||||
this.clientsService
|
||||
.saveKycContactLog(row.clientId, {
|
||||
channel: 'PHONE_CALL',
|
||||
notes: `[ACKNOWLEDGED] ${this.ackNote.trim() || 'Customer reached and informed of re-KYC requirement.'}`,
|
||||
officerName: this.ackOfficer.trim() || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
row.acknowledged = true;
|
||||
row.acknowledgedOn = new Date().toISOString().slice(0, 10);
|
||||
row.acknowledgedBy = this.ackOfficer.trim() || 'Current user';
|
||||
row.acknowledgedNote = this.ackNote.trim();
|
||||
this.ackOpen = false;
|
||||
this.toast(`${row.clientName} marked as contacted`);
|
||||
this.load();
|
||||
},
|
||||
error: () => {
|
||||
this.ackOpen = false;
|
||||
this.toast('Could not save the acknowledgement.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** The KYC screen is where the record is actually renewed. */
|
||||
openKyc(row: KycExpiryRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'kyc'
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────────────
|
||||
|
||||
stageClass(stage: KycExpiryStage): string {
|
||||
return `ke-stage ke-stage--${KYC_EXPIRY_STAGES[stage].tone}`;
|
||||
}
|
||||
|
||||
riskClass(category: number): string {
|
||||
const key = category === 3 ? 'high' : category === 2 ? 'mid' : 'low';
|
||||
return `ke-risk ke-risk--${key}`;
|
||||
}
|
||||
|
||||
/** "214 days overdue" / "in 17 days", as an officer would say it. */
|
||||
overdueText(row: KycExpiryRow): string {
|
||||
if (row.daysOverdue > 0) {
|
||||
return `${row.daysOverdue} days overdue`;
|
||||
}
|
||||
if (row.daysOverdue === 0) {
|
||||
return 'Due today';
|
||||
}
|
||||
return `Due in ${Math.abs(row.daysOverdue)} days`;
|
||||
}
|
||||
|
||||
deliveryClass(status: string): string {
|
||||
return status === 'SENT' ? 'ke-dlv ke-dlv--ok' : 'ke-dlv ke-dlv--bad';
|
||||
}
|
||||
|
||||
deliveryText(c: KycContactRow): string {
|
||||
switch (c.deliveryStatus) {
|
||||
case 'SENT':
|
||||
return 'Delivered';
|
||||
case 'FAILED':
|
||||
return 'Failed';
|
||||
case 'SKIPPED_NO_DESTINATION':
|
||||
return 'No destination on file';
|
||||
case 'PROVIDER_NOT_CONFIGURED':
|
||||
return 'Gateway not configured';
|
||||
default:
|
||||
return 'Recorded offline';
|
||||
}
|
||||
}
|
||||
|
||||
toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
clearTimeout(this.toastTimer);
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What happens to an account once its periodic re-KYC falls due.
|
||||
*
|
||||
* RBI's Master Direction on KYC does not treat an overdue re-KYC as a reason to shut a customer
|
||||
* out. The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not
|
||||
* to be closed merely because periodic KYC is pending, and that restrictions come only after the
|
||||
* customer has been given notice and time to respond. So the ladder below escalates slowly, and
|
||||
* every stage before PARTIAL_FREEZE leaves the account fully operative.
|
||||
*
|
||||
* The day counts are the bank's own policy, not RBI's — the Direction requires "adequate notice"
|
||||
* without fixing a number — so they live in one place here and can be moved without touching the
|
||||
* logic that reads them.
|
||||
*/
|
||||
|
||||
/** Where an overdue customer currently sits on the restriction ladder. */
|
||||
export type KycExpiryStage =
|
||||
/** Due date approaching; nothing is restricted and notices are going out. */
|
||||
| 'DUE_SOON'
|
||||
/** Past the due date but inside the grace window. Still fully operative. */
|
||||
| 'GRACE'
|
||||
/** Debits stopped, credits still accepted. The first actual restriction. */
|
||||
| 'PARTIAL_FREEZE'
|
||||
/** Both directions restricted, barring the always-allowed credits. */
|
||||
| 'INOPERATIVE';
|
||||
|
||||
/** Policy day counts, measured forward from the KYC expiry date. */
|
||||
export const KYC_EXPIRY_POLICY = {
|
||||
/** Days past expiry before debits are stopped. */
|
||||
graceDays: 90,
|
||||
/** Days past expiry before the account is marked inoperative. */
|
||||
inoperativeDays: 180
|
||||
};
|
||||
|
||||
/**
|
||||
* Credits that must keep landing whatever the KYC position.
|
||||
*
|
||||
* Government benefit transfers are the one category RBI has specifically directed banks not to
|
||||
* hold up, and interest and the bank's own loan recoveries are the account working rather than
|
||||
* the customer transacting on it.
|
||||
*/
|
||||
export const ALWAYS_ALLOWED = [
|
||||
'DBT / government subsidy credit',
|
||||
'Interest credit',
|
||||
'Own loan EMI auto-debit',
|
||||
'Reversal of a bank-initiated entry'
|
||||
];
|
||||
|
||||
export interface KycExpiryStageMeta {
|
||||
stage: KycExpiryStage;
|
||||
label: string;
|
||||
/** Colour key the template maps to a badge class. */
|
||||
tone: 'info' | 'warn' | 'danger' | 'critical';
|
||||
/** What the customer can still do, in the officer's words. */
|
||||
allowed: string[];
|
||||
/** What has stopped. Empty while the account is still fully operative. */
|
||||
blocked: string[];
|
||||
/** One line telling the officer what this stage means. */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export const KYC_EXPIRY_STAGES: Record<KycExpiryStage, KycExpiryStageMeta> = {
|
||||
DUE_SOON: {
|
||||
stage: 'DUE_SOON',
|
||||
label: 'Re-KYC due soon',
|
||||
tone: 'info',
|
||||
allowed: [
|
||||
'All debits',
|
||||
'All credits',
|
||||
'Cheque book & channels'
|
||||
],
|
||||
blocked: [],
|
||||
summary: 'Nothing is restricted. Notices are going out so the customer can complete re-KYC in time.'
|
||||
},
|
||||
GRACE: {
|
||||
stage: 'GRACE',
|
||||
label: 'Overdue — grace period',
|
||||
tone: 'warn',
|
||||
allowed: [
|
||||
'All debits',
|
||||
'All credits',
|
||||
'Cheque book & channels'
|
||||
],
|
||||
blocked: [],
|
||||
summary: 'Past the due date but still fully operative. Restrictions only follow if the customer does not respond.'
|
||||
},
|
||||
PARTIAL_FREEZE: {
|
||||
stage: 'PARTIAL_FREEZE',
|
||||
label: 'Debit freeze',
|
||||
tone: 'danger',
|
||||
allowed: [
|
||||
'All credits',
|
||||
'DBT / subsidy',
|
||||
'Interest credit'
|
||||
],
|
||||
blocked: [
|
||||
'Cash withdrawal',
|
||||
'Transfers & NEFT / RTGS / UPI out',
|
||||
'Cheque issue & clearing debits',
|
||||
'New standing instructions'
|
||||
],
|
||||
summary: 'Debits are stopped; money can still come in. The customer must visit the branch to have this lifted.'
|
||||
},
|
||||
INOPERATIVE: {
|
||||
stage: 'INOPERATIVE',
|
||||
label: 'Inoperative',
|
||||
tone: 'critical',
|
||||
allowed: [
|
||||
'DBT / subsidy',
|
||||
'Interest credit'
|
||||
],
|
||||
blocked: [
|
||||
'All debits',
|
||||
'Ordinary credits',
|
||||
'Cheque book issue',
|
||||
'All digital channels',
|
||||
'New products & renewals'
|
||||
],
|
||||
summary: 'Both directions restricted. Only the credits RBI requires be let through are still accepted.'
|
||||
}
|
||||
};
|
||||
|
||||
/** A customer whose re-KYC is due or overdue, with the ladder worked out. */
|
||||
export interface KycExpiryRow {
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
/** 1 Low / 2 Medium / 3 High. */
|
||||
riskCategory: number;
|
||||
riskLabel: string;
|
||||
expiryDate: string;
|
||||
/** Negative while the due date is still ahead. */
|
||||
daysOverdue: number;
|
||||
stage: KycExpiryStage;
|
||||
meta: KycExpiryStageMeta;
|
||||
mobile: string;
|
||||
email: string;
|
||||
/** Outreach already recorded against this customer. */
|
||||
contacts: KycContactRow[];
|
||||
contactCount: number;
|
||||
lastContactDate: string;
|
||||
/** True once an officer has confirmed the customer was reached and the case can rest. */
|
||||
acknowledged: boolean;
|
||||
acknowledgedOn: string;
|
||||
acknowledgedBy: string;
|
||||
acknowledgedNote: string;
|
||||
/** The day the next restriction bites, so the officer can warn the customer precisely. */
|
||||
nextActionDate: string;
|
||||
nextActionLabel: string;
|
||||
}
|
||||
|
||||
/** One recorded attempt to reach the customer. */
|
||||
export interface KycContactRow {
|
||||
date: string;
|
||||
channel: string;
|
||||
channelLabel: string;
|
||||
notes: string;
|
||||
officer: string;
|
||||
/** SENT means it actually left the gateway; anything else means the customer was not reached. */
|
||||
deliveryStatus: string;
|
||||
delivered: boolean;
|
||||
destinationMasked: string;
|
||||
}
|
||||
|
||||
/** Channels an officer can record, matching the backend's KycContactChannel. */
|
||||
export const CONTACT_CHANNELS = [
|
||||
{ key: 'EMAIL', label: 'Email', icon: 'envelope', transmits: true },
|
||||
{ key: 'SMS', label: 'SMS', icon: 'comment-dots', transmits: true },
|
||||
{ key: 'WHATSAPP', label: 'WhatsApp', icon: 'comment-dots', transmits: true },
|
||||
{ key: 'PHONE_CALL', label: 'Phone Call', icon: 'phone', transmits: false },
|
||||
{ key: 'BRANCH_VISIT', label: 'Branch Visit', icon: 'building-columns', transmits: false },
|
||||
{ key: 'FIELD_VISIT', label: 'Field Visit', icon: 'user-check', transmits: false }
|
||||
];
|
||||
|
||||
export function channelLabel(key: string): string {
|
||||
return CONTACT_CHANNELS.find((c) => c.key === key)?.label ?? key;
|
||||
}
|
||||
|
||||
/** Days between two ISO dates, positive when `later` is after `earlier`. */
|
||||
function daysBetween(earlier: string, later: string): number {
|
||||
const a = new Date(`${earlier}T00:00:00Z`).getTime();
|
||||
const b = new Date(`${later}T00:00:00Z`).getTime();
|
||||
return Math.round((b - a) / 86400000);
|
||||
}
|
||||
|
||||
function addDays(date: string, days: number): string {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rung of the ladder a customer is on, from how far past the due date they are.
|
||||
*
|
||||
* An acknowledged case is deliberately not exempt: acknowledging records that the customer was
|
||||
* reached, which is not the same as the re-KYC being done, and letting it hold off a freeze would
|
||||
* turn a contact note into a way of parking an overdue account indefinitely.
|
||||
*/
|
||||
export function expiryStage(daysOverdue: number): KycExpiryStage {
|
||||
if (daysOverdue >= KYC_EXPIRY_POLICY.inoperativeDays) {
|
||||
return 'INOPERATIVE';
|
||||
}
|
||||
if (daysOverdue >= KYC_EXPIRY_POLICY.graceDays) {
|
||||
return 'PARTIAL_FREEZE';
|
||||
}
|
||||
if (daysOverdue >= 0) {
|
||||
return 'GRACE';
|
||||
}
|
||||
return 'DUE_SOON';
|
||||
}
|
||||
|
||||
/** The next restriction and the day it lands, worded for the customer. */
|
||||
export function nextAction(expiryDate: string, stage: KycExpiryStage): { date: string; label: string } {
|
||||
switch (stage) {
|
||||
case 'DUE_SOON':
|
||||
return { date: expiryDate, label: 'Re-KYC falls due' };
|
||||
case 'GRACE':
|
||||
return {
|
||||
date: addDays(expiryDate, KYC_EXPIRY_POLICY.graceDays),
|
||||
label: 'Debit freeze applies'
|
||||
};
|
||||
case 'PARTIAL_FREEZE':
|
||||
return {
|
||||
date: addDays(expiryDate, KYC_EXPIRY_POLICY.inoperativeDays),
|
||||
label: 'Account marked inoperative'
|
||||
};
|
||||
default:
|
||||
// Nothing further is applied automatically — closure is a manual decision with its own notice.
|
||||
return { date: '', label: 'No further automatic restriction' };
|
||||
}
|
||||
}
|
||||
|
||||
export const RISK_LABELS: Record<number, string> = { 1: 'Low Risk', 2: 'Medium Risk', 3: 'High Risk' };
|
||||
|
||||
/** Builds one worklist row from a KYC record and its contact history. */
|
||||
export function buildExpiryRow(
|
||||
kyc: {
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
accountNo?: string;
|
||||
branchCode?: string;
|
||||
riskCategory?: number;
|
||||
expiryDate: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
acknowledged?: boolean;
|
||||
acknowledgedOn?: string;
|
||||
acknowledgedBy?: string;
|
||||
acknowledgedNote?: string;
|
||||
},
|
||||
contacts: KycContactRow[],
|
||||
today: string
|
||||
): KycExpiryRow {
|
||||
const daysOverdue = daysBetween(kyc.expiryDate, today);
|
||||
const stage = expiryStage(daysOverdue);
|
||||
const next = nextAction(kyc.expiryDate, stage);
|
||||
const risk = kyc.riskCategory ?? 1;
|
||||
const sorted = [...contacts].sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
return {
|
||||
clientId: kyc.clientId,
|
||||
clientName: kyc.clientName,
|
||||
accountNo: kyc.accountNo || '',
|
||||
branchCode: kyc.branchCode || '',
|
||||
riskCategory: risk,
|
||||
riskLabel: RISK_LABELS[risk] ?? 'Low Risk',
|
||||
expiryDate: kyc.expiryDate,
|
||||
daysOverdue,
|
||||
stage,
|
||||
meta: KYC_EXPIRY_STAGES[stage],
|
||||
mobile: kyc.mobile || '',
|
||||
email: kyc.email || '',
|
||||
contacts: sorted,
|
||||
contactCount: sorted.length,
|
||||
lastContactDate: sorted.length ? sorted[0].date : '',
|
||||
acknowledged: !!kyc.acknowledged,
|
||||
acknowledgedOn: kyc.acknowledgedOn || '',
|
||||
acknowledgedBy: kyc.acknowledgedBy || '',
|
||||
acknowledgedNote: kyc.acknowledgedNote || '',
|
||||
nextActionDate: next.date,
|
||||
nextActionLabel: next.label
|
||||
};
|
||||
}
|
||||
|
||||
export interface KycExpirySummary {
|
||||
total: number;
|
||||
grace: number;
|
||||
partialFreeze: number;
|
||||
inoperative: number;
|
||||
/** Lapsed customers nobody has reached out to yet — the real backlog. */
|
||||
notContacted: number;
|
||||
acknowledged: number;
|
||||
}
|
||||
|
||||
export function expirySummary(rows: KycExpiryRow[]): KycExpirySummary {
|
||||
return {
|
||||
total: rows.length,
|
||||
grace: rows.filter((r) => r.stage === 'GRACE').length,
|
||||
partialFreeze: rows.filter((r) => r.stage === 'PARTIAL_FREEZE').length,
|
||||
inoperative: rows.filter((r) => r.stage === 'INOPERATIVE').length,
|
||||
notContacted: rows.filter((r) => r.contactCount === 0).length,
|
||||
acknowledged: rows.filter((r) => r.acknowledged).length
|
||||
};
|
||||
}
|
||||
|
||||
/** Sorts the worklist so the most restricted, least contacted cases come first. */
|
||||
export function sortWorklist(rows: KycExpiryRow[]): KycExpiryRow[] {
|
||||
const rank: Record<KycExpiryStage, number> = {
|
||||
INOPERATIVE: 0,
|
||||
PARTIAL_FREEZE: 1,
|
||||
GRACE: 2,
|
||||
DUE_SOON: 3
|
||||
};
|
||||
return [...rows].sort(
|
||||
(a, b) =>
|
||||
rank[a.stage] - rank[b.stage] ||
|
||||
Number(a.acknowledged) - Number(b.acknowledged) ||
|
||||
a.contactCount - b.contactCount ||
|
||||
b.daysOverdue - a.daysOverdue
|
||||
);
|
||||
}
|
||||
+8
-6
@@ -8,12 +8,12 @@
|
||||
|
||||
@if (loading) {
|
||||
<div class="cert-empty">
|
||||
<fa-icon icon="spinner" [spin]="true" size="2x" style="color: #7c3aed; margin-bottom: 12px;"></fa-icon>
|
||||
<fa-icon icon="spinner" [spin]="true" size="2x" style="color: #7c3aed; margin-bottom: 12px"></fa-icon>
|
||||
<div>Loading live declarations and accounts from Fineract…</div>
|
||||
</div>
|
||||
} @else if (loadError) {
|
||||
<div class="cert-empty" style="color: #ef4444;">
|
||||
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px;"></fa-icon>
|
||||
<div class="cert-empty" style="color: #ef4444">
|
||||
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px"></fa-icon>
|
||||
<div>Could not load real data from Fineract. Check connection.</div>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -139,7 +139,9 @@
|
||||
<tr [class.cert-row--warn]="!row.row.eligibility.eligible">
|
||||
<td>
|
||||
<div class="cert-cust-name">{{ row.row.record.customerName }}</div>
|
||||
<div class="mono small muted">{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}</div>
|
||||
<div class="mono small muted">
|
||||
{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}
|
||||
</div>
|
||||
<div class="small muted">DOB {{ row.row.record.dob | date: 'd MMM y' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -306,8 +308,8 @@
|
||||
|
||||
@if (row.row.schedule.shieldedInterest > 0) {
|
||||
<div class="small muted">
|
||||
₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||
₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength
|
||||
of the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
|
||||
+13
-4
@@ -64,7 +64,11 @@ export interface RealForm15ghRow {
|
||||
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||
function toIso(value: unknown): string | null {
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [y, m, d] = value as number[];
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value as number[];
|
||||
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
@@ -118,7 +122,10 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
|
||||
private clientKycCache = new Map<string, Observable<any>>();
|
||||
private clientAccountsCache = new Map<string, Observable<any>>();
|
||||
|
||||
readonly availableFys = ['FY 2026-27', 'FY 2025-26'];
|
||||
readonly availableFys = [
|
||||
'FY 2026-27',
|
||||
'FY 2025-26'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRealData();
|
||||
@@ -311,9 +318,11 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
|
||||
depositPrincipal,
|
||||
fy: this.selectedFy,
|
||||
status,
|
||||
filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined,
|
||||
filedOn: hasDeclaration ? (toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01`) : undefined,
|
||||
assessed: true,
|
||||
interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly'
|
||||
interestPostingPeriodType:
|
||||
detailsList.find((d) => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code ||
|
||||
'savingsPostingInterestPeriodType.quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,11 @@ export function uinTaxYearPart(fy: string): string {
|
||||
*/
|
||||
export function buildUin(sequence: number, fy: string, tan: string): string {
|
||||
const digits = `${Math.max(0, Math.floor(sequence))}`.slice(-9).padStart(9, '0');
|
||||
const normalisedTan = (tan || '').toUpperCase().replace(/[^A-Z0-9]/g, '').padEnd(10, 'X').slice(0, 10);
|
||||
const normalisedTan = (tan || '')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, '')
|
||||
.padEnd(10, 'X')
|
||||
.slice(0, 10);
|
||||
return `D${digits}${uinTaxYearPart(fy)}${normalisedTan}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
<fa-icon icon="book" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Membership Register</h2>
|
||||
<div class="cert-head-s">
|
||||
Statutory register of members — one line per member, in order of admission.
|
||||
</div>
|
||||
<div class="cert-head-s">Statutory register of members — one line per member, in order of admission.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
@@ -98,9 +96,7 @@
|
||||
<input type="date" [(ngModel)]="toDate" />
|
||||
</div>
|
||||
|
||||
<button class="cert-clear" (click)="clearFilters()">
|
||||
<fa-icon icon="sync"></fa-icon> Clear
|
||||
</button>
|
||||
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Register -->
|
||||
|
||||
@@ -109,9 +109,7 @@
|
||||
<input type="date" [(ngModel)]="toDate" />
|
||||
</div>
|
||||
|
||||
<button class="cert-clear" (click)="clearFilters()">
|
||||
<fa-icon icon="sync"></fa-icon> Clear
|
||||
</button>
|
||||
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Report -->
|
||||
@@ -191,7 +189,9 @@
|
||||
<span class="cert-pill" [class]="'sar-pill--' + row.status.toLowerCase()">{{ row.statusLabel }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="cert-pill" [class]="'sar-pay--' + row.status.toLowerCase()">{{ row.paymentStatus }}</span>
|
||||
<span class="cert-pill" [class]="'sar-pay--' + row.status.toLowerCase()">{{
|
||||
row.paymentStatus
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="cert-row-actions">
|
||||
<fa-icon icon="chevron-right" class="sar-chevron"></fa-icon>
|
||||
|
||||
@@ -105,9 +105,7 @@ export class ShareAllotmentReportComponent implements OnInit {
|
||||
if (!items.length) {
|
||||
return of([] as AllotmentRow[]);
|
||||
}
|
||||
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(
|
||||
map((perAccount) => perAccount.flat())
|
||||
);
|
||||
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(map((perAccount) => perAccount.flat()));
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
|
||||
@@ -100,9 +100,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="cert-clear" (click)="clearFilters()">
|
||||
<fa-icon icon="sync"></fa-icon> Clear
|
||||
</button>
|
||||
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Register -->
|
||||
|
||||
+5
-1
@@ -26,6 +26,7 @@ import {
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
|
||||
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
@@ -55,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class CurrentProductChargesStepComponent implements OnInit {
|
||||
dialog = inject(MatDialog);
|
||||
private translateService = inject(TranslateService);
|
||||
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||
|
||||
@Input() CurrentProductsTemplate: any;
|
||||
@Input() currencyCode: UntypedFormControl;
|
||||
@@ -73,7 +75,9 @@ export class CurrentProductChargesStepComponent implements OnInit {
|
||||
pristine = true;
|
||||
|
||||
ngOnInit() {
|
||||
this.chargeData = this.CurrentProductsTemplate.chargeOptions;
|
||||
this.facilityChargeFilter
|
||||
.excludeFacilityCharges(this.CurrentProductsTemplate.chargeOptions)
|
||||
.subscribe((charges: any[]) => (this.chargeData = charges));
|
||||
|
||||
this.chargesDataSource = this.CurrentProductsTemplate.charges || [];
|
||||
this.pristine = true;
|
||||
|
||||
-15
@@ -174,21 +174,6 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="spl-feature">
|
||||
<span class="spl-feature__label">
|
||||
<i class="fas fa-arrow-right-arrow-left"></i>
|
||||
Withdrawal Facility
|
||||
</span>
|
||||
<select
|
||||
class="spl-select"
|
||||
[(ngModel)]="accountFacilityConfig.withdrawalFacility"
|
||||
(ngModelChange)="markAsDirty()"
|
||||
>
|
||||
<option [ngValue]="false">No</option>
|
||||
<option [ngValue]="true">Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="spl-feature">
|
||||
<span class="spl-feature__label">
|
||||
<i class="fas fa-id-card"></i>
|
||||
|
||||
-2
@@ -24,7 +24,6 @@ type BooleanFeatureKey =
|
||||
| 'neftFacility'
|
||||
| 'rtgsFacility'
|
||||
| 'interBankTotalCapFacility'
|
||||
| 'withdrawalFacility'
|
||||
| 'depositFacilityKyc'
|
||||
| 'virtualDebitCard';
|
||||
|
||||
@@ -76,7 +75,6 @@ export class CurrentProductLimitsStepComponent implements OnChanges {
|
||||
neftFacility: true,
|
||||
rtgsFacility: true,
|
||||
interBankTotalCapFacility: true,
|
||||
withdrawalFacility: true,
|
||||
depositFacilityKyc: true,
|
||||
virtualDebitCard: false
|
||||
};
|
||||
|
||||
-1
@@ -94,7 +94,6 @@ export class CurrentProductPreviewStepComponent implements OnInit, OnChanges {
|
||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
||||
];
|
||||
|
||||
+13
-6
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-current-product-settings-step',
|
||||
@@ -62,9 +63,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
|
||||
? (this.CurrentProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
|
||||
: false,
|
||||
isDormancyTrackingActive: isEditing ? (this.CurrentProductsTemplate.isDormancyTrackingActive ?? true) : true,
|
||||
daysToInactive: isEditing ? this.CurrentProductsTemplate.daysToInactive || 30 : 30,
|
||||
daysToDormancy: isEditing ? this.CurrentProductsTemplate.daysToDormancy || 60 : 60,
|
||||
daysToEscheat: isEditing ? this.CurrentProductsTemplate.daysToEscheat || 90 : 90,
|
||||
daysToInactive: isEditing
|
||||
? this.CurrentProductsTemplate.daysToInactive || DORMANCY_DEFAULTS.daysToInactive
|
||||
: DORMANCY_DEFAULTS.daysToInactive,
|
||||
daysToDormancy: isEditing
|
||||
? this.CurrentProductsTemplate.daysToDormancy || DORMANCY_DEFAULTS.daysToDormancy
|
||||
: DORMANCY_DEFAULTS.daysToDormancy,
|
||||
daysToEscheat: isEditing
|
||||
? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat
|
||||
: DORMANCY_DEFAULTS.daysToEscheat,
|
||||
// Overdraft fields
|
||||
allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false,
|
||||
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
|
||||
@@ -113,9 +120,9 @@ export class CurrentProductSettingsStepComponent implements OnInit {
|
||||
accountClosedZeroBalanceInDayClosing: [false],
|
||||
// Dormancy (used internally / by accounting step)
|
||||
isDormancyTrackingActive: [true],
|
||||
daysToInactive: [30],
|
||||
daysToDormancy: [60],
|
||||
daysToEscheat: [90],
|
||||
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
|
||||
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
|
||||
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
|
||||
// Overdraft (OD) Facility
|
||||
allowOverdraft: [false],
|
||||
overdraftLimit: [
|
||||
|
||||
-1
@@ -89,7 +89,6 @@ export class CurrentProductGeneralTabComponent {
|
||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility KYC' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default dormancy thresholds for a new product, in days from the last customer transaction.
|
||||
*
|
||||
* RBI's inoperative-accounts rules count the silence from the last *customer-induced* transaction —
|
||||
* a deposit or withdrawal the customer made, not interest the bank posts — and set the marks at a
|
||||
* year, two years and ten:
|
||||
*
|
||||
* 365 → inactive (an internal flag; nothing is restricted)
|
||||
* 730 → inoperative (two years; debits stop, reactivation needs the branch)
|
||||
* 3650 → escheat (ten years; the balance is due to RBI's DEAF)
|
||||
*
|
||||
* These are the values every product stepper starts a new product on, and the backend seeds the
|
||||
* same numbers (see the 0521 and 0524 changelogs). They live here rather than being repeated in
|
||||
* each stepper so a bank that revises its policy changes one file, not four.
|
||||
*
|
||||
* A product can still be configured away from these — they are defaults, not a ceiling — but a
|
||||
* materially shorter period is worth questioning: it would make an ordinary account, dormant for a
|
||||
* few weeks, show as overdue for inactivity.
|
||||
*/
|
||||
export const DORMANCY_DEFAULTS = {
|
||||
daysToInactive: 365,
|
||||
daysToDormancy: 730,
|
||||
daysToEscheat: 3650
|
||||
};
|
||||
+7
-6
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-fixed-deposit-product-currency-step',
|
||||
@@ -54,9 +55,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
|
||||
2,
|
||||
dormancyAlert: this.fixedDepositProductsTemplate.dormancyAlert ?? false,
|
||||
isDormancyTrackingActive: this.fixedDepositProductsTemplate.isDormancyTrackingActive ?? false,
|
||||
daysToInactive: this.fixedDepositProductsTemplate.daysToInactive ?? 30,
|
||||
daysToDormancy: this.fixedDepositProductsTemplate.daysToDormancy ?? 60,
|
||||
daysToEscheat: this.fixedDepositProductsTemplate.daysToEscheat ?? 90,
|
||||
daysToInactive: this.fixedDepositProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
|
||||
daysToDormancy: this.fixedDepositProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
|
||||
daysToEscheat: this.fixedDepositProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat,
|
||||
nominationFacility: this.fixedDepositProductsTemplate.nominationFacility ?? false,
|
||||
autoDebitFacility: this.fixedDepositProductsTemplate.autoDebitFacility ?? false
|
||||
});
|
||||
@@ -77,9 +78,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
|
||||
],
|
||||
dormancyAlert: [false],
|
||||
isDormancyTrackingActive: [false],
|
||||
daysToInactive: [30],
|
||||
daysToDormancy: [60],
|
||||
daysToEscheat: [90],
|
||||
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
|
||||
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
|
||||
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
|
||||
nominationFacility: [false],
|
||||
autoDebitFacility: [false]
|
||||
});
|
||||
|
||||
@@ -74,6 +74,17 @@ export class ProductsService {
|
||||
return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a savings product.
|
||||
*
|
||||
* The server refuses this once any account has been opened on the product — a product with a
|
||||
* history behind it cannot be removed without taking those accounts' terms with it — so this is
|
||||
* really only usable on one created by mistake or for a trial.
|
||||
*/
|
||||
deleteSavingProduct(savingProductId: string): Observable<any> {
|
||||
return this.http.delete(`/savingsproducts/${savingProductId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submitting a savings product update (above) puts it in the pending-approval temp table instead of
|
||||
* applying it directly. These calls drive the Approvals -> Pending Savings Product Edits screen.
|
||||
|
||||
+7
-6
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule }
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-recurring-deposit-product-currency-step',
|
||||
@@ -54,9 +55,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
|
||||
2,
|
||||
dormancyAlert: this.recurringDepositProductsTemplate.dormancyAlert ?? false,
|
||||
isDormancyTrackingActive: this.recurringDepositProductsTemplate.isDormancyTrackingActive ?? false,
|
||||
daysToInactive: this.recurringDepositProductsTemplate.daysToInactive ?? 30,
|
||||
daysToDormancy: this.recurringDepositProductsTemplate.daysToDormancy ?? 60,
|
||||
daysToEscheat: this.recurringDepositProductsTemplate.daysToEscheat ?? 90,
|
||||
daysToInactive: this.recurringDepositProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
|
||||
daysToDormancy: this.recurringDepositProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
|
||||
daysToEscheat: this.recurringDepositProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat,
|
||||
nominationFacility: this.recurringDepositProductsTemplate.nominationFacility ?? false,
|
||||
autoDebitFacility: this.recurringDepositProductsTemplate.autoDebitFacility ?? false
|
||||
});
|
||||
@@ -77,9 +78,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
|
||||
],
|
||||
dormancyAlert: [false],
|
||||
isDormancyTrackingActive: [false],
|
||||
daysToInactive: [30],
|
||||
daysToDormancy: [60],
|
||||
daysToEscheat: [90],
|
||||
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
|
||||
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
|
||||
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
|
||||
nominationFacility: [false],
|
||||
autoDebitFacility: [false]
|
||||
});
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@
|
||||
<mifosx-saving-product-accounting-step
|
||||
[savingProductsTemplate]="savingProductsTemplate"
|
||||
[accountingRuleData]="accountingRuleData"
|
||||
[isDormancyTrackingActive]="savingProductSettingsForm.get('isDormancyTrackingActive')"
|
||||
[isDormancyTrackingActive]="savingProductCurrencyForm.get('dormancyAlert')"
|
||||
[savingProductFormValid]="savingProductFormValid"
|
||||
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
|
||||
></mifosx-saving-product-accounting-step>
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@
|
||||
<mifosx-saving-product-accounting-step
|
||||
[savingProductsTemplate]="savingProductAndTemplate"
|
||||
[accountingRuleData]="accountingRuleData"
|
||||
[isDormancyTrackingActive]="savingProductSettingsForm.get('isDormancyTrackingActive')"
|
||||
[isDormancyTrackingActive]="savingProductCurrencyForm.get('dormancyAlert')"
|
||||
[savingProductFormValid]="savingProductFormValidAndNotPristine"
|
||||
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
|
||||
></mifosx-saving-product-accounting-step>
|
||||
|
||||
+92
-39
@@ -24,6 +24,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
|
||||
|
||||
/** Reserved otherGlMappings account names backing the two dedicated savings-interest GL rows.
|
||||
* They round trip through otherGlMappings because the backend persists only the core mappings
|
||||
* under CASH_BASED accounting, which is the only rule this screen offers. */
|
||||
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
|
||||
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
|
||||
|
||||
type AccountingRowConfig = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -180,13 +186,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
this.savingProductAccountingForm.patchValue({
|
||||
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
|
||||
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
|
||||
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
|
||||
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
|
||||
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
|
||||
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
|
||||
incomeFromInterestId: mappings.incomeFromInterest?.id || '',
|
||||
overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '',
|
||||
writeOffAccountId: mappings.writeOffAccount?.id || '',
|
||||
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
|
||||
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
|
||||
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
|
||||
@@ -200,10 +202,24 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
|
||||
this.setOtherGlMappings(
|
||||
'liabilitiesOtherGlMappings',
|
||||
glMappings?.liabilities || [],
|
||||
this.extractNamedGlMapping(
|
||||
glMappings?.liabilities || [],
|
||||
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
|
||||
'savingsInterestPayableAccountId',
|
||||
this.liabilityAccountOptions
|
||||
),
|
||||
this.liabilityAccountOptions
|
||||
);
|
||||
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
|
||||
this.setOtherGlMappings(
|
||||
'expensesOtherGlMappings',
|
||||
this.extractNamedGlMapping(
|
||||
glMappings?.expenses || [],
|
||||
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
|
||||
'savingsInterestPaidAccountId',
|
||||
this.expenseAccountOptions
|
||||
),
|
||||
this.expenseAccountOptions
|
||||
);
|
||||
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
|
||||
}
|
||||
}
|
||||
@@ -225,6 +241,22 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
return this.savingProductAccountingForm.get(controlName) as UntypedFormArray;
|
||||
}
|
||||
|
||||
/** Pulls a reserved entry out of a saved otherGlMappings list into its own dedicated control and
|
||||
* returns the remaining entries, so it renders as a named row rather than a free-form "Other" row. */
|
||||
private extractNamedGlMapping(
|
||||
otherGlMappings: any[],
|
||||
accountName: string,
|
||||
controlName: string,
|
||||
options: GLAccount[]
|
||||
): any[] {
|
||||
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
|
||||
if (match) {
|
||||
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
|
||||
this.savingProductAccountingForm.get(controlName)?.patchValue(glAccountId);
|
||||
}
|
||||
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
|
||||
}
|
||||
|
||||
private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) {
|
||||
const otherGlMappingsArray = this.getOtherGlMappingsArray(controlName);
|
||||
otherGlMappingsArray.clear();
|
||||
@@ -282,10 +314,35 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
}))
|
||||
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
|
||||
|
||||
/** The two named-but-not-backend-persisted rows are emitted as reserved otherGlMappings entries so
|
||||
* they survive a save/reload round trip under Cash accounting. */
|
||||
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
|
||||
const glAccountId = this.savingProductAccountingForm.get(controlName)?.value;
|
||||
if (!glAccountId) {
|
||||
return [];
|
||||
}
|
||||
const glCode = options.find((option) => option.id === glAccountId)?.glCode || '';
|
||||
return glCode ? [{ accountName, glCode }] : [];
|
||||
};
|
||||
|
||||
const otherGlMappings = {
|
||||
assets: mapOtherGlMappings('assetsOtherGlMappings', this.assetAccountOptions),
|
||||
liabilities: mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
|
||||
expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions),
|
||||
liabilities: [
|
||||
...namedAsOtherGlMapping(
|
||||
'savingsInterestPayableAccountId',
|
||||
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
|
||||
this.liabilityAccountOptions
|
||||
),
|
||||
...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
|
||||
],
|
||||
expenses: [
|
||||
...namedAsOtherGlMapping(
|
||||
'savingsInterestPaidAccountId',
|
||||
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
|
||||
this.expenseAccountOptions
|
||||
),
|
||||
...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions)
|
||||
],
|
||||
income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions)
|
||||
};
|
||||
|
||||
@@ -320,13 +377,21 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
|
||||
addControl('savingsReferenceAccountId', true);
|
||||
addControl('savingsControlAccountId', true);
|
||||
addControl('transfersInSuspenseAccountId', true);
|
||||
addControl('interestOnSavingsAccountId', true);
|
||||
addControl('incomeFromFeeAccountId', true);
|
||||
addControl('incomeFromPenaltyAccountId', true);
|
||||
addControl('incomeFromInterestId', true);
|
||||
addControl('writeOffAccountId', true);
|
||||
addControl('overdraftPortfolioControlId', false);
|
||||
// Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the
|
||||
// core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings)
|
||||
// rather than as named accounting params, which would be silently dropped. Optional by design.
|
||||
addControl('savingsInterestPayableAccountId', false);
|
||||
addControl('savingsInterestPaidAccountId', false);
|
||||
// Transfers In Suspense / Income From Interest / Write Off / Overdraft Portfolio Control are
|
||||
// intentionally not offered on this screen, so their controls are never added — leaving them in
|
||||
// with required validators would keep the form invalid with no visible field to fix.
|
||||
removeControl('transfersInSuspenseAccountId');
|
||||
removeControl('incomeFromInterestId');
|
||||
removeControl('writeOffAccountId');
|
||||
removeControl('overdraftPortfolioControlId');
|
||||
|
||||
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
|
||||
addControl('feesReceivableAccountId', true);
|
||||
@@ -360,6 +425,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
removeControl('interestPayableAccountId');
|
||||
removeControl('interestReceivableAccountId');
|
||||
removeControl('escheatLiabilityId');
|
||||
removeControl('savingsInterestPayableAccountId');
|
||||
removeControl('savingsInterestPaidAccountId');
|
||||
this.clearOtherGlMappings();
|
||||
}
|
||||
}
|
||||
@@ -370,6 +437,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
liabilitiesOtherGlMappings,
|
||||
expensesOtherGlMappings,
|
||||
incomeOtherGlMappings,
|
||||
// Carried inside otherGlMappings instead — the backend rejects unknown top-level accounting params.
|
||||
savingsInterestPayableAccountId,
|
||||
savingsInterestPaidAccountId,
|
||||
...savingProductAccounting
|
||||
} = this.savingProductAccountingForm.value;
|
||||
const otherGlMappings = this.getOtherGlMappings();
|
||||
@@ -404,13 +474,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
options: this.assetAccountOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'overdraftPortfolioControlId',
|
||||
label: 'Overdraft Portfolio Control',
|
||||
inputLabel: 'Overdraft portfolio control',
|
||||
controlName: 'overdraftPortfolioControlId',
|
||||
options: this.assetAccountOptions
|
||||
},
|
||||
{
|
||||
key: 'interestReceivableAccountId',
|
||||
label: 'Interest Receivable Account',
|
||||
@@ -454,12 +517,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'transfersInSuspenseAccountId',
|
||||
label: 'Transfers In Suspense Account',
|
||||
inputLabel: 'Transfers in suspense account',
|
||||
controlName: 'transfersInSuspenseAccountId',
|
||||
options: this.liabilityAccountOptions,
|
||||
required: true
|
||||
key: 'savingsInterestPayableAccountId',
|
||||
label: 'Savings Interest Payable',
|
||||
inputLabel: 'Savings interest payable',
|
||||
controlName: 'savingsInterestPayableAccountId',
|
||||
options: this.liabilityAccountOptions
|
||||
},
|
||||
{
|
||||
key: 'interestPayableAccountId',
|
||||
@@ -496,12 +558,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'writeOffAccountId',
|
||||
label: 'Write Off Account',
|
||||
inputLabel: 'Write off account',
|
||||
controlName: 'writeOffAccountId',
|
||||
options: this.expenseAccountOptions,
|
||||
required: true
|
||||
key: 'savingsInterestPaidAccountId',
|
||||
label: 'Savings Interest Paid',
|
||||
inputLabel: 'Savings interest paid',
|
||||
controlName: 'savingsInterestPaidAccountId',
|
||||
options: this.expenseAccountOptions
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'expensesOtherGlMappings',
|
||||
@@ -528,14 +589,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
controlName: 'incomeFromPenaltyAccountId',
|
||||
options: this.incomeAccountOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'incomeFromInterestId',
|
||||
label: 'Income From Interest',
|
||||
inputLabel: 'Income from interest',
|
||||
controlName: 'incomeFromInterestId',
|
||||
options: this.incomeAccountOptions,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'incomeOtherGlMappings',
|
||||
|
||||
+9
-1
@@ -26,6 +26,7 @@ import {
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
|
||||
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
@@ -55,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class SavingProductChargesStepComponent implements OnInit {
|
||||
dialog = inject(MatDialog);
|
||||
private translateService = inject(TranslateService);
|
||||
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||
|
||||
@Input() savingProductsTemplate: any;
|
||||
@Input() currencyCode: UntypedFormControl;
|
||||
@@ -73,8 +75,14 @@ export class SavingProductChargesStepComponent implements OnInit {
|
||||
pristine = true;
|
||||
|
||||
ngOnInit() {
|
||||
this.chargeData = this.savingProductsTemplate.chargeOptions;
|
||||
// Facility-priced charges (Cheque Book, ATM, UPI, ...) are levied by the facility mapping itself,
|
||||
// so they must not be offered here as well — see FacilityChargeFilterService.
|
||||
this.facilityChargeFilter
|
||||
.excludeFacilityCharges(this.savingProductsTemplate.chargeOptions)
|
||||
.subscribe((charges: any[]) => (this.chargeData = charges));
|
||||
|
||||
// Already-attached charges are shown as-is: filtering them here would silently drop them
|
||||
// from the payload on save.
|
||||
this.chargesDataSource = this.savingProductsTemplate.charges || [];
|
||||
this.pristine = true;
|
||||
|
||||
|
||||
+91
@@ -63,6 +63,97 @@
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The periods that will apply to every account opened on this product, shown as soon as
|
||||
tracking is switched on. Read-only until asked for: these are the RBI periods, and a product
|
||||
should get them unless somebody deliberately departs from them.
|
||||
-->
|
||||
@if (dormancyTrackingOn) {
|
||||
<div class="spc-dorm">
|
||||
<div class="spc-dorm__head">
|
||||
<div>
|
||||
<h4>Dormancy Periods</h4>
|
||||
<p>
|
||||
Counted from the last customer transaction — a deposit or withdrawal the customer makes. Interest the
|
||||
bank posts does not reset the clock.
|
||||
</p>
|
||||
</div>
|
||||
<div class="spc-dorm__actions">
|
||||
@if (editingDormancyDays) {
|
||||
<button type="button" class="spc-dorm__btn" (click)="resetDormancyDays()">
|
||||
<fa-icon icon="undo"></fa-icon> RBI defaults
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="spc-dorm__btn" (click)="toggleDormancyDaysEdit()">
|
||||
<fa-icon [icon]="editingDormancyDays ? 'times' : 'edit'"></fa-icon>
|
||||
{{ editingDormancyDays ? 'Cancel' : 'Edit' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spc-dorm__grid">
|
||||
<div class="spc-dorm__item">
|
||||
<span class="spc-dorm__label">Inactive after</span>
|
||||
@if (editingDormancyDays) {
|
||||
<mat-form-field appearance="outline" class="spc-dorm__field">
|
||||
<input matInput type="number" min="1" formControlName="daysToInactive" />
|
||||
<span matSuffix>days</span>
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<span class="spc-dorm__value">
|
||||
{{ savingProductCurrencyForm.value.daysToInactive }} <small>days</small>
|
||||
</span>
|
||||
}
|
||||
<span class="spc-dorm__note">Flagged internally. Nothing is restricted.</span>
|
||||
</div>
|
||||
|
||||
<div class="spc-dorm__item">
|
||||
<span class="spc-dorm__label">Dormant after</span>
|
||||
@if (editingDormancyDays) {
|
||||
<mat-form-field appearance="outline" class="spc-dorm__field">
|
||||
<input matInput type="number" min="1" formControlName="daysToDormancy" />
|
||||
<span matSuffix>days</span>
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<span class="spc-dorm__value">
|
||||
{{ savingProductCurrencyForm.value.daysToDormancy }} <small>days</small>
|
||||
</span>
|
||||
}
|
||||
<span class="spc-dorm__note">Debits stop. Credits still land.</span>
|
||||
</div>
|
||||
|
||||
<div class="spc-dorm__item">
|
||||
<span class="spc-dorm__label">Escheat after</span>
|
||||
@if (editingDormancyDays) {
|
||||
<mat-form-field appearance="outline" class="spc-dorm__field">
|
||||
<input matInput type="number" min="1" formControlName="daysToEscheat" />
|
||||
<span matSuffix>days</span>
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<span class="spc-dorm__value">
|
||||
{{ savingProductCurrencyForm.value.daysToEscheat }} <small>days</small>
|
||||
</span>
|
||||
}
|
||||
<span class="spc-dorm__note">Balance becomes due to RBI's DEAF.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (dormancyDaysOutOfOrder) {
|
||||
<div class="spc-dorm__error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
The periods have to run in order: dormant later than inactive, escheat later than dormant. An account
|
||||
cannot reach a later stage before an earlier one.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="spc-dorm__foot">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
Accounts opened on this product inherit these periods. An account that receives government benefit credits
|
||||
can be exempted individually, and is then never restricted for inactivity.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="spc-alert">
|
||||
<div class="spc-alert__content">
|
||||
<h4>Nomination Facility</h4>
|
||||
|
||||
+130
@@ -112,3 +112,133 @@ $gray-900: #202124;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dormancy periods, shown once tracking is switched on ─────────────────── */
|
||||
|
||||
.spc-dorm {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 1.15rem;
|
||||
border: 1px solid rgb(0 0 0 / 12%);
|
||||
border-left: 3px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
background: rgb(21 101 192 / 3%);
|
||||
}
|
||||
|
||||
.spc-dorm__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.7;
|
||||
max-width: 34rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.spc-dorm__actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spc-dorm__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid rgb(0 0 0 / 20%);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #1565c0;
|
||||
color: #1565c0;
|
||||
}
|
||||
}
|
||||
|
||||
.spc-dorm__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.spc-dorm__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.spc-dorm__label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.spc-dorm__value {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.spc-dorm__field {
|
||||
width: 100%;
|
||||
|
||||
/* The stepper's fields are roomier than this panel needs. */
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spc-dorm__note {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Periods that would have an account reach a later stage before an earlier one. */
|
||||
.spc-dorm__error {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
background: rgb(192 57 43 / 9%);
|
||||
color: #c0392b;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.spc-dorm__foot {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
+83
-2
@@ -12,6 +12,7 @@ import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-saving-product-currency-step',
|
||||
@@ -50,10 +51,68 @@ export class SavingProductCurrencyStepComponent implements OnInit {
|
||||
currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
|
||||
digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? 2,
|
||||
dormancyAlert: this.savingProductsTemplate.isDormancyTrackingActive ?? false,
|
||||
nominationFacility: this.savingProductsTemplate.nominationFacility ?? false
|
||||
nominationFacility: this.savingProductsTemplate.nominationFacility ?? false,
|
||||
daysToInactive: this.savingProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
|
||||
daysToDormancy: this.savingProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
|
||||
daysToEscheat: this.savingProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the periods are open for editing.
|
||||
*
|
||||
* They start read-only and showing the RBI defaults. A bank that wants its own numbers has to ask
|
||||
* for them, which keeps the regulatory periods as what a product gets unless somebody deliberately
|
||||
* departs from them.
|
||||
*/
|
||||
editingDormancyDays = false;
|
||||
|
||||
get dormancyTrackingOn(): boolean {
|
||||
return !!this.savingProductCurrencyForm.get('dormancyAlert')?.value;
|
||||
}
|
||||
|
||||
toggleDormancyDaysEdit(): void {
|
||||
this.editingDormancyDays = !this.editingDormancyDays;
|
||||
if (!this.editingDormancyDays) {
|
||||
// Leaving edit mode puts back whatever the product had, so a half-typed number is not kept.
|
||||
this.savingProductCurrencyForm.patchValue({
|
||||
daysToInactive: this.savingProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
|
||||
daysToDormancy: this.savingProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
|
||||
daysToEscheat: this.savingProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts the RBI periods back, for a product that was edited away from them. */
|
||||
resetDormancyDays(): void {
|
||||
this.savingProductCurrencyForm.patchValue({
|
||||
daysToInactive: DORMANCY_DEFAULTS.daysToInactive,
|
||||
daysToDormancy: DORMANCY_DEFAULTS.daysToDormancy,
|
||||
daysToEscheat: DORMANCY_DEFAULTS.daysToEscheat
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the periods no longer run inactive → dormant → escheat in order.
|
||||
*
|
||||
* A dormant threshold at or before the inactive one would have an account reach the later stage
|
||||
* first, which the job cannot act on sensibly, so the step says so rather than saving it.
|
||||
*/
|
||||
get dormancyDaysOutOfOrder(): boolean {
|
||||
const v = this.savingProductCurrencyForm.value;
|
||||
const inactive = Number(v.daysToInactive);
|
||||
const dormant = Number(v.daysToDormancy);
|
||||
const escheat = Number(v.daysToEscheat);
|
||||
if ([
|
||||
inactive,
|
||||
dormant,
|
||||
escheat
|
||||
].some((n) => !Number.isFinite(n) || n <= 0)) {
|
||||
return false;
|
||||
}
|
||||
return dormant <= inactive || escheat <= dormant;
|
||||
}
|
||||
|
||||
createSavingProductCurrencyForm() {
|
||||
this.savingProductCurrencyForm = this.formBuilder.group({
|
||||
currencyCode: [
|
||||
@@ -68,7 +127,21 @@ export class SavingProductCurrencyStepComponent implements OnInit {
|
||||
]
|
||||
],
|
||||
dormancyAlert: [false],
|
||||
nominationFacility: [false]
|
||||
nominationFacility: [false],
|
||||
// The RBI periods, counted from the last customer transaction. Editable, but a product gets
|
||||
// these unless somebody deliberately changes them.
|
||||
daysToInactive: [
|
||||
DORMANCY_DEFAULTS.daysToInactive,
|
||||
Validators.min(1)
|
||||
],
|
||||
daysToDormancy: [
|
||||
DORMANCY_DEFAULTS.daysToDormancy,
|
||||
Validators.min(1)
|
||||
],
|
||||
daysToEscheat: [
|
||||
DORMANCY_DEFAULTS.daysToEscheat,
|
||||
Validators.min(1)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +155,14 @@ export class SavingProductCurrencyStepComponent implements OnInit {
|
||||
nominationFacility: formValue.nominationFacility
|
||||
};
|
||||
|
||||
// The periods only mean anything while tracking is on; sending them with it off would store
|
||||
// thresholds against a product nothing acts on.
|
||||
if (formValue.dormancyAlert) {
|
||||
result.daysToInactive = formValue.daysToInactive;
|
||||
result.daysToDormancy = formValue.daysToDormancy;
|
||||
result.daysToEscheat = formValue.daysToEscheat;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
-15
@@ -174,21 +174,6 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="spl-feature">
|
||||
<span class="spl-feature__label">
|
||||
<i class="fas fa-arrow-right-arrow-left"></i>
|
||||
Withdrawal Facility
|
||||
</span>
|
||||
<select
|
||||
class="spl-select"
|
||||
[(ngModel)]="accountFacilityConfig.withdrawalFacility"
|
||||
(ngModelChange)="markAsDirty()"
|
||||
>
|
||||
<option [ngValue]="false">No</option>
|
||||
<option [ngValue]="true">Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="spl-feature">
|
||||
<span class="spl-feature__label">
|
||||
<i class="fas fa-id-card"></i>
|
||||
|
||||
-2
@@ -24,7 +24,6 @@ type BooleanFeatureKey =
|
||||
| 'neftFacility'
|
||||
| 'rtgsFacility'
|
||||
| 'interBankTotalCapFacility'
|
||||
| 'withdrawalFacility'
|
||||
| 'depositFacilityKyc'
|
||||
| 'virtualDebitCard';
|
||||
|
||||
@@ -76,7 +75,6 @@ export class SavingProductLimitsStepComponent implements OnChanges {
|
||||
neftFacility: true,
|
||||
rtgsFacility: true,
|
||||
interBankTotalCapFacility: true,
|
||||
withdrawalFacility: true,
|
||||
depositFacilityKyc: true,
|
||||
virtualDebitCard: false
|
||||
};
|
||||
|
||||
+40
-72
@@ -475,42 +475,35 @@
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Overdraft portfolio control</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.overdraftPortfolioControlId,
|
||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.interestReceivableAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Fees receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.feesReceivableAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Penalties receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.penaltiesReceivableAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@if (isAccrualAccounting()) {
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.interestReceivableAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Fees receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.feesReceivableAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Penalties receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.penaltiesReceivableAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-section">
|
||||
@@ -524,24 +517,17 @@
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Transfers in suspense account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.transfersInSuspenseAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest payable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.interestPayableAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@if (isAccrualAccounting()) {
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest payable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.interestPayableAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (savingProduct.isDormancyTrackingActive) {
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Escheat liability account</span>
|
||||
@@ -566,15 +552,6 @@
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Write off account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.writeOffAccountId,
|
||||
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-section">
|
||||
@@ -597,15 +574,6 @@
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Income from interest</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
savingProduct.incomeFromInterestId,
|
||||
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+1
-12
@@ -94,7 +94,6 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
|
||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
||||
];
|
||||
@@ -132,23 +131,14 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
|
||||
this.savingProduct.savingsReferenceAccountId,
|
||||
assetAccountData
|
||||
),
|
||||
overdraftPortfolioControl: this.accounting.glAccountLookUp(
|
||||
this.savingProduct.overdraftPortfolioControlId,
|
||||
assetAccountData
|
||||
),
|
||||
savingsControlAccount: this.accounting.glAccountLookUp(
|
||||
this.savingProduct.savingsControlAccountId,
|
||||
liabilityAccountData
|
||||
),
|
||||
transfersInSuspenseAccount: this.accounting.glAccountLookUp(
|
||||
this.savingProduct.transfersInSuspenseAccountId,
|
||||
liabilityAccountData
|
||||
),
|
||||
interestOnSavingsAccount: this.accounting.glAccountLookUp(
|
||||
this.savingProduct.interestOnSavingsAccountId,
|
||||
expenseAccountData
|
||||
),
|
||||
writeOffAccount: this.accounting.glAccountLookUp(this.savingProduct.writeOffAccountId, expenseAccountData),
|
||||
incomeFromFeeAccount: this.accounting.glAccountLookUp(
|
||||
this.savingProduct.incomeFromFeeAccountId,
|
||||
incomeAccountData
|
||||
@@ -156,8 +146,7 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
|
||||
incomeFromPenaltyAccount: this.accounting.glAccountLookUp(
|
||||
this.savingProduct.incomeFromPenaltyAccountId,
|
||||
incomeAccountData
|
||||
),
|
||||
incomeFromInterest: this.accounting.glAccountLookUp(this.savingProduct.incomeFromInterestId, incomeAccountData)
|
||||
)
|
||||
};
|
||||
|
||||
if (this.isAccrualAccounting()) {
|
||||
|
||||
+6
-11
@@ -70,11 +70,7 @@ export class SavingProductSettingsStepComponent implements OnInit {
|
||||
: this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0,
|
||||
accountClosedZeroBalanceInDayClosing: isEditing
|
||||
? (this.savingProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
|
||||
: false,
|
||||
isDormancyTrackingActive: isEditing ? (this.savingProductsTemplate.isDormancyTrackingActive ?? true) : true,
|
||||
daysToInactive: isEditing ? this.savingProductsTemplate.daysToInactive || 30 : 30,
|
||||
daysToDormancy: isEditing ? this.savingProductsTemplate.daysToDormancy || 60 : 60,
|
||||
daysToEscheat: isEditing ? this.savingProductsTemplate.daysToEscheat || 90 : 90
|
||||
: false
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -115,12 +111,11 @@ export class SavingProductSettingsStepComponent implements OnInit {
|
||||
enableLockinPeriod: [false],
|
||||
lockinPeriodFrequency: [''],
|
||||
lockinPeriodFrequencyType: [''],
|
||||
accountClosedZeroBalanceInDayClosing: [false],
|
||||
// Dormancy tracking fields (required when isDormancyTrackingActive is true)
|
||||
isDormancyTrackingActive: [true],
|
||||
daysToInactive: [30],
|
||||
daysToDormancy: [60],
|
||||
daysToEscheat: [90]
|
||||
accountClosedZeroBalanceInDayClosing: [false]
|
||||
// Dormancy is deliberately absent: it belongs to the Currency step, which is where it is shown
|
||||
// and edited. It was declared here as well, and because this step is spread into the payload
|
||||
// after that one, the numbers an officer typed were silently replaced by these untouched
|
||||
// defaults on the way to the server.
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -86,7 +86,6 @@ export class SavingProductGeneralTabComponent {
|
||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
||||
];
|
||||
|
||||
+1
-3
@@ -76,9 +76,7 @@
|
||||
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
|
||||
<span matSuffix> % </span>
|
||||
</mat-form-field>
|
||||
<p class="dd-hint">
|
||||
Applied to each member's own share investment, pro-rated for how long they held it.
|
||||
</p>
|
||||
<p class="dd-hint">Applied to each member's own share investment, pro-rated for how long they held it.</p>
|
||||
</div>
|
||||
|
||||
@if (loadingPreview) {
|
||||
|
||||
@@ -248,27 +248,67 @@ export class ViewDividendComponent implements OnInit {
|
||||
ws.getCell('A1').alignment = { horizontal: 'center' };
|
||||
|
||||
const heading: [string, any][] = [
|
||||
['Share Product', h?.productName ?? ''],
|
||||
['Financial Year', fy],
|
||||
['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''],
|
||||
['Dividend Rate (% per annum)', h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'],
|
||||
['Declared Amount', h?.declaredAmount != null ? Number(h.declaredAmount) : ''],
|
||||
['Distributed to Members', h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''],
|
||||
['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'],
|
||||
['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'],
|
||||
['Members Paid', h?.paidMembers ?? ''],
|
||||
['Members Below Minimum', h?.unpaidMembers ?? '']
|
||||
[
|
||||
'Share Product',
|
||||
h?.productName ?? ''
|
||||
],
|
||||
[
|
||||
'Financial Year',
|
||||
fy
|
||||
],
|
||||
[
|
||||
'Dividend Period',
|
||||
h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''
|
||||
],
|
||||
[
|
||||
'Dividend Rate (% per annum)',
|
||||
h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'
|
||||
],
|
||||
[
|
||||
'Declared Amount',
|
||||
h?.declaredAmount != null ? Number(h.declaredAmount) : ''
|
||||
],
|
||||
[
|
||||
'Distributed to Members',
|
||||
h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''
|
||||
],
|
||||
[
|
||||
'Payment Date',
|
||||
h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'
|
||||
],
|
||||
[
|
||||
'Status',
|
||||
h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'
|
||||
],
|
||||
[
|
||||
'Members Paid',
|
||||
h?.paidMembers ?? ''
|
||||
],
|
||||
[
|
||||
'Members Below Minimum',
|
||||
h?.unpaidMembers ?? ''
|
||||
]
|
||||
];
|
||||
heading.forEach(([label, value], i) => {
|
||||
const row = ws.getRow(i + 3);
|
||||
row.getCell(1).value = label;
|
||||
row.getCell(1).font = { bold: true };
|
||||
row.getCell(2).value = value;
|
||||
});
|
||||
heading.forEach(
|
||||
([
|
||||
label,
|
||||
value
|
||||
], i) => {
|
||||
const row = ws.getRow(i + 3);
|
||||
row.getCell(1).value = label;
|
||||
row.getCell(1).font = { bold: true };
|
||||
row.getCell(2).value = value;
|
||||
}
|
||||
);
|
||||
|
||||
const tableStart = heading.length + 5;
|
||||
const header = ws.getRow(tableStart);
|
||||
['Client Name', 'Share Account', 'Dividend Amount', 'Status'].forEach((t, i) => {
|
||||
[
|
||||
'Client Name',
|
||||
'Share Account',
|
||||
'Dividend Amount',
|
||||
'Status'
|
||||
].forEach((t, i) => {
|
||||
header.getCell(i + 1).value = t;
|
||||
header.getCell(i + 1).font = { bold: true };
|
||||
header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
|
||||
@@ -286,7 +326,10 @@ export class ViewDividendComponent implements OnInit {
|
||||
const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0));
|
||||
totalRow.getCell(2).value = 'Total';
|
||||
totalRow.getCell(2).font = { bold: true };
|
||||
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce((s: number, d: any) => s + (Number(d?.amount) || 0), 0);
|
||||
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce(
|
||||
(s: number, d: any) => s + (Number(d?.amount) || 0),
|
||||
0
|
||||
);
|
||||
totalRow.getCell(3).numFmt = '#,##0.00';
|
||||
totalRow.getCell(3).font = { bold: true };
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<!-- What the return is for, in the words a supervisor would use. A page titled only with a
|
||||
form number tells a clerk nothing about what they are about to file. -->
|
||||
<p>
|
||||
Membership and share capital as reported to the RBI, per share type — how many members hold
|
||||
each type, how much capital they have subscribed, and how much of the authorised issue
|
||||
remains unsold, with the previous financial year alongside for comparison.
|
||||
Membership and share capital as reported to the RBI, per share type — how many members hold each type, how much
|
||||
capital they have subscribed, and how much of the authorised issue remains unsold, with the previous financial
|
||||
year alongside for comparison.
|
||||
</p>
|
||||
</div>
|
||||
<div class="scr-header__actions">
|
||||
@@ -31,7 +31,12 @@
|
||||
<fa-icon icon="sync"></fa-icon>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" class="scr-btn scr-btn--primary" [disabled]="exporting || !report" (click)="exportToExcel()">
|
||||
<button
|
||||
type="button"
|
||||
class="scr-btn scr-btn--primary"
|
||||
[disabled]="exporting || !report"
|
||||
(click)="exportToExcel()"
|
||||
>
|
||||
<fa-icon icon="file-excel"></fa-icon>
|
||||
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
|
||||
</button>
|
||||
@@ -46,8 +51,8 @@
|
||||
<div class="scr-note">Loading the share register…</div>
|
||||
} @else if (report) {
|
||||
<div class="scr-period">
|
||||
Reporting period <strong>{{ report.periodStartDate }}</strong> to
|
||||
<strong>{{ report.periodEndDate }}</strong> · compared with FY {{ report.previousFinancialYear }}
|
||||
Reporting period <strong>{{ report.periodStartDate }}</strong> to <strong>{{ report.periodEndDate }}</strong> ·
|
||||
compared with FY {{ report.previousFinancialYear }}
|
||||
</div>
|
||||
|
||||
<!-- The bottom line first: a supervisor checks the society's totals before reading any single
|
||||
|
||||
@@ -154,7 +154,8 @@ export class ShareCapitalReturnComponent implements OnInit {
|
||||
ws.getCell('A1').alignment = { horizontal: 'center' };
|
||||
|
||||
ws.mergeCells('A2:L2');
|
||||
ws.getCell('A2').value = `Financial Year ${fy} (compared with ${prev}) · Period ${this.report.periodStartDate} to ${this.report.periodEndDate}`;
|
||||
ws.getCell('A2').value =
|
||||
`Financial Year ${fy} (compared with ${prev}) · Period ${this.report.periodStartDate} to ${this.report.periodEndDate}`;
|
||||
ws.getCell('A2').alignment = { horizontal: 'center' };
|
||||
|
||||
const headers = [
|
||||
@@ -215,7 +216,10 @@ export class ShareCapitalReturnComponent implements OnInit {
|
||||
totalRow.getCell(9).value = t.membersClosingPrev;
|
||||
totalRow.getCell(12).value = t.capitalClosing;
|
||||
totalRow.getCell(13).value = t.capitalClosingPrev;
|
||||
[12, 13].forEach((c) => (totalRow.getCell(c).numFmt = '#,##0.00'));
|
||||
[
|
||||
12,
|
||||
13
|
||||
].forEach((c) => (totalRow.getCell(c).numFmt = '#,##0.00'));
|
||||
for (let c = 1; c <= 13; c++) {
|
||||
totalRow.getCell(c).font = { bold: true };
|
||||
}
|
||||
|
||||
@@ -64,6 +64,23 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
|
||||
},
|
||||
{
|
||||
path: 'kyc-expiry',
|
||||
data: { title: 'Re-KYC Expiry & Outreach', breadcrumb: 'Re-KYC Expiry & Outreach' },
|
||||
loadComponent: () => import('../kyc-expiry/kyc-expiry.component').then((m) => m.KycExpiryComponent)
|
||||
},
|
||||
{
|
||||
path: 'savings-interest',
|
||||
data: { title: 'Savings Interest Posted', breadcrumb: 'Savings Interest Posted' },
|
||||
loadComponent: () =>
|
||||
import('../interest-report/interest-report.component').then((m) => m.InterestReportComponent)
|
||||
},
|
||||
{
|
||||
path: 'dormancy',
|
||||
data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' },
|
||||
loadComponent: () =>
|
||||
import('../dormancy-report/dormancy-report.component').then((m) => m.DormancyReportComponent)
|
||||
},
|
||||
{
|
||||
path: 'tds-interest',
|
||||
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,18 +27,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (sampleCount) {
|
||||
<div class="rr-banner rr-banner--sample">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
<strong>{{ sampleCount }} demonstration rows</strong> are included alongside
|
||||
{{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a
|
||||
<span class="rr-tag">Sample</span> tag in the table and must not be acted on as compliance data.
|
||||
</span>
|
||||
<button class="rr-btn rr-btn--slim" (click)="load()"><fa-icon icon="undo"></fa-icon> Reload live feed</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- FILTERS -->
|
||||
<div class="rr-filters">
|
||||
<div class="rr-filter-row">
|
||||
@@ -228,148 +216,173 @@
|
||||
|
||||
<!-- CUSTOMER WISE -->
|
||||
@if (view === 'customer') {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Risk</th>
|
||||
<th class="num">Alerts</th>
|
||||
<th>AML Rules Triggered</th>
|
||||
<th class="num">Amount under review</th>
|
||||
<th>Last Alert</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of customerRows; track c.clientId) {
|
||||
<tr
|
||||
class="rr-cust"
|
||||
[class.open]="expanded === c.clientId"
|
||||
[class.rr-row--escalated]="c.escalated"
|
||||
(click)="toggle(c.clientId)"
|
||||
>
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
<td class="mono">{{ c.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ c.customerName }}</strong>
|
||||
@if (c.openCount > 0) {
|
||||
<span class="rr-tag">{{ c.openCount }} open</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ c.accountNo || '—' }}</td>
|
||||
<td>{{ c.branchCode || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
|
||||
</td>
|
||||
<td class="num">{{ c.alertCount }}</td>
|
||||
<td class="rr-rules-cell">
|
||||
@for (label of c.ruleLabels; track label) {
|
||||
<span class="rr-rule">{{ label }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
|
||||
<td class="mono">{{ c.lastAlertDate }}</td>
|
||||
<td class="rr-verdict-cell">
|
||||
<span [class]="verdictClass(c)">{{ verdictText(c) }}</span>
|
||||
@if (c.directionReason) {
|
||||
<div class="rr-verdict-why">{{ c.directionReason }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Grouped by verdict: the section heading answers "what do I do about these?" once, so the
|
||||
row does not have to repeat it in a Move To and a Position column. -->
|
||||
@for (g of customerGroups; track g.key) {
|
||||
<div class="rr-group" [class]="'rr-group--' + g.tone">
|
||||
<button class="rr-group-head" (click)="toggleGroup(g.key)">
|
||||
<fa-icon [icon]="collapsedGroups[g.key] ? 'caret-right' : 'caret-down'"></fa-icon>
|
||||
<span class="rr-group-t">{{ g.title }}</span>
|
||||
<span class="rr-group-n">{{ g.rows.length }}</span>
|
||||
<span class="rr-group-h">{{ g.hint }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded === c.clientId) {
|
||||
<tr class="rr-detail">
|
||||
<td colspan="11">
|
||||
<!-- One block per alert: what the rule is, then the entries it fired on. -->
|
||||
@for (a of c.alerts; track a.alertId) {
|
||||
<div class="rr-case">
|
||||
<div class="rr-case-head">
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
||||
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
|
||||
<span class="rr-spacer"></span>
|
||||
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
|
||||
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
||||
</div>
|
||||
<div class="rr-case-why">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span><strong>Why it triggered:</strong> {{ a.rule.description }} — {{ txnDetail(a) }}</span>
|
||||
</div>
|
||||
@if (!collapsedGroups[g.key]) {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Rating on file</th>
|
||||
<th class="num">Alerts</th>
|
||||
<th>AML Rules Triggered</th>
|
||||
<th class="num">Amount under review</th>
|
||||
<th>Last Alert</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of g.rows; track c.clientId) {
|
||||
<tr
|
||||
class="rr-cust"
|
||||
[class.open]="expanded === c.clientId"
|
||||
[class.rr-row--escalated]="c.escalated"
|
||||
(click)="toggle(c.clientId)"
|
||||
>
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
<td class="mono">{{ c.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ c.customerName }}</strong>
|
||||
@if (c.openCount > 0) {
|
||||
<span class="rr-tag">{{ c.openCount }} open</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ c.accountNo || '—' }}</td>
|
||||
<td>{{ c.branchCode || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
|
||||
</td>
|
||||
<td class="num">{{ c.alertCount }}</td>
|
||||
<td class="rr-rules-cell">
|
||||
@for (label of c.ruleLabels; track label) {
|
||||
<span class="rr-rule">{{ label }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
|
||||
<td class="mono">{{ c.lastAlertDate }}</td>
|
||||
<!-- The verdict is the section this row sits in, so the cell only carries the
|
||||
button that acts on it, and the reason behind it. -->
|
||||
<td class="rr-verdict-cell">
|
||||
<button
|
||||
class="rr-kyc-link"
|
||||
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
|
||||
(click)="openKyc(c, $event)"
|
||||
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
|
||||
</button>
|
||||
@if (c.directionReason) {
|
||||
<div class="rr-verdict-why">{{ c.directionReason }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<table class="rr-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Txn Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Channel</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="num">Balance After</th>
|
||||
<th>Narration</th>
|
||||
<th>Counterparty</th>
|
||||
<th>AML Rule Triggered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of a.txns; track t.txnId) {
|
||||
<tr [class.rr-leg--ctr]="t.ctrReportable">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td>
|
||||
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">
|
||||
{{ t.amount | number: '1.0-0' }}
|
||||
@if (t.ctrReportable) {
|
||||
<span class="rr-tag rr-tag--ctr">CTR</span>
|
||||
@if (expanded === c.clientId) {
|
||||
<tr class="rr-detail">
|
||||
<td colspan="11">
|
||||
<!-- One block per alert: what the rule is, then the entries it fired on. -->
|
||||
@for (a of c.alerts; track a.alertId) {
|
||||
<div class="rr-case">
|
||||
<div class="rr-case-head">
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
||||
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
|
||||
<span class="rr-spacer"></span>
|
||||
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
|
||||
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
||||
</div>
|
||||
<div class="rr-case-why">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span
|
||||
><strong>Why it triggered:</strong> {{ a.rule.description }} —
|
||||
{{ txnDetail(a) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<table class="rr-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Txn Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Channel</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="num">Balance After</th>
|
||||
<th>Narration</th>
|
||||
<th>Counterparty</th>
|
||||
<th>AML Rule Triggered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of a.txns; track t.txnId) {
|
||||
<tr [class.rr-leg--ctr]="t.ctrReportable">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td>
|
||||
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">
|
||||
{{ t.amount | number: '1.0-0' }}
|
||||
@if (t.ctrReportable) {
|
||||
<span class="rr-tag rr-tag--ctr">CTR</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}
|
||||
</td>
|
||||
<td>{{ t.narration || '—' }}</td>
|
||||
<td>{{ t.counterparty || '—' }}</td>
|
||||
<td>
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
@if (a.txns.length > 1) {
|
||||
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
|
||||
<td>{{ t.narration || '—' }}</td>
|
||||
<td>{{ t.counterparty || '—' }}</td>
|
||||
<td>
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
@if (a.txns.length > 1) {
|
||||
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="rr-case-foot">
|
||||
<div class="rr-suggest">
|
||||
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
|
||||
</div>
|
||||
<div class="rr-case-remark">
|
||||
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
|
||||
{{ a.remarks || 'no remarks recorded' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-case-foot">
|
||||
<div class="rr-suggest">
|
||||
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
|
||||
</div>
|
||||
<div class="rr-case-remark">
|
||||
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
|
||||
{{ a.remarks || 'no remarks recorded' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="11" class="rr-empty">
|
||||
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="rr-empty">No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ALERT WISE -->
|
||||
@@ -400,9 +413,6 @@
|
||||
<td class="mono">{{ a.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ a.customerName }}</strong>
|
||||
@if (!a.live) {
|
||||
<span class="rr-tag">Sample</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ a.accountNo || '—' }}</td>
|
||||
<td>{{ a.branchCode || '—' }}</td>
|
||||
|
||||
@@ -139,12 +139,6 @@
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&--sample {
|
||||
background: var(--r-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTERS */
|
||||
@@ -403,6 +397,84 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* VERDICT GROUPS — the section heading carries the verdict, so rows need not repeat it. */
|
||||
.rr-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-left: 4px solid var(--r-g300);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.rr-group-t {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* The count sits next to the title so a reviewer sees the size of each pile at a glance. */
|
||||
.rr-group-n {
|
||||
min-width: 24px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--r-g100, #f1f5f9);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rr-group-h {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
/* One colour per verdict, matched to the arrows already used in the rows. */
|
||||
.rr-group--str .rr-group-head {
|
||||
border-left-color: #dc2626;
|
||||
}
|
||||
|
||||
.rr-group--str .rr-group-n {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.rr-group--up .rr-group-head {
|
||||
border-left-color: #ea580c;
|
||||
}
|
||||
|
||||
.rr-group--up .rr-group-n {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.rr-group--down .rr-group-head {
|
||||
border-left-color: #16a34a;
|
||||
}
|
||||
|
||||
.rr-group--down .rr-group-n {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.rr-group--hold .rr-group-head {
|
||||
border-left-color: var(--r-g300);
|
||||
}
|
||||
|
||||
.rr-group .rr-table-wrap {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* TABLE */
|
||||
.rr-table-wrap {
|
||||
background: #fff;
|
||||
@@ -751,6 +823,75 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Where the rating should land, read straight off the Current Risk column beside it. */
|
||||
.rr-move-cell {
|
||||
min-width: 150px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.rr-move {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--up {
|
||||
background: var(--r-red-l);
|
||||
color: var(--r-red);
|
||||
}
|
||||
|
||||
/* Coming down is a relief, not an alarm — same green the verdict uses. */
|
||||
&--down {
|
||||
background: var(--r-green-l);
|
||||
color: var(--r-green);
|
||||
}
|
||||
|
||||
&--hold {
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-move-why {
|
||||
font-size: 10px;
|
||||
color: var(--r-g500);
|
||||
margin-top: 3px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Drill from the verdict into the KYC screen, where the rating is actually moved. */
|
||||
.rr-kyc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--r-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g500);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--r-blue);
|
||||
color: var(--r-blue);
|
||||
}
|
||||
|
||||
/* A rating that has to move gets the emphasis; a settled row stays quiet. */
|
||||
&--act {
|
||||
border-color: var(--r-blue);
|
||||
color: var(--r-blue);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
@@ -37,8 +38,7 @@ import {
|
||||
RiskLevel,
|
||||
channelLabel,
|
||||
mapLiveAlert,
|
||||
riskFromCategory,
|
||||
sampleRiskAlerts
|
||||
riskFromCategory
|
||||
} from './risk-review-data';
|
||||
import {
|
||||
DateRange,
|
||||
@@ -57,6 +57,15 @@ import {
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
||||
|
||||
/** One block of the customer view: the customers that share a single verdict. */
|
||||
interface RiskCustomerGroup {
|
||||
key: string;
|
||||
title: string;
|
||||
hint: string;
|
||||
tone: 'str' | 'up' | 'down' | 'hold';
|
||||
rows: RiskCustomerRow[];
|
||||
}
|
||||
|
||||
/** Starting value for the memo, used before the first filter pass runs. */
|
||||
const EMPTY_SUMMARY: RiskReviewSummary = {
|
||||
customers: 0,
|
||||
@@ -82,9 +91,8 @@ const EMPTY_SUMMARY: RiskReviewSummary = {
|
||||
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
|
||||
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
|
||||
*
|
||||
* Rows come from the live rule engine. If the engine has raised nothing (or is
|
||||
* unreachable) the report falls back to a deterministic sample set and says so
|
||||
* on screen and in the workbook, so sample rows are never taken for live data.
|
||||
* Rows come from the live rule engine only. If the engine has raised nothing
|
||||
* (or is unreachable) the report shows as empty rather than padding itself.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-review-report',
|
||||
@@ -100,6 +108,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private aml = inject(AmlService);
|
||||
private clients = inject(ClientsService);
|
||||
private scope = inject(BranchScopeService);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
@@ -122,22 +131,13 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private liveAlerts: RiskAlertRow[] = [];
|
||||
loading = true;
|
||||
|
||||
/**
|
||||
* Live alerts and the demonstration set, in one worklist.
|
||||
*
|
||||
* Every row keeps its `live` flag and sample rows are tagged in the table, so
|
||||
* a reviewer can still tell them apart at a glance.
|
||||
*/
|
||||
/** The worklist — live alerts from the rule engine. */
|
||||
private alerts: RiskAlertRow[] = [];
|
||||
|
||||
get hasLive(): boolean {
|
||||
return this.liveAlerts.length > 0;
|
||||
}
|
||||
|
||||
get sampleCount(): number {
|
||||
return this.alerts.filter((a) => !a.live).length;
|
||||
}
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -198,12 +198,16 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Live alerts first, then the demonstration set behind them. */
|
||||
/**
|
||||
* Only what the AML engine actually raised.
|
||||
*
|
||||
* The demonstration set that used to be appended here is gone: a compliance report that mixes
|
||||
* invented customers into live alerts is worse than an empty one, because every count, total and
|
||||
* rule tally on the screen is then a number nobody can act on. An empty period now reads as an
|
||||
* empty period.
|
||||
*/
|
||||
private compose(): void {
|
||||
this.alerts = [
|
||||
...this.liveAlerts,
|
||||
...sampleRiskAlerts()
|
||||
];
|
||||
this.alerts = [...this.liveAlerts];
|
||||
this.memoKey = '';
|
||||
}
|
||||
|
||||
@@ -334,6 +338,64 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
return this.model.customers;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customers split by what the reviewer actually has to do about them.
|
||||
*
|
||||
* The report's whole question is "does the rating on file still hold?", and there are only four
|
||||
* answers. Grouping by the answer puts that question in the section heading, where it is read
|
||||
* once, instead of repeating it on every row across a Move To and a Position column that said the
|
||||
* same thing in different words.
|
||||
*
|
||||
* Ordered by what a reviewer must deal with first: an STR sits with the Principal Officer against
|
||||
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
|
||||
* only a correction, and matching ratings need nothing at all.
|
||||
*/
|
||||
get customerGroups(): RiskCustomerGroup[] {
|
||||
const rows = this.customerRows;
|
||||
const escalated = rows.filter((c) => c.escalated);
|
||||
const rest = rows.filter((c) => !c.escalated);
|
||||
|
||||
const groups: RiskCustomerGroup[] = [
|
||||
{
|
||||
key: 'escalated',
|
||||
title: 'With the Principal Officer',
|
||||
hint: 'An STR has been filed. The rating is held where it is until the outcome — nothing to apply here.',
|
||||
tone: 'str',
|
||||
rows: escalated
|
||||
},
|
||||
{
|
||||
key: 'up',
|
||||
title: 'Rating needs raising',
|
||||
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
|
||||
tone: 'up',
|
||||
rows: rest.filter((c) => c.direction === 'up')
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
title: 'Rating can be lowered',
|
||||
hint: 'Activity no longer supports the rating on file.',
|
||||
tone: 'down',
|
||||
rows: rest.filter((c) => c.direction === 'down')
|
||||
},
|
||||
{
|
||||
key: 'same',
|
||||
title: 'No change needed',
|
||||
hint: 'The rating on file already matches the behaviour. Listed for the record.',
|
||||
tone: 'hold',
|
||||
rows: rest.filter((c) => c.direction === 'same')
|
||||
}
|
||||
];
|
||||
|
||||
return groups.filter((g) => g.rows.length > 0);
|
||||
}
|
||||
|
||||
/** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */
|
||||
collapsedGroups: Record<string, boolean> = { same: true };
|
||||
|
||||
toggleGroup(key: string): void {
|
||||
this.collapsedGroups[key] = !this.collapsedGroups[key];
|
||||
}
|
||||
|
||||
get txnRows(): RiskTxnView[] {
|
||||
return this.model.txns;
|
||||
}
|
||||
@@ -393,6 +455,74 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
return 'rr-verdict';
|
||||
}
|
||||
|
||||
/** Colours the target rating by which way it moves. */
|
||||
moveClass(c: RiskCustomerRow): string {
|
||||
if (c.direction === 'up') {
|
||||
return 'rr-move rr-move--up';
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return 'rr-move rr-move--down';
|
||||
}
|
||||
return 'rr-move rr-move--hold';
|
||||
}
|
||||
|
||||
/**
|
||||
* The short "from → to" line under the target rating.
|
||||
*
|
||||
* The reason column already carries the full argument; this only has to say what is
|
||||
* happening to the rating, so a reviewer scanning the column never has to read a
|
||||
* paragraph to find out whether a customer is moving.
|
||||
*/
|
||||
moveHint(c: RiskCustomerRow): string {
|
||||
const from = this.riskText(c.currentRisk);
|
||||
const to = this.riskText(c.recommendedRisk);
|
||||
|
||||
if (c.escalated) {
|
||||
return `${from} → hold at ${to} till STR outcome`;
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return `${from} → ${to} as per AML rules`;
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return `${from} → ${to}, activity does not support ${from}`;
|
||||
}
|
||||
// Nothing to move: say why it is staying rather than leaving the cell mute.
|
||||
return c.currentRisk ? `Behaviour matches ${from} — no change` : 'No rating on record yet';
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the customer's KYC tab so the reviewer can act on the verdict.
|
||||
*
|
||||
* The report only ever recommends — nothing here writes the rating. Moving a customer
|
||||
* is a KYC amendment and stays on the KYC screen, where the override checkbox, the
|
||||
* remarks field and the review-date recalculation all live together.
|
||||
*/
|
||||
openKyc(c: RiskCustomerRow, event: Event): void {
|
||||
// The row itself expands on click; the button must not do both.
|
||||
event.stopPropagation();
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
c.clientId,
|
||||
'kyc'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the reviewer is going to the KYC screen to do.
|
||||
*
|
||||
* An escalated case is worded as a review rather than a move: the rating is held
|
||||
* pending the Principal Officer's conclusion, so there is nothing to apply yet.
|
||||
*/
|
||||
kycActionText(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
return 'Open KYC';
|
||||
}
|
||||
if (c.direction === 'up' || c.direction === 'down') {
|
||||
return `Set ${RISK_LABEL[c.recommendedRisk]} in KYC`;
|
||||
}
|
||||
return 'Open KYC';
|
||||
}
|
||||
|
||||
/** Transaction detail as the reviewer would read it in one line. */
|
||||
txnDetail(a: RiskAlertView): string {
|
||||
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
|
||||
@@ -409,9 +539,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
try {
|
||||
const s = this.summary;
|
||||
const subtitle =
|
||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
|
||||
`Source: live AML monitoring engine` +
|
||||
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
|
||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + `Source: live AML monitoring engine`;
|
||||
|
||||
await exportWorkbook(
|
||||
[
|
||||
@@ -533,6 +661,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
'Last Alert',
|
||||
'Rules Point At',
|
||||
'Recommended Risk',
|
||||
'Move To',
|
||||
'Open Cases',
|
||||
'Position',
|
||||
'Basis'
|
||||
@@ -551,6 +680,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
38,
|
||||
11,
|
||||
24,
|
||||
70
|
||||
@@ -569,6 +699,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
c.lastAlertDate,
|
||||
RISK_LABEL[c.proposedRisk],
|
||||
RISK_LABEL[c.recommendedRisk],
|
||||
this.moveHint(c),
|
||||
c.openCount,
|
||||
this.verdictText(c),
|
||||
c.directionReason || '—'
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<div class="sdi-tabs">
|
||||
<button [class.active]="activeTab === 'products'" (click)="backToProducts()">Products</button>
|
||||
<button [class.active]="activeTab === 'summary'" (click)="loadSummary()">All Accounts</button>
|
||||
@if (selectedProduct) {
|
||||
<button [class.active]="activeTab === 'accounts'" (click)="backToAccounts()">
|
||||
{{ selectedProduct.name }}
|
||||
@@ -41,6 +42,89 @@
|
||||
<div class="sdi-loading">Loading…</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
All Accounts: one line per savings account, across every product.
|
||||
|
||||
The ledger behind this holds a row per account per day — interest is worked out on the balance
|
||||
each day carried, and a reversed backdated entry means every day after it is recomputed. This
|
||||
reads the top of that ledger, so the whole book is one row each.
|
||||
-->
|
||||
@if (activeTab === 'summary' && !loading) {
|
||||
<div class="sdi-summary-bar">
|
||||
<input
|
||||
class="sdi-search"
|
||||
type="text"
|
||||
[(ngModel)]="summarySearch"
|
||||
placeholder="Search account number, customer or product"
|
||||
/>
|
||||
<span class="sdi-spacer"></span>
|
||||
<span class="sdi-total">
|
||||
{{ filteredSummary.length }} accounts · accrued this cycle
|
||||
<strong>{{ summaryAccruedTotal | number: '1.2-2' }}</strong> · posted to date
|
||||
<strong>{{ summaryPostedTotal | number: '1.2-2' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (filteredSummary.length === 0) {
|
||||
<div class="sdi-loading">
|
||||
No accounts in the ledger yet. Run the accrual, or use Backfill to rebuild it from activation.
|
||||
</div>
|
||||
} @else {
|
||||
<table class="sdi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Product</th>
|
||||
<th>As On</th>
|
||||
<th class="num">Balance</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Interest Today</th>
|
||||
<th>Cycle</th>
|
||||
<th class="num">Accrued This Cycle</th>
|
||||
<th>Next Posting</th>
|
||||
<th class="num">Posted To Date</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredSummary; track row.savings_account_id) {
|
||||
<tr [class.sdi-row--posting]="row.is_posting_day" [class.sdi-row--below]="row.below_min_balance">
|
||||
<td class="mono">{{ row.account_no }}</td>
|
||||
<td>{{ row.client_name || '—' }}</td>
|
||||
<td>
|
||||
{{ row.product_name }}
|
||||
<div class="sdi-sub">{{ postingPeriodLabel(row.posting_period_enum ?? 0) }} posting</div>
|
||||
</td>
|
||||
<td class="mono">{{ formatDate(row.as_on_date) }}</td>
|
||||
<td class="num">{{ row.closing_balance | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ row.interest_rate | number: '1.2-2' }}%</td>
|
||||
<td class="num">{{ row.interest_today | number: '1.4-4' }}</td>
|
||||
<td>
|
||||
#{{ row.cycle_number }}
|
||||
<div class="sdi-sub mono">{{ formatDate(row.cycle_start_date) }}</div>
|
||||
</td>
|
||||
<td class="num strong">{{ row.interest_this_cycle | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="mono">{{ formatDate(row.cycle_end_date) }}</span>
|
||||
@if (row.is_posting_day) {
|
||||
<div class="sdi-sub sdi-sub--due">posting today</div>
|
||||
} @else {
|
||||
<div class="sdi-sub">in {{ row.days_to_posting }} days</div>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ row.interest_posted_total | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<!-- The day-by-day working, for the account whose figure is being questioned. -->
|
||||
<button class="sdi-link" (click)="openLedgerFor(row)">Days</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Products: one row per savings product, with where it stands in its timetable. -->
|
||||
@if (activeTab === 'products' && !loading) {
|
||||
<table class="sdi-table">
|
||||
@@ -71,8 +155,11 @@
|
||||
<td>{{ formatDate(product.next_posting_date) }}</td>
|
||||
<td>
|
||||
@if (product.last_run_status) {
|
||||
<span class="sdi-chip" [class.ok]="product.last_run_status === 'COMPLETED'"
|
||||
[class.bad]="product.last_run_status === 'FAILED'">
|
||||
<span
|
||||
class="sdi-chip"
|
||||
[class.ok]="product.last_run_status === 'COMPLETED'"
|
||||
[class.bad]="product.last_run_status === 'FAILED'"
|
||||
>
|
||||
{{ product.last_run_status }}
|
||||
</span>
|
||||
} @else {
|
||||
@@ -81,7 +168,9 @@
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td></tr>
|
||||
<tr>
|
||||
<td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -106,7 +195,9 @@
|
||||
<tbody>
|
||||
@for (account of accounts; track account.savings_account_id) {
|
||||
<tr class="clickable" (click)="openAccount(account)">
|
||||
<td><strong>{{ account.account_no }}</strong></td>
|
||||
<td>
|
||||
<strong>{{ account.account_no }}</strong>
|
||||
</td>
|
||||
<td>{{ account.client_name || '—' }}</td>
|
||||
<td>{{ formatDate(account.balance_date) }}</td>
|
||||
<td class="num">{{ account.opening_balance | number: '1.2-2' }}</td>
|
||||
@@ -114,7 +205,9 @@
|
||||
<td class="num" [class.zero]="account.below_min_balance">
|
||||
{{ account.daily_interest | number: '1.4-4' }}
|
||||
@if (account.below_min_balance) {
|
||||
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
|
||||
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing"
|
||||
>below min</span
|
||||
>
|
||||
}
|
||||
</td>
|
||||
<td class="num accent">{{ account.cycle_cumulative_interest | number: '1.2-2' }}</td>
|
||||
@@ -122,7 +215,9 @@
|
||||
<td>{{ formatDate(account.cycle_end_date) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="9" class="sdi-empty">No ledger rows for this product yet.</td></tr>
|
||||
<tr>
|
||||
<td colspan="9" class="sdi-empty">No ledger rows for this product yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -156,7 +251,9 @@
|
||||
<td class="num" [class.zero]="day.below_min_balance">
|
||||
{{ day.daily_interest | number: '1.4-4' }}
|
||||
@if (day.below_min_balance) {
|
||||
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
|
||||
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing"
|
||||
>below min</span
|
||||
>
|
||||
}
|
||||
</td>
|
||||
<td class="num accent">{{ day.cycle_cumulative_interest | number: '1.4-4' }}</td>
|
||||
@@ -170,7 +267,9 @@
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="7" class="sdi-empty">No days recorded for this account.</td></tr>
|
||||
<tr>
|
||||
<td colspan="7" class="sdi-empty">No days recorded for this account.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -200,15 +299,18 @@
|
||||
<td class="num">{{ run.distributed_amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ run.rounding_residual | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'"
|
||||
[class.bad]="run.status === 'FAILED'">{{ run.status }}</span>
|
||||
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'" [class.bad]="run.status === 'FAILED'">{{
|
||||
run.status
|
||||
}}</span>
|
||||
@if (run.failure_reason) {
|
||||
<div class="sdi-sub">{{ run.failure_reason }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="7" class="sdi-empty">No posting runs yet.</td></tr>
|
||||
<tr>
|
||||
<td colspan="7" class="sdi-empty">No posting runs yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 12%);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
button {
|
||||
@@ -87,12 +87,12 @@
|
||||
td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: rgb(0 0 0 / 3%);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
@@ -106,13 +106,13 @@
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(21, 101, 192, 0.06);
|
||||
background: rgb(21 101 192 / 6%);
|
||||
}
|
||||
}
|
||||
|
||||
// The day the cycle is paid out and the sum goes back to zero.
|
||||
tr.posting-row {
|
||||
background: rgba(46, 125, 50, 0.08);
|
||||
background: rgb(46 125 50 / 8%);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -144,15 +144,15 @@
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
background: rgb(0 0 0 / 8%);
|
||||
|
||||
&.ok {
|
||||
background: rgba(46, 125, 50, 0.15);
|
||||
background: rgb(46 125 50 / 15%);
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background: rgba(198, 40, 40, 0.15);
|
||||
background: rgb(198 40 40 / 15%);
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
@@ -164,10 +164,74 @@
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
background: rgb(0 0 0 / 8%);
|
||||
|
||||
&.paid {
|
||||
background: rgba(46, 125, 50, 0.2);
|
||||
background: rgb(46 125 50 / 20%);
|
||||
color: #2e7d32;
|
||||
}
|
||||
}
|
||||
|
||||
// ── All Accounts: one line per account ───────────────────────────────────────
|
||||
|
||||
.sdi-summary-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sdi-search {
|
||||
min-width: 20rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid rgb(0 0 0 / 20%);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sdi-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sdi-total {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// The day the cycle is paid out — the accrued figure goes back to zero after it.
|
||||
tr.sdi-row--posting {
|
||||
background: rgb(46 125 50 / 8%);
|
||||
}
|
||||
|
||||
// Under the product minimum, so this day earns nothing at all.
|
||||
tr.sdi-row--below {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sdi-sub--due {
|
||||
color: #2e7d32;
|
||||
font-weight: 700;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sdi-link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
color: #1565c0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
SavingsInterestAccount,
|
||||
SavingsInterestDay,
|
||||
SavingsInterestProduct,
|
||||
SavingsInterestRun
|
||||
SavingsInterestRun,
|
||||
SavingsInterestSummaryRow
|
||||
} from './savings-daily-interest.service';
|
||||
|
||||
/**
|
||||
@@ -37,13 +38,17 @@ import {
|
||||
export class SavingsDailyInterestComponent implements OnInit {
|
||||
private service = inject(SavingsDailyInterestService);
|
||||
|
||||
activeTab: 'products' | 'accounts' | 'ledger' | 'runs' = 'products';
|
||||
activeTab: 'products' | 'summary' | 'accounts' | 'ledger' | 'runs' = 'products';
|
||||
|
||||
products: SavingsInterestProduct[] = [];
|
||||
accounts: SavingsInterestAccount[] = [];
|
||||
ledger: SavingsInterestDay[] = [];
|
||||
runs: SavingsInterestRun[] = [];
|
||||
|
||||
/** One row per account across every product. */
|
||||
summary: SavingsInterestSummaryRow[] = [];
|
||||
summarySearch = '';
|
||||
|
||||
selectedProduct: SavingsInterestProduct | null = null;
|
||||
selectedAccount: SavingsInterestAccount | null = null;
|
||||
|
||||
@@ -95,6 +100,39 @@ export class SavingsDailyInterestComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the day-by-day working for a row on the All Accounts list.
|
||||
*
|
||||
* The summary row carries what the ledger tab needs to caption itself, so it is mapped across
|
||||
* rather than the account being re-fetched: the two views read the same ledger, and a second call
|
||||
* would only be able to disagree with the row the user just clicked.
|
||||
*/
|
||||
openLedgerFor(row: SavingsInterestSummaryRow): void {
|
||||
this.selectedAccount = {
|
||||
savings_account_id: row.savings_account_id,
|
||||
account_no: row.account_no,
|
||||
client_name: row.client_name ?? '',
|
||||
balance_date: row.as_on_date,
|
||||
opening_balance: row.opening_balance,
|
||||
closing_balance: row.closing_balance,
|
||||
daily_interest: row.interest_today,
|
||||
cycle_cumulative_interest: row.interest_this_cycle,
|
||||
cycle_number: row.cycle_number,
|
||||
cycle_start_date: row.cycle_start_date,
|
||||
cycle_end_date: row.cycle_end_date,
|
||||
below_min_balance: row.below_min_balance
|
||||
};
|
||||
this.activeTab = 'ledger';
|
||||
this.loading = true;
|
||||
this.service.getAccountLedger(row.savings_account_id).subscribe({
|
||||
next: (data) => {
|
||||
this.ledger = data ?? [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
loadRuns(): void {
|
||||
this.activeTab = 'runs';
|
||||
this.loading = true;
|
||||
@@ -107,6 +145,43 @@ export class SavingsDailyInterestComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account on one line: today's balance and interest, the cycle it is in, and what it has
|
||||
* been paid.
|
||||
*
|
||||
* Loaded unfiltered and narrowed in the browser. The whole book is one row per account, which is
|
||||
* small enough to hold, and filtering locally means typing in the search box does not re-query.
|
||||
*/
|
||||
loadSummary(): void {
|
||||
this.activeTab = 'summary';
|
||||
this.loading = true;
|
||||
this.service.getSummary().subscribe({
|
||||
next: (data) => {
|
||||
this.summary = data ?? [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
get filteredSummary(): SavingsInterestSummaryRow[] {
|
||||
const needle = this.summarySearch.trim().toLowerCase();
|
||||
if (!needle) {
|
||||
return this.summary;
|
||||
}
|
||||
return this.summary.filter((r) =>
|
||||
`${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase().includes(needle)
|
||||
);
|
||||
}
|
||||
|
||||
get summaryAccruedTotal(): number {
|
||||
return this.filteredSummary.reduce((s, r) => s + Number(r.interest_this_cycle ?? 0), 0);
|
||||
}
|
||||
|
||||
get summaryPostedTotal(): number {
|
||||
return this.filteredSummary.reduce((s, r) => s + Number(r.interest_posted_total ?? 0), 0);
|
||||
}
|
||||
|
||||
// -------------------- actions --------------------
|
||||
|
||||
/**
|
||||
@@ -160,7 +235,11 @@ export class SavingsDailyInterestComponent implements OnInit {
|
||||
if (!value || value.length < 3) {
|
||||
return '—';
|
||||
}
|
||||
const [year, month, day] = value;
|
||||
const [
|
||||
year,
|
||||
month,
|
||||
day
|
||||
] = value;
|
||||
return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,85 @@ export interface SavingsInterestAccount {
|
||||
below_min_balance: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per savings account: where that account stands right now.
|
||||
*
|
||||
* The ledger behind this holds a row per account per day, because interest is worked out on the
|
||||
* balance each day carried and a reversed backdated entry means every day after it is recomputed.
|
||||
* This is the current position read off the top of that ledger — today's figures, the cycle the
|
||||
* account is in, and what it has been paid so far — so a screen can show one line per account
|
||||
* without reading a month of rows to find it.
|
||||
*/
|
||||
export interface SavingsInterestSummaryRow {
|
||||
savings_account_id: number;
|
||||
account_no: string;
|
||||
client_id: number | null;
|
||||
client_name: string | null;
|
||||
office_name: string | null;
|
||||
savings_product_id: number;
|
||||
product_name: string;
|
||||
currency_code: string;
|
||||
|
||||
/** The latest day the ledger has accrued for. */
|
||||
as_on_date: number[];
|
||||
opening_balance: number;
|
||||
closing_balance: number;
|
||||
interest_today: number;
|
||||
|
||||
cycle_number: number;
|
||||
cycle_start_date: number[];
|
||||
cycle_end_date: number[];
|
||||
/** Accrued since the cycle began; resets to the day's interest after a posting. */
|
||||
interest_this_cycle: number;
|
||||
days_to_posting: number;
|
||||
is_posting_day: boolean;
|
||||
posting_date: number[] | null;
|
||||
|
||||
interest_rate: number;
|
||||
days_in_year: number;
|
||||
compounding_period_enum: number | null;
|
||||
posting_period_enum: number | null;
|
||||
calculation_type_enum: number | null;
|
||||
min_balance_for_interest: number | null;
|
||||
below_min_balance: boolean;
|
||||
|
||||
/** Everything ever posted to the account from this ledger. */
|
||||
interest_posted_total: number;
|
||||
last_posted_on: number[] | null;
|
||||
/** How many days the ledger holds for the account. */
|
||||
days_accrued: number;
|
||||
last_recomputed_on: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One interest posting: what a member was actually paid, and on what.
|
||||
*
|
||||
* Read from the savings transaction rather than the ledger — the ledger clears its posted amount
|
||||
* when the next cycle opens, so it cannot answer what was paid last quarter. The transaction is
|
||||
* the payment and stays.
|
||||
*/
|
||||
export interface SavingsInterestPostingRow {
|
||||
transaction_id: number;
|
||||
savings_account_id: number;
|
||||
account_no: string;
|
||||
client_id: number | null;
|
||||
client_name: string | null;
|
||||
office_name: string | null;
|
||||
savings_product_id: number;
|
||||
product_name: string;
|
||||
/** The product's own rhythm: 1 daily, 4 monthly, 5 quarterly, 6 half-yearly, 7 annual. */
|
||||
posting_period_enum: number;
|
||||
interest_rate: number;
|
||||
currency_code: string;
|
||||
/** ISO yyyy-MM-dd — formatted server-side, because this endpoint serialises with a plain Gson. */
|
||||
posting_date: string;
|
||||
interest_amount: number;
|
||||
/** Balance the interest was credited onto, and what it became. */
|
||||
balance_before: number;
|
||||
balance_after: number;
|
||||
recorded_on: string | null;
|
||||
}
|
||||
|
||||
/** One day of one account: the working behind that day's interest. */
|
||||
export interface SavingsInterestDay {
|
||||
balance_date: number[];
|
||||
@@ -100,6 +179,61 @@ export class SavingsDailyInterestService {
|
||||
return this.http.get<SavingsInterestAccount[]>(`${this.base}/products/${productId}/accounts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per account, across every product — the whole book's current interest position.
|
||||
*
|
||||
* Narrow it with any of the filters; all three are optional and combine.
|
||||
*/
|
||||
getSummary(filters?: {
|
||||
productId?: number;
|
||||
officeName?: string;
|
||||
accountNo?: string;
|
||||
}): Observable<SavingsInterestSummaryRow[]> {
|
||||
let params = new HttpParams();
|
||||
if (filters?.productId) {
|
||||
params = params.set('productId', filters.productId);
|
||||
}
|
||||
if (filters?.officeName) {
|
||||
params = params.set('officeName', filters.officeName);
|
||||
}
|
||||
if (filters?.accountNo) {
|
||||
params = params.set('accountNo', filters.accountNo);
|
||||
}
|
||||
return this.http.get<SavingsInterestSummaryRow[]>(`${this.base}/summary`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest actually paid to members, one row per posting.
|
||||
*
|
||||
* `period` narrows to one posting rhythm (1 daily, 4 monthly, 5 quarterly, 6 half-yearly,
|
||||
* 7 annual), matching the product's own posting period.
|
||||
*/
|
||||
getPostings(filters?: {
|
||||
period?: number;
|
||||
productId?: number;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
officeName?: string;
|
||||
}): Observable<SavingsInterestPostingRow[]> {
|
||||
let params = new HttpParams();
|
||||
if (filters?.period) {
|
||||
params = params.set('period', filters.period);
|
||||
}
|
||||
if (filters?.productId) {
|
||||
params = params.set('productId', filters.productId);
|
||||
}
|
||||
if (filters?.fromDate) {
|
||||
params = params.set('fromDate', filters.fromDate);
|
||||
}
|
||||
if (filters?.toDate) {
|
||||
params = params.set('toDate', filters.toDate);
|
||||
}
|
||||
if (filters?.officeName) {
|
||||
params = params.set('officeName', filters.officeName);
|
||||
}
|
||||
return this.http.get<SavingsInterestPostingRow[]>(`${this.base}/postings`, { params });
|
||||
}
|
||||
|
||||
getAccountLedger(accountId: number, limit = 400): Observable<SavingsInterestDay[]> {
|
||||
const params = new HttpParams().set('limit', limit);
|
||||
return this.http.get<SavingsInterestDay[]>(`${this.base}/accounts/${accountId}`, { params });
|
||||
|
||||
-2
@@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
|
||||
neftFacility: false,
|
||||
rtgsFacility: false,
|
||||
interBankTotalCapFacility: false,
|
||||
withdrawalFacility: false,
|
||||
depositFacilityKyc: false,
|
||||
virtualDebitCard: false
|
||||
};
|
||||
@@ -64,7 +63,6 @@ const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [
|
||||
{ key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' }
|
||||
];
|
||||
|
||||
+5
-1
@@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { SavingsService } from '../../savings.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@@ -43,6 +44,7 @@ export class AddChargeSavingsAccountComponent implements OnInit {
|
||||
private dateUtils = inject(Dates);
|
||||
private savingsService = inject(SavingsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||
|
||||
/** Minimum Due Date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
@@ -68,7 +70,9 @@ export class AddChargeSavingsAccountComponent implements OnInit {
|
||||
*/
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { savingsAccountActionData: any }) => {
|
||||
this.savingsChargeOptions = data.savingsAccountActionData.chargeOptions;
|
||||
this.facilityChargeFilter
|
||||
.excludeFacilityCharges(data.savingsAccountActionData.chargeOptions)
|
||||
.subscribe((charges: any[]) => (this.savingsChargeOptions = charges));
|
||||
});
|
||||
this.savingAccountId = this.route.snapshot.params['savingAccountId'];
|
||||
}
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
|
||||
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||
import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
||||
@@ -73,6 +74,7 @@ export class SavingsAccountChargesStepComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private dateUtils = inject(Dates);
|
||||
private translateService = inject(TranslateService);
|
||||
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||
|
||||
private _savingsAccountProductTemplate: any;
|
||||
|
||||
@@ -81,7 +83,9 @@ export class SavingsAccountChargesStepComponent implements OnInit {
|
||||
set savingsAccountProductTemplate(value: any) {
|
||||
this._savingsAccountProductTemplate = value;
|
||||
if (value) {
|
||||
this.chargeData = value.chargeOptions || [];
|
||||
this.facilityChargeFilter
|
||||
.excludeFacilityCharges(value.chargeOptions)
|
||||
.subscribe((charges: any[]) => (this.chargeData = charges));
|
||||
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
|
||||
}
|
||||
}
|
||||
|
||||
-12
@@ -174,18 +174,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dig-card" [class.dig-card--on]="facilityConfig.withdrawalFacility">
|
||||
<div class="dig-card__icon"><i class="fas fa-arrow-right-arrow-left"></i></div>
|
||||
<div class="dig-card__body">
|
||||
<span class="dig-card__label">Withdrawal Facility</span>
|
||||
<span class="dig-card__status">{{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
<label class="dig-toggle">
|
||||
<input type="checkbox" [(ngModel)]="facilityConfig.withdrawalFacility" />
|
||||
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dig-card" [class.dig-card--on]="facilityConfig.depositFacilityKyc">
|
||||
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
|
||||
<div class="dig-card__body">
|
||||
|
||||
-1
@@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = {
|
||||
neftFacility: false,
|
||||
rtgsFacility: false,
|
||||
interBankTotalCapFacility: false,
|
||||
withdrawalFacility: false,
|
||||
depositFacilityKyc: false,
|
||||
virtualDebitCard: false
|
||||
};
|
||||
|
||||
-1
@@ -73,7 +73,6 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
|
||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, shareReplay } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Charges that are mapped to an account facility (ATM, UPI, Cheque Book, ...) are priced by the
|
||||
* Facility Charge Mapping screen and levied automatically when that facility is enabled. Offering
|
||||
* them again in a product/account charge picker would attach the same charge a second time and
|
||||
* double-charge the customer, so they are filtered out of those pickers.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FacilityChargeFilterService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
/** Facility mappings rarely change within a session, so the lookup is fetched once and shared. */
|
||||
private mappedChargeIds$: Observable<Set<number>>;
|
||||
|
||||
/**
|
||||
* Ids of every charge currently mapped to a facility.
|
||||
* Falls back to an empty set if the lookup fails, so a charge picker still renders.
|
||||
*/
|
||||
getFacilityMappedChargeIds(): Observable<Set<number>> {
|
||||
if (!this.mappedChargeIds$) {
|
||||
this.mappedChargeIds$ = this.http.get('/facilitychargemappings').pipe(
|
||||
map(
|
||||
(mappings: any) =>
|
||||
new Set<number>(
|
||||
((mappings as any[]) || [])
|
||||
.map((mapping) => Number(mapping?.chargeId))
|
||||
.filter((chargeId) => Number.isFinite(chargeId))
|
||||
)
|
||||
),
|
||||
catchError(() => of(new Set<number>())),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
}
|
||||
return this.mappedChargeIds$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes facility-mapped charges from a charge option list.
|
||||
* @param charges Charge options straight off a product/account template.
|
||||
* @returns The same list without any charge priced by a facility mapping.
|
||||
*/
|
||||
excludeFacilityCharges(charges: any[]): Observable<any[]> {
|
||||
return this.getFacilityMappedChargeIds().pipe(
|
||||
map((mappedChargeIds) =>
|
||||
(charges || []).filter((charge) => !mappedChargeIds.has(Number(charge?.id ?? charge?.chargeId)))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
<div class="dap__header">
|
||||
<div class="dap__header-left">
|
||||
<div class="dap__icon-wrap">
|
||||
<i [class]="isProductMode ? 'fas fa-shield-alt' : 'fas fa-exclamation-triangle'"></i>
|
||||
<!-- A healthy account gets the shield, not the warning triangle: the panel is showing it
|
||||
its thresholds and the benefit exemption, not raising an alarm. -->
|
||||
<i [class]="isProductMode || isHealthy ? 'fas fa-shield-alt' : 'fas fa-exclamation-triangle'"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="dap__title">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user