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 { ReconciliationComponent } from './reconciliation/reconciliation.component';
|
||||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||||
import { DayBookComponent } from './day-book/day-book.component';
|
import { DayBookComponent } from './day-book/day-book.component';
|
||||||
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
|
|
||||||
import { VoucherViewComponent } from './voucher-view/voucher-view.component';
|
|
||||||
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
||||||
import { BranchDayComponent } from './branch-day/branch-day.component';
|
import { BranchDayComponent } from './branch-day/branch-day.component';
|
||||||
import { VaultPositionComponent } from './vault-position/vault-position.component';
|
import { VaultPositionComponent } from './vault-position/vault-position.component';
|
||||||
@@ -226,20 +224,23 @@ const routes: Routes = [
|
|||||||
component: DayBookComponent,
|
component: DayBookComponent,
|
||||||
data: { title: 'Day Book', breadcrumb: 'Day Book' }
|
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',
|
path: 'received-report',
|
||||||
component: VoucherExceptionsComponent,
|
|
||||||
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'voucher',
|
|
||||||
component: VoucherViewComponent,
|
|
||||||
data: { title: 'Voucher', breadcrumb: 'Voucher' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'voucher-report',
|
|
||||||
component: VoucherReportComponent,
|
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',
|
path: 'closing-report',
|
||||||
|
|||||||
@@ -312,6 +312,47 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: #0f172a;
|
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 {
|
.pg-step {
|
||||||
|
|||||||
@@ -126,66 +126,6 @@ export class CashierService {
|
|||||||
return this.http.get('/cashreports/day-book', { params });
|
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.
|
* 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
|
* 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.
|
* 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);
|
let params = new HttpParams().set('from', from).set('to', to);
|
||||||
if (username) {
|
if (username) {
|
||||||
params = params.set('username', username);
|
params = params.set('username', username);
|
||||||
@@ -201,25 +147,14 @@ export class CashierService {
|
|||||||
if (voucherType) {
|
if (voucherType) {
|
||||||
params = params.set('voucherType', 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 });
|
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
|
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||||
* stood then, and what it holds now.
|
* stood then, and what it holds now.
|
||||||
|
|||||||
@@ -27,12 +27,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 -->
|
<!-- Summary totals bar -->
|
||||||
<div class="closing-summary">
|
<div class="closing-summary">
|
||||||
<div class="closing-summary__item closing-summary__item--in">
|
<div class="closing-summary__item closing-summary__item--in">
|
||||||
<span class="closing-summary__label">Total Deposits (IN)</span>
|
<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>
|
||||||
<div class="closing-summary__sep">−</div>
|
<div class="closing-summary__sep">−</div>
|
||||||
<div class="closing-summary__item closing-summary__item--out">
|
<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-pos]="netTotal >= 0"
|
||||||
[class.closing-summary__item--net-neg]="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>
|
<span class="closing-summary__value">₹{{ netTotal | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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) {
|
@if (!showGl) {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
@if (noteRows.length > 0) {
|
@if (noteRows.length > 0 || ddCashTotal > 0) {
|
||||||
<div class="pos-section-label">Notes</div>
|
<div class="pos-section-label">Notes</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -93,7 +146,42 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</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>
|
</table>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,8 +229,20 @@
|
|||||||
</table>
|
</table>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (loaded && rows.length === 0 && exchangeRows.length === 0) {
|
<!-- Not shown when a Demand Draft row is on the table: there is something to read then, and
|
||||||
<div class="pg-empty">No cash denomination data for this date.</div>
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,38 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
loading = false;
|
loading = false;
|
||||||
loaded = 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
|
// GL View
|
||||||
showGl = false;
|
showGl = false;
|
||||||
glRows: any[] = [];
|
glRows: any[] = [];
|
||||||
@@ -66,6 +98,33 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
get noteRows(): any[] {
|
get noteRows(): any[] {
|
||||||
return this.rows.filter((r) => r.kind !== 'coin');
|
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[] {
|
get coinRows(): any[] {
|
||||||
return this.rows.filter((r) => r.kind === 'coin');
|
return this.rows.filter((r) => r.kind === 'coin');
|
||||||
}
|
}
|
||||||
@@ -92,6 +151,15 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
this.depositTotal = Number(res?.depositTotal || 0);
|
this.depositTotal = Number(res?.depositTotal || 0);
|
||||||
this.withdrawalTotal = Number(res?.withdrawalTotal || 0);
|
this.withdrawalTotal = Number(res?.withdrawalTotal || 0);
|
||||||
this.netTotal = Number(res?.netTotal || 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.loading = false;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
},
|
},
|
||||||
@@ -101,6 +169,13 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
this.depositTotal = 0;
|
this.depositTotal = 0;
|
||||||
this.withdrawalTotal = 0;
|
this.withdrawalTotal = 0;
|
||||||
this.netTotal = 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.loading = false;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,42 +65,6 @@
|
|||||||
@if (errorMsg) {
|
@if (errorMsg) {
|
||||||
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
<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) {
|
@if (loading) {
|
||||||
<div class="pg-empty">Loading…</div>
|
<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.
|
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="pg-card">
|
<!-- One table per source register rather than one long list. The two read differently: the
|
||||||
<table class="pg-table">
|
cashier's rows are cash that actually crossed the counter, the clerk's are requests nobody
|
||||||
<thead>
|
has actioned yet. Mixed together they invited the wrong sum. -->
|
||||||
<tr>
|
@for (s of sections; track s.key) {
|
||||||
<th>Voucher No</th>
|
<div class="pg-card db-section">
|
||||||
<th>Dr/Cr</th>
|
<div class="db-section__head">
|
||||||
<th>Type</th>
|
<strong>{{ s.label }}</strong>
|
||||||
<th>Account</th>
|
<span class="db-muted">{{ s.hint }}</span>
|
||||||
<th>Party</th>
|
<span class="db-section__count">{{ s.rows.length }} row{{ s.rows.length === 1 ? '' : 's' }}</span>
|
||||||
<th class="num">Amount</th>
|
</div>
|
||||||
<th>Payment</th>
|
<table class="pg-table">
|
||||||
<th>Status</th>
|
<thead>
|
||||||
<th>By</th>
|
<tr>
|
||||||
<th>Approved By</th>
|
<th>Voucher No</th>
|
||||||
<th>Slip Check</th>
|
<th>Dr/Cr</th>
|
||||||
<th>Time</th>
|
<th>Type</th>
|
||||||
<th></th>
|
<th>Account</th>
|
||||||
</tr>
|
<th>Party</th>
|
||||||
</thead>
|
<th class="num">Amount</th>
|
||||||
<tbody>
|
<th>Payment</th>
|
||||||
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
|
<th>Status</th>
|
||||||
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
<th>By</th>
|
||||||
<td>
|
<th>Approved By</th>
|
||||||
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
<th>Time</th>
|
||||||
</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
|
<tbody>
|
||||||
enough: it cannot be printed, exported, or read without a mouse. -->
|
@for (r of s.rows; track r.voucherNo ?? r.section + r.txnId) {
|
||||||
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
|
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||||||
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
|
<td>
|
||||||
<td colspan="13">
|
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||||||
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
|
</td>
|
||||||
<strong>Exception</strong>
|
<td>
|
||||||
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
|
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||||||
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
|
{{ r.side }}
|
||||||
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
|
</span>
|
||||||
}
|
</td>
|
||||||
— {{ r.verifyRemarks }}
|
<td>{{ r.kind }}</td>
|
||||||
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
|
<td>
|
||||||
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
|
{{ r.accountNo ?? '—' }}
|
||||||
@if (r.resolutionStatus) {
|
@if (r.accountType) {
|
||||||
<div class="db-exc-row__resolution">
|
<span class="db-muted">· {{ r.accountType }}</span>
|
||||||
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
|
|
||||||
{{ r.resolutionNotes }}
|
|
||||||
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td>{{ r.party ?? '—' }}</td>
|
||||||
}
|
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||||||
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
|
<td>
|
||||||
next to the row they describe. -->
|
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||||
@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>
|
</td>
|
||||||
</tr>
|
<td>{{ r.handledBy ?? '—' }}</td>
|
||||||
}
|
<td>
|
||||||
|
{{ r.approvedBy ?? '—' }}
|
||||||
<!-- Attachment panel for this voucher. -->
|
@if (r.approvedBy && r.actionedAt) {
|
||||||
@if (attachRow === r) {
|
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||||||
<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>
|
</td>
|
||||||
|
<td>{{ timeOf(r.submittedAt) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
</tbody>
|
||||||
</tbody>
|
<tfoot>
|
||||||
<tfoot>
|
<tr>
|
||||||
<tr>
|
<td colspan="5">{{ s.label }} subtotal</td>
|
||||||
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
|
<td class="num">
|
||||||
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
|
@if (s.credit > 0 && s.debit > 0) {
|
||||||
<td colspan="7"></td>
|
₹{{ s.credit | number: '1.2-2' }} CR · ₹{{ s.debit | number: '1.2-2' }} DR
|
||||||
</tr>
|
} @else if (s.credit > 0) {
|
||||||
</tfoot>
|
₹{{ s.credit | number: '1.2-2' }} CR
|
||||||
</table>
|
} @else if (s.debit > 0) {
|
||||||
</div>
|
₹{{ 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) {
|
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
|
||||||
|
|||||||
@@ -32,6 +32,34 @@
|
|||||||
color: #94a3b8;
|
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. */
|
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
|
||||||
.db-side {
|
.db-side {
|
||||||
display: inline-block;
|
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
|
/* Raising an exception is a deliberate, consequential act — styled to read that way without
|
||||||
looking destructive, since nothing is deleted. */
|
looking destructive, since nothing is deleted. */
|
||||||
.pg-btn--warn {
|
.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 {
|
.pg-table tfoot td {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
|||||||
@@ -13,8 +13,18 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
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 {
|
interface DayBookRow {
|
||||||
voucherNo: string | null;
|
voucherNo: string | null;
|
||||||
|
section: DayBookSection;
|
||||||
side: 'CR' | 'DR';
|
side: 'CR' | 'DR';
|
||||||
kind: string;
|
kind: string;
|
||||||
accountNo: string | null;
|
accountNo: string | null;
|
||||||
@@ -29,18 +39,6 @@ interface DayBookRow {
|
|||||||
txnId: string | null;
|
txnId: string | null;
|
||||||
submittedAt: string | null;
|
submittedAt: string | null;
|
||||||
actionedAt: 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();
|
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
|
* The day's rows split by where they came from, in the order they are read.
|
||||||
* visible on its own row as history, but it is no longer something anyone has to act on.
|
*
|
||||||
|
* 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[] {
|
get sections(): {
|
||||||
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
|
key: DayBookSection;
|
||||||
}
|
label: string;
|
||||||
|
hint: string;
|
||||||
/** Closed exceptions, kept for the record rather than for action. */
|
rows: DayBookRow[];
|
||||||
get resolvedExceptionCount(): number {
|
credit: number;
|
||||||
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
|
debit: number;
|
||||||
}
|
}[] {
|
||||||
|
const order: { key: DayBookSection; label: string; hint: string }[] = [
|
||||||
/** Human label for a resolution code. */
|
{ key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' },
|
||||||
resolutionLabel(status: string | null): string {
|
{ key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' }
|
||||||
switch (status) {
|
];
|
||||||
case 'RESOLVED_SYSTEM_ERROR':
|
return order
|
||||||
return 'System entry was wrong';
|
.map((s) => {
|
||||||
case 'RESOLVED_SLIP_ERROR':
|
const rows = this.filteredRows.filter((r) => r.section === s.key);
|
||||||
return 'Slip was written wrong';
|
return {
|
||||||
case 'RESOLVED_NO_ACTION':
|
...s,
|
||||||
return 'No action needed';
|
rows,
|
||||||
default:
|
credit: rows
|
||||||
return status ?? '';
|
.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))
|
||||||
/** Vouchers nobody has checked against a slip yet. */
|
.reduce((t, r) => t + Number(r.amount ?? 0), 0)
|
||||||
get uncheckedCount(): number {
|
};
|
||||||
return this.rows.filter((r) => !r.verifyStatus).length;
|
})
|
||||||
}
|
.filter((s) => s.rows.length > 0);
|
||||||
|
|
||||||
toggleExceptionsOnly(): void {
|
|
||||||
this.exceptionsOnly = !this.exceptionsOnly;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
|
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
|
||||||
get filteredRows(): DayBookRow[] {
|
get filteredRows(): DayBookRow[] {
|
||||||
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
|
const base = this.rows;
|
||||||
const q = this.search.trim().toLowerCase();
|
const q = this.search.trim().toLowerCase();
|
||||||
if (!q) return base;
|
if (!q) return base;
|
||||||
return base.filter((r) =>
|
return base.filter((r) =>
|
||||||
@@ -231,236 +228,9 @@ export class DayBookComponent implements OnInit {
|
|||||||
return t ? t.slice(0, 5) : '—';
|
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 {
|
exportCsv(): void {
|
||||||
const header = [
|
const header = [
|
||||||
|
'Section',
|
||||||
'Voucher No',
|
'Voucher No',
|
||||||
'Side',
|
'Side',
|
||||||
'Type',
|
'Type',
|
||||||
@@ -471,18 +241,11 @@ export class DayBookComponent implements OnInit {
|
|||||||
'Status',
|
'Status',
|
||||||
'Handled By',
|
'Handled By',
|
||||||
'Approved By',
|
'Approved By',
|
||||||
'Slip Check',
|
|
||||||
'Slip Amount',
|
|
||||||
'Remarks',
|
|
||||||
'Checked By',
|
|
||||||
'Resolution',
|
|
||||||
'Resolution Notes',
|
|
||||||
'Closed By',
|
|
||||||
'Attachments',
|
|
||||||
'Time'
|
'Time'
|
||||||
];
|
];
|
||||||
const lines = this.filteredRows.map((r) =>
|
const lines = this.filteredRows.map((r) =>
|
||||||
[
|
[
|
||||||
|
r.section,
|
||||||
r.voucherNo ?? '',
|
r.voucherNo ?? '',
|
||||||
r.side,
|
r.side,
|
||||||
r.kind,
|
r.kind,
|
||||||
@@ -493,14 +256,6 @@ export class DayBookComponent implements OnInit {
|
|||||||
r.status,
|
r.status,
|
||||||
r.handledBy ?? '',
|
r.handledBy ?? '',
|
||||||
r.approvedBy ?? '',
|
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)
|
this.timeOf(r.submittedAt)
|
||||||
]
|
]
|
||||||
// Quote every field and double any embedded quote: names and narrations routinely
|
// 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>
|
<option [ngValue]="27">027 · Industrial</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="ws-f ws-f--paytype">
|
||||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Transaction Type">TXN TP.</label>
|
<label class="ws-f__lbl ws-f__lbl--accent" title="Transaction Type">TXN TP.</label>
|
||||||
<select
|
<select
|
||||||
@@ -182,7 +199,22 @@
|
|||||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(blur)="onAcNoBlur(row, i)"
|
(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>
|
||||||
<div class="ws-f ws-f--ecsno">
|
<div class="ws-f ws-f--ecsno">
|
||||||
@@ -340,7 +372,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
<!-- ── 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-row ws-group-row--mt">
|
||||||
<div class="ws-group ws-group--flex">
|
<div class="ws-group ws-group--flex">
|
||||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||||
@@ -375,7 +407,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
<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>
|
</label>
|
||||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
@@ -387,6 +419,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,6 +447,18 @@
|
|||||||
</div>
|
</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) ─────────── -->
|
<!-- ── APPROVED WITHDRAWAL BANNER (cashier disburse flow) ─────────── -->
|
||||||
@if (!isClerk && loadingWithdrawal) {
|
@if (!isClerk && loadingWithdrawal) {
|
||||||
<div class="ws-withdrawal-banner ws-withdrawal-banner--loading">
|
<div class="ws-withdrawal-banner ws-withdrawal-banner--loading">
|
||||||
@@ -1404,7 +1465,12 @@
|
|||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--type">
|
<div class="ws-f ws-f--type">
|
||||||
<label class="ws-f__lbl">TYPE</label>
|
<label class="ws-f__lbl">TYPE</label>
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
<select
|
||||||
|
class="ws-f__ctl"
|
||||||
|
[(ngModel)]="row.typ"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
(change)="onTypChange(row)"
|
||||||
|
>
|
||||||
@for (t of typOptions; track t.value) {
|
@for (t of typOptions; track t.value) {
|
||||||
<option [value]="t.value">{{ t.label }}</option>
|
<option [value]="t.value">{{ t.label }}</option>
|
||||||
}
|
}
|
||||||
@@ -1412,18 +1478,36 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
<label class="ws-f__lbl">BRANCH</label>
|
<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]="1">001 · Main</option>
|
||||||
<option [ngValue]="2">002 · M.G. Road</option>
|
<option [ngValue]="2">002 · M.G. Road</option>
|
||||||
<option [ngValue]="14">014 · Station Rd</option>
|
<option [ngValue]="14">014 · Station Rd</option>
|
||||||
<option [ngValue]="27">027 · Industrial</option>
|
<option [ngValue]="27">027 · Industrial</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="ws-f ws-f--paytype">
|
||||||
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
||||||
<select
|
<select
|
||||||
class="ws-f__ctl ws-f__ctl--accent"
|
class="ws-f__ctl ws-f__ctl--accent"
|
||||||
[(ngModel)]="row.paymentTypeId"
|
[(ngModel)]="row.paymentTypeId"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
@@ -1436,6 +1520,7 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
(change)="
|
(change)="
|
||||||
row.acTyp === 'GL' &&
|
row.acTyp === 'GL' &&
|
||||||
row.typ !== 'Disbursement' &&
|
row.typ !== 'Disbursement' &&
|
||||||
@@ -1462,7 +1547,14 @@
|
|||||||
[value]="row.acNo"
|
[value]="row.acNo"
|
||||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||||
(blur)="onAcNoBlur(row, i)"
|
(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>
|
||||||
<div class="ws-f ws-f--ecsno">
|
<div class="ws-f ws-f--ecsno">
|
||||||
@@ -1591,14 +1683,19 @@
|
|||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--desc">
|
<div class="ws-f ws-f--desc">
|
||||||
<label class="ws-f__lbl">DESCRIPTION</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
<!-- ── 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-row ws-group-row--mt">
|
||||||
<div class="ws-group ws-group--flex">
|
<div class="ws-group ws-group--flex">
|
||||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||||
@@ -1612,6 +1709,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
[value]="row.ddCharges ?? ''"
|
[value]="row.ddCharges ?? ''"
|
||||||
(input)="row.ddCharges = +$any($event.target).value"
|
(input)="row.ddCharges = +$any($event.target).value"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1632,7 +1730,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
<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>
|
</label>
|
||||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
@@ -1644,6 +1742,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3384,3 +3384,67 @@ select.te-cell {
|
|||||||
transform: rotate(360deg);
|
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';
|
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
|
||||||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||||
import { OrganizationService } from '../../organization/organization.service';
|
import { OrganizationService } from '../../organization/organization.service';
|
||||||
|
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
|
||||||
|
|
||||||
interface TxnRow {
|
interface TxnRow {
|
||||||
typ: string;
|
typ: string;
|
||||||
@@ -51,6 +52,7 @@ interface TxnRow {
|
|||||||
routingCode: string;
|
routingCode: string;
|
||||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||||
ddCharges: number | null;
|
ddCharges: number | null;
|
||||||
|
sponsorBankId?: number | null;
|
||||||
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
/** 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()). */
|
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||||
resolutionNumber?: string;
|
resolutionNumber?: string;
|
||||||
@@ -1523,7 +1525,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||||
*/
|
*/
|
||||||
paymentTypesForRow(row: TxnRow): any[] {
|
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) {
|
if (!this.isClerk) {
|
||||||
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
|
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
|
||||||
// clerk/back-office approval flows, never straight through the cash drawer.
|
// clerk/back-office approval flows, never straight through the cash drawer.
|
||||||
@@ -1541,6 +1550,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
if (isShareSettlementCredit) {
|
if (isShareSettlementCredit) {
|
||||||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
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;
|
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.
|
// 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).
|
// funding a newly-activated account's opening balance from a GL source).
|
||||||
{ value: 'Fund Transfer', label: 'Fund Transfer' },
|
{ 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: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
|
||||||
{ value: 'DD', label: 'DD (Demand Draft)' }
|
{ value: 'DD', label: 'DD (Demand Draft)' }
|
||||||
];
|
];
|
||||||
@@ -1692,6 +1715,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
|
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sponsorBanks: any[] = [];
|
||||||
|
ddGlMapping: any = null;
|
||||||
|
|
||||||
// ── Denomination ──────────────────────────────────────────────────────────
|
// ── Denomination ──────────────────────────────────────────────────────────
|
||||||
notes: Denomination[] = [];
|
notes: Denomination[] = [];
|
||||||
coins: Denomination[] = [];
|
coins: Denomination[] = [];
|
||||||
@@ -1752,6 +1778,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.loadCashVaultGl();
|
this.loadCashVaultGl();
|
||||||
this.loadDdGstPercentage();
|
this.loadDdGstPercentage();
|
||||||
|
this.loadSponsorBanks();
|
||||||
|
|
||||||
if (this.reviewMode) {
|
if (this.reviewMode) {
|
||||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||||
@@ -1842,12 +1869,85 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
.getGlMappings()
|
.getGlMappings()
|
||||||
.pipe(catchError(() => of([])))
|
.pipe(catchError(() => of([])))
|
||||||
.subscribe((mappings: any[]) => {
|
.subscribe((mappings: any[]) => {
|
||||||
const mapping = mappings && mappings.length ? mappings[0] : null;
|
this.ddGlMapping = mappings && mappings.length ? mappings[0] : null;
|
||||||
this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
|
this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18);
|
||||||
this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
|
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 {
|
private loadDayStatus(): void {
|
||||||
this.cashierService
|
this.cashierService
|
||||||
.getCashierDayStatus()
|
.getCashierDayStatus()
|
||||||
@@ -2093,9 +2193,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
row.chequeNo = '';
|
row.chequeNo = '';
|
||||||
row.cheqDate = '';
|
row.cheqDate = '';
|
||||||
break;
|
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':
|
case 'Cheque':
|
||||||
row.dbCr = 'Dr';
|
row.dbCr = 'Dr';
|
||||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||||
break;
|
break;
|
||||||
case 'Disbursement':
|
case 'Disbursement':
|
||||||
case 'Fund Transfer':
|
case 'Fund Transfer':
|
||||||
@@ -2121,16 +2224,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
row.chequeNo = '';
|
row.chequeNo = '';
|
||||||
row.cheqDate = '';
|
row.cheqDate = '';
|
||||||
break;
|
break;
|
||||||
// A DD is bought with cash: the customer hands over Face Value + Charges + GST, so it is money
|
// A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is
|
||||||
// coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment type and
|
// money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment
|
||||||
// AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST panel never
|
// type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST
|
||||||
// appears and the row would post as an ordinary entry with the charges silently dropped.
|
// 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':
|
case 'DD':
|
||||||
row.dbCr = 'Cr';
|
row.dbCr = 'Cr';
|
||||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
|
||||||
row.acTyp = 'GL';
|
row.acTyp = 'GL';
|
||||||
|
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
|
||||||
row.chequeNo = '';
|
row.chequeNo = '';
|
||||||
row.cheqDate = '';
|
row.cheqDate = '';
|
||||||
|
if (this.sponsorBanks.length > 0 && !row.sponsorBankId) {
|
||||||
|
row.sponsorBankId = this.sponsorBanks[0].id;
|
||||||
|
}
|
||||||
|
this.applyDdGlMappingToRow(row);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2145,10 +2257,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return pt?.id ?? null;
|
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 {
|
isDdCashGlRow(row: TxnRow): boolean {
|
||||||
|
if (this.isDdAccountDebitRow(row)) return false;
|
||||||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
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. */
|
/** 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;
|
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).
|
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||||||
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
* 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;
|
const f = cfg.matchField;
|
||||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
// 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
|
// 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
|
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||||
// gated on "exactly one row came back" — it never would be for those types.
|
|
||||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
|
||||||
const partialMatch = !exactMatch
|
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;
|
: 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) {
|
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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.loadingAccount = false;
|
this.loadingAccount = false;
|
||||||
@@ -3048,7 +3442,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const returnedId = acc.glCode ?? acc.accountNo;
|
const returnedId = acc.glCode ?? acc.accountNo;
|
||||||
if (returnedId && returnedId !== acNo) {
|
if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) {
|
||||||
this.loadingAccount = false;
|
this.loadingAccount = false;
|
||||||
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
|
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
|
||||||
return false;
|
return false;
|
||||||
@@ -3294,7 +3688,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
{ label: 'NEFT Facility', active: !!f.neftFacility },
|
{ label: 'NEFT Facility', active: !!f.neftFacility },
|
||||||
{ label: 'RTGS Facility', active: !!f.rtgsFacility },
|
{ label: 'RTGS Facility', active: !!f.rtgsFacility },
|
||||||
{ label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility },
|
{ label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility },
|
||||||
{ label: 'Withdrawal', active: !!f.withdrawalFacility },
|
|
||||||
{ label: 'Deposit (KYC)', active: !!f.depositFacilityKyc },
|
{ label: 'Deposit (KYC)', active: !!f.depositFacilityKyc },
|
||||||
{ label: 'Virtual Debit Card', active: !!f.virtualDebitCard },
|
{ label: 'Virtual Debit Card', active: !!f.virtualDebitCard },
|
||||||
{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }
|
{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }
|
||||||
@@ -4579,6 +4972,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
confirmSubmit(): void {
|
confirmSubmit(): void {
|
||||||
this.showConfirmDialog = false;
|
this.showConfirmDialog = false;
|
||||||
const row = this.selectedRow!;
|
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 amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
|
||||||
const isDeposit = row.dbCr === 'Cr';
|
const isDeposit = row.dbCr === 'Cr';
|
||||||
const command = isDeposit ? 'deposit' : 'withdrawal';
|
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">
|
<header class="pg-head vr-no-print">
|
||||||
<div>
|
<div>
|
||||||
<div class="pg-eyebrow">CASHIER · REPORT</div>
|
<div class="pg-eyebrow">CASHIER · REPORT</div>
|
||||||
<h1 class="pg-title">Voucher Report</h1>
|
<h1 class="pg-title">{{ title }}</h1>
|
||||||
<p class="pg-sub">
|
<p class="pg-sub">{{ subtitle }}</p>
|
||||||
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pg-toolbar">
|
<div class="pg-toolbar">
|
||||||
<label class="pg-field">
|
<label class="pg-field">
|
||||||
@@ -42,10 +40,8 @@
|
|||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||||
<mat-icon>refresh</mat-icon> Run
|
<mat-icon>refresh</mat-icon> Run
|
||||||
</button>
|
</button>
|
||||||
@if (report && report.count > 0) {
|
@if (report && count > 0) {
|
||||||
<button type="button" class="pg-btn" (click)="print()">
|
<button type="button" class="pg-btn" (click)="print()"><mat-icon>print</mat-icon> Print</button>
|
||||||
<mat-icon>print</mat-icon> Print
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -64,8 +60,7 @@
|
|||||||
@if (report.from !== report.to) {
|
@if (report.from !== report.to) {
|
||||||
— {{ report.to | date: 'dd MMM yyyy' }}
|
— {{ report.to | date: 'dd MMM yyyy' }}
|
||||||
}
|
}
|
||||||
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
|
· {{ count }} {{ noun }}{{ count === 1 ? '' : 's' }} · ₹{{ totalAmount | number: '1.2-2' }}
|
||||||
· ₹{{ report.totalAmount | number: '1.2-2' }}
|
|
||||||
@if (report.username) {
|
@if (report.username) {
|
||||||
· {{ report.username }}
|
· {{ report.username }}
|
||||||
}
|
}
|
||||||
@@ -74,10 +69,46 @@
|
|||||||
@if (!report.canSeeAllUsers) {
|
@if (!report.canSeeAllUsers) {
|
||||||
<span class="vr-scope-note">Your own vouchers</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (report.count === 0) {
|
<!-- A voucher with no CR/DR prefix belongs to neither report. Say so, so the two reports not
|
||||||
<div class="pg-empty">No vouchers were raised in this period.</div>
|
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 {
|
} @else {
|
||||||
<div class="vr-summaries">
|
<div class="vr-summaries">
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
@@ -129,7 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pg-card">
|
<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">
|
<table class="pg-table vr-rows">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -143,33 +174,39 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (row of report.vouchers; track row.voucherNo) {
|
@for (row of rows; track row.voucherNo) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
|
{{ row.voucherNo }}
|
||||||
{{ row.voucherNo }}
|
|
||||||
</a>
|
|
||||||
@if (row.verificationStatus === 'EXCEPTION') {
|
@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>
|
||||||
<td>{{ row.voucherType || '—' }}</td>
|
<td>{{ row.voucherType || '—' }}</td>
|
||||||
<td>
|
<!-- A voucher raised before its sides were recorded has neither. Say that once,
|
||||||
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
across both cells, rather than showing two dashes that read as "nowhere". -->
|
||||||
@if (row.debitAccount) {
|
@if (!hasSides(row)) {
|
||||||
<div class="vr-acct">{{ row.debitAccount }}</div>
|
<td colspan="2" class="vr-nosides">Sides not recorded</td>
|
||||||
}
|
} @else {
|
||||||
</td>
|
<td>
|
||||||
<td>
|
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||||
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
@if (row.debitAccount) {
|
||||||
@if (row.creditAccount) {
|
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||||
<div class="vr-acct">{{ row.creditAccount }}</div>
|
}
|
||||||
}
|
</td>
|
||||||
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
<td>
|
||||||
@if (row.extraLines > 0) {
|
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||||
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
@if (row.creditAccount) {
|
||||||
}
|
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||||
</td>
|
}
|
||||||
|
<!-- 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 class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||||
<td>{{ row.username || '—' }}</td>
|
<td>{{ row.username || '—' }}</td>
|
||||||
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
|
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
|
||||||
|
|||||||
@@ -36,16 +36,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vr-rows {
|
.vr-rows {
|
||||||
.vr-link {
|
|
||||||
color: #1d4ed8;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vr-flag {
|
.vr-flag {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -65,6 +55,13 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #6b7280;
|
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. */
|
/* Printing a report should put the register on paper — the filters are how you reached it. */
|
||||||
@@ -72,9 +69,4 @@
|
|||||||
.vr-no-print {
|
.vr-no-print {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vr-link {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,19 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { RouterLink } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
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. */
|
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||||
interface VoucherRow {
|
interface VoucherRow {
|
||||||
voucherNo: string;
|
voucherNo: string;
|
||||||
@@ -30,6 +38,13 @@ interface VoucherRow {
|
|||||||
creditAccount: string | null;
|
creditAccount: string | null;
|
||||||
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
|
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
|
||||||
extraLines: number;
|
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 {
|
interface VoucherReport {
|
||||||
@@ -38,6 +53,8 @@ interface VoucherReport {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
|
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
|
||||||
canSeeAllUsers: boolean;
|
canSeeAllUsers: boolean;
|
||||||
|
/** Whether the server restricted the register to cash movements. */
|
||||||
|
cashOnly: boolean;
|
||||||
vouchers: VoucherRow[];
|
vouchers: VoucherRow[];
|
||||||
count: number;
|
count: number;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
@@ -60,12 +77,12 @@ interface VoucherReport {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
RouterLink,
|
|
||||||
MatIconModule
|
MatIconModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VoucherReportComponent implements OnInit {
|
export class VoucherReportComponent implements OnInit {
|
||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
/** Day is the working default; a month is what you ask for at a close. */
|
/** Day is the working default; a month is what you ask for at a close. */
|
||||||
period: 'DAY' | 'MONTH' = 'DAY';
|
period: 'DAY' | 'MONTH' = 'DAY';
|
||||||
@@ -74,21 +91,67 @@ export class VoucherReportComponent implements OnInit {
|
|||||||
usernameFilter = '';
|
usernameFilter = '';
|
||||||
typeFilter = '';
|
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;
|
report: VoucherReport | null = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
error: string | null = null;
|
error: string | null = null;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
|
||||||
|
this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
|
||||||
this.load();
|
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 {
|
load(): void {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
const { from, to } = this.range();
|
const { from, to } = this.range();
|
||||||
this.service
|
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(
|
.pipe(
|
||||||
catchError((err: any) => {
|
catchError((err: any) => {
|
||||||
this.error =
|
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')}`;
|
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. */
|
/** Totals per user, for the summary a manager reads before the rows. */
|
||||||
get byUser(): { username: string; count: number; total: number }[] {
|
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 }>();
|
const tally = new Map<string, { username: string; count: number; total: number }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = row.username ?? '—';
|
const key = row.username ?? '—';
|
||||||
@@ -141,7 +290,7 @@ export class VoucherReportComponent implements OnInit {
|
|||||||
|
|
||||||
/** Totals per voucher type — deposits against withdrawals against journals. */
|
/** Totals per voucher type — deposits against withdrawals against journals. */
|
||||||
get byType(): { type: string; count: number; total: number }[] {
|
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 }>();
|
const tally = new Map<string, { type: string; count: number; total: number }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = row.voucherType ?? '—';
|
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 } : {};
|
const params = status && status !== 'ALL' ? { status } : {};
|
||||||
return this.http.get('/clerk/dividend-dashboard/locks', { params });
|
return this.http.get('/clerk/dividend-dashboard/locks', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,6 +350,7 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
||||||
/* The active marker is this underline, so every tab reserves the space for it — otherwise the
|
/* 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. */
|
strip would shift by 2px each time the selection moved. */
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ export class DividendDashboardComponent implements OnInit {
|
|||||||
lockYears: string[] = [];
|
lockYears: string[] = [];
|
||||||
lockYearFilter = 'ALL';
|
lockYearFilter = 'ALL';
|
||||||
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadSummary();
|
this.loadSummary();
|
||||||
this.loadDeclarations();
|
this.loadDeclarations();
|
||||||
|
|||||||
@@ -1043,4 +1043,56 @@ export class ClientsService {
|
|||||||
getKycContactLog(clientId: string | number): Observable<any[]> {
|
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||||
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
|
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
|
Today's Activity
|
||||||
</a>
|
</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>
|
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
||||||
</nav>
|
</nav>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -288,27 +247,17 @@
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="cashier-nav__item"
|
class="cashier-nav__item"
|
||||||
[routerLink]="['/cashier/day-book']"
|
[routerLink]="['/cashier/received-report']"
|
||||||
routerLinkActive="cashier-nav__item--active"
|
routerLinkActive="cashier-nav__item--active"
|
||||||
>
|
>
|
||||||
<fa-icon icon="book"></fa-icon> Day Book
|
<fa-icon icon="list"></fa-icon> Received
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="cashier-nav__item"
|
class="cashier-nav__item"
|
||||||
[routerLink]="['/cashier/voucher-exceptions']"
|
[routerLink]="['/cashier/payment-report']"
|
||||||
routerLinkActive="cashier-nav__item--active"
|
routerLinkActive="cashier-nav__item--active"
|
||||||
>
|
>
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
|
<fa-icon icon="list"></fa-icon> Payment
|
||||||
</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
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="cashier-nav__item"
|
class="cashier-nav__item"
|
||||||
@@ -685,6 +634,15 @@
|
|||||||
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||||
</button>
|
</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,
|
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.
|
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">
|
<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
|
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
|
||||||
</button>
|
</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']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
|
||||||
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
|
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
|
||||||
</button>
|
</button>
|
||||||
@@ -1152,15 +1101,6 @@
|
|||||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
|
||||||
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
|
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
|
||||||
</button>
|
</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']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
|
||||||
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
|
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
|
||||||
</button>
|
</button>
|
||||||
@@ -1253,12 +1193,6 @@
|
|||||||
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
|
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
|
||||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
|
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
|
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
|
||||||
|
|||||||
+8
-5
@@ -367,15 +367,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Compliance / AML Action Triage -->
|
<!-- Compliance / AML Action Triage -->
|
||||||
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
|
<div
|
||||||
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
|
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
|
Compliance & AML Triage
|
||||||
</h4>
|
</h4>
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||||
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
|
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{ standalone: true }">
|
||||||
Flag as suspicious (escalate to STR cases under RBI compliance)
|
Flag as suspicious (escalate to STR cases under RBI compliance)
|
||||||
</mat-checkbox>
|
</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)
|
Mark as whitelisted (prevent future AML alerts for this account)
|
||||||
</mat-checkbox>
|
</mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-1
@@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
|
|||||||
import { CurrentAccountsService } from '../../current-accounts.service';
|
import { CurrentAccountsService } from '../../current-accounts.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
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';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,7 @@ export class AddChargeCurrentAccountComponent implements OnInit {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private currentAccountsService = inject(CurrentAccountsService);
|
private currentAccountsService = inject(CurrentAccountsService);
|
||||||
|
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
/** Minimum Due Date allowed. */
|
/** Minimum Due Date allowed. */
|
||||||
@@ -66,7 +68,9 @@ export class AddChargeCurrentAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
this.route.data.subscribe((data: { currentAccountActionData: any }) => {
|
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'];
|
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 { MatButton, MatIconButton } from '@angular/material/button';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
|
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 { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
||||||
@@ -73,6 +74,7 @@ export class CurrentAccountChargesStepComponent implements OnInit {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private translateService = inject(TranslateService);
|
private translateService = inject(TranslateService);
|
||||||
|
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||||
|
|
||||||
/** Current Account Product Template */
|
/** Current Account Product Template */
|
||||||
private _currentAccountProductTemplate: any;
|
private _currentAccountProductTemplate: any;
|
||||||
@@ -80,7 +82,9 @@ export class CurrentAccountChargesStepComponent implements OnInit {
|
|||||||
set currentAccountProductTemplate(value: any) {
|
set currentAccountProductTemplate(value: any) {
|
||||||
this._currentAccountProductTemplate = value;
|
this._currentAccountProductTemplate = value;
|
||||||
if (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
|
// Only pre-populate from product template for new accounts
|
||||||
if (!this.currentAccountTemplate?.id) {
|
if (!this.currentAccountTemplate?.id) {
|
||||||
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
|
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
|
||||||
|
|||||||
-12
@@ -174,18 +174,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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" [class.dig-card--on]="facilityConfig.depositFacilityKyc">
|
||||||
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
|
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
|
||||||
<div class="dig-card__body">
|
<div class="dig-card__body">
|
||||||
|
|||||||
-1
@@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = {
|
|||||||
neftFacility: false,
|
neftFacility: false,
|
||||||
rtgsFacility: false,
|
rtgsFacility: false,
|
||||||
interBankTotalCapFacility: false,
|
interBankTotalCapFacility: false,
|
||||||
withdrawalFacility: false,
|
|
||||||
depositFacilityKyc: false,
|
depositFacilityKyc: false,
|
||||||
virtualDebitCard: false,
|
virtualDebitCard: false,
|
||||||
internetBanking: false,
|
internetBanking: false,
|
||||||
|
|||||||
-1
@@ -76,7 +76,6 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
|
|||||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
|
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
|
||||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
|
||||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' },
|
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' },
|
||||||
{ key: 'internetBanking', label: 'Internet Banking' },
|
{ key: 'internetBanking', label: 'Internet Banking' },
|
||||||
|
|||||||
-2
@@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
|
|||||||
neftFacility: false,
|
neftFacility: false,
|
||||||
rtgsFacility: false,
|
rtgsFacility: false,
|
||||||
interBankTotalCapFacility: false,
|
interBankTotalCapFacility: false,
|
||||||
withdrawalFacility: false,
|
|
||||||
depositFacilityKyc: false,
|
depositFacilityKyc: false,
|
||||||
virtualDebitCard: false,
|
virtualDebitCard: false,
|
||||||
internetBanking: 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: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
|
||||||
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
|
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
|
||||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
|
{ 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: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
|
||||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' },
|
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' },
|
||||||
{ key: 'internetBanking', label: 'Internet Banking', icon: 'fa-globe' },
|
{ 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>
|
<tfoot>
|
||||||
<tr class="row-total expense">
|
<tr class="row-total expense">
|
||||||
<td colspan="2">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="acr-note">
|
<p class="acr-note">
|
||||||
<fa-icon icon="info-circle"></fa-icon> Individual item-wise figures for this schedule are not broken
|
<fa-icon icon="info-circle"></fa-icon> Individual item-wise figures for this schedule are not broken out
|
||||||
out here — only the audited total is shown, to avoid presenting a sub-item split that could not be
|
here — only the audited total is shown, to avoid presenting a sub-item split that could not be independently
|
||||||
independently verified against the source statement.
|
verified against the source statement.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
+14
-2
@@ -150,7 +150,13 @@ export class AccountingReportsDialogComponent {
|
|||||||
|
|
||||||
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
|
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
|
||||||
pnl: PnlLine[] = [
|
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-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 },
|
||||||
{
|
{
|
||||||
code: 'I-03',
|
code: 'I-03',
|
||||||
@@ -167,7 +173,13 @@ export class AccountingReportsDialogComponent {
|
|||||||
label: 'Net Income on Sale of Fixed Assets',
|
label: 'Net Income on Sale of Fixed Assets',
|
||||||
amount: 0.3
|
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',
|
code: 'E-01',
|
||||||
group: 'EXPENDITURE',
|
group: 'EXPENDITURE',
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ export class CimsXbrlDialogComponent {
|
|||||||
name: 'OSS-4',
|
name: 'OSS-4',
|
||||||
fullName: 'Connected Lending & Large Exposure Statement',
|
fullName: 'Connected Lending & Large Exposure Statement',
|
||||||
description:
|
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',
|
cimsCode: 'CIMS_OSS_004',
|
||||||
taxonomy: 'rbi-cims-oss4-2024-04-01',
|
taxonomy: 'rbi-cims-oss4-2024-04-01',
|
||||||
periodicity: 'Quarterly',
|
periodicity: 'Quarterly',
|
||||||
@@ -729,7 +729,7 @@ export class CimsXbrlDialogComponent {
|
|||||||
name: 'OSS-5',
|
name: 'OSS-5',
|
||||||
fullName: 'Institutional & Membership Profile',
|
fullName: 'Institutional & Membership Profile',
|
||||||
description:
|
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',
|
cimsCode: 'CIMS_OSS_005',
|
||||||
taxonomy: 'rbi-cims-oss5-2024-04-01',
|
taxonomy: 'rbi-cims-oss5-2024-04-01',
|
||||||
periodicity: 'Annual',
|
periodicity: 'Annual',
|
||||||
|
|||||||
@@ -27,7 +27,15 @@ interface DemoClient {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './fd-rd-entry-dialog.component.html',
|
templateUrl: './fd-rd-entry-dialog.component.html',
|
||||||
styleUrls: ['./fd-rd-entry-dialog.component.scss'],
|
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 {
|
export class FdRdEntryDialogComponent {
|
||||||
private dialogRef = inject(MatDialogRef<FdRdEntryDialogComponent>);
|
private dialogRef = inject(MatDialogRef<FdRdEntryDialogComponent>);
|
||||||
@@ -53,8 +61,16 @@ export class FdRdEntryDialogComponent {
|
|||||||
{ id: 'C-10689', name: 'Vikram Traders' }
|
{ id: 'C-10689', name: 'Vikram Traders' }
|
||||||
];
|
];
|
||||||
|
|
||||||
payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c'];
|
payModes = [
|
||||||
interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout'];
|
'Cash',
|
||||||
|
'Cheque',
|
||||||
|
'Transfer from Savings A/c'
|
||||||
|
];
|
||||||
|
interestPayoutOptions = [
|
||||||
|
'Cumulative (Paid on Maturity)',
|
||||||
|
'Monthly Payout',
|
||||||
|
'Quarterly Payout'
|
||||||
|
];
|
||||||
maturityInstructions = [
|
maturityInstructions = [
|
||||||
'Renew Principal & Interest',
|
'Renew Principal & Interest',
|
||||||
'Renew Principal Only, Payout 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) {
|
@if (loading) {
|
||||||
<div class="cert-empty">
|
<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>Loading live declarations and accounts from Fineract…</div>
|
||||||
</div>
|
</div>
|
||||||
} @else if (loadError) {
|
} @else if (loadError) {
|
||||||
<div class="cert-empty" style="color: #ef4444;">
|
<div class="cert-empty" style="color: #ef4444">
|
||||||
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px;"></fa-icon>
|
<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>Could not load real data from Fineract. Check connection.</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -139,7 +139,9 @@
|
|||||||
<tr [class.cert-row--warn]="!row.row.eligibility.eligible">
|
<tr [class.cert-row--warn]="!row.row.eligibility.eligible">
|
||||||
<td>
|
<td>
|
||||||
<div class="cert-cust-name">{{ row.row.record.customerName }}</div>
|
<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>
|
<div class="small muted">DOB {{ row.row.record.dob | date: 'd MMM y' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -306,8 +308,8 @@
|
|||||||
|
|
||||||
@if (row.row.schedule.shieldedInterest > 0) {
|
@if (row.row.schedule.shieldedInterest > 0) {
|
||||||
<div class="small muted">
|
<div class="small muted">
|
||||||
₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength
|
||||||
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
of the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+13
-4
@@ -64,7 +64,11 @@ export interface RealForm15ghRow {
|
|||||||
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||||
function toIso(value: unknown): string | null {
|
function toIso(value: unknown): string | null {
|
||||||
if (Array.isArray(value) && value.length >= 3) {
|
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')}`;
|
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
if (typeof value === 'string' && value.trim()) {
|
if (typeof value === 'string' && value.trim()) {
|
||||||
@@ -118,7 +122,10 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
|
|||||||
private clientKycCache = new Map<string, Observable<any>>();
|
private clientKycCache = new Map<string, Observable<any>>();
|
||||||
private clientAccountsCache = 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 {
|
ngOnInit(): void {
|
||||||
this.loadRealData();
|
this.loadRealData();
|
||||||
@@ -311,9 +318,11 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
|
|||||||
depositPrincipal,
|
depositPrincipal,
|
||||||
fy: this.selectedFy,
|
fy: this.selectedFy,
|
||||||
status,
|
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,
|
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 {
|
export function buildUin(sequence: number, fy: string, tan: string): string {
|
||||||
const digits = `${Math.max(0, Math.floor(sequence))}`.slice(-9).padStart(9, '0');
|
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}`;
|
return `D${digits}${uinTaxYearPart(fy)}${normalisedTan}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
<fa-icon icon="book" class="cert-head-ic"></fa-icon>
|
<fa-icon icon="book" class="cert-head-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="cert-head-t">Membership Register</h2>
|
<h2 class="cert-head-t">Membership Register</h2>
|
||||||
<div class="cert-head-s">
|
<div class="cert-head-s">Statutory register of members — one line per member, in order of admission.</div>
|
||||||
Statutory register of members — one line per member, in order of admission.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-head-r">
|
<div class="cert-head-r">
|
||||||
@@ -98,9 +96,7 @@
|
|||||||
<input type="date" [(ngModel)]="toDate" />
|
<input type="date" [(ngModel)]="toDate" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="cert-clear" (click)="clearFilters()">
|
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
|
||||||
<fa-icon icon="sync"></fa-icon> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Register -->
|
<!-- Register -->
|
||||||
|
|||||||
@@ -109,9 +109,7 @@
|
|||||||
<input type="date" [(ngModel)]="toDate" />
|
<input type="date" [(ngModel)]="toDate" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="cert-clear" (click)="clearFilters()">
|
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
|
||||||
<fa-icon icon="sync"></fa-icon> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Report -->
|
<!-- Report -->
|
||||||
@@ -191,7 +189,9 @@
|
|||||||
<span class="cert-pill" [class]="'sar-pill--' + row.status.toLowerCase()">{{ row.statusLabel }}</span>
|
<span class="cert-pill" [class]="'sar-pill--' + row.status.toLowerCase()">{{ row.statusLabel }}</span>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
||||||
<td class="cert-row-actions">
|
<td class="cert-row-actions">
|
||||||
<fa-icon icon="chevron-right" class="sar-chevron"></fa-icon>
|
<fa-icon icon="chevron-right" class="sar-chevron"></fa-icon>
|
||||||
|
|||||||
@@ -105,9 +105,7 @@ export class ShareAllotmentReportComponent implements OnInit {
|
|||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return of([] as AllotmentRow[]);
|
return of([] as AllotmentRow[]);
|
||||||
}
|
}
|
||||||
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(
|
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(map((perAccount) => perAccount.flat()));
|
||||||
map((perAccount) => perAccount.flat())
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.loadError = true;
|
this.loadError = true;
|
||||||
|
|||||||
@@ -100,9 +100,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="cert-clear" (click)="clearFilters()">
|
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
|
||||||
<fa-icon icon="sync"></fa-icon> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Register -->
|
<!-- Register -->
|
||||||
|
|||||||
+5
-1
@@ -26,6 +26,7 @@ import {
|
|||||||
MatRowDef,
|
MatRowDef,
|
||||||
MatRow
|
MatRow
|
||||||
} from '@angular/material/table';
|
} from '@angular/material/table';
|
||||||
|
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||||
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
|
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
|
||||||
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
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 {
|
export class CurrentProductChargesStepComponent implements OnInit {
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
private translateService = inject(TranslateService);
|
private translateService = inject(TranslateService);
|
||||||
|
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||||
|
|
||||||
@Input() CurrentProductsTemplate: any;
|
@Input() CurrentProductsTemplate: any;
|
||||||
@Input() currencyCode: UntypedFormControl;
|
@Input() currencyCode: UntypedFormControl;
|
||||||
@@ -73,7 +75,9 @@ export class CurrentProductChargesStepComponent implements OnInit {
|
|||||||
pristine = true;
|
pristine = true;
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.chargeData = this.CurrentProductsTemplate.chargeOptions;
|
this.facilityChargeFilter
|
||||||
|
.excludeFacilityCharges(this.CurrentProductsTemplate.chargeOptions)
|
||||||
|
.subscribe((charges: any[]) => (this.chargeData = charges));
|
||||||
|
|
||||||
this.chargesDataSource = this.CurrentProductsTemplate.charges || [];
|
this.chargesDataSource = this.CurrentProductsTemplate.charges || [];
|
||||||
this.pristine = true;
|
this.pristine = true;
|
||||||
|
|||||||
-15
@@ -174,21 +174,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="spl-feature">
|
||||||
<span class="spl-feature__label">
|
<span class="spl-feature__label">
|
||||||
<i class="fas fa-id-card"></i>
|
<i class="fas fa-id-card"></i>
|
||||||
|
|||||||
-2
@@ -24,7 +24,6 @@ type BooleanFeatureKey =
|
|||||||
| 'neftFacility'
|
| 'neftFacility'
|
||||||
| 'rtgsFacility'
|
| 'rtgsFacility'
|
||||||
| 'interBankTotalCapFacility'
|
| 'interBankTotalCapFacility'
|
||||||
| 'withdrawalFacility'
|
|
||||||
| 'depositFacilityKyc'
|
| 'depositFacilityKyc'
|
||||||
| 'virtualDebitCard';
|
| 'virtualDebitCard';
|
||||||
|
|
||||||
@@ -76,7 +75,6 @@ export class CurrentProductLimitsStepComponent implements OnChanges {
|
|||||||
neftFacility: true,
|
neftFacility: true,
|
||||||
rtgsFacility: true,
|
rtgsFacility: true,
|
||||||
interBankTotalCapFacility: true,
|
interBankTotalCapFacility: true,
|
||||||
withdrawalFacility: true,
|
|
||||||
depositFacilityKyc: true,
|
depositFacilityKyc: true,
|
||||||
virtualDebitCard: false
|
virtualDebitCard: false
|
||||||
};
|
};
|
||||||
|
|||||||
-1
@@ -94,7 +94,6 @@ export class CurrentProductPreviewStepComponent implements OnInit, OnChanges {
|
|||||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
||||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
|
||||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
{ 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 { MatCheckbox } from '@angular/material/checkbox';
|
||||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-current-product-settings-step',
|
selector: 'mifosx-current-product-settings-step',
|
||||||
@@ -62,9 +63,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
|
|||||||
? (this.CurrentProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
|
? (this.CurrentProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
|
||||||
: false,
|
: false,
|
||||||
isDormancyTrackingActive: isEditing ? (this.CurrentProductsTemplate.isDormancyTrackingActive ?? true) : true,
|
isDormancyTrackingActive: isEditing ? (this.CurrentProductsTemplate.isDormancyTrackingActive ?? true) : true,
|
||||||
daysToInactive: isEditing ? this.CurrentProductsTemplate.daysToInactive || 30 : 30,
|
daysToInactive: isEditing
|
||||||
daysToDormancy: isEditing ? this.CurrentProductsTemplate.daysToDormancy || 60 : 60,
|
? this.CurrentProductsTemplate.daysToInactive || DORMANCY_DEFAULTS.daysToInactive
|
||||||
daysToEscheat: isEditing ? this.CurrentProductsTemplate.daysToEscheat || 90 : 90,
|
: 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
|
// Overdraft fields
|
||||||
allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false,
|
allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false,
|
||||||
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
|
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
|
||||||
@@ -113,9 +120,9 @@ export class CurrentProductSettingsStepComponent implements OnInit {
|
|||||||
accountClosedZeroBalanceInDayClosing: [false],
|
accountClosedZeroBalanceInDayClosing: [false],
|
||||||
// Dormancy (used internally / by accounting step)
|
// Dormancy (used internally / by accounting step)
|
||||||
isDormancyTrackingActive: [true],
|
isDormancyTrackingActive: [true],
|
||||||
daysToInactive: [30],
|
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
|
||||||
daysToDormancy: [60],
|
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
|
||||||
daysToEscheat: [90],
|
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
|
||||||
// Overdraft (OD) Facility
|
// Overdraft (OD) Facility
|
||||||
allowOverdraft: [false],
|
allowOverdraft: [false],
|
||||||
overdraftLimit: [
|
overdraftLimit: [
|
||||||
|
|||||||
-1
@@ -89,7 +89,6 @@ export class CurrentProductGeneralTabComponent {
|
|||||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
||||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
|
||||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility KYC' },
|
{ key: 'depositFacilityKyc', label: 'Deposit Facility KYC' },
|
||||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
{ 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 { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-fixed-deposit-product-currency-step',
|
selector: 'mifosx-fixed-deposit-product-currency-step',
|
||||||
@@ -54,9 +55,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
|
|||||||
2,
|
2,
|
||||||
dormancyAlert: this.fixedDepositProductsTemplate.dormancyAlert ?? false,
|
dormancyAlert: this.fixedDepositProductsTemplate.dormancyAlert ?? false,
|
||||||
isDormancyTrackingActive: this.fixedDepositProductsTemplate.isDormancyTrackingActive ?? false,
|
isDormancyTrackingActive: this.fixedDepositProductsTemplate.isDormancyTrackingActive ?? false,
|
||||||
daysToInactive: this.fixedDepositProductsTemplate.daysToInactive ?? 30,
|
daysToInactive: this.fixedDepositProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
|
||||||
daysToDormancy: this.fixedDepositProductsTemplate.daysToDormancy ?? 60,
|
daysToDormancy: this.fixedDepositProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
|
||||||
daysToEscheat: this.fixedDepositProductsTemplate.daysToEscheat ?? 90,
|
daysToEscheat: this.fixedDepositProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat,
|
||||||
nominationFacility: this.fixedDepositProductsTemplate.nominationFacility ?? false,
|
nominationFacility: this.fixedDepositProductsTemplate.nominationFacility ?? false,
|
||||||
autoDebitFacility: this.fixedDepositProductsTemplate.autoDebitFacility ?? false
|
autoDebitFacility: this.fixedDepositProductsTemplate.autoDebitFacility ?? false
|
||||||
});
|
});
|
||||||
@@ -77,9 +78,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
|
|||||||
],
|
],
|
||||||
dormancyAlert: [false],
|
dormancyAlert: [false],
|
||||||
isDormancyTrackingActive: [false],
|
isDormancyTrackingActive: [false],
|
||||||
daysToInactive: [30],
|
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
|
||||||
daysToDormancy: [60],
|
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
|
||||||
daysToEscheat: [90],
|
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
|
||||||
nominationFacility: [false],
|
nominationFacility: [false],
|
||||||
autoDebitFacility: [false]
|
autoDebitFacility: [false]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ export class ProductsService {
|
|||||||
return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct);
|
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
|
* 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.
|
* 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 { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-recurring-deposit-product-currency-step',
|
selector: 'mifosx-recurring-deposit-product-currency-step',
|
||||||
@@ -54,9 +55,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
|
|||||||
2,
|
2,
|
||||||
dormancyAlert: this.recurringDepositProductsTemplate.dormancyAlert ?? false,
|
dormancyAlert: this.recurringDepositProductsTemplate.dormancyAlert ?? false,
|
||||||
isDormancyTrackingActive: this.recurringDepositProductsTemplate.isDormancyTrackingActive ?? false,
|
isDormancyTrackingActive: this.recurringDepositProductsTemplate.isDormancyTrackingActive ?? false,
|
||||||
daysToInactive: this.recurringDepositProductsTemplate.daysToInactive ?? 30,
|
daysToInactive: this.recurringDepositProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
|
||||||
daysToDormancy: this.recurringDepositProductsTemplate.daysToDormancy ?? 60,
|
daysToDormancy: this.recurringDepositProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
|
||||||
daysToEscheat: this.recurringDepositProductsTemplate.daysToEscheat ?? 90,
|
daysToEscheat: this.recurringDepositProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat,
|
||||||
nominationFacility: this.recurringDepositProductsTemplate.nominationFacility ?? false,
|
nominationFacility: this.recurringDepositProductsTemplate.nominationFacility ?? false,
|
||||||
autoDebitFacility: this.recurringDepositProductsTemplate.autoDebitFacility ?? false
|
autoDebitFacility: this.recurringDepositProductsTemplate.autoDebitFacility ?? false
|
||||||
});
|
});
|
||||||
@@ -77,9 +78,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
|
|||||||
],
|
],
|
||||||
dormancyAlert: [false],
|
dormancyAlert: [false],
|
||||||
isDormancyTrackingActive: [false],
|
isDormancyTrackingActive: [false],
|
||||||
daysToInactive: [30],
|
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
|
||||||
daysToDormancy: [60],
|
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
|
||||||
daysToEscheat: [90],
|
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
|
||||||
nominationFacility: [false],
|
nominationFacility: [false],
|
||||||
autoDebitFacility: [false]
|
autoDebitFacility: [false]
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@
|
|||||||
<mifosx-saving-product-accounting-step
|
<mifosx-saving-product-accounting-step
|
||||||
[savingProductsTemplate]="savingProductsTemplate"
|
[savingProductsTemplate]="savingProductsTemplate"
|
||||||
[accountingRuleData]="accountingRuleData"
|
[accountingRuleData]="accountingRuleData"
|
||||||
[isDormancyTrackingActive]="savingProductSettingsForm.get('isDormancyTrackingActive')"
|
[isDormancyTrackingActive]="savingProductCurrencyForm.get('dormancyAlert')"
|
||||||
[savingProductFormValid]="savingProductFormValid"
|
[savingProductFormValid]="savingProductFormValid"
|
||||||
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
|
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
|
||||||
></mifosx-saving-product-accounting-step>
|
></mifosx-saving-product-accounting-step>
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@
|
|||||||
<mifosx-saving-product-accounting-step
|
<mifosx-saving-product-accounting-step
|
||||||
[savingProductsTemplate]="savingProductAndTemplate"
|
[savingProductsTemplate]="savingProductAndTemplate"
|
||||||
[accountingRuleData]="accountingRuleData"
|
[accountingRuleData]="accountingRuleData"
|
||||||
[isDormancyTrackingActive]="savingProductSettingsForm.get('isDormancyTrackingActive')"
|
[isDormancyTrackingActive]="savingProductCurrencyForm.get('dormancyAlert')"
|
||||||
[savingProductFormValid]="savingProductFormValidAndNotPristine"
|
[savingProductFormValid]="savingProductFormValidAndNotPristine"
|
||||||
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
|
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
|
||||||
></mifosx-saving-product-accounting-step>
|
></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';
|
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 = {
|
type AccountingRowConfig = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -180,13 +186,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
this.savingProductAccountingForm.patchValue({
|
this.savingProductAccountingForm.patchValue({
|
||||||
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
|
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
|
||||||
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
|
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
|
||||||
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
|
|
||||||
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
|
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
|
||||||
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
|
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
|
||||||
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
|
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
|
||||||
incomeFromInterestId: mappings.incomeFromInterest?.id || '',
|
|
||||||
overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '',
|
|
||||||
writeOffAccountId: mappings.writeOffAccount?.id || '',
|
|
||||||
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
|
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
|
||||||
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
|
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
|
||||||
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
|
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
|
||||||
@@ -200,10 +202,24 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
|
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
|
||||||
this.setOtherGlMappings(
|
this.setOtherGlMappings(
|
||||||
'liabilitiesOtherGlMappings',
|
'liabilitiesOtherGlMappings',
|
||||||
glMappings?.liabilities || [],
|
this.extractNamedGlMapping(
|
||||||
|
glMappings?.liabilities || [],
|
||||||
|
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
|
||||||
|
'savingsInterestPayableAccountId',
|
||||||
|
this.liabilityAccountOptions
|
||||||
|
),
|
||||||
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);
|
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +241,22 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
return this.savingProductAccountingForm.get(controlName) as UntypedFormArray;
|
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[]) {
|
private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) {
|
||||||
const otherGlMappingsArray = this.getOtherGlMappingsArray(controlName);
|
const otherGlMappingsArray = this.getOtherGlMappingsArray(controlName);
|
||||||
otherGlMappingsArray.clear();
|
otherGlMappingsArray.clear();
|
||||||
@@ -282,10 +314,35 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
}))
|
}))
|
||||||
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
|
.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 = {
|
const otherGlMappings = {
|
||||||
assets: mapOtherGlMappings('assetsOtherGlMappings', this.assetAccountOptions),
|
assets: mapOtherGlMappings('assetsOtherGlMappings', this.assetAccountOptions),
|
||||||
liabilities: mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
|
liabilities: [
|
||||||
expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions),
|
...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)
|
income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -320,13 +377,21 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
|
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
|
||||||
addControl('savingsReferenceAccountId', true);
|
addControl('savingsReferenceAccountId', true);
|
||||||
addControl('savingsControlAccountId', true);
|
addControl('savingsControlAccountId', true);
|
||||||
addControl('transfersInSuspenseAccountId', true);
|
|
||||||
addControl('interestOnSavingsAccountId', true);
|
addControl('interestOnSavingsAccountId', true);
|
||||||
addControl('incomeFromFeeAccountId', true);
|
addControl('incomeFromFeeAccountId', true);
|
||||||
addControl('incomeFromPenaltyAccountId', true);
|
addControl('incomeFromPenaltyAccountId', true);
|
||||||
addControl('incomeFromInterestId', true);
|
// Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the
|
||||||
addControl('writeOffAccountId', true);
|
// core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings)
|
||||||
addControl('overdraftPortfolioControlId', false);
|
// 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)) {
|
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
|
||||||
addControl('feesReceivableAccountId', true);
|
addControl('feesReceivableAccountId', true);
|
||||||
@@ -360,6 +425,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
removeControl('interestPayableAccountId');
|
removeControl('interestPayableAccountId');
|
||||||
removeControl('interestReceivableAccountId');
|
removeControl('interestReceivableAccountId');
|
||||||
removeControl('escheatLiabilityId');
|
removeControl('escheatLiabilityId');
|
||||||
|
removeControl('savingsInterestPayableAccountId');
|
||||||
|
removeControl('savingsInterestPaidAccountId');
|
||||||
this.clearOtherGlMappings();
|
this.clearOtherGlMappings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +437,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
liabilitiesOtherGlMappings,
|
liabilitiesOtherGlMappings,
|
||||||
expensesOtherGlMappings,
|
expensesOtherGlMappings,
|
||||||
incomeOtherGlMappings,
|
incomeOtherGlMappings,
|
||||||
|
// Carried inside otherGlMappings instead — the backend rejects unknown top-level accounting params.
|
||||||
|
savingsInterestPayableAccountId,
|
||||||
|
savingsInterestPaidAccountId,
|
||||||
...savingProductAccounting
|
...savingProductAccounting
|
||||||
} = this.savingProductAccountingForm.value;
|
} = this.savingProductAccountingForm.value;
|
||||||
const otherGlMappings = this.getOtherGlMappings();
|
const otherGlMappings = this.getOtherGlMappings();
|
||||||
@@ -404,13 +474,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
options: this.assetAccountOptions,
|
options: this.assetAccountOptions,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'overdraftPortfolioControlId',
|
|
||||||
label: 'Overdraft Portfolio Control',
|
|
||||||
inputLabel: 'Overdraft portfolio control',
|
|
||||||
controlName: 'overdraftPortfolioControlId',
|
|
||||||
options: this.assetAccountOptions
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'interestReceivableAccountId',
|
key: 'interestReceivableAccountId',
|
||||||
label: 'Interest Receivable Account',
|
label: 'Interest Receivable Account',
|
||||||
@@ -454,12 +517,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'transfersInSuspenseAccountId',
|
key: 'savingsInterestPayableAccountId',
|
||||||
label: 'Transfers In Suspense Account',
|
label: 'Savings Interest Payable',
|
||||||
inputLabel: 'Transfers in suspense account',
|
inputLabel: 'Savings interest payable',
|
||||||
controlName: 'transfersInSuspenseAccountId',
|
controlName: 'savingsInterestPayableAccountId',
|
||||||
options: this.liabilityAccountOptions,
|
options: this.liabilityAccountOptions
|
||||||
required: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'interestPayableAccountId',
|
key: 'interestPayableAccountId',
|
||||||
@@ -496,12 +558,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'writeOffAccountId',
|
key: 'savingsInterestPaidAccountId',
|
||||||
label: 'Write Off Account',
|
label: 'Savings Interest Paid',
|
||||||
inputLabel: 'Write off account',
|
inputLabel: 'Savings interest paid',
|
||||||
controlName: 'writeOffAccountId',
|
controlName: 'savingsInterestPaidAccountId',
|
||||||
options: this.expenseAccountOptions,
|
options: this.expenseAccountOptions
|
||||||
required: true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
otherMappingsControlName: 'expensesOtherGlMappings',
|
otherMappingsControlName: 'expensesOtherGlMappings',
|
||||||
@@ -528,14 +589,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
controlName: 'incomeFromPenaltyAccountId',
|
controlName: 'incomeFromPenaltyAccountId',
|
||||||
options: this.incomeAccountOptions,
|
options: this.incomeAccountOptions,
|
||||||
required: true
|
required: true
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'incomeFromInterestId',
|
|
||||||
label: 'Income From Interest',
|
|
||||||
inputLabel: 'Income from interest',
|
|
||||||
controlName: 'incomeFromInterestId',
|
|
||||||
options: this.incomeAccountOptions,
|
|
||||||
required: true
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
otherMappingsControlName: 'incomeOtherGlMappings',
|
otherMappingsControlName: 'incomeOtherGlMappings',
|
||||||
|
|||||||
+9
-1
@@ -26,6 +26,7 @@ import {
|
|||||||
MatRowDef,
|
MatRowDef,
|
||||||
MatRow
|
MatRow
|
||||||
} from '@angular/material/table';
|
} from '@angular/material/table';
|
||||||
|
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||||
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
|
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
|
||||||
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
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 {
|
export class SavingProductChargesStepComponent implements OnInit {
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
private translateService = inject(TranslateService);
|
private translateService = inject(TranslateService);
|
||||||
|
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||||
|
|
||||||
@Input() savingProductsTemplate: any;
|
@Input() savingProductsTemplate: any;
|
||||||
@Input() currencyCode: UntypedFormControl;
|
@Input() currencyCode: UntypedFormControl;
|
||||||
@@ -73,8 +75,14 @@ export class SavingProductChargesStepComponent implements OnInit {
|
|||||||
pristine = true;
|
pristine = true;
|
||||||
|
|
||||||
ngOnInit() {
|
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.chargesDataSource = this.savingProductsTemplate.charges || [];
|
||||||
this.pristine = true;
|
this.pristine = true;
|
||||||
|
|
||||||
|
|||||||
+91
@@ -63,6 +63,97 @@
|
|||||||
</mat-radio-group>
|
</mat-radio-group>
|
||||||
</div>
|
</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">
|
||||||
<div class="spc-alert__content">
|
<div class="spc-alert__content">
|
||||||
<h4>Nomination Facility</h4>
|
<h4>Nomination Facility</h4>
|
||||||
|
|||||||
+130
@@ -112,3 +112,133 @@ $gray-900: #202124;
|
|||||||
flex-wrap: wrap;
|
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 { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-saving-product-currency-step',
|
selector: 'mifosx-saving-product-currency-step',
|
||||||
@@ -50,10 +51,68 @@ export class SavingProductCurrencyStepComponent implements OnInit {
|
|||||||
currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
|
currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
|
||||||
digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? 2,
|
digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? 2,
|
||||||
dormancyAlert: this.savingProductsTemplate.isDormancyTrackingActive ?? false,
|
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() {
|
createSavingProductCurrencyForm() {
|
||||||
this.savingProductCurrencyForm = this.formBuilder.group({
|
this.savingProductCurrencyForm = this.formBuilder.group({
|
||||||
currencyCode: [
|
currencyCode: [
|
||||||
@@ -68,7 +127,21 @@ export class SavingProductCurrencyStepComponent implements OnInit {
|
|||||||
]
|
]
|
||||||
],
|
],
|
||||||
dormancyAlert: [false],
|
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
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-15
@@ -174,21 +174,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="spl-feature">
|
||||||
<span class="spl-feature__label">
|
<span class="spl-feature__label">
|
||||||
<i class="fas fa-id-card"></i>
|
<i class="fas fa-id-card"></i>
|
||||||
|
|||||||
-2
@@ -24,7 +24,6 @@ type BooleanFeatureKey =
|
|||||||
| 'neftFacility'
|
| 'neftFacility'
|
||||||
| 'rtgsFacility'
|
| 'rtgsFacility'
|
||||||
| 'interBankTotalCapFacility'
|
| 'interBankTotalCapFacility'
|
||||||
| 'withdrawalFacility'
|
|
||||||
| 'depositFacilityKyc'
|
| 'depositFacilityKyc'
|
||||||
| 'virtualDebitCard';
|
| 'virtualDebitCard';
|
||||||
|
|
||||||
@@ -76,7 +75,6 @@ export class SavingProductLimitsStepComponent implements OnChanges {
|
|||||||
neftFacility: true,
|
neftFacility: true,
|
||||||
rtgsFacility: true,
|
rtgsFacility: true,
|
||||||
interBankTotalCapFacility: true,
|
interBankTotalCapFacility: true,
|
||||||
withdrawalFacility: true,
|
|
||||||
depositFacilityKyc: true,
|
depositFacilityKyc: true,
|
||||||
virtualDebitCard: false
|
virtualDebitCard: false
|
||||||
};
|
};
|
||||||
|
|||||||
+40
-72
@@ -475,42 +475,35 @@
|
|||||||
)
|
)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="spa-gl-row">
|
@if (isAccrualAccounting()) {
|
||||||
<span class="spa-gl-row__label">Overdraft portfolio control</span>
|
<div class="spa-gl-row">
|
||||||
<span class="spa-gl-row__value">{{
|
<span class="spa-gl-row__label">Interest receivable account</span>
|
||||||
formatGlAccountById(
|
<span class="spa-gl-row__value">{{
|
||||||
savingProduct.overdraftPortfolioControlId,
|
formatGlAccountById(
|
||||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
savingProduct.interestReceivableAccountId,
|
||||||
)
|
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||||
}}</span>
|
)
|
||||||
</div>
|
}}</span>
|
||||||
<div class="spa-gl-row">
|
</div>
|
||||||
<span class="spa-gl-row__label">Interest receivable account</span>
|
<div class="spa-gl-row">
|
||||||
<span class="spa-gl-row__value">{{
|
<span class="spa-gl-row__label">Fees receivable account</span>
|
||||||
formatGlAccountById(
|
<span class="spa-gl-row__value">{{
|
||||||
savingProduct.interestReceivableAccountId,
|
formatGlAccountById(
|
||||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
savingProduct.feesReceivableAccountId,
|
||||||
)
|
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||||
}}</span>
|
)
|
||||||
</div>
|
}}</span>
|
||||||
<div class="spa-gl-row">
|
</div>
|
||||||
<span class="spa-gl-row__label">Fees receivable account</span>
|
<div class="spa-gl-row">
|
||||||
<span class="spa-gl-row__value">{{
|
<span class="spa-gl-row__label">Penalties receivable account</span>
|
||||||
formatGlAccountById(
|
<span class="spa-gl-row__value">{{
|
||||||
savingProduct.feesReceivableAccountId,
|
formatGlAccountById(
|
||||||
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
savingProduct.penaltiesReceivableAccountId,
|
||||||
)
|
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||||
}}</span>
|
)
|
||||||
</div>
|
}}</span>
|
||||||
<div class="spa-gl-row">
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="spa-gl-section">
|
<div class="spa-gl-section">
|
||||||
@@ -524,24 +517,17 @@
|
|||||||
)
|
)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="spa-gl-row">
|
@if (isAccrualAccounting()) {
|
||||||
<span class="spa-gl-row__label">Transfers in suspense account</span>
|
<div class="spa-gl-row">
|
||||||
<span class="spa-gl-row__value">{{
|
<span class="spa-gl-row__label">Interest payable account</span>
|
||||||
formatGlAccountById(
|
<span class="spa-gl-row__value">{{
|
||||||
savingProduct.transfersInSuspenseAccountId,
|
formatGlAccountById(
|
||||||
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
savingProduct.interestPayableAccountId,
|
||||||
)
|
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||||
}}</span>
|
)
|
||||||
</div>
|
}}</span>
|
||||||
<div class="spa-gl-row">
|
</div>
|
||||||
<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) {
|
@if (savingProduct.isDormancyTrackingActive) {
|
||||||
<div class="spa-gl-row">
|
<div class="spa-gl-row">
|
||||||
<span class="spa-gl-row__label">Escheat liability account</span>
|
<span class="spa-gl-row__label">Escheat liability account</span>
|
||||||
@@ -566,15 +552,6 @@
|
|||||||
)
|
)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="spa-gl-section">
|
<div class="spa-gl-section">
|
||||||
@@ -597,15 +574,6 @@
|
|||||||
)
|
)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-12
@@ -94,7 +94,6 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
|
|||||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
||||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
|
||||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
||||||
];
|
];
|
||||||
@@ -132,23 +131,14 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
|
|||||||
this.savingProduct.savingsReferenceAccountId,
|
this.savingProduct.savingsReferenceAccountId,
|
||||||
assetAccountData
|
assetAccountData
|
||||||
),
|
),
|
||||||
overdraftPortfolioControl: this.accounting.glAccountLookUp(
|
|
||||||
this.savingProduct.overdraftPortfolioControlId,
|
|
||||||
assetAccountData
|
|
||||||
),
|
|
||||||
savingsControlAccount: this.accounting.glAccountLookUp(
|
savingsControlAccount: this.accounting.glAccountLookUp(
|
||||||
this.savingProduct.savingsControlAccountId,
|
this.savingProduct.savingsControlAccountId,
|
||||||
liabilityAccountData
|
liabilityAccountData
|
||||||
),
|
),
|
||||||
transfersInSuspenseAccount: this.accounting.glAccountLookUp(
|
|
||||||
this.savingProduct.transfersInSuspenseAccountId,
|
|
||||||
liabilityAccountData
|
|
||||||
),
|
|
||||||
interestOnSavingsAccount: this.accounting.glAccountLookUp(
|
interestOnSavingsAccount: this.accounting.glAccountLookUp(
|
||||||
this.savingProduct.interestOnSavingsAccountId,
|
this.savingProduct.interestOnSavingsAccountId,
|
||||||
expenseAccountData
|
expenseAccountData
|
||||||
),
|
),
|
||||||
writeOffAccount: this.accounting.glAccountLookUp(this.savingProduct.writeOffAccountId, expenseAccountData),
|
|
||||||
incomeFromFeeAccount: this.accounting.glAccountLookUp(
|
incomeFromFeeAccount: this.accounting.glAccountLookUp(
|
||||||
this.savingProduct.incomeFromFeeAccountId,
|
this.savingProduct.incomeFromFeeAccountId,
|
||||||
incomeAccountData
|
incomeAccountData
|
||||||
@@ -156,8 +146,7 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
|
|||||||
incomeFromPenaltyAccount: this.accounting.glAccountLookUp(
|
incomeFromPenaltyAccount: this.accounting.glAccountLookUp(
|
||||||
this.savingProduct.incomeFromPenaltyAccountId,
|
this.savingProduct.incomeFromPenaltyAccountId,
|
||||||
incomeAccountData
|
incomeAccountData
|
||||||
),
|
)
|
||||||
incomeFromInterest: this.accounting.glAccountLookUp(this.savingProduct.incomeFromInterestId, incomeAccountData)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.isAccrualAccounting()) {
|
if (this.isAccrualAccounting()) {
|
||||||
|
|||||||
+6
-11
@@ -70,11 +70,7 @@ export class SavingProductSettingsStepComponent implements OnInit {
|
|||||||
: this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0,
|
: this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0,
|
||||||
accountClosedZeroBalanceInDayClosing: isEditing
|
accountClosedZeroBalanceInDayClosing: isEditing
|
||||||
? (this.savingProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
|
? (this.savingProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
|
||||||
: 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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -115,12 +111,11 @@ export class SavingProductSettingsStepComponent implements OnInit {
|
|||||||
enableLockinPeriod: [false],
|
enableLockinPeriod: [false],
|
||||||
lockinPeriodFrequency: [''],
|
lockinPeriodFrequency: [''],
|
||||||
lockinPeriodFrequencyType: [''],
|
lockinPeriodFrequencyType: [''],
|
||||||
accountClosedZeroBalanceInDayClosing: [false],
|
accountClosedZeroBalanceInDayClosing: [false]
|
||||||
// Dormancy tracking fields (required when isDormancyTrackingActive is true)
|
// Dormancy is deliberately absent: it belongs to the Currency step, which is where it is shown
|
||||||
isDormancyTrackingActive: [true],
|
// and edited. It was declared here as well, and because this step is spread into the payload
|
||||||
daysToInactive: [30],
|
// after that one, the numbers an officer typed were silently replaced by these untouched
|
||||||
daysToDormancy: [60],
|
// defaults on the way to the server.
|
||||||
daysToEscheat: [90]
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1
@@ -86,7 +86,6 @@ export class SavingProductGeneralTabComponent {
|
|||||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
|
||||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
|
||||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
{ 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" />
|
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
|
||||||
<span matSuffix> % </span>
|
<span matSuffix> % </span>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<p class="dd-hint">
|
<p class="dd-hint">Applied to each member's own share investment, pro-rated for how long they held it.</p>
|
||||||
Applied to each member's own share investment, pro-rated for how long they held it.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loadingPreview) {
|
@if (loadingPreview) {
|
||||||
|
|||||||
@@ -248,27 +248,67 @@ export class ViewDividendComponent implements OnInit {
|
|||||||
ws.getCell('A1').alignment = { horizontal: 'center' };
|
ws.getCell('A1').alignment = { horizontal: 'center' };
|
||||||
|
|
||||||
const heading: [string, any][] = [
|
const heading: [string, any][] = [
|
||||||
['Share Product', h?.productName ?? ''],
|
[
|
||||||
['Financial Year', fy],
|
'Share Product',
|
||||||
['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''],
|
h?.productName ?? ''
|
||||||
['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) : ''],
|
'Financial Year',
|
||||||
['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'],
|
fy
|
||||||
['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'],
|
],
|
||||||
['Members Paid', h?.paidMembers ?? ''],
|
[
|
||||||
['Members Below Minimum', h?.unpaidMembers ?? '']
|
'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) => {
|
heading.forEach(
|
||||||
const row = ws.getRow(i + 3);
|
([
|
||||||
row.getCell(1).value = label;
|
label,
|
||||||
row.getCell(1).font = { bold: true };
|
value
|
||||||
row.getCell(2).value = 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 tableStart = heading.length + 5;
|
||||||
const header = ws.getRow(tableStart);
|
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).value = t;
|
||||||
header.getCell(i + 1).font = { bold: true };
|
header.getCell(i + 1).font = { bold: true };
|
||||||
header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
|
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));
|
const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0));
|
||||||
totalRow.getCell(2).value = 'Total';
|
totalRow.getCell(2).value = 'Total';
|
||||||
totalRow.getCell(2).font = { bold: true };
|
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).numFmt = '#,##0.00';
|
||||||
totalRow.getCell(3).font = { bold: true };
|
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
|
<!-- 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. -->
|
form number tells a clerk nothing about what they are about to file. -->
|
||||||
<p>
|
<p>
|
||||||
Membership and share capital as reported to the RBI, per share type — how many members hold
|
Membership and share capital as reported to the RBI, per share type — how many members hold each type, how much
|
||||||
each type, how much capital they have subscribed, and how much of the authorised issue
|
capital they have subscribed, and how much of the authorised issue remains unsold, with the previous financial
|
||||||
remains unsold, with the previous financial year alongside for comparison.
|
year alongside for comparison.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="scr-header__actions">
|
<div class="scr-header__actions">
|
||||||
@@ -31,7 +31,12 @@
|
|||||||
<fa-icon icon="sync"></fa-icon>
|
<fa-icon icon="sync"></fa-icon>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</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>
|
<fa-icon icon="file-excel"></fa-icon>
|
||||||
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
|
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -46,8 +51,8 @@
|
|||||||
<div class="scr-note">Loading the share register…</div>
|
<div class="scr-note">Loading the share register…</div>
|
||||||
} @else if (report) {
|
} @else if (report) {
|
||||||
<div class="scr-period">
|
<div class="scr-period">
|
||||||
Reporting period <strong>{{ report.periodStartDate }}</strong> to
|
Reporting period <strong>{{ report.periodStartDate }}</strong> to <strong>{{ report.periodEndDate }}</strong> ·
|
||||||
<strong>{{ report.periodEndDate }}</strong> · compared with FY {{ report.previousFinancialYear }}
|
compared with FY {{ report.previousFinancialYear }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The bottom line first: a supervisor checks the society's totals before reading any single
|
<!-- 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.getCell('A1').alignment = { horizontal: 'center' };
|
||||||
|
|
||||||
ws.mergeCells('A2:L2');
|
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' };
|
ws.getCell('A2').alignment = { horizontal: 'center' };
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
@@ -215,7 +216,10 @@ export class ShareCapitalReturnComponent implements OnInit {
|
|||||||
totalRow.getCell(9).value = t.membersClosingPrev;
|
totalRow.getCell(9).value = t.membersClosingPrev;
|
||||||
totalRow.getCell(12).value = t.capitalClosing;
|
totalRow.getCell(12).value = t.capitalClosing;
|
||||||
totalRow.getCell(13).value = t.capitalClosingPrev;
|
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++) {
|
for (let c = 1; c <= 13; c++) {
|
||||||
totalRow.getCell(c).font = { bold: true };
|
totalRow.getCell(c).font = { bold: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,23 @@ const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
|
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',
|
path: 'tds-interest',
|
||||||
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
|
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>
|
||||||
</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 -->
|
<!-- FILTERS -->
|
||||||
<div class="rr-filters">
|
<div class="rr-filters">
|
||||||
<div class="rr-filter-row">
|
<div class="rr-filter-row">
|
||||||
@@ -228,148 +216,173 @@
|
|||||||
|
|
||||||
<!-- CUSTOMER WISE -->
|
<!-- CUSTOMER WISE -->
|
||||||
@if (view === 'customer') {
|
@if (view === 'customer') {
|
||||||
<div class="rr-table-wrap">
|
<!-- Grouped by verdict: the section heading answers "what do I do about these?" once, so the
|
||||||
<table class="rr-table">
|
row does not have to repeat it in a Move To and a Position column. -->
|
||||||
<thead>
|
@for (g of customerGroups; track g.key) {
|
||||||
<tr>
|
<div class="rr-group" [class]="'rr-group--' + g.tone">
|
||||||
<th></th>
|
<button class="rr-group-head" (click)="toggleGroup(g.key)">
|
||||||
<th>Customer ID</th>
|
<fa-icon [icon]="collapsedGroups[g.key] ? 'caret-right' : 'caret-down'"></fa-icon>
|
||||||
<th>Customer Name</th>
|
<span class="rr-group-t">{{ g.title }}</span>
|
||||||
<th>Account Number</th>
|
<span class="rr-group-n">{{ g.rows.length }}</span>
|
||||||
<th>Branch</th>
|
<span class="rr-group-h">{{ g.hint }}</span>
|
||||||
<th>Current Risk</th>
|
</button>
|
||||||
<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>
|
|
||||||
|
|
||||||
@if (expanded === c.clientId) {
|
@if (!collapsedGroups[g.key]) {
|
||||||
<tr class="rr-detail">
|
<div class="rr-table-wrap">
|
||||||
<td colspan="11">
|
<table class="rr-table">
|
||||||
<!-- One block per alert: what the rule is, then the entries it fired on. -->
|
<thead>
|
||||||
@for (a of c.alerts; track a.alertId) {
|
<tr>
|
||||||
<div class="rr-case">
|
<th></th>
|
||||||
<div class="rr-case-head">
|
<th>Customer ID</th>
|
||||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
<th>Customer Name</th>
|
||||||
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
<th>Account Number</th>
|
||||||
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
|
<th>Branch</th>
|
||||||
<span class="rr-spacer"></span>
|
<th>Rating on file</th>
|
||||||
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
|
<th class="num">Alerts</th>
|
||||||
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
<th>AML Rules Triggered</th>
|
||||||
</div>
|
<th class="num">Amount under review</th>
|
||||||
<div class="rr-case-why">
|
<th>Last Alert</th>
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
<th>Action</th>
|
||||||
<span><strong>Why it triggered:</strong> {{ a.rule.description }} — {{ txnDetail(a) }}</span>
|
</tr>
|
||||||
</div>
|
</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">
|
@if (expanded === c.clientId) {
|
||||||
<thead>
|
<tr class="rr-detail">
|
||||||
<tr>
|
<td colspan="11">
|
||||||
<th>Txn Date</th>
|
<!-- One block per alert: what the rule is, then the entries it fired on. -->
|
||||||
<th>Txn ID</th>
|
@for (a of c.alerts; track a.alertId) {
|
||||||
<th>Channel</th>
|
<div class="rr-case">
|
||||||
<th>Dr/Cr</th>
|
<div class="rr-case-head">
|
||||||
<th class="num">Amount</th>
|
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||||
<th class="num">Balance After</th>
|
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
||||||
<th>Narration</th>
|
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
|
||||||
<th>Counterparty</th>
|
<span class="rr-spacer"></span>
|
||||||
<th>AML Rule Triggered</th>
|
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
|
||||||
</tr>
|
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div class="rr-case-why">
|
||||||
@for (t of a.txns; track t.txnId) {
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
<tr [class.rr-leg--ctr]="t.ctrReportable">
|
<span
|
||||||
<td class="mono">{{ t.date }}</td>
|
><strong>Why it triggered:</strong> {{ a.rule.description }} —
|
||||||
<td class="mono">{{ t.txnId }}</td>
|
{{ txnDetail(a) }}</span
|
||||||
<td>
|
>
|
||||||
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
|
</div>
|
||||||
</td>
|
|
||||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
<table class="rr-subtable">
|
||||||
<td class="num strong">
|
<thead>
|
||||||
{{ t.amount | number: '1.0-0' }}
|
<tr>
|
||||||
@if (t.ctrReportable) {
|
<th>Txn Date</th>
|
||||||
<span class="rr-tag rr-tag--ctr">CTR</span>
|
<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>
|
</tbody>
|
||||||
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
|
</table>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="rr-case-foot">
|
<div class="rr-case-foot">
|
||||||
<div class="rr-suggest">
|
<div class="rr-suggest">
|
||||||
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
|
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
|
||||||
</div>
|
</div>
|
||||||
<div class="rr-case-remark">
|
<div class="rr-case-remark">
|
||||||
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
|
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
|
||||||
{{ a.remarks || 'no remarks recorded' }}
|
{{ a.remarks || 'no remarks recorded' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</td>
|
}
|
||||||
</tr>
|
</tbody>
|
||||||
}
|
</table>
|
||||||
} @empty {
|
</div>
|
||||||
<tr>
|
}
|
||||||
<td colspan="11" class="rr-empty">
|
</div>
|
||||||
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
|
} @empty {
|
||||||
</td>
|
<div class="rr-empty">No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.</div>
|
||||||
</tr>
|
}
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ALERT WISE -->
|
<!-- ALERT WISE -->
|
||||||
@@ -400,9 +413,6 @@
|
|||||||
<td class="mono">{{ a.clientId }}</td>
|
<td class="mono">{{ a.clientId }}</td>
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ a.customerName }}</strong>
|
<strong>{{ a.customerName }}</strong>
|
||||||
@if (!a.live) {
|
|
||||||
<span class="rr-tag">Sample</span>
|
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="mono">{{ a.accountNo || '—' }}</td>
|
<td class="mono">{{ a.accountNo || '—' }}</td>
|
||||||
<td>{{ a.branchCode || '—' }}</td>
|
<td>{{ a.branchCode || '—' }}</td>
|
||||||
|
|||||||
@@ -139,12 +139,6 @@
|
|||||||
span {
|
span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--sample {
|
|
||||||
background: var(--r-amber-l);
|
|
||||||
border: 1px solid #fcd34d;
|
|
||||||
color: #78350f;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FILTERS */
|
/* FILTERS */
|
||||||
@@ -403,6 +397,84 @@
|
|||||||
font-weight: 600;
|
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 */
|
/* TABLE */
|
||||||
.rr-table-wrap {
|
.rr-table-wrap {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -751,6 +823,75 @@
|
|||||||
white-space: normal;
|
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 {
|
.rr-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
|
|
||||||
@@ -37,8 +38,7 @@ import {
|
|||||||
RiskLevel,
|
RiskLevel,
|
||||||
channelLabel,
|
channelLabel,
|
||||||
mapLiveAlert,
|
mapLiveAlert,
|
||||||
riskFromCategory,
|
riskFromCategory
|
||||||
sampleRiskAlerts
|
|
||||||
} from './risk-review-data';
|
} from './risk-review-data';
|
||||||
import {
|
import {
|
||||||
DateRange,
|
DateRange,
|
||||||
@@ -57,6 +57,15 @@ import {
|
|||||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
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. */
|
/** Starting value for the memo, used before the first filter pass runs. */
|
||||||
const EMPTY_SUMMARY: RiskReviewSummary = {
|
const EMPTY_SUMMARY: RiskReviewSummary = {
|
||||||
customers: 0,
|
customers: 0,
|
||||||
@@ -82,9 +91,8 @@ const EMPTY_SUMMARY: RiskReviewSummary = {
|
|||||||
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
|
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
|
||||||
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
|
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
|
||||||
*
|
*
|
||||||
* Rows come from the live rule engine. If the engine has raised nothing (or is
|
* Rows come from the live rule engine only. If the engine has raised nothing
|
||||||
* unreachable) the report falls back to a deterministic sample set and says so
|
* (or is unreachable) the report shows as empty rather than padding itself.
|
||||||
* on screen and in the workbook, so sample rows are never taken for live data.
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-risk-review-report',
|
selector: 'mifosx-risk-review-report',
|
||||||
@@ -100,6 +108,7 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
private aml = inject(AmlService);
|
private aml = inject(AmlService);
|
||||||
private clients = inject(ClientsService);
|
private clients = inject(ClientsService);
|
||||||
private scope = inject(BranchScopeService);
|
private scope = inject(BranchScopeService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
readonly period = GL_PERIOD;
|
readonly period = GL_PERIOD;
|
||||||
readonly branches = GL_BRANCHES;
|
readonly branches = GL_BRANCHES;
|
||||||
@@ -122,22 +131,13 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
private liveAlerts: RiskAlertRow[] = [];
|
private liveAlerts: RiskAlertRow[] = [];
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
/**
|
/** The worklist — live alerts from the rule engine. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private alerts: RiskAlertRow[] = [];
|
private alerts: RiskAlertRow[] = [];
|
||||||
|
|
||||||
get hasLive(): boolean {
|
get hasLive(): boolean {
|
||||||
return this.liveAlerts.length > 0;
|
return this.liveAlerts.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get sampleCount(): number {
|
|
||||||
return this.alerts.filter((a) => !a.live).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Filter state ───────────────────────────────────────────────────────────
|
// ── 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 {
|
private compose(): void {
|
||||||
this.alerts = [
|
this.alerts = [...this.liveAlerts];
|
||||||
...this.liveAlerts,
|
|
||||||
...sampleRiskAlerts()
|
|
||||||
];
|
|
||||||
this.memoKey = '';
|
this.memoKey = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +338,64 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
return this.model.customers;
|
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[] {
|
get txnRows(): RiskTxnView[] {
|
||||||
return this.model.txns;
|
return this.model.txns;
|
||||||
}
|
}
|
||||||
@@ -393,6 +455,74 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
return 'rr-verdict';
|
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. */
|
/** Transaction detail as the reviewer would read it in one line. */
|
||||||
txnDetail(a: RiskAlertView): string {
|
txnDetail(a: RiskAlertView): string {
|
||||||
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
|
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
|
||||||
@@ -409,9 +539,7 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
try {
|
try {
|
||||||
const s = this.summary;
|
const s = this.summary;
|
||||||
const subtitle =
|
const subtitle =
|
||||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
|
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + `Source: live AML monitoring engine`;
|
||||||
`Source: live AML monitoring engine` +
|
|
||||||
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
|
|
||||||
|
|
||||||
await exportWorkbook(
|
await exportWorkbook(
|
||||||
[
|
[
|
||||||
@@ -533,6 +661,7 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
'Last Alert',
|
'Last Alert',
|
||||||
'Rules Point At',
|
'Rules Point At',
|
||||||
'Recommended Risk',
|
'Recommended Risk',
|
||||||
|
'Move To',
|
||||||
'Open Cases',
|
'Open Cases',
|
||||||
'Position',
|
'Position',
|
||||||
'Basis'
|
'Basis'
|
||||||
@@ -551,6 +680,7 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
12,
|
12,
|
||||||
14,
|
14,
|
||||||
16,
|
16,
|
||||||
|
38,
|
||||||
11,
|
11,
|
||||||
24,
|
24,
|
||||||
70
|
70
|
||||||
@@ -569,6 +699,7 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
c.lastAlertDate,
|
c.lastAlertDate,
|
||||||
RISK_LABEL[c.proposedRisk],
|
RISK_LABEL[c.proposedRisk],
|
||||||
RISK_LABEL[c.recommendedRisk],
|
RISK_LABEL[c.recommendedRisk],
|
||||||
|
this.moveHint(c),
|
||||||
c.openCount,
|
c.openCount,
|
||||||
this.verdictText(c),
|
this.verdictText(c),
|
||||||
c.directionReason || '—'
|
c.directionReason || '—'
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
<div class="sdi-tabs">
|
<div class="sdi-tabs">
|
||||||
<button [class.active]="activeTab === 'products'" (click)="backToProducts()">Products</button>
|
<button [class.active]="activeTab === 'products'" (click)="backToProducts()">Products</button>
|
||||||
|
<button [class.active]="activeTab === 'summary'" (click)="loadSummary()">All Accounts</button>
|
||||||
@if (selectedProduct) {
|
@if (selectedProduct) {
|
||||||
<button [class.active]="activeTab === 'accounts'" (click)="backToAccounts()">
|
<button [class.active]="activeTab === 'accounts'" (click)="backToAccounts()">
|
||||||
{{ selectedProduct.name }}
|
{{ selectedProduct.name }}
|
||||||
@@ -41,6 +42,89 @@
|
|||||||
<div class="sdi-loading">Loading…</div>
|
<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. -->
|
<!-- Products: one row per savings product, with where it stands in its timetable. -->
|
||||||
@if (activeTab === 'products' && !loading) {
|
@if (activeTab === 'products' && !loading) {
|
||||||
<table class="sdi-table">
|
<table class="sdi-table">
|
||||||
@@ -71,8 +155,11 @@
|
|||||||
<td>{{ formatDate(product.next_posting_date) }}</td>
|
<td>{{ formatDate(product.next_posting_date) }}</td>
|
||||||
<td>
|
<td>
|
||||||
@if (product.last_run_status) {
|
@if (product.last_run_status) {
|
||||||
<span class="sdi-chip" [class.ok]="product.last_run_status === 'COMPLETED'"
|
<span
|
||||||
[class.bad]="product.last_run_status === 'FAILED'">
|
class="sdi-chip"
|
||||||
|
[class.ok]="product.last_run_status === 'COMPLETED'"
|
||||||
|
[class.bad]="product.last_run_status === 'FAILED'"
|
||||||
|
>
|
||||||
{{ product.last_run_status }}
|
{{ product.last_run_status }}
|
||||||
</span>
|
</span>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -81,7 +168,9 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -106,7 +195,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@for (account of accounts; track account.savings_account_id) {
|
@for (account of accounts; track account.savings_account_id) {
|
||||||
<tr class="clickable" (click)="openAccount(account)">
|
<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>{{ account.client_name || '—' }}</td>
|
||||||
<td>{{ formatDate(account.balance_date) }}</td>
|
<td>{{ formatDate(account.balance_date) }}</td>
|
||||||
<td class="num">{{ account.opening_balance | number: '1.2-2' }}</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">
|
<td class="num" [class.zero]="account.below_min_balance">
|
||||||
{{ account.daily_interest | number: '1.4-4' }}
|
{{ account.daily_interest | number: '1.4-4' }}
|
||||||
@if (account.below_min_balance) {
|
@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>
|
||||||
<td class="num accent">{{ account.cycle_cumulative_interest | number: '1.2-2' }}</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>
|
<td>{{ formatDate(account.cycle_end_date) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -156,7 +251,9 @@
|
|||||||
<td class="num" [class.zero]="day.below_min_balance">
|
<td class="num" [class.zero]="day.below_min_balance">
|
||||||
{{ day.daily_interest | number: '1.4-4' }}
|
{{ day.daily_interest | number: '1.4-4' }}
|
||||||
@if (day.below_min_balance) {
|
@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>
|
||||||
<td class="num accent">{{ day.cycle_cumulative_interest | number: '1.4-4' }}</td>
|
<td class="num accent">{{ day.cycle_cumulative_interest | number: '1.4-4' }}</td>
|
||||||
@@ -170,7 +267,9 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -200,15 +299,18 @@
|
|||||||
<td class="num">{{ run.distributed_amount | number: '1.2-2' }}</td>
|
<td class="num">{{ run.distributed_amount | number: '1.2-2' }}</td>
|
||||||
<td class="num">{{ run.rounding_residual | number: '1.2-2' }}</td>
|
<td class="num">{{ run.rounding_residual | number: '1.2-2' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'"
|
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'" [class.bad]="run.status === 'FAILED'">{{
|
||||||
[class.bad]="run.status === 'FAILED'">{{ run.status }}</span>
|
run.status
|
||||||
|
}}</span>
|
||||||
@if (run.failure_reason) {
|
@if (run.failure_reason) {
|
||||||
<div class="sdi-sub">{{ run.failure_reason }}</div>
|
<div class="sdi-sub">{{ run.failure_reason }}</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex-wrap: wrap;
|
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;
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -87,12 +87,12 @@
|
|||||||
td {
|
td {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: rgb(0 0 0 / 3%);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
@@ -106,13 +106,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&: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.
|
// The day the cycle is paid out and the sum goes back to zero.
|
||||||
tr.posting-row {
|
tr.posting-row {
|
||||||
background: rgba(46, 125, 50, 0.08);
|
background: rgb(46 125 50 / 8%);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,15 +144,15 @@
|
|||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
background: rgba(0, 0, 0, 0.08);
|
background: rgb(0 0 0 / 8%);
|
||||||
|
|
||||||
&.ok {
|
&.ok {
|
||||||
background: rgba(46, 125, 50, 0.15);
|
background: rgb(46 125 50 / 15%);
|
||||||
color: #2e7d32;
|
color: #2e7d32;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bad {
|
&.bad {
|
||||||
background: rgba(198, 40, 40, 0.15);
|
background: rgb(198 40 40 / 15%);
|
||||||
color: #c62828;
|
color: #c62828;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,10 +164,74 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: rgba(0, 0, 0, 0.08);
|
background: rgb(0 0 0 / 8%);
|
||||||
|
|
||||||
&.paid {
|
&.paid {
|
||||||
background: rgba(46, 125, 50, 0.2);
|
background: rgb(46 125 50 / 20%);
|
||||||
color: #2e7d32;
|
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,
|
SavingsInterestAccount,
|
||||||
SavingsInterestDay,
|
SavingsInterestDay,
|
||||||
SavingsInterestProduct,
|
SavingsInterestProduct,
|
||||||
SavingsInterestRun
|
SavingsInterestRun,
|
||||||
|
SavingsInterestSummaryRow
|
||||||
} from './savings-daily-interest.service';
|
} from './savings-daily-interest.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,13 +38,17 @@ import {
|
|||||||
export class SavingsDailyInterestComponent implements OnInit {
|
export class SavingsDailyInterestComponent implements OnInit {
|
||||||
private service = inject(SavingsDailyInterestService);
|
private service = inject(SavingsDailyInterestService);
|
||||||
|
|
||||||
activeTab: 'products' | 'accounts' | 'ledger' | 'runs' = 'products';
|
activeTab: 'products' | 'summary' | 'accounts' | 'ledger' | 'runs' = 'products';
|
||||||
|
|
||||||
products: SavingsInterestProduct[] = [];
|
products: SavingsInterestProduct[] = [];
|
||||||
accounts: SavingsInterestAccount[] = [];
|
accounts: SavingsInterestAccount[] = [];
|
||||||
ledger: SavingsInterestDay[] = [];
|
ledger: SavingsInterestDay[] = [];
|
||||||
runs: SavingsInterestRun[] = [];
|
runs: SavingsInterestRun[] = [];
|
||||||
|
|
||||||
|
/** One row per account across every product. */
|
||||||
|
summary: SavingsInterestSummaryRow[] = [];
|
||||||
|
summarySearch = '';
|
||||||
|
|
||||||
selectedProduct: SavingsInterestProduct | null = null;
|
selectedProduct: SavingsInterestProduct | null = null;
|
||||||
selectedAccount: SavingsInterestAccount | 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 {
|
loadRuns(): void {
|
||||||
this.activeTab = 'runs';
|
this.activeTab = 'runs';
|
||||||
this.loading = true;
|
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 --------------------
|
// -------------------- actions --------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,7 +235,11 @@ export class SavingsDailyInterestComponent implements OnInit {
|
|||||||
if (!value || value.length < 3) {
|
if (!value || value.length < 3) {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
const [year, month, day] = value;
|
const [
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day
|
||||||
|
] = value;
|
||||||
return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`;
|
return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,85 @@ export interface SavingsInterestAccount {
|
|||||||
below_min_balance: boolean;
|
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. */
|
/** One day of one account: the working behind that day's interest. */
|
||||||
export interface SavingsInterestDay {
|
export interface SavingsInterestDay {
|
||||||
balance_date: number[];
|
balance_date: number[];
|
||||||
@@ -100,6 +179,61 @@ export class SavingsDailyInterestService {
|
|||||||
return this.http.get<SavingsInterestAccount[]>(`${this.base}/products/${productId}/accounts`);
|
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[]> {
|
getAccountLedger(accountId: number, limit = 400): Observable<SavingsInterestDay[]> {
|
||||||
const params = new HttpParams().set('limit', limit);
|
const params = new HttpParams().set('limit', limit);
|
||||||
return this.http.get<SavingsInterestDay[]>(`${this.base}/accounts/${accountId}`, { params });
|
return this.http.get<SavingsInterestDay[]>(`${this.base}/accounts/${accountId}`, { params });
|
||||||
|
|||||||
-2
@@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
|
|||||||
neftFacility: false,
|
neftFacility: false,
|
||||||
rtgsFacility: false,
|
rtgsFacility: false,
|
||||||
interBankTotalCapFacility: false,
|
interBankTotalCapFacility: false,
|
||||||
withdrawalFacility: false,
|
|
||||||
depositFacilityKyc: false,
|
depositFacilityKyc: false,
|
||||||
virtualDebitCard: 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: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
|
||||||
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
|
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
|
||||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
|
{ 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: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
|
||||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-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 { SavingsService } from '../../savings.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export class AddChargeSavingsAccountComponent implements OnInit {
|
|||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private savingsService = inject(SavingsService);
|
private savingsService = inject(SavingsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
|
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||||
|
|
||||||
/** Minimum Due Date allowed. */
|
/** Minimum Due Date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
@@ -68,7 +70,9 @@ export class AddChargeSavingsAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
this.route.data.subscribe((data: { savingsAccountActionData: any }) => {
|
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'];
|
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 { MatButton, MatIconButton } from '@angular/material/button';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
|
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 { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
||||||
@@ -73,6 +74,7 @@ export class SavingsAccountChargesStepComponent implements OnInit {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private translateService = inject(TranslateService);
|
private translateService = inject(TranslateService);
|
||||||
|
private facilityChargeFilter = inject(FacilityChargeFilterService);
|
||||||
|
|
||||||
private _savingsAccountProductTemplate: any;
|
private _savingsAccountProductTemplate: any;
|
||||||
|
|
||||||
@@ -81,7 +83,9 @@ export class SavingsAccountChargesStepComponent implements OnInit {
|
|||||||
set savingsAccountProductTemplate(value: any) {
|
set savingsAccountProductTemplate(value: any) {
|
||||||
this._savingsAccountProductTemplate = value;
|
this._savingsAccountProductTemplate = value;
|
||||||
if (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 }));
|
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-12
@@ -174,18 +174,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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" [class.dig-card--on]="facilityConfig.depositFacilityKyc">
|
||||||
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
|
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
|
||||||
<div class="dig-card__body">
|
<div class="dig-card__body">
|
||||||
|
|||||||
-1
@@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = {
|
|||||||
neftFacility: false,
|
neftFacility: false,
|
||||||
rtgsFacility: false,
|
rtgsFacility: false,
|
||||||
interBankTotalCapFacility: false,
|
interBankTotalCapFacility: false,
|
||||||
withdrawalFacility: false,
|
|
||||||
depositFacilityKyc: false,
|
depositFacilityKyc: false,
|
||||||
virtualDebitCard: false
|
virtualDebitCard: false
|
||||||
};
|
};
|
||||||
|
|||||||
-1
@@ -73,7 +73,6 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
|
|||||||
{ key: 'neftFacility', label: 'NEFT Facility' },
|
{ key: 'neftFacility', label: 'NEFT Facility' },
|
||||||
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
{ key: 'rtgsFacility', label: 'RTGS Facility' },
|
||||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
|
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
|
||||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
|
||||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
{ 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">
|
||||||
<div class="dap__header-left">
|
<div class="dap__header-left">
|
||||||
<div class="dap__icon-wrap">
|
<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>
|
<div>
|
||||||
<div class="dap__title">
|
<div class="dap__title">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Input, OnInit } from '@angular/core';
|
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
export type DormancyRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'escheat';
|
export type DormancyRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'escheat';
|
||||||
@@ -17,7 +17,7 @@ export type DormancyRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'es
|
|||||||
styleUrls: ['./dormancy-alert-panel.component.scss'],
|
styleUrls: ['./dormancy-alert-panel.component.scss'],
|
||||||
imports: [...STANDALONE_SHARED_IMPORTS]
|
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||||
})
|
})
|
||||||
export class DormancyAlertPanelComponent implements OnInit {
|
export class DormancyAlertPanelComponent implements OnInit, OnChanges {
|
||||||
/** Pass full account data — for ACCOUNT VIEW (general tab) */
|
/** Pass full account data — for ACCOUNT VIEW (general tab) */
|
||||||
@Input() accountData: any;
|
@Input() accountData: any;
|
||||||
/** Pass product template — for ACCOUNT CREATION (currency step) */
|
/** Pass product template — for ACCOUNT CREATION (currency step) */
|
||||||
@@ -48,13 +48,40 @@ export class DormancyAlertPanelComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stepper reuses one panel instance while the user switches products, so the thresholds have
|
||||||
|
* to be recomputed on every input change — without this the panel keeps showing the previously
|
||||||
|
* selected product's days.
|
||||||
|
*/
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes['productTemplate'] || changes['accountData']) {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
if (this.productTemplate && !this.accountData) {
|
if (this.productTemplate && !this.accountData) {
|
||||||
this.initFromProductTemplate();
|
this.initFromProductTemplate();
|
||||||
} else if (this.accountData) {
|
} else if (this.accountData) {
|
||||||
this.initFromAccountData();
|
this.initFromAccountData();
|
||||||
|
} else {
|
||||||
|
this.resetThresholds();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clears stale thresholds when neither a product nor an account is bound. */
|
||||||
|
private resetThresholds(): void {
|
||||||
|
this.daysToInactive = null;
|
||||||
|
this.daysToDormancy = null;
|
||||||
|
this.daysToEscheat = null;
|
||||||
|
this.isMonitoringActive = false;
|
||||||
|
this.isAlertEnabled = false;
|
||||||
|
this.shouldShow = false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Product Template Mode (Currency Step) ─────────────────────
|
// ── Product Template Mode (Currency Step) ─────────────────────
|
||||||
private initFromProductTemplate(): void {
|
private initFromProductTemplate(): void {
|
||||||
const pt = this.productTemplate;
|
const pt = this.productTemplate;
|
||||||
@@ -113,10 +140,15 @@ export class DormancyAlertPanelComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.computeRiskFromAccount();
|
this.computeRiskFromAccount();
|
||||||
// Only surface this panel once the account has actually left "Active" —
|
// Shown wherever dormancy monitoring applies, so a branch can read the thresholds and how far
|
||||||
// i.e. subStatus is Inactive/Dormant/Escheat. A healthy active account
|
// the account is from each of them. The template drops the risk timeline while the account is
|
||||||
// (statusId === 0) should never show a danger-colored dormancy alert.
|
// still healthy, where there is no risk to show.
|
||||||
this.shouldShow = this.statusId > 0;
|
this.shouldShow = this.statusId > 0 || this.isMonitoringActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the account has not left "Active", so there is no risk timeline worth showing. */
|
||||||
|
get isHealthy(): boolean {
|
||||||
|
return this.statusId === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeRiskFromAccount(): void {
|
private computeRiskFromAccount(): void {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user