From fbce73fd173f5573ec8462cdf61bb77efc51926c Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 3 Sep 2026 18:26:22 +0530 Subject: [PATCH] feat: cash-only registers, day book sections and DD amendments --- src/app/cashier/cashier-routing.module.ts | 29 +- src/app/cashier/cashier-shared.scss | 41 + src/app/cashier/cashier.service.ts | 89 +- .../closing-cash-report.component.html | 112 +- .../closing-cash-report.component.ts | 75 ++ .../cashier/day-book/day-book.component.html | 349 ++---- .../cashier/day-book/day-book.component.scss | 95 +- .../cashier/day-book/day-book.component.ts | 339 +----- .../cashier-issue-dd-dialog.component.ts | 497 ++++++++ .../transaction-entry.component.html | 133 ++- .../transaction-entry.component.scss | 64 + .../transaction-entry.component.ts | 443 ++++++- .../voucher-exceptions.component.html | 183 --- .../voucher-exceptions.component.scss | 120 -- .../voucher-exceptions.component.ts | 248 ---- .../voucher-report.component.html | 105 +- .../voucher-report.component.scss | 22 +- .../voucher-report.component.ts | 159 ++- .../voucher-view/voucher-view.component.html | 144 --- .../voucher-view/voucher-view.component.scss | 109 -- .../voucher-view/voucher-view.component.ts | 143 --- src/app/clerk/dividend-dashboard.service.ts | 1 - .../dividend-dashboard.component.scss | 1 + .../dividend-dashboard.component.ts | 1 - src/app/clients/clients.service.ts | 52 + src/app/core/shell/shell.component.html | 92 +- .../item-decision-dialog.component.html | 13 +- .../item-decision-dialog.component.ts | 8 +- .../add-charge-current-account.component.ts | 6 +- .../current-account-charges-step.component.ts | 6 +- ...urrent-account-digital-step.component.html | 12 - .../current-account-digital-step.component.ts | 1 - .../current-account-preview-step.component.ts | 1 - ...edit-current-account-features.component.ts | 2 - .../dormancy-report.component.html | 398 +++++++ .../dormancy-report.component.scss | 778 +++++++++++++ .../dormancy-report.component.ts | 585 ++++++++++ .../dormancy-report.compute.ts | 218 ++++ .../accounting-reports-dialog.component.html | 10 +- .../accounting-reports-dialog.component.ts | 16 +- .../cims-xbrl-dialog.component.ts | 4 +- .../fd-rd-entry-dialog.component.ts | 22 +- .../interest-report.component.html | 205 ++++ .../interest-report.component.scss | 470 ++++++++ .../interest-report.component.ts | 445 +++++++ src/app/kyc-expiry/kyc-expiry.component.html | 430 +++++++ src/app/kyc-expiry/kyc-expiry.component.scss | 814 +++++++++++++ src/app/kyc-expiry/kyc-expiry.component.ts | 431 +++++++ src/app/kyc-expiry/kyc-expiry.compute.ts | 328 ++++++ .../real-form15gh-view.component.html | 14 +- .../real-form15gh-view.component.ts | 17 +- .../form-15gh/tds-15gh.compute.ts | 6 +- .../membership-register.component.html | 8 +- .../share-allotment-report.component.html | 8 +- .../share-allotment-report.component.ts | 4 +- .../share-member/share-member.component.html | 4 +- .../current-product-charges-step.component.ts | 6 +- ...current-product-limits-step.component.html | 15 - .../current-product-limits-step.component.ts | 2 - .../current-product-preview-step.component.ts | 1 - ...current-product-settings-step.component.ts | 19 +- .../current-product-general-tab.component.ts | 1 - src/app/products/dormancy-defaults.ts | 32 + ...deposit-product-currency-step.component.ts | 13 +- src/app/products/products.service.ts | 11 + ...deposit-product-currency-step.component.ts | 13 +- .../create-saving-product.component.html | 2 +- .../edit-saving-product.component.html | 2 +- ...aving-product-accounting-step.component.ts | 131 ++- .../saving-product-charges-step.component.ts | 10 +- ...aving-product-currency-step.component.html | 91 ++ ...aving-product-currency-step.component.scss | 130 +++ .../saving-product-currency-step.component.ts | 85 +- .../saving-product-limits-step.component.html | 15 - .../saving-product-limits-step.component.ts | 2 - ...saving-product-preview-step.component.html | 112 +- .../saving-product-preview-step.component.ts | 13 +- .../saving-product-settings-step.component.ts | 17 +- .../saving-product-general-tab.component.ts | 1 - .../declare-dividend.component.html | 4 +- .../view-dividend/view-dividend.component.ts | 79 +- .../share-capital-return.component.html | 17 +- .../share-capital-return.component.ts | 8 +- src/app/reports/reports-routing.module.ts | 17 + src/app/risk-review/risk-review-data.ts | 1032 +---------------- .../risk-review-report.component.html | 314 ++--- .../risk-review-report.component.scss | 153 ++- .../risk-review-report.component.ts | 177 ++- .../savings-daily-interest.component.html | 124 +- .../savings-daily-interest.component.scss | 84 +- .../savings-daily-interest.component.ts | 85 +- .../savings-daily-interest.service.ts | 134 +++ ...edit-savings-account-features.component.ts | 2 - .../add-charge-savings-account.component.ts | 6 +- .../savings-account-charges-step.component.ts | 6 +- ...avings-account-digital-step.component.html | 12 - .../savings-account-digital-step.component.ts | 1 - .../savings-account-preview-step.component.ts | 1 - .../charges/facility-charge-filter.service.ts | 66 ++ .../dormancy-alert-panel.component.html | 4 +- .../dormancy-alert-panel.component.ts | 44 +- .../sponsor-dd/dd-list/dd-list.component.html | 26 +- .../sponsor-dd/dd-list/dd-list.component.ts | 198 +++- .../gl-settings-dialog.component.ts | 228 ++-- src/app/sponsor-dd/sponsor-dd.service.ts | 42 + .../client-edit-email-dialog.component.html | 21 +- .../client-edit-email-dialog.component.ts | 8 +- ...s-account-edit-compare-dialog.component.ts | 1 - .../checker-inbox-and-tasks.component.html | 8 + src/app/tasks/tasks-routing.module.ts | 16 + src/app/tds-reports/tds-statutory.compute.ts | 31 +- 111 files changed, 9076 insertions(+), 3507 deletions(-) create mode 100644 src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts delete mode 100644 src/app/cashier/voucher-exceptions/voucher-exceptions.component.html delete mode 100644 src/app/cashier/voucher-exceptions/voucher-exceptions.component.scss delete mode 100644 src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts delete mode 100644 src/app/cashier/voucher-view/voucher-view.component.html delete mode 100644 src/app/cashier/voucher-view/voucher-view.component.scss delete mode 100644 src/app/cashier/voucher-view/voucher-view.component.ts create mode 100644 src/app/dormancy-report/dormancy-report.component.html create mode 100644 src/app/dormancy-report/dormancy-report.component.scss create mode 100644 src/app/dormancy-report/dormancy-report.component.ts create mode 100644 src/app/dormancy-report/dormancy-report.compute.ts create mode 100644 src/app/interest-report/interest-report.component.html create mode 100644 src/app/interest-report/interest-report.component.scss create mode 100644 src/app/interest-report/interest-report.component.ts create mode 100644 src/app/kyc-expiry/kyc-expiry.component.html create mode 100644 src/app/kyc-expiry/kyc-expiry.component.scss create mode 100644 src/app/kyc-expiry/kyc-expiry.component.ts create mode 100644 src/app/kyc-expiry/kyc-expiry.compute.ts create mode 100644 src/app/products/dormancy-defaults.ts create mode 100644 src/app/shared/charges/facility-charge-filter.service.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index ded74e9be..6ff299f77 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -20,8 +20,6 @@ import { CashierPositionComponent } from './cashier-position/cashier-position.co import { ReconciliationComponent } from './reconciliation/reconciliation.component'; import { CashHistoryComponent } from './cash-history/cash-history.component'; import { DayBookComponent } from './day-book/day-book.component'; -import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component'; -import { VoucherViewComponent } from './voucher-view/voucher-view.component'; import { VoucherReportComponent } from './voucher-report/voucher-report.component'; import { BranchDayComponent } from './branch-day/branch-day.component'; import { VaultPositionComponent } from './vault-position/vault-position.component'; @@ -226,20 +224,23 @@ const routes: Routes = [ component: DayBookComponent, data: { title: 'Day Book', breadcrumb: 'Day Book' } }, + // The register is read one side at a time: Received is the CR side, Payment the DR side. + // Same component both times — the side it shows comes from `data.side`. + // + // `cashOnly` is what makes these the cashier's own registers rather than the whole voucher + // book: a cashier deals in notes and coins, so only vouchers that actually moved cash + // belong here, and the two reports then reconcile against the drawer and the closing + // report. A back-office screen wanting every voucher points at the same component without + // the flag. { - path: 'voucher-exceptions', - component: VoucherExceptionsComponent, - data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' } - }, - { - path: 'voucher', - component: VoucherViewComponent, - data: { title: 'Voucher', breadcrumb: 'Voucher' } - }, - { - path: 'voucher-report', + path: 'received-report', component: VoucherReportComponent, - data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' } + data: { title: 'Received Report', breadcrumb: 'Received Report', side: 'CR', cashOnly: true } + }, + { + path: 'payment-report', + component: VoucherReportComponent, + data: { title: 'Payment Report', breadcrumb: 'Payment Report', side: 'DR', cashOnly: true } }, { path: 'closing-report', diff --git a/src/app/cashier/cashier-shared.scss b/src/app/cashier/cashier-shared.scss index 90f9984cc..09ece3b90 100644 --- a/src/app/cashier/cashier-shared.scss +++ b/src/app/cashier/cashier-shared.scss @@ -312,6 +312,47 @@ font-variant-numeric: tabular-nums; color: #0f172a; } + + // Names the part of a total that has no row in the table below, so the figures do not read as + // failing to add up. + &__note { + margin-top: 2px; + font-size: 10px; + line-height: 1.4; + color: #64748b; + } + + // The drawer strip sits under the counter totals and is the figure a physical count is checked + // against, so it is marked out as the one to read rather than a second row of the same thing. + &--balance { + margin-top: 10px; + border-color: #cbd5e1; + box-shadow: 0 1px 3px rgb(15 23 42 / 8%); + } +} + +.closing-note { + margin: 8px 0 0; + font-size: 11px; + line-height: 1.5; + color: #64748b; +} + +// Says what a row without a denomination is, next to its chip. +.closing-row__hint { + margin-left: 6px; + font-size: 11px; + color: #64748b; +} + +// A totals row, marked off from the rows it sums. Defined here rather than only in the day book's +// own stylesheet, which the other cashier screens do not load. +.pg-table tfoot td { + background: #f8fafc; + font-weight: 800; + font-size: 12px; + color: #334155; + border-top: 1px solid #e2e8f0; } .pg-step { diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 7e18feab0..d9c9853a5 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -126,66 +126,6 @@ export class CashierService { return this.http.get('/cashreports/day-book', { params }); } - // ── Voucher physical verification & attachments ── - // The voucher number always travels in the body or a query parameter, never as a path segment: - // numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the - // encoded slashes with a 400 before the request reaches the resource. - - /** Records the outcome of checking a voucher against the paper slip. */ - verifyVoucher( - voucherNo: string, - status: 'VERIFIED' | 'EXCEPTION', - slipAmount?: number, - remarks?: string - ): Observable { - 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 { - return this.http.post('/vouchers/attachments', { - voucherNo, - fileName, - contentType, - fileData, - description: description ?? null - }); - } - - listVoucherAttachments(voucherNo: string): Observable { - const params = new HttpParams().set('voucherNo', voucherNo); - return this.http.get('/vouchers/attachments', { params }); - } - - deleteVoucherAttachment(id: number): Observable { - 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 { - 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 { - const params = new HttpParams().set('status', status); - return this.http.get('/vouchers/exceptions', { params }); - } - /** * The vouchers raised over a period, one row each, with what was debited and what credited. * @@ -193,7 +133,13 @@ export class CashierService { * own work whatever they ask for — so `username` is a filter for those allowed to use it, not a * way to choose whose report this is. */ - voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable { + voucherReport( + from: string, + to: string, + username?: string, + voucherType?: string, + cashOnly?: boolean + ): Observable { let params = new HttpParams().set('from', from).set('to', to); if (username) { params = params.set('username', username); @@ -201,25 +147,14 @@ export class CashierService { if (voucherType) { params = params.set('voucherType', voucherType); } + // Only sent when asked for: the server treats a missing flag as the full register, so the + // cashier screens opt in to cash and the back-office ones keep seeing everything. + if (cashOnly) { + params = params.set('cashOnly', 'true'); + } return this.http.get('/vouchers/report', { params }); } - /** - * One voucher and the debit and credit sides it is made of — what a printed voucher shows. - * - * The voucher number travels as a query parameter, not a path segment: numbers are formatted - * CR/2708/0001 and the server rejects the encoded slashes in a path. - */ - voucherDetail(voucherNo: string): Observable { - 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 { - return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes }); - } - /** * Account register for one date: what opened and closed that day, each account's balance as it * stood then, and what it holds now. diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html index 503df7df1..0f9ad0019 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.html +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html @@ -27,12 +27,23 @@ - @if (!showGl && rows.length > 0) { + + @if (!showGl && (rows.length > 0 || ddCashTotal > 0)) {
Total Deposits (IN) - ₹{{ depositTotal | number: '1.2-2' }} + ₹{{ depositTotalWithDd | number: '1.2-2' }} + @if (ddCashTotal > 0) { + + + incl. ₹{{ ddCashTotal | number: '1.2-2' }} not itemised ({{ ddCashCount }} cash DD{{ + ddCashCount === 1 ? '' : 's' + }}) + + }
−
@@ -45,15 +56,57 @@ [class.closing-summary__item--net-pos]="netTotal >= 0" [class.closing-summary__item--net-neg]="netTotal < 0" > - Net Closing Cash + + Net Counter Movement ₹{{ netTotal | number: '1.2-2' }}
} + + @if (!showGl && scopedToCashier && closingBalance !== null) { +
+
+ Opening Balance + ₹{{ openingBalance | number: '1.2-2' }} +
+
+
+
+ Cash Movement + ₹{{ cashMovement | number: '1.2-2' }} +
+
=
+
+ Closing Balance + ₹{{ closingBalance | number: '1.2-2' }} +
+
+ @if (cashMovement !== null && netTotal !== cashMovement) { + +

+ 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. +

+ } + } + @if (!showGl) {
- @if (noteRows.length > 0) { + @if (noteRows.length > 0 || ddCashTotal > 0) { @@ -93,7 +146,42 @@ } + + + @if (ddCashTotal > 0) { + + + + + + + + + + } + + + + + + + + + + + +
+ Not itemised + {{ ddCashCount }} cash DD{{ ddCashCount === 1 ? '' : 's' }} + —₹{{ ddCashTotal | number: '1.2-2' }}—₹0.00—+₹{{ ddCashTotal | number: '1.2-2' }}
Total{{ noteDepositCount }}₹{{ noteDepositAmount | number: '1.2-2' }}{{ noteWithdrawalCount }}₹{{ noteWithdrawalAmount | number: '1.2-2' }} + {{ noteNetAmount >= 0 ? '+' : '−' }}₹{{ + (noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2' + }} +
} @@ -141,8 +229,20 @@ } - @if (loaded && rows.length === 0 && exchangeRows.length === 0) { -
No cash denomination data for this date.
+ + @if (loaded && rows.length === 0 && exchangeRows.length === 0 && ddCashTotal === 0) { + +
+ @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. + } +
}
} diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts index 81d6c3634..a7d938a0a 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -38,6 +38,38 @@ export class ClosingCashReportComponent implements OnInit { loading = false; loaded = false; + /** + * Cash taken at the counter for a Demand Draft. + * + * A cash DD asks for a denomination breakdown but does not require one, and this report is built + * entirely from breakdowns — so that cash was missing from the drawer figure even though the + * ledger held it and the cashier held the notes. It is counted from the request itself and shown + * on its own line, because the denomination rows below cannot account for it. + */ + ddCashTotal = 0; + ddCashCount = 0; + /** Deposits including the DD cash — the figure the drawer is actually checked against. */ + depositTotalWithDd = 0; + + /** + * The drawer, opening to closing. + * + * `netTotal` above is only the day's counter movement — deposits less withdrawals. It is not what + * the cashier is holding, because it carries nothing forward from yesterday and ignores vault + * allocations and inter-cashier legs. The closing balance is the figure that has to match a + * physical count at the end of the day, so it is reported separately rather than inferred from + * the counter totals. + * + * Null when the server did not scope the report to one cashier: a branch-wide opening balance is + * the branch day register's business, not this report's. + */ + openingBalance: number | null = null; + closingBalance: number | null = null; + /** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */ + cashMovement: number | null = null; + /** Whether the figures above are one cashier's drawer or the whole branch's counter work. */ + scopedToCashier = false; + // GL View showGl = false; glRows: any[] = []; @@ -66,6 +98,33 @@ export class ClosingCashReportComponent implements OnInit { get noteRows(): any[] { return this.rows.filter((r) => r.kind !== 'coin'); } + + /** + * Totals for the notes table, including the un-itemised Demand Draft cash. + * + * The DD row carries an amount but no piece count, so pieces and amounts are summed separately + * rather than one being derived from the other — a denomination times a count would not reach the + * DD's figure, and adding a fictional count to make it balance would misstate the drawer. + */ + get noteDepositCount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.depositCount ?? 0), 0); + } + + get noteDepositAmount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.depositAmount ?? 0), 0) + this.ddCashTotal; + } + + get noteWithdrawalCount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.withdrawalCount ?? 0), 0); + } + + get noteWithdrawalAmount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0); + } + + get noteNetAmount(): number { + return this.noteDepositAmount - this.noteWithdrawalAmount; + } get coinRows(): any[] { return this.rows.filter((r) => r.kind === 'coin'); } @@ -92,6 +151,15 @@ export class ClosingCashReportComponent implements OnInit { this.depositTotal = Number(res?.depositTotal || 0); this.withdrawalTotal = Number(res?.withdrawalTotal || 0); this.netTotal = Number(res?.netTotal || 0); + this.ddCashTotal = Number(res?.ddCashTotal || 0); + this.ddCashCount = Number(res?.ddCashCount || 0); + this.depositTotalWithDd = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0); + this.scopedToCashier = res?.scopedToCashier === true; + // Sent only for a cashier-scoped call, and read as absent rather than zero when missing — + // a zero opening balance and no opening balance at all are different statements. + this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance); + this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance); + this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement); this.loading = false; this.loaded = true; }, @@ -101,6 +169,13 @@ export class ClosingCashReportComponent implements OnInit { this.depositTotal = 0; this.withdrawalTotal = 0; this.netTotal = 0; + this.ddCashTotal = 0; + this.ddCashCount = 0; + this.depositTotalWithDd = 0; + this.scopedToCashier = false; + this.openingBalance = null; + this.closingBalance = null; + this.cashMovement = null; this.loading = false; this.loaded = true; } diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html index 12b14a2d2..54d302add 100644 --- a/src/app/cashier/day-book/day-book.component.html +++ b/src/app/cashier/day-book/day-book.component.html @@ -65,42 +65,6 @@ @if (errorMsg) {
{{ errorMsg }}
} - - - @if (!loading && exceptionRows.length > 0) { -
-
- warning - - {{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation - - @if (resolvedExceptionCount > 0) { - ({{ resolvedExceptionCount }} already closed) - } - -
- @for (e of exceptionRows; track e.voucherNo) { -
- {{ e.voucherNo }} - - System ₹{{ e.amount | number: '1.2-2' }} - @if (e.slipAmount != null && e.slipAmount !== e.amount) { - · Slip ₹{{ e.slipAmount | number: '1.2-2' }} - (diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }}) - } - - {{ e.verifyRemarks }} - — {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }} -
- } -
- } - @if (loading) {
Loading…
} @@ -139,252 +103,87 @@ Totals count approved vouchers only — pending and rejected rows are listed but never move the money.

-
- - - - - - - - - - - - - - - - - - - - @for (r of filteredRows; track r.voucherNo ?? r.submittedAt) { - - - - - - - - - - - - - - + + @for (s of sections; track s.key) { +
+
+ {{ s.label }} + {{ s.hint }} + {{ s.rows.length }} row{{ s.rows.length === 1 ? '' : 's' }} +
+
Voucher NoDr/CrTypeAccountPartyAmountPaymentStatusByApproved BySlip CheckTime
- {{ r.voucherNo ?? '—' }} - - - {{ r.side }} - - {{ r.kind }} - {{ r.accountNo ?? '—' }} - @if (r.accountType) { - · {{ r.accountType }} - } - {{ r.party ?? '—' }}₹{{ r.amount | number: '1.2-2' }}{{ r.paymentTypeName ?? '—' }} - {{ r.status }} - {{ r.handledBy ?? '—' }} - {{ r.approvedBy ?? '—' }} - @if (r.approvedBy && r.actionedAt) { - · {{ timeOf(r.actionedAt) }} - } - - @if (r.verifyStatus === 'VERIFIED') { - ✓ MATCHED - } @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) { - - - ✓ RESOLVED - - } @else if (r.verifyStatus === 'EXCEPTION') { - - ⚠ EXCEPTION - - } @else { - Not checked - } - {{ timeOf(r.submittedAt) }} - - -
+ + + + + + + + + + + + + - - - @if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) { - - + + @for (r of s.rows; track r.voucherNo ?? r.section + r.txnId) { + + + + + - - } - - - @if (verifyRow === r) { - - + + + - - } - - - @if (attachRow === r) { - - + + } - } - - - - - - - - -
Voucher NoDr/CrTypeAccountPartyAmountPaymentStatusByApproved ByTime
- {{ r.resolutionStatus ? 'task_alt' : 'warning' }} - Exception - @if (r.slipAmount != null && r.slipAmount !== r.amount) { - — slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference - ₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }}) - } - — {{ r.verifyRemarks }} - · raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }} - - @if (r.resolutionStatus) { -
- Closed — {{ resolutionLabel(r.resolutionStatus) }}: - {{ r.resolutionNotes }} - · by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }} -
+
+ {{ r.voucherNo ?? '—' }} + + + {{ r.side }} + + {{ r.kind }} + {{ r.accountNo ?? '—' }} + @if (r.accountType) { + · {{ r.accountType }} }
-
-
- Slip check — {{ r.voucherNo }} - -
-
-
- System recorded - ₹{{ r.amount | number: '1.2-2' }} -
- - @if (verifySlipBlank) { -
- Difference - — -
- } @else if (verifyMismatch) { -
- Difference - ₹{{ verifyDifference | number: '1.2-2' }} -
- } @else { -
- Match - Amounts agree -
- } -
- - @if (verifyMismatch) { -

- Saving this marks the voucher as an exception for investigation — it does not - change the posted amount. -

- } -
- - - @if (!verifyMismatch) { - - } - -
-
+
{{ r.party ?? '—' }}₹{{ r.amount | number: '1.2-2' }}{{ r.paymentTypeName ?? '—' }} + {{ r.status }}
-
-
- Attachments — {{ r.voucherNo }} - -
- - @if (attachUploading) { -

Uploading…

- } - @if (attachLoading) { -

Loading…

- } - @if (!attachLoading && attachments.length === 0) { -

Nothing attached to this voucher yet.

- } - @for (a of attachments; track a.id) { -
- description - {{ a.fileName }} - {{ a.fileSize }} bytes · {{ a.uploadedByName }} - - -
- } -
+
{{ r.handledBy ?? '—' }} + {{ r.approvedBy ?? '—' }} + @if (r.approvedBy && r.actionedAt) { + · {{ timeOf(r.actionedAt) }} + } {{ timeOf(r.submittedAt) }}
Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)₹{{ shownCredit - shownDebit | number: '1.2-2' }}
-
+ + + + {{ s.label }} subtotal + + @if (s.credit > 0 && s.debit > 0) { + ₹{{ s.credit | number: '1.2-2' }} CR · ₹{{ s.debit | number: '1.2-2' }} DR + } @else if (s.credit > 0) { + ₹{{ s.credit | number: '1.2-2' }} CR + } @else if (s.debit > 0) { + ₹{{ s.debit | number: '1.2-2' }} DR + } @else { + — + } + + + + + + + } } @if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) { diff --git a/src/app/cashier/day-book/day-book.component.scss b/src/app/cashier/day-book/day-book.component.scss index 6f8b430d1..32ee649b3 100644 --- a/src/app/cashier/day-book/day-book.component.scss +++ b/src/app/cashier/day-book/day-book.component.scss @@ -32,6 +32,34 @@ color: #94a3b8; } +/* One card per source register. The heading says which register, and why its rows read differently + from the others, so the three are not added up as though they were one list. */ +.db-section { + margin-bottom: 14px; + + &__head { + display: flex; + align-items: baseline; + gap: 10px; + padding: 10px 14px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 12px; + + strong { + font-size: 13px; + color: #0d1b2a; + } + } + + &__count { + margin-left: auto; + font-size: 11px; + font-weight: 700; + color: #64748b; + } +} + /* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */ .db-side { display: inline-block; @@ -169,41 +197,6 @@ } } -/* The reason spelled out under its own flagged row. */ -.db-exc-row td { - background: #fef2f2 !important; - border-bottom: 1px solid #fecaca !important; - color: #7f1d1d; - font-size: 12px; - padding: 8px 14px !important; - - mat-icon { - font-size: 15px; - width: 15px; - height: 15px; - vertical-align: -3px; - margin-right: 4px; - } -} - -/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer - something anyone has to act on. */ -.db-exc-row--resolved td { - background: #f0fdf4 !important; - border-bottom: 1px solid #bbf7d0 !important; - color: #14532d; -} - -.db-exc-row__resolution { - margin-top: 4px; - padding-top: 4px; - border-top: 1px dotted #bbf7d0; -} - -.db-actions { - white-space: nowrap; -} - /* Raising an exception is a deliberate, consequential act — styled to read that way without looking destructive, since nothing is deleted. */ .pg-btn--warn { @@ -352,38 +345,6 @@ } } -.db-upload { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 12px; -} - -.db-file { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - margin-bottom: 6px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 6px; - font-size: 12px; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: #64748b; - } - - &__name { - font-weight: 700; - color: #0d1b2a; - } -} - .pg-table tfoot td { background: #f8fafc; font-weight: 800; diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index 534f9d7ca..418c340fa 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -13,8 +13,18 @@ import { MatIconModule } from '@angular/material/icon'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; +/** + * Which part of the day's work a row came from. Set by the server, one per source register. + * + * Only counter business appears in the day book. Cash moved between the vault and the drawers is + * the bank shifting its own money, not a customer transaction, and it is read on the Cashier + * Position and Closing Cash Report where the drawer itself is the subject. + */ +type DayBookSection = 'CASHIER' | 'CLERK'; + interface DayBookRow { voucherNo: string | null; + section: DayBookSection; side: 'CR' | 'DR'; kind: string; accountNo: string | null; @@ -29,18 +39,6 @@ interface DayBookRow { txnId: string | null; submittedAt: string | null; actionedAt: string | null; - /** null until someone has physically checked the slip. */ - verifyStatus: 'VERIFIED' | 'EXCEPTION' | null; - slipAmount: number | null; - verifyRemarks: string | null; - verifiedByName: string | null; - verifiedAt: string | null; - /** Set once a clerk or manager has closed the exception; null while it is still open. */ - resolutionStatus: string | null; - resolutionNotes: string | null; - resolvedByName: string | null; - resolvedAt: string | null; - attachmentCount: number; } /** @@ -128,48 +126,47 @@ export class DayBookComponent implements OnInit { this.load(); } - /** Show only the flagged vouchers — set from the exception banner. */ - exceptionsOnly = false; - /** - * Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays - * visible on its own row as history, but it is no longer something anyone has to act on. + * The day's rows split by where they came from, in the order they are read. + * + * Cashier work first — that is the counter — then the clerk's outstanding requests. An empty + * section is dropped rather than shown as a heading over nothing. + * + * A section is not "who typed it": a clerk raises a withdrawal that a cashier pays out, so the + * heading says which register the entry belongs to, and the By column still names the person. */ - get exceptionRows(): DayBookRow[] { - return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus); - } - - /** Closed exceptions, kept for the record rather than for action. */ - get resolvedExceptionCount(): number { - return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length; - } - - /** Human label for a resolution code. */ - resolutionLabel(status: string | null): string { - switch (status) { - case 'RESOLVED_SYSTEM_ERROR': - return 'System entry was wrong'; - case 'RESOLVED_SLIP_ERROR': - return 'Slip was written wrong'; - case 'RESOLVED_NO_ACTION': - return 'No action needed'; - default: - return status ?? ''; - } - } - - /** Vouchers nobody has checked against a slip yet. */ - get uncheckedCount(): number { - return this.rows.filter((r) => !r.verifyStatus).length; - } - - toggleExceptionsOnly(): void { - this.exceptionsOnly = !this.exceptionsOnly; + get sections(): { + key: DayBookSection; + label: string; + hint: string; + rows: DayBookRow[]; + credit: number; + debit: number; + }[] { + const order: { key: DayBookSection; label: string; hint: string }[] = [ + { key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' }, + { key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' } + ]; + return order + .map((s) => { + const rows = this.filteredRows.filter((r) => r.section === s.key); + return { + ...s, + rows, + credit: rows + .filter((r) => r.side === 'CR' && this.countsAsMoney(r)) + .reduce((t, r) => t + Number(r.amount ?? 0), 0), + debit: rows + .filter((r) => r.side === 'DR' && this.countsAsMoney(r)) + .reduce((t, r) => t + Number(r.amount ?? 0), 0) + }; + }) + .filter((s) => s.rows.length > 0); } /** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */ get filteredRows(): DayBookRow[] { - const base = this.exceptionsOnly ? this.exceptionRows : this.rows; + const base = this.rows; const q = this.search.trim().toLowerCase(); if (!q) return base; return base.filter((r) => @@ -231,236 +228,9 @@ export class DayBookComponent implements OnInit { return t ? t.slice(0, 5) : '—'; } - // ── Physical slip verification ──────────────────────────────────────────── - - /** Row whose verification panel is open, or null when none is. */ - verifyRow: DayBookRow | null = null; - verifySlipAmount: number | null = null; - verifyRemarks = ''; - verifySaving = false; - - openVerify(r: DayBookRow): void { - this.verifyRow = r; - // Pre-filled with the system's own figure: the common case is that the two agree, and when - // they don't the cashier only has to change the digits that actually differ. - this.verifySlipAmount = Number(r.amount ?? 0); - this.verifyRemarks = r.verifyRemarks ?? ''; - this.attachRow = null; - } - - closeVerify(): void { - this.verifyRow = null; - this.verifySlipAmount = null; - this.verifyRemarks = ''; - } - - /** True when the amount typed off the slip differs from what the system recorded. */ - get verifyMismatch(): boolean { - if (!this.verifyRow || this.verifySlipAmount == null) return false; - return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0); - } - - /** - * True while the slip box is empty. Clearing the field must not read as "amounts agree" — an - * unanswered question is not a match, so the panel shows neither verdict until a figure is typed. - */ - get verifySlipBlank(): boolean { - return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === ''; - } - - get verifyDifference(): number { - if (!this.verifyRow || this.verifySlipAmount == null) return 0; - return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0); - } - - /** - * Saves the verdict. The status is derived from the figures rather than picked by the user — - * if the slip and the system disagree it is an exception, whatever anyone intended to click. - */ - saveVerify(): void { - const row = this.verifyRow; - if (!row?.voucherNo) return; - - const isException = this.verifyMismatch; - if (isException && !this.verifyRemarks.trim()) { - this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.'; - return; - } - - this.verifySaving = true; - this.errorMsg = ''; - this.service - .verifyVoucher( - row.voucherNo, - isException ? 'EXCEPTION' : 'VERIFIED', - isException ? Number(this.verifySlipAmount) : undefined, - this.verifyRemarks.trim() || undefined - ) - .pipe( - catchError((err: any) => { - this.errorMsg = - err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.'; - return of(null); - }) - ) - .subscribe((res: any) => { - this.verifySaving = false; - if (res) { - this.closeVerify(); - this.load(); - } - }); - } - - /** - * Flags a voucher whose amounts agree — the problem is something other than the figure: a - * missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the - * check" action: erasing a verdict would erase the evidence that anything was ever wrong, which - * is exactly what an investigation needs. - */ - raiseException(): void { - const row = this.verifyRow; - if (!row?.voucherNo) return; - - if (!this.verifyRemarks.trim()) { - this.errorMsg = 'Say what the problem is before raising an exception.'; - return; - } - - this.verifySaving = true; - this.errorMsg = ''; - this.service - // The slip amount is sent as-is: the figures match, so it doubles as the record of what - // both the slip and the system said at the time the exception was raised. - .verifyVoucher( - row.voucherNo, - 'EXCEPTION', - Number(this.verifySlipAmount ?? row.amount ?? 0), - this.verifyRemarks.trim() - ) - .pipe( - catchError((err: any) => { - this.errorMsg = - err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.'; - return of(null); - }) - ) - .subscribe((res: any) => { - this.verifySaving = false; - if (res) { - this.closeVerify(); - this.load(); - } - }); - } - - // ── Attachments ─────────────────────────────────────────────────────────── - - attachRow: DayBookRow | null = null; - attachments: any[] = []; - attachLoading = false; - attachUploading = false; - - openAttach(r: DayBookRow): void { - this.attachRow = r; - this.verifyRow = null; - this.attachments = []; - if (!r.voucherNo) return; - this.attachLoading = true; - this.service - .listVoucherAttachments(r.voucherNo) - .pipe(catchError(() => of(null))) - .subscribe((res: any) => { - this.attachLoading = false; - this.attachments = res?.attachments ?? []; - }); - } - - closeAttach(): void { - this.attachRow = null; - this.attachments = []; - } - - onFileSelected(event: Event): void { - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - const row = this.attachRow; - if (!file || !row?.voucherNo) return; - - // 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file - // only to have it refused. - if (file.size > 10 * 1024 * 1024) { - this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.'; - input.value = ''; - return; - } - - const reader = new FileReader(); - reader.onload = () => { - this.attachUploading = true; - this.errorMsg = ''; - this.service - .uploadVoucherAttachment( - row.voucherNo!, - file.name, - file.type || 'application/octet-stream', - String(reader.result) - ) - .pipe( - catchError((err: any) => { - this.errorMsg = - err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.'; - return of(null); - }) - ) - .subscribe((res: any) => { - this.attachUploading = false; - input.value = ''; - if (res) { - this.openAttach(row); - this.load(); - } - }); - }; - reader.readAsDataURL(file); - } - - /** - * Fetched through HttpClient rather than window.open: the download endpoint needs the auth - * header the interceptor adds, which a bare browser navigation would not carry. The blob is - * then handed to the browser as a normal save. - */ - downloadAttachment(a: any): void { - this.service - .downloadVoucherAttachment(a.id) - .pipe(catchError(() => of(null))) - .subscribe((blob: Blob | null) => { - if (!blob) { - this.errorMsg = 'Could not download that file.'; - return; - } - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = a.fileName ?? 'attachment'; - link.click(); - URL.revokeObjectURL(url); - }); - } - - deleteAttachment(a: any): void { - const row = this.attachRow; - this.service - .deleteVoucherAttachment(a.id) - .pipe(catchError(() => of(null))) - .subscribe(() => { - if (row) this.openAttach(row); - this.load(); - }); - } - exportCsv(): void { const header = [ + 'Section', 'Voucher No', 'Side', 'Type', @@ -471,18 +241,11 @@ export class DayBookComponent implements OnInit { 'Status', 'Handled By', 'Approved By', - 'Slip Check', - 'Slip Amount', - 'Remarks', - 'Checked By', - 'Resolution', - 'Resolution Notes', - 'Closed By', - 'Attachments', 'Time' ]; const lines = this.filteredRows.map((r) => [ + r.section, r.voucherNo ?? '', r.side, r.kind, @@ -493,14 +256,6 @@ export class DayBookComponent implements OnInit { r.status, r.handledBy ?? '', r.approvedBy ?? '', - r.verifyStatus ?? 'NOT CHECKED', - r.slipAmount != null ? String(r.slipAmount) : '', - r.verifyRemarks ?? '', - r.verifiedByName ?? '', - r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '', - r.resolutionNotes ?? '', - r.resolvedByName ?? '', - String(r.attachmentCount ?? 0), this.timeOf(r.submittedAt) ] // Quote every field and double any embedded quote: names and narrations routinely diff --git a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts new file mode 100644 index 000000000..50e51c402 --- /dev/null +++ b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts @@ -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: ` +
+
+
+ receipt_long +
+

{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}

+

+ Next available DD leaflet auto-selected · + {{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }} +

+
+
+ +
+ + + +
+
+ Auto-Selected Leaflet + {{ data.leaflet.ddNumber }} + {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }} +
+
+
+ Face Value + ₹{{ data.faceValue | number: '1.2-2' }} + + Charges ₹{{ data.chargesAmount | number: '1.2-2' }} +
+
+
+ GST ({{ data.gstPercentage }}%) + ₹{{ data.gstAmount | number: '1.2-2' }} + on charges only +
+
+
+ {{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }} + ₹{{ data.totalCashPayable | number: '1.2-2' }} + + @if (isAccountDebit) { + {{ data.accountNo || 'Account' }} + } @else { + Denominations verified + } + +
+
+ + + @if (isAccountDebit) { +
+ account_balance_wallet +
+ Debiting {{ data.accountNo }} + @if (data.accountHolderName) { + · {{ data.accountHolderName }} + } +
+ ₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker + approves. No cash is taken at the counter. +
+
+
+ } + +
+ + @if (!isAccountDebit) { +
Walk-in Purchaser Details
+ +
+ + Walk-in Purchaser Name * + + Purchaser name is required + Must be at least 3 characters + + + + 12-Digit Aadhaar Number * + + Masked to last 4 digits before storage + Aadhaar number is required + Must be exactly 12 numeric digits + +
+ + + Purchaser Address / Location * + + Address is required + + } + + +
Beneficiary & Payment Details
+ + + Beneficiary Name (In Favour Of) * + + Beneficiary name is required + Max 120 characters + + +
+ + Payable At - Branch * + + Payable Branch is required + + + + Payable At - City * + + Payable City is required + +
+
+
+ + + + + +
+ `, + 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); + 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 + } + ); + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index afcdc4bf7..8496b5dc4 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -134,6 +134,23 @@ + @if (row.typ === 'DD') { +
+ + +
+ }
+ +
+ @if (row.typ === 'DD') { +
+ + +
+ }
@@ -1591,14 +1683,19 @@
- +
- @if (isDdCashGlRow(row)) { + @if (isDdRow(row)) {
DD Charges & GST — Admin Rate {{ ddGstPercentage }}% @@ -1612,6 +1709,7 @@ type="number" [value]="row.ddCharges ?? ''" (input)="row.ddCharges = +$any($event.target).value" + (click)="$event.stopPropagation()" placeholder="0.00" />
@@ -1632,7 +1730,7 @@
₹ @@ -1644,6 +1742,23 @@ />
+ +
+ +
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index bc31277d3..36cd56460 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -3384,3 +3384,67 @@ select.te-cell { transform: rotate(360deg); } } + +.ws-dd-unconfigured-banner { + display: flex; + align-items: center; + gap: 12px; + margin: 10px 16px; + padding: 12px 16px; + background: #fffbeb; + border: 1.5px solid #fcd34d; + border-radius: 8px; + color: #92400e; + font-size: 13px; + + mat-icon { + color: #d97706; + font-size: 24px; + width: 24px; + height: 24px; + flex-shrink: 0; + } + + &__text { + flex: 1; + line-height: 1.4; + + strong { + color: #78350f; + } + } +} + +.te-dd-issue-btn { + display: inline-flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, #0284c7, #0ea5e9); + color: #fff; + border: none; + border-radius: 6px; + padding: 7px 16px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 2px 4px rgb(2 132 199 / 35%); + transition: all 0.15s ease-in-out; + letter-spacing: 0.2px; + + &:hover:not(:disabled) { + background: linear-gradient(135deg, #0369a1, #0284c7); + box-shadow: 0 3px 8px rgb(2 132 199 / 50%); + transform: translateY(-1px); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + background: #cbd5e1; + color: #64748b; + cursor: not-allowed; + box-shadow: none; + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index e17b55db9..fbbd61837 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -32,6 +32,7 @@ import { } from '../../shared/customer-search-dialog/customer-search-dialog.component'; import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service'; import { OrganizationService } from '../../organization/organization.service'; +import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component'; interface TxnRow { typ: string; @@ -51,6 +52,7 @@ interface TxnRow { routingCode: string; /** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */ ddCharges: number | null; + sponsorBankId?: number | null; /** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings * account — governs the share redemption (see submitShareRedemptionTransfer()). */ resolutionNumber?: string; @@ -1523,7 +1525,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { * the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()). */ paymentTypesForRow(row: TxnRow): any[] { - const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined); + // Sponsor-bank payment types are excluded. They were added for the Demand Draft module, which + // picks its bank from m_sponsor_bank on the DD row itself, and they are not counter instruments + // — a clerk choosing "SBI" as the TXN TP. of a withdrawal says nothing about how the money + // moves. They are recognised by their description rather than by name so that adding another + // sponsor bank does not put it back on this list. + const tellerFacing = this.paymentTypes.filter( + (pt: any) => !pt.isSystemDefined && !/sponsor bank/i.test(pt.description ?? '') + ); if (!this.isClerk) { // Cashier counter takes cash only — cheque/transfer/DD instruments go through their own // clerk/back-office approval flows, never straight through the cash drawer. @@ -1541,6 +1550,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { if (isShareSettlementCredit) { return tellerFacing.filter((pt: any) => !pt.isCashPayment); } + // A withdrawal slip is a cash instruction and nothing else: the clerk raises it, the cashier + // counts the notes out against the token. There is no non-cash way to settle one, so cash is + // the only choice offered rather than the default among several. + if (row.typ === 'Withdrawal Slip') { + return tellerFacing.filter((pt: any) => !!pt.isCashPayment); + } + // A clerk's Demand Draft is never funded by cash they take themselves. Cash for a draft is + // counted at the counter, so it comes either from the cashier's own DD screen or from an + // approved cashier deposit request the clerk points the draft at; the clerk's own route is to + // debit the member's account. Offering cash here let a draft be raised against notes no drawer + // had counted, which is exactly how the six cash DDs with no denomination breakdown arose. + if (row.typ === 'DD') { + return tellerFacing.filter((pt: any) => !pt.isCashPayment); + } + // Everything else can go either way, so both are offered and the clerk says which. A cheque or + // a bank transfer normally settles through the instrument, a disbursement or fund transfer + // between accounts — the type alone does not fix it to one or the other. return tellerFacing; } @@ -1664,9 +1690,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // wrong for crediting a GL amount into a savings/current account for any other reason (e.g. // funding a newly-activated account's opening balance from a GL source). { value: 'Fund Transfer', label: 'Fund Transfer' }, - // NOTE: the exact same TxnRow.typ string is used at every dispatch/default-setting site below — - // search for 'Disbursement' whenever this needs to change again, since 'Fund Transfer' always - // rides alongside it in every one of those checks rather than having its own separate case. { value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' }, { value: 'DD', label: 'DD (Demand Draft)' } ]; @@ -1692,6 +1715,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions; } + sponsorBanks: any[] = []; + ddGlMapping: any = null; + // ── Denomination ────────────────────────────────────────────────────────── notes: Denomination[] = []; coins: Denomination[] = []; @@ -1752,6 +1778,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.loadCashVaultGl(); this.loadDdGstPercentage(); + this.loadSponsorBanks(); if (this.reviewMode) { this.loadRequestIntoForm(this.reviewRequestId!, 'review'); @@ -1842,12 +1869,85 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .getGlMappings() .pipe(catchError(() => of([]))) .subscribe((mappings: any[]) => { - const mapping = mappings && mappings.length ? mappings[0] : null; - this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18); - this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null; + this.ddGlMapping = mappings && mappings.length ? mappings[0] : null; + this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18); + this.ddLimitAmount = this.ddGlMapping?.ddLimitAmount != null ? Number(this.ddGlMapping.ddLimitAmount) : null; + if (this.ddGlMapping?.ddPayableGlAccountId) { + if (this.ddGlMapping.ddPayableGlCode) { + this.applyDdGlMappingToDdRows(); + } else { + this.http + .get(`/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(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`) + .pipe(catchError(() => of(null))) + .subscribe((glAcc: any) => { + if (glAcc) { + this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id); + this.ddGlMapping.ddPayableGlAccountName = glAcc.name; + row.acNo = this.ddGlMapping.ddPayableGlCode; + this.selectedRow = row; + this.loadAccountDetails(row.acNo, 'GL'); + } + }); + } + } + + private applyDdGlMappingToDdRows(): void { + for (const row of this.rows) { + if (row.typ === 'DD') { + this.applyDdGlMappingToRow(row); + } + } + } + + private loadSponsorBanks(): void { + this.sponsorDDService + .getSponsorBanks() + .pipe(catchError(() => of([]))) + .subscribe((banks: any[]) => { + this.sponsorBanks = banks || []; + for (const row of this.rows) { + if (row.typ === 'DD' && !row.sponsorBankId && this.sponsorBanks.length > 0) { + row.sponsorBankId = this.sponsorBanks[0].id; + } + } + }); + } + + get isDdGlMappingConfigured(): boolean { + return !!(this.ddGlMapping && this.ddGlMapping.ddPayableGlAccountId && this.ddGlMapping.cashGlAccountId); + } + private loadDayStatus(): void { this.cashierService .getCashierDayStatus() @@ -2093,9 +2193,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.chequeNo = ''; row.cheqDate = ''; break; + // A cheque clears through the bank rather than the drawer, so it opens on the transfer + // instrument. Cash is still on its dropdown — a cheque can be cashed over the counter — but + // it is the exception, not what the field should start on. case 'Cheque': row.dbCr = 'Dr'; - row.paymentTypeId = this.findCashPaymentTypeId(); + row.paymentTypeId = this.findTransferPaymentTypeId(); break; case 'Disbursement': case 'Fund Transfer': @@ -2121,16 +2224,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.chequeNo = ''; row.cheqDate = ''; break; - // A DD is bought with cash: the customer hands over Face Value + Charges + GST, so it is money - // coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment type and - // AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST panel never - // appears and the row would post as an ordinary entry with the charges silently dropped. + // A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is + // money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment + // type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST + // panel never appears and the row would post as an ordinary entry with the charges silently + // dropped. + // + // A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no + // longer offers cash, so defaulting to it would open the field on a value it cannot show. + // isDdCashGlRow() still holds for them through AC.TP = GL. case 'DD': row.dbCr = 'Cr'; - row.paymentTypeId = this.findCashPaymentTypeId(); row.acTyp = 'GL'; + row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId(); row.chequeNo = ''; row.cheqDate = ''; + if (this.sponsorBanks.length > 0 && !row.sponsorBankId) { + row.sponsorBankId = this.sponsorBanks[0].id; + } + this.applyDdGlMappingToRow(row); break; } } @@ -2145,10 +2257,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return pt?.id ?? null; } - /** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */ + /** TYPE=DD + TXN TP=Cash */ isDdCashGlRow(row: TxnRow): boolean { + if (this.isDdAccountDebitRow(row)) return false; const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); - return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL'; + return row.typ === 'DD' && (row.acTyp === 'GL' || !row.acTyp || !!pt?.isCashPayment); + } + + /** + * TYPE=DD funded by debiting a member's savings/current account rather than by cash. + * + * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit + * would let a draft be raised against an account with no cash to reconcile it against — and the + * clerk flow is the one that carries a maker-checker step over the member's balance. + */ + isDdAccountDebitRow(row: TxnRow): boolean { + return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB'); + } + + /** + * Any DD row the charges/GST panel applies to. + * + * Charges and GST come from the admin's DD configuration and are collected whichever way the + * draft is paid for — the difference is only where the money comes from, so the panel is the same + * for both and the label on the total changes rather than the arithmetic. + */ + isDdRow(row: TxnRow): boolean { + return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row); } /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */ @@ -2179,6 +2314,258 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005; } + branchNameFor(branchId: number): string { + const map: Record = { + 1: '001 · Main', + 2: '002 · M.G. Road', + 14: '014 · Station Rd', + 27: '027 · Industrial' + }; + return map[branchId] || `Branch ${branchId}`; + } + + /** + * Cashier Walk-in DD creation flow: + * 1. Validates one-time persistent GL configuration + * 2. Auto-selects next available leaflet + * 3. Opens dialog to capture purchaser/beneficiary details + * 4. Submits issue request directly with physical denominations for manager approval + */ + openIssueWalkinDd(row: TxnRow): void { + if (!this.isDdGlMappingConfigured) { + this.showAlert('error', 'DD GL Mapping Not Configured', [ + 'DD GL mapping is not configured.', + 'Please ask an administrator to set up the GL accounts in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.' + ]); + return; + } + const faceValue = Number(row.amount); + if (!faceValue || faceValue <= 0) { + this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']); + return; + } + const chargesAmount = Number(row.ddCharges) || 0; + const gstAmount = this.ddGstAmountFor(row); + const totalCashPayable = this.ddTotalPayableFor(row); + const accountDebit = this.isDdAccountDebitRow(row); + + // An account-funded draft needs the account resolved to a Fineract id before it can be sent: + // the server takes a savings/current account id and a client id, not the typed number. Both are + // filled in by the A/C NO. lookup, so an unresolved account means the number was never found. + let fundingAccountId: number | null = null; + let fundingClientId: number | null = null; + if (accountDebit) { + const acNo = (row.acNo || '').trim(); + if (!acNo) { + this.showAlert('error', 'Account Required', [ + 'Enter the account number the Demand Draft is funded from.' + ]); + return; + } + fundingAccountId = this.rowAccountIds[acNo] ?? null; + fundingClientId = this.rowClientIds[acNo] ?? null; + if (!fundingAccountId || !fundingClientId) { + this.showAlert('error', 'Account Not Found', [ + `Account ${acNo} could not be resolved.`, + 'Check the account number and wait for its details to load before raising the draft.' + ]); + return; + } + } + + // Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a + // denomination count left over from an earlier row must not be checked against it. + if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) { + this.showAlert('error', 'Denomination Mismatch', [ + `Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`, + 'Please verify and match the physical cash denomination count.' + ]); + return; + } + + this.saving = true; + this.sponsorDDService.getNextAvailableLeaflet(row.br, row.sponsorBankId ?? undefined).subscribe({ + next: (leaflet) => { + this.saving = false; + if (!leaflet) { + this.showAlert('error', 'No Available Leaflets', [ + 'No available Demand Draft leaflets found for this branch and sponsor bank.', + 'Please register a DD inventory series first.' + ]); + return; + } + + const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId); + const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br; + + const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, { + width: '600px', + disableClose: true, + data: { + leaflet, + branchName, + sponsorBankName: selectedBank?.name || 'Sponsor Bank', + faceValue, + chargesAmount, + gstPercentage: this.ddGstPercentage, + gstAmount, + totalCashPayable, + paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const), + accountNo: accountDebit ? row.acNo : undefined, + accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined + } + }); + + dialogRef.afterClosed().subscribe((result) => { + if (!result) return; + if (accountDebit) { + this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!); + } else { + this.submitWalkinCashDd(row, leaflet, result); + } + }); + }, + error: (err) => { + this.saving = false; + this.showAlert('error', 'Error Auto-Selecting DD Leaflet', [err.error?.message || err.message]); + } + }); + } + + /** + * Raises a Demand Draft funded by debiting a member's savings or current account. + * + * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's + * account is only touched when a checker approves, which is also when the balance is re-checked — + * so a draft raised against a sufficient balance can still be refused if the balance has since + * gone. No denominations are sent, because no cash is taken. + * + * The account goes in savingsAccountId or currentAccountId, never both: the server insists on + * exactly one, since which table the account lives in decides which control GL is debited. + */ + private submitAccountDebitDd( + row: TxnRow, + leaflet: any, + beneficiaryData: any, + fundingAccountId: number, + fundingClientId: number + ): void { + const d = new Date(); + const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; + + const payload = { + clientId: fundingClientId, + beneficiaryName: beneficiaryData.beneficiaryName, + payableAtBranch: beneficiaryData.payableAtBranch, + payableAtCity: beneficiaryData.payableAtCity, + faceValue: Number(row.amount), + chargesAmount: Number(row.ddCharges) || 0, + issuingBranchId: row.br, + issuedDate, + paymentMethod: 'ACCOUNT_DEBIT' as const, + savingsAccountId: isCurrent ? undefined : fundingAccountId, + currentAccountId: isCurrent ? fundingAccountId : undefined + }; + + this.saving = true; + this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({ + next: () => { + this.saving = false; + this.showAlert('success', 'DD Issuance Request Submitted', [ + `Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}) submitted successfully!`, + `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} on approval.`, + 'Sent to Manager / Head Cashier for maker-checker approval.' + ]); + row.amount = null; + row.ddCharges = null; + row.description = ''; + }, + error: (err) => { + this.saving = false; + this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]); + } + }); + } + + private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void { + // Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked + // right but matched nothing, so every cash DD went in with no breakdown at all and its money + // vanished from the closing report -- the notes were counted on screen and then dropped. + const denominationMap: { [key: string]: number } = {}; + for (const d of [ + ...this.notes, + ...this.coins + ]) { + const count = this.countFor(d); + if (count > 0) { + denominationMap[this.denomKey(d)] = count; + } + } + + // The breakdown is the only record of which notes were taken, and the server insists it add up + // to the cash collected. Checked here so the cashier is told which figure is wrong while the + // counts are still on screen, rather than getting a rejection after the fact. + // + // ddTotalPayableFor rounds up to the whole rupee actually collected, which is what the notes + // can add to; the exact figure with paise is accepted too, for a draft that lands on a rupee. + const totalCollected = Number(this.ddTotalPayableFor(row)); + const exactPayable = Number(row.amount || 0) + (Number(row.ddCharges) || 0) + this.ddGstAmountFor(row); + const countedTotal = Object.entries(denominationMap).reduce( + (sum, [ + key, + count + ]) => sum + Number(key.split(':')[1]) * count, + 0 + ); + if (countedTotal !== totalCollected && countedTotal !== exactPayable) { + this.showAlert('error', 'Denomination Entry Does Not Match', [ + countedTotal === 0 + ? 'Enter the notes and coins taken from the purchaser before submitting.' + : `The notes and coins entered come to ₹${countedTotal.toFixed(2)}, but ₹${totalCollected.toFixed(2)} is collected (face value + charges + GST).`, + 'The breakdown must equal the cash taken — it is what the closing report and the drawer are checked against.' + ]); + return; + } + + const d = new Date(); + const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + + const payload = { + walkinPurchaserName: purchaserData.walkinPurchaserName, + walkinPurchaserAddress: purchaserData.walkinPurchaserAddress, + walkinPurchaserAadhaar: purchaserData.walkinPurchaserAadhaar, + beneficiaryName: purchaserData.beneficiaryName, + payableAtBranch: purchaserData.payableAtBranch, + payableAtCity: purchaserData.payableAtCity, + faceValue: Number(row.amount), + chargesAmount: Number(row.ddCharges) || 0, + issuingBranchId: row.br, + issuedDate, + paymentMethod: 'CASH' as const, + denominationData: Object.keys(denominationMap).length > 0 ? JSON.stringify(denominationMap) : undefined + }; + + this.saving = true; + this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({ + next: () => { + this.saving = false; + this.showAlert('success', 'DD Issuance Request Submitted', [ + `Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}, Total Cash: ₹${this.ddTotalPayableFor(row)}) submitted successfully!`, + 'Sent to Manager / Head Cashier for maker-checker approval.' + ]); + row.amount = null; + row.ddCharges = null; + row.description = ''; + this.clearDenominations(); + }, + error: (err) => { + this.saving = false; + this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]); + } + }); + } + /** * Submits a clerk double-entry request (maker-checker, PENDING until manager approves). * Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations. @@ -2811,16 +3198,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const f = cfg.matchField; // Exact match first, then suffix/partial match. Some account types (e.g. Share — its // list endpoint only accepts offset/limit, no accountNo filter at all) always return the - // FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be - // gated on "exactly one row came back" — it never would be for those types. - const exactMatch = allItems.find((x: any) => x[f] === acNo); + const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim()); const partialMatch = !exactMatch - ? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo))) + ? (allItems.find((x: any) => String(x[f] ?? '').endsWith(acNo)) ?? + allItems.find((x: any) => String(x[f] ?? '').includes(acNo))) : null; - const match = exactMatch ?? partialMatch ?? null; + const idMatch = + !exactMatch && !partialMatch && acTyp === 'GL' + ? allItems.find((x: any) => String(x.id) === String(acNo)) + : null; + const match = exactMatch ?? partialMatch ?? idMatch ?? null; if (match?.id) { + const matchedGlCode = match[f] ?? acNo; + if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) { + this.selectedRow.acNo = match.glCode; + } const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; - this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch); + this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch); return; } this.loadingAccount = false; @@ -3048,7 +3442,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return false; } const returnedId = acc.glCode ?? acc.accountNo; - if (returnedId && returnedId !== acNo) { + if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) { this.loadingAccount = false; this.accountError = 'Account mismatch. Please re-enter account number and try again.'; return false; @@ -3294,7 +3688,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { { label: 'NEFT Facility', active: !!f.neftFacility }, { label: 'RTGS Facility', active: !!f.rtgsFacility }, { label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility }, - { label: 'Withdrawal', active: !!f.withdrawalFacility }, { label: 'Deposit (KYC)', active: !!f.depositFacilityKyc }, { label: 'Virtual Debit Card', active: !!f.virtualDebitCard }, { label: 'OD', active: !!this.accountDetails?.allowOverdraft } @@ -4579,6 +4972,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { confirmSubmit(): void { this.showConfirmDialog = false; const row = this.selectedRow!; + if (row.typ === 'DD') { + this.openIssueWalkinDd(row); + return; + } const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal); const isDeposit = row.dbCr === 'Cr'; const command = isDeposit ? 'deposit' : 'withdrawal'; diff --git a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html deleted file mode 100644 index 6185d289f..000000000 --- a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html +++ /dev/null @@ -1,183 +0,0 @@ - - -
-
-
-
CASHIER · EXCEPTIONS
-

Voucher Exceptions

-

- 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. -

-
-
- - -
-
- - @if (errorMsg) { -
{{ errorMsg }}
- } - - @if (!canResolve && loaded) { -
- info - 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. -
- } - - @if (loading) { -
Loading…
- } - - @if (!loading && loaded && exceptions.length === 0) { -
- check_circle -

{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}

-
- } - - @for (e of exceptions; track e.voucherNo) { -
-
- {{ e.voucherNo }} - @if (e.resolutionStatus) { - RESOLVED - } @else { - ⚠ OPEN - } - - {{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }} - - @if (!e.resolutionStatus && canResolve) { - - } -
- -
-
- System recorded - ₹{{ e.systemAmount | number: '1.2-2' }} -
- @if (!amountsAgree(e)) { -
- Written on slip - ₹{{ e.slipAmount | number: '1.2-2' }} -
-
- Difference - ₹{{ difference(e) | number: '1.2-2' }} -
- } @else { -
- Amounts - Agree — flagged for another reason -
- } -
- -

Cashier reported: {{ e.remarks }}

- - @if (e.resolutionStatus) { -
- {{ resolutionLabel(e.resolutionStatus) }} — {{ e.resolutionNotes }} - · closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }} -
- } - - - @if (resolveRow === e) { -
-
- Close exception — {{ e.voucherNo }} - -
- - - - - -
- Supporting document -

- An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or - whatever the decision rests on. -

- - @if (attachUploading) { -

Uploading…

- } - @if (attachLoading) { -

Loading…

- } - @if (!attachLoading && attachments.length === 0) { -

No document attached yet — required before closing.

- } - @for (a of attachments; track a.id) { -
- description - {{ a.fileName }} - {{ a.uploadedByName }} - -
- } -
- -
- - -
-
- } -
- } -
diff --git a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.scss b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.scss deleted file mode 100644 index e01627b12..000000000 --- a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.scss +++ /dev/null @@ -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; - } -} diff --git a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts deleted file mode 100644 index cc47dd41b..000000000 --- a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts +++ /dev/null @@ -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; - } -} diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html index fc13e78c3..0fac35882 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.html +++ b/src/app/cashier/voucher-report/voucher-report.component.html @@ -10,10 +10,8 @@
CASHIER · REPORT
-

Voucher Report

-

- Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it. -

+

{{ title }}

+

{{ subtitle }}

@@ -64,8 +60,7 @@ @if (report.from !== report.to) { — {{ report.to | date: 'dd MMM yyyy' }} } - · {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }} - · ₹{{ report.totalAmount | number: '1.2-2' }} + · {{ count }} {{ noun }}{{ count === 1 ? '' : 's' }} · ₹{{ totalAmount | number: '1.2-2' }} @if (report.username) { · {{ report.username }} } @@ -74,10 +69,46 @@ @if (!report.canSeeAllUsers) { Your own vouchers } + + @if (cashOnly) { + Cash only + } - @if (report.count === 0) { -
No vouchers were raised in this period.
+ + @if (unclassifiedCount > 0) { +
+ {{ 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. +
+ } + + + @if (uncountedCount > 0) { +
+ {{ 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. +
+ } + + + @if (missingSidesCount > 0) { +
+ {{ 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. +
+ } + + @if (count === 0) { +
No {{ noun }}s were raised in this period.
} @else {
@@ -129,7 +160,7 @@
- + @@ -143,33 +174,39 @@ - @for (row of report.vouchers; track row.voucherNo) { + @for (row of rows; track row.voucherNo) { - - + + @if (!hasSides(row)) { + + } @else { + + + } diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss index 00aeecb1c..690eb1893 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.scss +++ b/src/app/cashier/voucher-report/voucher-report.component.scss @@ -36,16 +36,6 @@ } .vr-rows { - .vr-link { - color: #1d4ed8; - text-decoration: none; - font-weight: 500; - - &:hover { - text-decoration: underline; - } - } - .vr-flag { margin-left: 4px; font-size: 15px; @@ -65,6 +55,13 @@ font-size: 11px; color: #6b7280; } + + // Spans the Debit and Credit cells for a voucher raised before its sides were recorded. + .vr-nosides { + font-size: 11px; + font-style: italic; + color: #9ca3af; + } } /* Printing a report should put the register on paper — the filters are how you reached it. */ @@ -72,9 +69,4 @@ .vr-no-print { display: none !important; } - - .vr-link { - color: inherit; - text-decoration: none; - } } diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts index e80bc59c6..1d34966fa 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.ts +++ b/src/app/cashier/voucher-report/voucher-report.component.ts @@ -9,11 +9,19 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; +/** + * Which side of the register a screen shows. + * + * 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route, + * so the two screens are the same component pointed at opposite sides. + */ +export type VoucherSide = 'CR' | 'DR'; + /** One voucher as the register shows it: the number, the two sides, and who raised it. */ interface VoucherRow { voucherNo: string; @@ -30,6 +38,13 @@ interface VoucherRow { creditAccount: string | null; /** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */ extraLines: number; + /** + * Whether a denomination breakdown was recorded against this voucher. + * + * True proves cash was counted. False does not prove the opposite: several registers pay real + * money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash. + */ + denominationCounted: boolean; } interface VoucherReport { @@ -38,6 +53,8 @@ interface VoucherReport { username: string | null; /** Set by the server from the caller's roles; a cashier is never offered the user filter. */ canSeeAllUsers: boolean; + /** Whether the server restricted the register to cash movements. */ + cashOnly: boolean; vouchers: VoucherRow[]; count: number; totalAmount: number; @@ -60,12 +77,12 @@ interface VoucherReport { imports: [ CommonModule, FormsModule, - RouterLink, MatIconModule ] }) export class VoucherReportComponent implements OnInit { private service = inject(CashierService); + private route = inject(ActivatedRoute); /** Day is the working default; a month is what you ask for at a close. */ period: 'DAY' | 'MONTH' = 'DAY'; @@ -74,21 +91,67 @@ export class VoucherReportComponent implements OnInit { usernameFilter = ''; typeFilter = ''; + /** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */ + side: VoucherSide = 'CR'; + + /** + * Whether this screen is the cash register rather than the whole voucher register. + * + * A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a + * clearing settlement, a cheque book fee — are left out, and everything that could have crossed + * the counter stays in. The filter errs towards keeping a row: a book entry on the register can + * be seen and recognised, whereas cash that never appears is invisible until it turns up as an + * unexplained difference at the end of the day. + */ + cashOnly = false; + report: VoucherReport | null = null; loading = false; error: string | null = null; ngOnInit(): void { + this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR'; + this.cashOnly = this.route.snapshot.data['cashOnly'] === true; this.load(); } + get isReceived(): boolean { + return this.side === 'CR'; + } + + get title(): string { + return this.isReceived ? 'Received Report' : 'Payment Report'; + } + + get subtitle(): string { + if (this.cashOnly) { + return this.isReceived + ? 'Cash taken in over a day or a month, and who took it — book entries left out.' + : 'Cash paid out over a day or a month, and who paid it — book entries left out.'; + } + return this.isReceived + ? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.' + : 'Every payment voucher raised over a day or a month — money paid out, and who paid it.'; + } + + /** The word for one row on this side, for counts and the empty state. */ + get noun(): string { + return this.isReceived ? 'receipt' : 'payment'; + } + load(): void { this.loading = true; this.error = null; const { from, to } = this.range(); this.service - .voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined) + .voucherReport( + from, + to, + this.usernameFilter.trim() || undefined, + this.typeFilter.trim() || undefined, + this.cashOnly + ) .pipe( catchError((err: any) => { this.error = @@ -125,9 +188,95 @@ export class VoucherReportComponent implements OnInit { return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; } + /** + * The rows belonging to this screen's side. + * + * The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the + * server stamps when it raises the voucher: a deposit credits the member and gets CR, everything + * that takes money out gets DR. That prefix is the authoritative record of the side, so it is + * matched directly rather than re-deriving the side from the voucher type here — a new type added + * on the server then lands on the correct report with no change needed on this screen. + */ + get rows(): VoucherRow[] { + const all = this.report?.vouchers ?? []; + return all.filter((row) => this.sideOf(row) === this.side); + } + + /** + * Whether a voucher has its two sides recorded against it. + * + * The registers raise a voucher when the request is made and only attach its sides once it is + * approved and posted, so a voucher raised before that step existed carries a number but no + * lines. Those rows are labelled rather than shown as an empty dash, which reads like the money + * went nowhere. + */ + hasSides(row: VoucherRow): boolean { + return !!(row.debitParty || row.creditParty); + } + + /** How many rows on this side are missing their debit/credit lines. */ + get missingSidesCount(): number { + return this.rows.filter((row) => !this.hasSides(row)).length; + } + + /** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */ + private sideOf(row: VoucherRow): VoucherSide | null { + const no = (row.voucherNo ?? '').toUpperCase(); + if (no.startsWith('CR/')) { + return 'CR'; + } + if (no.startsWith('DR/')) { + return 'DR'; + } + return null; + } + + /** How many rows on this side — not the server's count, which spans both. */ + get count(): number { + return this.rows.length; + } + + /** Total for this side only. */ + get totalAmount(): number { + return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0); + } + + /** + * The part of this side's total whose notes and coins were actually counted. + * + * Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown, + * because the register that raised it records none. So this is the confirmed part of the cash, + * not the whole of it, and the gap is reported rather than treated as book entries. + */ + get countedTotal(): number { + return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0); + } + + /** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */ + get uncountedCount(): number { + return this.rows.filter((row) => !row.denominationCounted).length; + } + + /** How much of this side's total has no note-and-coin breakdown behind it. */ + get uncountedTotal(): number { + return this.totalAmount - this.countedTotal; + } + + /** + * Vouchers whose number carries neither prefix, so they appear on neither report. + * + * Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the + * register is exactly the kind of thing a cashier must be told about, not left to discover when + * the two reports do not add up to the day. + */ + get unclassifiedCount(): number { + const all = this.report?.vouchers ?? []; + return all.filter((row) => this.sideOf(row) === null).length; + } + /** Totals per user, for the summary a manager reads before the rows. */ get byUser(): { username: string; count: number; total: number }[] { - const rows = this.report?.vouchers ?? []; + const rows = this.rows; const tally = new Map(); for (const row of rows) { const key = row.username ?? '—'; @@ -141,7 +290,7 @@ export class VoucherReportComponent implements OnInit { /** Totals per voucher type — deposits against withdrawals against journals. */ get byType(): { type: string; count: number; total: number }[] { - const rows = this.report?.vouchers ?? []; + const rows = this.rows; const tally = new Map(); for (const row of rows) { const key = row.voucherType ?? '—'; diff --git a/src/app/cashier/voucher-view/voucher-view.component.html b/src/app/cashier/voucher-view/voucher-view.component.html deleted file mode 100644 index 64c2f7d9c..000000000 --- a/src/app/cashier/voucher-view/voucher-view.component.html +++ /dev/null @@ -1,144 +0,0 @@ - - -
-
-
-
CASHIER · VOUCHER
-

Voucher

-

- Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited, - and for how much. -

-
-
- - - @if (voucher) { - - } -
-
- - @if (error) { -
{{ error }}
- } - - @if (loading) { -
Loading…
- } - - @if (voucher && !loading) { -
-
-
-
{{ voucher.voucherNo }}
-
- {{ voucher.voucherType || '—' }} - @if (voucher.branchName) { - · {{ voucher.branchName }} - } -
-
-
-
₹{{ voucher.amount | number: '1.2-2' }}
-
- @if (voucher.createdAt) { - {{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }} - } - @if (voucher.createdBy) { - · {{ voucher.createdBy }} - } -
-
-
- - @if (voucher.verificationStatus) { -
- {{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }} - {{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }} -
- } - - - @if (hasNoLines) { -
- No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept. -
- } @else { -
- - {{ row.voucherNo }} - + {{ row.voucherNo }} @if (row.verificationStatus === 'EXCEPTION') { - error_outline + error_outline } {{ row.voucherType || '—' }} -
{{ row.debitParty || '—' }}
- @if (row.debitAccount) { -
{{ row.debitAccount }}
- } -
-
{{ row.creditParty || '—' }}
- @if (row.creditAccount) { -
{{ row.creditAccount }}
- } - - @if (row.extraLines > 0) { -
+{{ row.extraLines }} more on voucher
- } -
Sides not recorded +
{{ row.debitParty || '—' }}
+ @if (row.debitAccount) { +
{{ row.debitAccount }}
+ } +
+
{{ row.creditParty || '—' }}
+ @if (row.creditAccount) { +
{{ row.creditAccount }}
+ } + + @if (row.extraLines > 0) { +
+{{ row.extraLines }} more on voucher
+ } +
{{ row.amount | number: '1.2-2' }} {{ row.username || '—' }} {{ row.createdAt | date: 'dd MMM, HH:mm' }}
- - - - - - - - - - @for (line of debitLines; track line.lineNo) { - - - - - - - } - @for (line of creditLines; track line.lineNo) { - - - - - - - } - - - - - - - - -
ParticularsAccountDebitCredit
-
{{ line.partyName || '—' }}
- @if (line.description) { -
{{ line.description }}
- } -
{{ line.accountNo || '—' }}{{ line.amount | number: '1.2-2' }}—
-
{{ line.partyName || '—' }}
- @if (line.description) { -
{{ line.description }}
- } -
{{ line.accountNo || '—' }}—{{ line.amount | number: '1.2-2' }}
Total{{ voucher.totalDebit | number: '1.2-2' }}{{ voucher.totalCredit | number: '1.2-2' }}
- - - @if (!voucher.balanced) { -
- This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it. -
- } - } -
- } -
diff --git a/src/app/cashier/voucher-view/voucher-view.component.scss b/src/app/cashier/voucher-view/voucher-view.component.scss deleted file mode 100644 index 1cd92f93c..000000000 --- a/src/app/cashier/voucher-view/voucher-view.component.scss +++ /dev/null @@ -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; - } -} diff --git a/src/app/cashier/voucher-view/voucher-view.component.ts b/src/app/cashier/voucher-view/voucher-view.component.ts deleted file mode 100644 index 53cb33701..000000000 --- a/src/app/cashier/voucher-view/voucher-view.component.ts +++ /dev/null @@ -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(); - } -} diff --git a/src/app/clerk/dividend-dashboard.service.ts b/src/app/clerk/dividend-dashboard.service.ts index dbd093ce5..9493f5632 100644 --- a/src/app/clerk/dividend-dashboard.service.ts +++ b/src/app/clerk/dividend-dashboard.service.ts @@ -39,5 +39,4 @@ export class DividendDashboardService { const params = status && status !== 'ALL' ? { status } : {}; return this.http.get('/clerk/dividend-dashboard/locks', { params }); } - } diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss index e59af736e..a452f8856 100644 --- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss @@ -350,6 +350,7 @@ flex: 0 0 auto; padding: 9px 14px; border: 0; + /* The active marker is this underline, so every tab reserves the space for it — otherwise the strip would shift by 2px each time the selection moved. */ border-bottom: 2px solid transparent; diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts index b486325d0..1c5ea3642 100644 --- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts @@ -102,7 +102,6 @@ export class DividendDashboardComponent implements OnInit { lockYears: string[] = []; lockYearFilter = 'ALL'; - ngOnInit(): void { this.loadSummary(); this.loadDeclarations(); diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 18a2fef29..f6f8db064 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -1043,4 +1043,56 @@ export class ClientsService { getKycContactLog(clientId: string | number): Observable { return this.http.get(`/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 { + return this.http.get('/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 { + const params = subStatus ? new HttpParams().set('subStatus', String(subStatus)) : undefined; + return this.http.get('/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 { + 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 { + return this.http.post(`/savingsaccounts/deaf-remittance/${savingsAccountId}/refund`, { + locale: 'en', + dateFormat: 'yyyy-MM-dd', + refundedOn + }); + } } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 76cc5daa2..7338b6eb3 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -184,47 +184,6 @@ Today's Activity } - - - - - - - - Exceptions - - - - - - - - Voucher - - - - - - - - - - - Voucher Report - ESC Show Header } @else { @@ -288,27 +247,17 @@ - Day Book + Received - Exceptions - - - Voucher - - - Voucher Report + Payment Risk Review Report + + + -
-

+
+

Compliance & AML Triage

-
- +
+ Flag as suspicious (escalate to STR cases under RBI compliance) - + Mark as whitelisted (prevent future AML alerts for this account)
diff --git a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts index 9de8ec6fd..ab56dbecf 100644 --- a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts +++ b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts @@ -145,7 +145,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy { cases = []; } } - + const newRef = 101 + cases.length; const newCase: any = { reportType: 'STR', @@ -171,10 +171,10 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy { narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`, status: 'po-review' }; - + cases.unshift(newCase); localStorage.setItem('fiu_str_cases', JSON.stringify(cases)); - + const storedCtr = localStorage.getItem('fiu_ctr_txns'); if (storedCtr) { try { @@ -201,7 +201,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy { whitelisted.push(account.toString()); localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted)); } - + const storedCtr = localStorage.getItem('fiu_ctr_txns'); if (storedCtr) { try { diff --git a/src/app/current-accounts/current-account-actions/add-charge-current-account/add-charge-current-account.component.ts b/src/app/current-accounts/current-account-actions/add-charge-current-account/add-charge-current-account.component.ts index 671a5b8fb..32e8cce12 100644 --- a/src/app/current-accounts/current-account-actions/add-charge-current-account/add-charge-current-account.component.ts +++ b/src/app/current-accounts/current-account-actions/add-charge-current-account/add-charge-current-account.component.ts @@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router'; import { CurrentAccountsService } from '../../current-accounts.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -40,6 +41,7 @@ export class AddChargeCurrentAccountComponent implements OnInit { private router = inject(Router); private dateUtils = inject(Dates); private currentAccountsService = inject(CurrentAccountsService); + private facilityChargeFilter = inject(FacilityChargeFilterService); private settingsService = inject(SettingsService); /** Minimum Due Date allowed. */ @@ -66,7 +68,9 @@ export class AddChargeCurrentAccountComponent implements OnInit { */ constructor() { this.route.data.subscribe((data: { currentAccountActionData: any }) => { - this.currentAccountChargeOptions = data.currentAccountActionData.chargeOptions; + this.facilityChargeFilter + .excludeFacilityCharges(data.currentAccountActionData.chargeOptions) + .subscribe((charges: any[]) => (this.currentAccountChargeOptions = charges)); }); this.currentAccountId = this.route.snapshot.params['currentAccountId']; } diff --git a/src/app/current-accounts/current-account-stepper/current-account-charges-step/current-account-charges-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-charges-step/current-account-charges-step.component.ts index c9c5a906d..37032404d 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-charges-step/current-account-charges-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-charges-step/current-account-charges-step.component.ts @@ -36,6 +36,7 @@ import { TranslateService } from '@ngx-translate/core'; import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; +import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service'; import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../pipes/format-number.pipe'; @@ -73,6 +74,7 @@ export class CurrentAccountChargesStepComponent implements OnInit { private dialog = inject(MatDialog); private dateUtils = inject(Dates); private translateService = inject(TranslateService); + private facilityChargeFilter = inject(FacilityChargeFilterService); /** Current Account Product Template */ private _currentAccountProductTemplate: any; @@ -80,7 +82,9 @@ export class CurrentAccountChargesStepComponent implements OnInit { set currentAccountProductTemplate(value: any) { this._currentAccountProductTemplate = value; if (value) { - this.chargeData = value.chargeOptions || []; + this.facilityChargeFilter + .excludeFacilityCharges(value.chargeOptions) + .subscribe((charges: any[]) => (this.chargeData = charges)); // Only pre-populate from product template for new accounts if (!this.currentAccountTemplate?.id) { this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId })); diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html index f13275c06..ec427c6c3 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html @@ -174,18 +174,6 @@
-
-
-
- Withdrawal Facility - {{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }} -
- -
-
diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts index bcfb111da..b9159ace9 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts @@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = { neftFacility: false, rtgsFacility: false, interBankTotalCapFacility: false, - withdrawalFacility: false, depositFacilityKyc: false, virtualDebitCard: false, internetBanking: false, diff --git a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts index 0683c968d..86d0ac187 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts @@ -76,7 +76,6 @@ export class CurrentAccountPreviewStepComponent implements OnChanges { { key: 'neftFacility', label: 'NEFT Facility' }, { key: 'rtgsFacility', label: 'RTGS Facility' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card' }, { key: 'internetBanking', label: 'Internet Banking' }, diff --git a/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts index bca2ab003..9fcd83c44 100644 --- a/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts +++ b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts @@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record = { neftFacility: false, rtgsFacility: false, interBankTotalCapFacility: false, - withdrawalFacility: false, depositFacilityKyc: false, virtualDebitCard: false, internetBanking: false, @@ -66,7 +65,6 @@ const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [ { key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' }, { key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' }, { key: 'internetBanking', label: 'Internet Banking', icon: 'fa-globe' }, diff --git a/src/app/dormancy-report/dormancy-report.component.html b/src/app/dormancy-report/dormancy-report.component.html new file mode 100644 index 000000000..52db5ef8d --- /dev/null +++ b/src/app/dormancy-report/dormancy-report.component.html @@ -0,0 +1,398 @@ + + +
+ +
+
+

Inactive, Dormant & DEAF Reports

+
+ 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. +
+
+
+ + +
+
+ + @if (errorMessage) { +
+ +
{{ errorMessage }}
+
+ } + + + @if (summary.pendingRemittanceCount > 0) { +
+ +
+ ₹{{ summary.pendingRemittance | number: '1.0-0' }} from + {{ summary.pendingRemittanceCount }} 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. + +
+
+ } + + +
+
Report criteria
+
+
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + +
+ @for (t of tabs; track t) { + + } +
+ + +
+
+ +
+ {{ stageMeta[tab].label }}. {{ stageMeta[tab].effect }} +
Revival: {{ stageMeta[tab].revival }}
+
+
+
+ {{ visibleRows.length }} accounts · + ₹{{ visibleBalance | number: '1.0-0' }} total balance +
+
+ + @if (tab === 'ESCHEAT') { +
+ + + + ₹{{ summary.pendingRemittance | number: '1.0-0' }} not remitted + + ₹{{ summary.remittedBalance | number: '1.0-0' }} with the fund + @if (summary.claimedBackCount > 0) { + ₹{{ summary.claimedBackBalance | number: '1.0-0' }} claimed back + } + + + @if (selected.size > 0) { + {{ selected.size }} selected · ₹{{ selectedTotal | number: '1.0-0' }} + + } + +
+ } + + + @if (loading) { +
Loading…
+ } @else if (visibleRows.length === 0) { +
No accounts at this stage match the criteria above.
+ } @else { +
+ + + + @if (tab === 'ESCHEAT') { + + } + + + + + + + + @if (tab === 'ESCHEAT') { + + + } @else { + + } + + + + + @for (r of visibleRows; track r.accountId) { + + @if (tab === 'ESCHEAT') { + + } + + + + + + + + + + + + + + + @if (tab === 'ESCHEAT') { + + + } @else { + + } + + + + } + +
+ + Account NoCustomerBranchProduct{{ tab === 'ESCHEAT' ? 'Balance Escheated' : 'Balance' }}Last Customer TxnIdle ForEscheated OnRemittance to RBINext Stage
+ + + {{ r.accountNo }} + {{ r.clientName }} +
{{ r.clientId }} · {{ r.mobile }}
+
{{ branchName(r.branchCode) }}{{ r.productName }}{{ r.balance | number: '1.0-0' }}{{ r.lastCustomerTxnDate }} + {{ idleText(r.daysIdle) }} + {{ r.escheatedOn || '—' }} + + {{ r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted' }} + + @if (r.remittedOn) { +
{{ r.remittedOn }}
+ } + @if (r.deafReference) { +
{{ r.deafReference }}
+ } + @if (r.refundedOn) { +
Claimed back {{ r.refundedOn }}
+ } +
+
{{ r.nextStageLabel }}
+
+ {{ r.nextStageDate }} + @if (isNearingEscheat(r)) { + · {{ daysToNext(r) }} days + } +
+
+ + + @if (tab === 'ESCHEAT' && r.remittance === 'REMITTED' && !r.refundedOn) { + + } +
+
+ } + + + @if (tab === 'ESCHEAT') { +
+ +
+ Depositor Education and Awareness Fund. 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. +
+
+ } +
+ + +@if (remitOpen) { +
+
+
+

Record remittance to RBI's DEAF

+ +
+ +
+
+
+ {{ selected.size }} + accounts +
+
+ ₹{{ selectedTotal | number: '1.0-0' }} + total remitted +
+
+ +
+ + 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. +
+ + + + + + + + + + + @if (remitError) { +
{{ remitError }}
+ } +
+ +
+ + +
+
+
+} + + +@if (refundOpen && refundRow) { +
+
+
+

Record a claim against DEAF

+ +
+ +
+
+ {{ refundRow.clientName }} + {{ refundRow.accountNo }} +
+ +
+
+ ₹{{ refundRow.balance | number: '1.0-0' }} + claimed +
+
+ {{ refundRow.remittedOn }} + remitted to RBI +
+
+ +
+ + 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. +
+ + + + + @if (refundError) { +
{{ refundError }}
+ } +
+ +
+ + +
+
+
+} + +@if (toastVisible) { +
{{ toastMsg }}
+} diff --git a/src/app/dormancy-report/dormancy-report.component.scss b/src/app/dormancy-report/dormancy-report.component.scss new file mode 100644 index 000000000..8afc7605e --- /dev/null +++ b/src/app/dormancy-report/dormancy-report.component.scss @@ -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); +} diff --git a/src/app/dormancy-report/dormancy-report.component.ts b/src/app/dormancy-report/dormancy-report.component.ts new file mode 100644 index 000000000..7e6404121 --- /dev/null +++ b/src/app/dormancy-report/dormancy-report.component.ts @@ -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(); + + 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}` + ); + } +} diff --git a/src/app/dormancy-report/dormancy-report.compute.ts b/src/app/dormancy-report/dormancy-report.compute.ts new file mode 100644 index 000000000..8b6f09100 --- /dev/null +++ b/src/app/dormancy-report/dormancy-report.compute.ts @@ -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 = { + 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); +} diff --git a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.html b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.html index d6da52a04..11caf2490 100644 --- a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.html +++ b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.html @@ -441,16 +441,18 @@ - Total Contingent Liabilities: ₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh + Total Contingent Liabilities: ₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh

-   Individual item-wise figures for this schedule are not broken - out here — only the audited total is shown, to avoid presenting a sub-item split that could not be - independently verified against the source statement. +   Individual item-wise figures for this schedule are not broken out + here — only the audited total is shown, to avoid presenting a sub-item split that could not be independently + verified against the source statement.

diff --git a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts index 1d9f12a9a..eddee214a 100644 --- a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts +++ b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts @@ -150,7 +150,13 @@ export class AccountingReportsDialogComponent { // -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures -------------------- pnl: PnlLine[] = [ - { code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances & Bills', amount: 102641.73 }, + { + code: 'I-01', + group: 'INCOME', + subgroup: 'Interest Earned', + label: 'Interest on Advances & Bills', + amount: 102641.73 + }, { code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 }, { code: 'I-03', @@ -167,7 +173,13 @@ export class AccountingReportsDialogComponent { label: 'Net Income on Sale of Fixed Assets', amount: 0.3 }, - { code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Other Income (ATM/POS/UPI/Rental/Misc)', amount: 280 }, + { + code: 'I-06', + group: 'INCOME', + subgroup: 'Other Income', + label: 'Other Income (ATM/POS/UPI/Rental/Misc)', + amount: 280 + }, { code: 'E-01', group: 'EXPENDITURE', diff --git a/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts b/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts index 85a72408d..9bcfe90ce 100644 --- a/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts +++ b/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts @@ -659,7 +659,7 @@ export class CimsXbrlDialogComponent { name: 'OSS-4', fullName: 'Connected Lending & Large Exposure Statement', description: - 'Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank\'s own capital/advances scale for consistency.', + "Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank's own capital/advances scale for consistency.", cimsCode: 'CIMS_OSS_004', taxonomy: 'rbi-cims-oss4-2024-04-01', periodicity: 'Quarterly', @@ -729,7 +729,7 @@ export class CimsXbrlDialogComponent { name: 'OSS-5', fullName: 'Institutional & Membership Profile', description: - 'Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank\'s real paid-up capital figure.', + "Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank's real paid-up capital figure.", cimsCode: 'CIMS_OSS_005', taxonomy: 'rbi-cims-oss5-2024-04-01', periodicity: 'Annual', diff --git a/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts b/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts index 3eed23f0b..380d44cf6 100644 --- a/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts +++ b/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts @@ -27,7 +27,15 @@ interface DemoClient { standalone: true, templateUrl: './fd-rd-entry-dialog.component.html', styleUrls: ['./fd-rd-entry-dialog.component.scss'], - imports: [CommonModule, DecimalPipe, FormsModule, MatDialogModule, MatButtonModule, MatTooltipModule, FaIconComponent] + imports: [ + CommonModule, + DecimalPipe, + FormsModule, + MatDialogModule, + MatButtonModule, + MatTooltipModule, + FaIconComponent + ] }) export class FdRdEntryDialogComponent { private dialogRef = inject(MatDialogRef); @@ -53,8 +61,16 @@ export class FdRdEntryDialogComponent { { id: 'C-10689', name: 'Vikram Traders' } ]; - payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c']; - interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout']; + payModes = [ + 'Cash', + 'Cheque', + 'Transfer from Savings A/c' + ]; + interestPayoutOptions = [ + 'Cumulative (Paid on Maturity)', + 'Monthly Payout', + 'Quarterly Payout' + ]; maturityInstructions = [ 'Renew Principal & Interest', 'Renew Principal Only, Payout Interest', diff --git a/src/app/interest-report/interest-report.component.html b/src/app/interest-report/interest-report.component.html new file mode 100644 index 000000000..3d7b36957 --- /dev/null +++ b/src/app/interest-report/interest-report.component.html @@ -0,0 +1,205 @@ + + +
+ +
+
+

Savings Interest Posted

+
+ 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. +
+
+
+ + +
+
+ + @if (errorMessage) { +
{{ errorMessage }}
+ } + + + @if (rhythmWarnings.length > 0) { +
+ +
+ Postings do not follow the product's rhythm. + @for (w of rhythmWarnings; track w.product) { +
+ {{ w.product }} is configured {{ w.label }} ({{ w.expected }}) but has paid on + {{ w.dates }} different dates. +
+ } +
+ 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. +
+
+
+ } + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ @for (p of periods; track p.enumValue) { + + } +
+ + +
+ + + + @if (periodPick[activePeriod]) { + + } + + + {{ availablePicks(activePeriod).length }} {{ activeMeta.pickUnit + }}{{ availablePicks(activePeriod).length === 1 ? '' : 's' }} with postings + +
+ + +
+
+ +
+ {{ activeMeta.label }}. {{ activeMeta.cadence }} — every account on a product configured to + post {{ activeMeta.label.toLowerCase() }}. +
+
+
+ {{ visibleAccounts }} accounts · + {{ visibleRows.length }} postings · + ₹{{ visibleTotal | number: '1.2-2' }} paid +
+
+ + + @if (loading) { +
Loading…
+ } @else if (visibleRows.length === 0) { +
+ No {{ activeMeta.label.toLowerCase() }} interest posted in this period. + @if (activePeriod !== 1) { + A cycle that has not ended yet has nothing to show. + } +
+ } @else { + @for (group of groupedByDate; track group.date) { +
+
+ {{ formatDate(group.date) }} + + {{ group.accounts }} account{{ group.accounts === 1 ? '' : 's' }} · + ₹{{ group.total | number: '1.2-2' }} + +
+ +
+ + + + + + + + + + + + + + + + @for (r of group.rows; track r.transaction_id) { + + + + + + + + + + + + } + +
Account NoCustomerProductRateBalance BeforeInterest PostedBalance AfterBranch
{{ r.account_no }}{{ r.client_name || '—' }}{{ r.product_name }}{{ r.interest_rate | number: '1.2-2' }}%{{ r.balance_before | number: '1.2-2' }}{{ r.interest_amount | number: '1.2-2' }}{{ r.balance_after | number: '1.2-2' }}{{ r.office_name || '—' }} + +
+
+
+ } + } +
diff --git a/src/app/interest-report/interest-report.component.scss b/src/app/interest-report/interest-report.component.scss new file mode 100644 index 000000000..0f7994039 --- /dev/null +++ b/src/app/interest-report/interest-report.component.scss @@ -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); +} diff --git a/src/app/interest-report/interest-report.component.ts b/src/app/interest-report/interest-report.component.ts new file mode 100644 index 000000000..948dff9bd --- /dev/null +++ b/src/app/interest-report/interest-report.component.ts @@ -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 = {}; + + 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(); + 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 = { 1: 100000, 4: 24, 5: 8, 6: 4, 7: 2 }; + const expectedWord: Record = { + 1: 'daily', + 4: 'about one a month', + 5: 'about four a year', + 6: 'two a year', + 7: 'one a year' + }; + + const byProduct = new Map }>(); + 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(); + 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)}`); + } +} diff --git a/src/app/kyc-expiry/kyc-expiry.component.html b/src/app/kyc-expiry/kyc-expiry.component.html new file mode 100644 index 000000000..5879612c5 --- /dev/null +++ b/src/app/kyc-expiry/kyc-expiry.component.html @@ -0,0 +1,430 @@ + + +
+ +
+
+

Expired KYC & Customer Outreach

+
+ 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. +
+
+ +
+ + +
+ +
+ RBI Master Direction on KYC. An account is not closed or restricted merely because periodic + re-KYC is pending. Nothing stops for the first {{ policy.graceDays }} days past the due date; + debits stop after that, and the account is marked inoperative after + {{ policy.inoperativeDays }} days. 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: + {{ alwaysAllowed.join(' · ') }} +
+
+ + @if (errorMessage) { +
{{ errorMessage }}
+ } + + +
+ @for (key of stageKeys; track key) { + + } +
+ + +
+ + + + + + + + + + {{ visibleRows.length }} of {{ summary.total }} customers +
+ + + @if (loading) { +
Loading worklist…
+ } @else if (visibleRows.length === 0) { +
No customers match these filters.
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (r of visibleRows; track r.clientId) { + + + + + + + + + + + + + + + + + + + + + + + + @if (expanded === r.clientId) { + + + + } + } + +
CustomerAccountRiskKYC ExpiryPositionWhat is blockedNext stepOutreachActions
+ + + {{ r.clientName }} +
{{ r.clientId }}
+
+ {{ r.accountNo || '—' }} +
Branch {{ r.branchCode || '—' }}
+
+ {{ r.riskLabel }} + + {{ r.expiryDate }} +
{{ overdueText(r) }}
+
+ {{ r.meta.label }} + + @if (r.meta.blocked.length === 0) { + Nothing blocked — fully operative + } @else { + @for (b of r.meta.blocked; track b) { + {{ b }} + } + } + + @if (r.nextActionDate) { +
{{ r.nextActionLabel }}
+
{{ r.nextActionDate }}
+ } @else { + {{ r.nextActionLabel }} + } +
+ @if (r.contactCount === 0) { + Not contacted + } @else { + {{ r.contactCount }} attempt{{ r.contactCount > 1 ? 's' : '' }} +
last {{ r.lastContactDate }}
+ } + @if (r.acknowledged) { +
Customer reached
+ } +
+
+ + + + +
+
+ + +
+
+
+
+

Still allowed

+
    + @for (a of r.meta.allowed; track a) { +
  • {{ a }}
  • + } +
+
{{ r.meta.summary }}
+
+ +
+

Blocked

+ @if (r.meta.blocked.length === 0) { +
+ Nothing is restricted at this stage. The account operates normally while the customer is given + time to complete re-KYC. +
+ } @else { +
    + @for (b of r.meta.blocked; track b) { +
  • {{ b }}
  • + } +
+ } +
+ Never held up: {{ alwaysAllowed.join(' · ') }} +
+
+ +
+

Contact on file

+
+ Mobile{{ r.mobile || 'Not on file' }} +
+
+ Email{{ r.email || 'Not on file' }} +
+ @if (r.acknowledged) { +
+ Customer reached on {{ r.acknowledgedOn }} by {{ r.acknowledgedBy }}. + @if (r.acknowledgedNote) { +
{{ r.acknowledgedNote }}
+ } +
+ } +
+
+ +

Outreach history

+ @if (r.contacts.length === 0) { +
+ No attempt recorded yet. Use the actions on the row to log an email, SMS, call or visit. +
+ } @else { + + + + + + + + + + + + + @for (c of r.contacts; track c.date + c.channel + c.notes) { + + + + + + + + + } + +
DateChannelDestinationOutcomeOfficerNotes
{{ c.date }}{{ c.channelLabel }}{{ c.destinationMasked || '—' }} + {{ deliveryText(c) }} + {{ c.officer }}{{ c.notes }}
+ } +
+
+ } +
+ + +@if (contactOpen && contactRow) { +
+
+
+

Record customer contact

+ +
+ +
+
+ {{ contactRow.clientName }} + {{ contactRow.accountNo }} + {{ contactRow.meta.label }} +
+ + +
+ @for (c of channels; track c.key) { + + } +
+ +
+ @if (contactDestinationMissing) { + + No destination on file for this channel — the attempt will be recorded but nothing is sent. + } @else { + + @if (contactChannelTransmits) { + Will be sent to {{ contactDestination }} + } @else { + Recorded as made {{ contactDestination }} + } + } +
+ + + + + + + + @if (contactError) { +
{{ contactError }}
+ } +
+ +
+ + +
+
+
+} + + +@if (ackOpen && ackRow) { +
+
+
+

Mark customer as contacted

+ +
+ +
+
+ {{ ackRow.clientName }} + {{ ackRow.accountNo }} +
+ +
+ + 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. +
+ + + + + + +
+ +
+ + +
+
+
+} + +@if (toastVisible) { +
{{ toastMsg }}
+} diff --git a/src/app/kyc-expiry/kyc-expiry.component.scss b/src/app/kyc-expiry/kyc-expiry.component.scss new file mode 100644 index 000000000..45a37f6d6 --- /dev/null +++ b/src/app/kyc-expiry/kyc-expiry.component.scss @@ -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; +} diff --git a/src/app/kyc-expiry/kyc-expiry.component.ts b/src/app/kyc-expiry/kyc-expiry.component.ts new file mode 100644 index 000000000..bc95be300 --- /dev/null +++ b/src/app/kyc-expiry/kyc-expiry.component.ts @@ -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); + } +} diff --git a/src/app/kyc-expiry/kyc-expiry.compute.ts b/src/app/kyc-expiry/kyc-expiry.compute.ts new file mode 100644 index 000000000..d92ec8cfc --- /dev/null +++ b/src/app/kyc-expiry/kyc-expiry.compute.ts @@ -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 = { + 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 = { 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 = { + 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 + ); +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html index bc34a7490..949239f5c 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html @@ -8,12 +8,12 @@ @if (loading) {
- +
Loading live declarations and accounts from Fineract…
} @else if (loadError) { -
- +
+
Could not load real data from Fineract. Check connection.
} @else { @@ -139,7 +139,9 @@
{{ row.row.record.customerName }}
-
{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}
+
+ {{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }} +
DOB {{ row.row.record.dob | date: 'd MMM y' }}
@@ -306,8 +308,8 @@ @if (row.row.schedule.shieldedInterest > 0) {
- ₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of - the declaration — reportable in the quarterly {{ statute.statement }} return. + ₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength + of the declaration — reportable in the quarterly {{ statute.statement }} return.
} diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts index 1793ce61c..dfac843d7 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts @@ -64,7 +64,11 @@ export interface RealForm15ghRow { /** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */ function toIso(value: unknown): string | null { if (Array.isArray(value) && value.length >= 3) { - const [y, m, d] = value as number[]; + const [ + y, + m, + d + ] = value as number[]; return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`; } if (typeof value === 'string' && value.trim()) { @@ -118,7 +122,10 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy { private clientKycCache = new Map>(); private clientAccountsCache = new Map>(); - readonly availableFys = ['FY 2026-27', 'FY 2025-26']; + readonly availableFys = [ + 'FY 2026-27', + 'FY 2025-26' + ]; ngOnInit(): void { this.loadRealData(); @@ -311,9 +318,11 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy { depositPrincipal, fy: this.selectedFy, status, - filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined, + filedOn: hasDeclaration ? (toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01`) : undefined, assessed: true, - interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly' + interestPostingPeriodType: + detailsList.find((d) => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || + 'savingsPostingInterestPeriodType.quarterly' }; } diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts index 32ee84d6d..9cba8c989 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts @@ -226,7 +226,11 @@ export function uinTaxYearPart(fy: string): string { */ export function buildUin(sequence: number, fy: string, tan: string): string { const digits = `${Math.max(0, Math.floor(sequence))}`.slice(-9).padStart(9, '0'); - const normalisedTan = (tan || '').toUpperCase().replace(/[^A-Z0-9]/g, '').padEnd(10, 'X').slice(0, 10); + const normalisedTan = (tan || '') + .toUpperCase() + .replace(/[^A-Z0-9]/g, '') + .padEnd(10, 'X') + .slice(0, 10); return `D${digits}${uinTaxYearPart(fy)}${normalisedTan}`; } diff --git a/src/app/letter-mgmt/membership-register/membership-register.component.html b/src/app/letter-mgmt/membership-register/membership-register.component.html index 4f566df96..011126148 100644 --- a/src/app/letter-mgmt/membership-register/membership-register.component.html +++ b/src/app/letter-mgmt/membership-register/membership-register.component.html @@ -12,9 +12,7 @@

Membership Register

-
- Statutory register of members — one line per member, in order of admission. -
+
Statutory register of members — one line per member, in order of admission.
@@ -98,9 +96,7 @@
- +
diff --git a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html index dde70faf7..a79ae80f2 100644 --- a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html +++ b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html @@ -109,9 +109,7 @@

- + @@ -191,7 +189,9 @@ {{ row.statusLabel }} - {{ row.paymentStatus }} + {{ + row.paymentStatus + }} diff --git a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts index 76ad90743..12783988e 100644 --- a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts +++ b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts @@ -105,9 +105,7 @@ export class ShareAllotmentReportComponent implements OnInit { if (!items.length) { return of([] as AllotmentRow[]); } - return forkJoin(items.map((item) => this.buildAllotments(item))).pipe( - map((perAccount) => perAccount.flat()) - ); + return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(map((perAccount) => perAccount.flat())); }), catchError(() => { this.loadError = true; diff --git a/src/app/letter-mgmt/share-member/share-member.component.html b/src/app/letter-mgmt/share-member/share-member.component.html index 751400d14..4d86ed839 100644 --- a/src/app/letter-mgmt/share-member/share-member.component.html +++ b/src/app/letter-mgmt/share-member/share-member.component.html @@ -100,9 +100,7 @@ - + diff --git a/src/app/products/current-products/current-product-stepper/current-product-charges-step/current-product-charges-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-charges-step/current-product-charges-step.component.ts index d6cafa12d..8daa84920 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-charges-step/current-product-charges-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-charges-step/current-product-charges-step.component.ts @@ -26,6 +26,7 @@ import { MatRowDef, MatRow } from '@angular/material/table'; +import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service'; import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -55,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CurrentProductChargesStepComponent implements OnInit { dialog = inject(MatDialog); private translateService = inject(TranslateService); + private facilityChargeFilter = inject(FacilityChargeFilterService); @Input() CurrentProductsTemplate: any; @Input() currencyCode: UntypedFormControl; @@ -73,7 +75,9 @@ export class CurrentProductChargesStepComponent implements OnInit { pristine = true; ngOnInit() { - this.chargeData = this.CurrentProductsTemplate.chargeOptions; + this.facilityChargeFilter + .excludeFacilityCharges(this.CurrentProductsTemplate.chargeOptions) + .subscribe((charges: any[]) => (this.chargeData = charges)); this.chargesDataSource = this.CurrentProductsTemplate.charges || []; this.pristine = true; diff --git a/src/app/products/current-products/current-product-stepper/current-product-limits-step/current-product-limits-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-limits-step/current-product-limits-step.component.html index 0a78185a6..0fba6e324 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-limits-step/current-product-limits-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-limits-step/current-product-limits-step.component.html @@ -174,21 +174,6 @@ - -
@@ -31,7 +31,12 @@ Refresh - @@ -46,8 +51,8 @@
Loading the share register…
} @else if (report) {
- Reporting period {{ report.periodStartDate }} to - {{ report.periodEndDate }} · compared with FY {{ report.previousFinancialYear }} + Reporting period {{ report.periodStartDate }} to {{ report.periodEndDate }} · + compared with FY {{ report.previousFinancialYear }}
@@ -228,148 +216,173 @@ @if (view === 'customer') { -
- - - - - - - - - - - - - - - - - - @for (c of customerRows; track c.clientId) { - - - - - - - - - - - - - + + @for (g of customerGroups; track g.key) { +
+ - @if (expanded === c.clientId) { -
- + } - - - } - } @empty { - - - - } - -
Customer IDCustomer NameAccount NumberBranchCurrent RiskAlertsAML Rules TriggeredAmount under reviewLast AlertPosition
- - {{ c.clientId }} - {{ c.customerName }} - @if (c.openCount > 0) { - {{ c.openCount }} open - } - {{ c.accountNo || '—' }}{{ c.branchCode || '—' }} - {{ riskText(c.currentRisk) }} - {{ c.alertCount }} - @for (label of c.ruleLabels; track label) { - {{ label }} - } - {{ c.totalAmount | number: '1.0-0' }}{{ c.lastAlertDate }} - {{ verdictText(c) }} - @if (c.directionReason) { -
{{ c.directionReason }}
- } -
- - @for (a of c.alerts; track a.alertId) { -
-
- {{ a.rule.label }} - {{ a.rule.category }} - {{ a.alertId }} · raised {{ a.alertDate }} - - ₹{{ a.amount | number: '1.0-0' }} - {{ statusMeta[a.status].label }} -
-
- - Why it triggered: {{ a.rule.description }} — {{ txnDetail(a) }} -
+ @if (!collapsedGroups[g.key]) { +
+ + + + + + + + + + + + + + + + + + @for (c of g.rows; track c.clientId) { + + + + + + + + + + + + + + -
Customer IDCustomer NameAccount NumberBranchRating on fileAlertsAML Rules TriggeredAmount under reviewLast AlertAction
+ + {{ c.clientId }} + {{ c.customerName }} + @if (c.openCount > 0) { + {{ c.openCount }} open + } + {{ c.accountNo || '—' }}{{ c.branchCode || '—' }} + {{ riskText(c.currentRisk) }} + {{ c.alertCount }} + @for (label of c.ruleLabels; track label) { + {{ label }} + } + {{ c.totalAmount | number: '1.0-0' }}{{ c.lastAlertDate }} + + @if (c.directionReason) { +
{{ c.directionReason }}
+ } +
- - - - - - - - - - - - - - - @for (t of a.txns; track t.txnId) { - - - - - - + +
Txn DateTxn IDChannelDr/CrAmountBalance AfterNarrationCounterpartyAML Rule Triggered
{{ t.date }}{{ t.txnId }} - {{ channelLabel(t.channel) }} - {{ t.drCr }} - {{ t.amount | number: '1.0-0' }} - @if (t.ctrReportable) { - CTR + @if (expanded === c.clientId) { +
+ + @for (a of c.alerts; track a.alertId) { +
+
+ {{ a.rule.label }} + {{ a.rule.category }} + {{ a.alertId }} · raised {{ a.alertDate }} + + ₹{{ a.amount | number: '1.0-0' }} + {{ statusMeta[a.status].label }} +
+
+ + Why it triggered: {{ a.rule.description }} — + {{ txnDetail(a) }} +
+ + + + + + + + + + + + + + + + + @for (t of a.txns; track t.txnId) { + + + + + + + + + + + } - - - - - - - } - -
Txn DateTxn IDChannelDr/CrAmountBalance AfterNarrationCounterpartyAML Rule Triggered
{{ t.date }}{{ t.txnId }} + {{ channelLabel(t.channel) }} + {{ t.drCr }} + {{ t.amount | number: '1.0-0' }} + @if (t.ctrReportable) { + CTR + } + + {{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }} + {{ t.narration || '—' }}{{ t.counterparty || '—' }} + {{ a.rule.label }} + @if (a.txns.length > 1) { + leg {{ $index + 1 }} of {{ a.txns.length }} + } +
{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}{{ t.narration || '—' }}{{ t.counterparty || '—' }} - {{ a.rule.label }} - @if (a.txns.length > 1) { - leg {{ $index + 1 }} of {{ a.txns.length }} - } -
+
-
-
- {{ a.rule.suggestedAction }} -
-
- Reviewer: {{ a.reviewedBy || 'not yet assigned' }} — - {{ a.remarks || 'no remarks recorded' }} -
-
-
+
+
+ {{ a.rule.suggestedAction }} +
+
+ Reviewer: {{ a.reviewedBy || 'not yet assigned' }} — + {{ a.remarks || 'no remarks recorded' }} +
+
+
+ } +
- No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}. -
-
+ } + + +
+ } +
+ } @empty { +
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
+ } } @@ -400,9 +413,6 @@ {{ a.clientId }} {{ a.customerName }} - @if (!a.live) { - Sample - } {{ a.accountNo || '—' }} {{ a.branchCode || '—' }} diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index aacb799ff..117eae800 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -139,12 +139,6 @@ span { flex: 1; } - - &--sample { - background: var(--r-amber-l); - border: 1px solid #fcd34d; - color: #78350f; - } } /* FILTERS */ @@ -403,6 +397,84 @@ font-weight: 600; } +/* VERDICT GROUPS — the section heading carries the verdict, so rows need not repeat it. */ +.rr-group { + margin-bottom: 14px; +} + +.rr-group-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + background: #fff; + border: 1px solid var(--r-g200); + border-left: 4px solid var(--r-g300); + border-radius: 10px; + cursor: pointer; + text-align: left; + font: inherit; +} + +.rr-group-t { + font-size: 14px; + font-weight: 700; +} + +/* The count sits next to the title so a reviewer sees the size of each pile at a glance. */ +.rr-group-n { + min-width: 24px; + padding: 1px 8px; + border-radius: 999px; + background: var(--r-g100, #f1f5f9); + font-size: 12px; + font-weight: 700; + text-align: center; +} + +.rr-group-h { + flex: 1; + font-size: 12px; + color: var(--r-g500); +} + +/* One colour per verdict, matched to the arrows already used in the rows. */ +.rr-group--str .rr-group-head { + border-left-color: #dc2626; +} + +.rr-group--str .rr-group-n { + background: #fee2e2; + color: #991b1b; +} + +.rr-group--up .rr-group-head { + border-left-color: #ea580c; +} + +.rr-group--up .rr-group-n { + background: #ffedd5; + color: #9a3412; +} + +.rr-group--down .rr-group-head { + border-left-color: #16a34a; +} + +.rr-group--down .rr-group-n { + background: #dcfce7; + color: #166534; +} + +.rr-group--hold .rr-group-head { + border-left-color: var(--r-g300); +} + +.rr-group .rr-table-wrap { + margin-top: 6px; +} + /* TABLE */ .rr-table-wrap { background: #fff; @@ -751,6 +823,75 @@ white-space: normal; } +/* Where the rating should land, read straight off the Current Risk column beside it. */ +.rr-move-cell { + min-width: 150px; + max-width: 190px; +} + +.rr-move { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; + + &--up { + background: var(--r-red-l); + color: var(--r-red); + } + + /* Coming down is a relief, not an alarm — same green the verdict uses. */ + &--down { + background: var(--r-green-l); + color: var(--r-green); + } + + &--hold { + background: var(--r-g100); + color: var(--r-g500); + } +} + +.rr-move-why { + font-size: 10px; + color: var(--r-g500); + margin-top: 3px; + white-space: normal; +} + +/* Drill from the verdict into the KYC screen, where the rating is actually moved. */ +.rr-kyc-link { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 6px; + padding: 3px 9px; + border: 1px solid var(--r-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 10.5px; + font-weight: 600; + color: var(--r-g500); + white-space: nowrap; + cursor: pointer; + + &:hover { + border-color: var(--r-blue); + color: var(--r-blue); + } + + /* A rating that has to move gets the emphasis; a settled row stays quiet. */ + &--act { + border-color: var(--r-blue); + color: var(--r-blue); + } +} + .rr-tag { display: inline-block; margin-left: 6px; diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index 58ef27234..bd59f7bd6 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -9,6 +9,7 @@ /** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -37,8 +38,7 @@ import { RiskLevel, channelLabel, mapLiveAlert, - riskFromCategory, - sampleRiskAlerts + riskFromCategory } from './risk-review-data'; import { DateRange, @@ -57,6 +57,15 @@ import { type PeriodMode = 'day' | 'week' | 'month' | 'range'; type ViewMode = 'customer' | 'alert' | 'txn'; +/** One block of the customer view: the customers that share a single verdict. */ +interface RiskCustomerGroup { + key: string; + title: string; + hint: string; + tone: 'str' | 'up' | 'down' | 'hold'; + rows: RiskCustomerRow[]; +} + /** Starting value for the memo, used before the first filter pass runs. */ const EMPTY_SUMMARY: RiskReviewSummary = { customers: 0, @@ -82,9 +91,8 @@ const EMPTY_SUMMARY: RiskReviewSummary = { * rating, move it, refresh KYC, or escalate to the Principal Officer for an * STR — which is the vocabulary in `REVIEW_STATUS_META`. * - * Rows come from the live rule engine. If the engine has raised nothing (or is - * unreachable) the report falls back to a deterministic sample set and says so - * on screen and in the workbook, so sample rows are never taken for live data. + * Rows come from the live rule engine only. If the engine has raised nothing + * (or is unreachable) the report shows as empty rather than padding itself. */ @Component({ selector: 'mifosx-risk-review-report', @@ -100,6 +108,7 @@ export class RiskReviewReportComponent implements OnInit { private aml = inject(AmlService); private clients = inject(ClientsService); private scope = inject(BranchScopeService); + private router = inject(Router); readonly period = GL_PERIOD; readonly branches = GL_BRANCHES; @@ -122,22 +131,13 @@ export class RiskReviewReportComponent implements OnInit { private liveAlerts: RiskAlertRow[] = []; loading = true; - /** - * Live alerts and the demonstration set, in one worklist. - * - * Every row keeps its `live` flag and sample rows are tagged in the table, so - * a reviewer can still tell them apart at a glance. - */ + /** The worklist — live alerts from the rule engine. */ private alerts: RiskAlertRow[] = []; get hasLive(): boolean { return this.liveAlerts.length > 0; } - get sampleCount(): number { - return this.alerts.filter((a) => !a.live).length; - } - // ── Filter state ─────────────────────────────────────────────────────────── /** @@ -198,12 +198,16 @@ export class RiskReviewReportComponent implements OnInit { }); } - /** Live alerts first, then the demonstration set behind them. */ + /** + * Only what the AML engine actually raised. + * + * The demonstration set that used to be appended here is gone: a compliance report that mixes + * invented customers into live alerts is worse than an empty one, because every count, total and + * rule tally on the screen is then a number nobody can act on. An empty period now reads as an + * empty period. + */ private compose(): void { - this.alerts = [ - ...this.liveAlerts, - ...sampleRiskAlerts() - ]; + this.alerts = [...this.liveAlerts]; this.memoKey = ''; } @@ -334,6 +338,64 @@ export class RiskReviewReportComponent implements OnInit { return this.model.customers; } + /** + * The customers split by what the reviewer actually has to do about them. + * + * The report's whole question is "does the rating on file still hold?", and there are only four + * answers. Grouping by the answer puts that question in the section heading, where it is read + * once, instead of repeating it on every row across a Move To and a Position column that said the + * same thing in different words. + * + * Ordered by what a reviewer must deal with first: an STR sits with the Principal Officer against + * a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is + * only a correction, and matching ratings need nothing at all. + */ + get customerGroups(): RiskCustomerGroup[] { + const rows = this.customerRows; + const escalated = rows.filter((c) => c.escalated); + const rest = rows.filter((c) => !c.escalated); + + const groups: RiskCustomerGroup[] = [ + { + key: 'escalated', + title: 'With the Principal Officer', + hint: 'An STR has been filed. The rating is held where it is until the outcome — nothing to apply here.', + tone: 'str', + rows: escalated + }, + { + key: 'up', + title: 'Rating needs raising', + hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.', + tone: 'up', + rows: rest.filter((c) => c.direction === 'up') + }, + { + key: 'down', + title: 'Rating can be lowered', + hint: 'Activity no longer supports the rating on file.', + tone: 'down', + rows: rest.filter((c) => c.direction === 'down') + }, + { + key: 'same', + title: 'No change needed', + hint: 'The rating on file already matches the behaviour. Listed for the record.', + tone: 'hold', + rows: rest.filter((c) => c.direction === 'same') + } + ]; + + return groups.filter((g) => g.rows.length > 0); + } + + /** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */ + collapsedGroups: Record = { same: true }; + + toggleGroup(key: string): void { + this.collapsedGroups[key] = !this.collapsedGroups[key]; + } + get txnRows(): RiskTxnView[] { return this.model.txns; } @@ -393,6 +455,74 @@ export class RiskReviewReportComponent implements OnInit { return 'rr-verdict'; } + /** Colours the target rating by which way it moves. */ + moveClass(c: RiskCustomerRow): string { + if (c.direction === 'up') { + return 'rr-move rr-move--up'; + } + if (c.direction === 'down') { + return 'rr-move rr-move--down'; + } + return 'rr-move rr-move--hold'; + } + + /** + * The short "from → to" line under the target rating. + * + * The reason column already carries the full argument; this only has to say what is + * happening to the rating, so a reviewer scanning the column never has to read a + * paragraph to find out whether a customer is moving. + */ + moveHint(c: RiskCustomerRow): string { + const from = this.riskText(c.currentRisk); + const to = this.riskText(c.recommendedRisk); + + if (c.escalated) { + return `${from} → hold at ${to} till STR outcome`; + } + if (c.direction === 'up') { + return `${from} → ${to} as per AML rules`; + } + if (c.direction === 'down') { + return `${from} → ${to}, activity does not support ${from}`; + } + // Nothing to move: say why it is staying rather than leaving the cell mute. + return c.currentRisk ? `Behaviour matches ${from} — no change` : 'No rating on record yet'; + } + + /** + * Opens the customer's KYC tab so the reviewer can act on the verdict. + * + * The report only ever recommends — nothing here writes the rating. Moving a customer + * is a KYC amendment and stays on the KYC screen, where the override checkbox, the + * remarks field and the review-date recalculation all live together. + */ + openKyc(c: RiskCustomerRow, event: Event): void { + // The row itself expands on click; the button must not do both. + event.stopPropagation(); + this.router.navigate([ + '/clients', + c.clientId, + 'kyc' + ]); + } + + /** + * What the reviewer is going to the KYC screen to do. + * + * An escalated case is worded as a review rather than a move: the rating is held + * pending the Principal Officer's conclusion, so there is nothing to apply yet. + */ + kycActionText(c: RiskCustomerRow): string { + if (c.escalated) { + return 'Open KYC'; + } + if (c.direction === 'up' || c.direction === 'down') { + return `Set ${RISK_LABEL[c.recommendedRisk]} in KYC`; + } + return 'Open KYC'; + } + /** Transaction detail as the reviewer would read it in one line. */ txnDetail(a: RiskAlertView): string { const amount = `₹${a.amount.toLocaleString('en-IN')}`; @@ -409,9 +539,7 @@ export class RiskReviewReportComponent implements OnInit { try { const s = this.summary; const subtitle = - `Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + - `Source: live AML monitoring engine` + - (this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : ''); + `Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + `Source: live AML monitoring engine`; await exportWorkbook( [ @@ -533,6 +661,7 @@ export class RiskReviewReportComponent implements OnInit { 'Last Alert', 'Rules Point At', 'Recommended Risk', + 'Move To', 'Open Cases', 'Position', 'Basis' @@ -551,6 +680,7 @@ export class RiskReviewReportComponent implements OnInit { 12, 14, 16, + 38, 11, 24, 70 @@ -569,6 +699,7 @@ export class RiskReviewReportComponent implements OnInit { c.lastAlertDate, RISK_LABEL[c.proposedRisk], RISK_LABEL[c.recommendedRisk], + this.moveHint(c), c.openCount, this.verdictText(c), c.directionReason || '—' diff --git a/src/app/savings-daily-interest/savings-daily-interest.component.html b/src/app/savings-daily-interest/savings-daily-interest.component.html index d52213206..59c3d7334 100644 --- a/src/app/savings-daily-interest/savings-daily-interest.component.html +++ b/src/app/savings-daily-interest/savings-daily-interest.component.html @@ -24,6 +24,7 @@
+ @if (selectedProduct) {
-
-
-
- Withdrawal Facility - {{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }} -
- -
-
diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts index 6add60514..b3aac19d9 100644 --- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts @@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = { neftFacility: false, rtgsFacility: false, interBankTotalCapFacility: false, - withdrawalFacility: false, depositFacilityKyc: false, virtualDebitCard: false }; diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts index 32b227bcd..880e51d90 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts @@ -73,7 +73,6 @@ export class SavingsAccountPreviewStepComponent implements OnChanges { { key: 'neftFacility', label: 'NEFT Facility' }, { key: 'rtgsFacility', label: 'RTGS Facility' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card' } ]; diff --git a/src/app/shared/charges/facility-charge-filter.service.ts b/src/app/shared/charges/facility-charge-filter.service.ts new file mode 100644 index 000000000..d9da72250 --- /dev/null +++ b/src/app/shared/charges/facility-charge-filter.service.ts @@ -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>; + + /** + * 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> { + if (!this.mappedChargeIds$) { + this.mappedChargeIds$ = this.http.get('/facilitychargemappings').pipe( + map( + (mappings: any) => + new Set( + ((mappings as any[]) || []) + .map((mapping) => Number(mapping?.chargeId)) + .filter((chargeId) => Number.isFinite(chargeId)) + ) + ), + catchError(() => of(new Set())), + 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 { + return this.getFacilityMappedChargeIds().pipe( + map((mappedChargeIds) => + (charges || []).filter((charge) => !mappedChargeIds.has(Number(charge?.id ?? charge?.chargeId))) + ) + ); + } +} diff --git a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html index c85e9182c..500edea7e 100644 --- a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html +++ b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html @@ -12,7 +12,9 @@
- + +
diff --git a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts index 4a0e7f944..35663d419 100644 --- a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts +++ b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts @@ -6,7 +6,7 @@ * 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'; 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'], imports: [...STANDALONE_SHARED_IMPORTS] }) -export class DormancyAlertPanelComponent implements OnInit { +export class DormancyAlertPanelComponent implements OnInit, OnChanges { /** Pass full account data — for ACCOUNT VIEW (general tab) */ @Input() accountData: any; /** Pass product template — for ACCOUNT CREATION (currency step) */ @@ -48,13 +48,40 @@ export class DormancyAlertPanelComponent implements OnInit { } 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) { this.initFromProductTemplate(); } else if (this.accountData) { 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) ───────────────────── private initFromProductTemplate(): void { const pt = this.productTemplate; @@ -113,10 +140,15 @@ export class DormancyAlertPanelComponent implements OnInit { } this.computeRiskFromAccount(); - // Only surface this panel once the account has actually left "Active" — - // i.e. subStatus is Inactive/Dormant/Escheat. A healthy active account - // (statusId === 0) should never show a danger-colored dormancy alert. - this.shouldShow = this.statusId > 0; + // Shown wherever dormancy monitoring applies, so a branch can read the thresholds and how far + // the account is from each of them. The template drops the risk timeline while the account is + // still healthy, where there is no risk to show. + 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 { diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index 25bd2f873..a7b1fa4ff 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -122,18 +122,40 @@ Actions
+ Pending Approval (Req #{{ element.pendingIssueRequestId }}) + + + + Amendment Pending (Req #{{ element.pendingAmendmentRequestId }}) + `, @@ -101,25 +134,22 @@ import { SponsorDDService } from '../sponsor-dd.service'; .full-width { width: 100%; } + .row-two-fields { + display: flex; + gap: 16px; + } + .half-width { + flex: 1; + } .hint-text { color: #64748b; font-size: 13px; - margin: 0 0 12px 0; + margin: 0 0 16px 0; + line-height: 1.4; } - .readonly-accounts { - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 12px 16px; - background: #f8fafc; - } - .account-row { - display: flex; - justify-content: space-between; - font-size: 13px; - padding: 4px 0; - color: #334155; - } - .account-row span { + .loading-state { + padding: 24px; + text-align: center; color: #64748b; } ` @@ -127,6 +157,7 @@ import { SponsorDDService } from '../sponsor-dd.service'; }) export class GlSettingsDialogComponent implements OnInit { private fb = inject(FormBuilder); + private http = inject(HttpClient); private sponsorDDService = inject(SponsorDDService); private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); @@ -135,9 +166,27 @@ export class GlSettingsDialogComponent implements OnInit { loading = true; saving = false; mapping: any = null; + glAccounts: any[] = []; ngOnInit(): void { this.form = this.fb.group({ + ddPayableGlAccountId: [ + null, + [Validators.required] + ], + chargesIncomeGlAccountId: [ + null, + [Validators.required] + ], + gstPayableGlAccountId: [ + null, + [Validators.required] + ], + cashGlAccountId: [ + null, + [Validators.required] + ], + chequeClearingGlAccountId: [null], gstPercentage: [ 18, [ @@ -151,20 +200,42 @@ export class GlSettingsDialogComponent implements OnInit { [Validators.min(0)] ] }); - this.sponsorDDService.getGlMappings().subscribe({ - next: (mappings) => { - this.mapping = mappings && mappings.length ? mappings[0] : null; - if (this.mapping) { - this.form.patchValue({ - gstPercentage: this.mapping.gstPercentage ?? 18, - ddLimitAmount: this.mapping.ddLimitAmount ?? null - }); - } - this.loading = false; + + this.loadGlAccountsAndMapping(); + } + + loadGlAccountsAndMapping(): void { + this.loading = true; + this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({ + next: (accounts) => { + this.glAccounts = accounts || []; + this.sponsorDDService.getGlMappings().subscribe({ + next: (mappings) => { + this.mapping = mappings && mappings.length ? mappings[0] : null; + if (this.mapping) { + this.form.patchValue({ + ddPayableGlAccountId: this.mapping.ddPayableGlAccountId ?? null, + chargesIncomeGlAccountId: this.mapping.chargesIncomeGlAccountId ?? null, + gstPayableGlAccountId: this.mapping.gstPayableGlAccountId ?? null, + cashGlAccountId: this.mapping.cashGlAccountId ?? null, + chequeClearingGlAccountId: this.mapping.chequeClearingGlAccountId ?? null, + gstPercentage: this.mapping.gstPercentage ?? 18, + ddLimitAmount: this.mapping.ddLimitAmount ?? null + }); + } + this.loading = false; + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error loading GL mapping: ' + (err.error?.message || err.message), 'Close', { + duration: 5000 + }); + } + }); }, error: (err) => { this.loading = false; - this.snackBar.open('Error loading GL settings: ' + (err.error?.message || err.message), 'Close', { + this.snackBar.open('Error loading GL accounts: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); } @@ -172,29 +243,32 @@ export class GlSettingsDialogComponent implements OnInit { } onSave(): void { - if (this.form.invalid || !this.mapping) return; + if (this.form.invalid) return; this.saving = true; + const val = this.form.value; this.sponsorDDService .upsertGlMapping({ sponsorBankId: 0, - ddPayableGlAccountId: this.mapping.ddPayableGlAccountId, - chequeClearingGlAccountId: this.mapping.chequeClearingGlAccountId, - cashGlAccountId: this.mapping.cashGlAccountId, - chargesIncomeGlAccountId: this.mapping.chargesIncomeGlAccountId, - cashSuspenseGlAccountId: this.mapping.cashSuspenseGlAccountId, - gstPercentage: this.form.value.gstPercentage, - gstPayableGlAccountId: this.mapping.gstPayableGlAccountId, - ddLimitAmount: this.form.value.ddLimitAmount + ddPayableGlAccountId: val.ddPayableGlAccountId, + chequeClearingGlAccountId: val.chequeClearingGlAccountId, + cashGlAccountId: val.cashGlAccountId, + chargesIncomeGlAccountId: val.chargesIncomeGlAccountId, + cashSuspenseGlAccountId: this.mapping?.cashSuspenseGlAccountId || val.cashGlAccountId, + gstPercentage: val.gstPercentage, + gstPayableGlAccountId: val.gstPayableGlAccountId, + ddLimitAmount: val.ddLimitAmount }) .subscribe({ next: () => { - this.snackBar.open('GST percentage updated', 'Close', { duration: 3000 }); + this.snackBar.open('Sponsor DD GL Mapping & GST Settings saved successfully!', 'Close', { duration: 3000 }); this.dialogRef.close(true); }, error: (err) => { this.saving = false; - this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); + this.snackBar.open('Error saving GL settings: ' + (err.error?.message || err.message), 'Close', { + duration: 5000 + }); } }); } diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 463135776..7f6e34ff4 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -49,6 +49,18 @@ export class SponsorDDService { return this.http.get(`/sponsordd/leaflets/by-number/${ddNumber}`); } + getNextAvailableLeaflet(branchId?: number, sponsorBankId?: number, inventoryId?: number): Observable { + const params: any = {}; + if (branchId) params.branchId = branchId; + if (sponsorBankId) params.sponsorBankId = sponsorBankId; + if (inventoryId) params.inventoryId = inventoryId; + return this.http.get('/sponsordd/leaflets/next-available', { params }); + } + + getSponsorBanks(): Observable { + return this.http.get('/sponsorbanks'); + } + // ── Actions ── issueDemandDraft( leafletId: number, @@ -102,6 +114,7 @@ export class SponsorDDService { chequeNumber?: string; chequeBankName?: string; cashierDepositRequestId?: number; + denominationData?: string; } ): Observable { return this.http.post(`/sponsordd/leaflets/${leafletId}/issue-request`, data); @@ -205,6 +218,35 @@ export class SponsorDDService { return this.http.post(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason }); } + // ── DD amendment requests (maker-checker) ── + // Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else + // is amendable: the money behind the face value has already moved, so a draft wrong in that way + // is cancelled and re-issued rather than edited. + + /** + * Maker step. A field left blank keeps what the draft already carries, so correcting only the + * city does not mean retyping the beneficiary and risking a second mistake. + */ + requestDdAmendment( + leafletId: number, + changes: { beneficiaryName?: string; payableAtBranch?: string; payableAtCity?: string; reason: string } + ): Observable { + return this.http.post(`/sponsordd/leaflets/${leafletId}/amendment-request`, changes); + } + + getAllAmendmentRequests(status?: string): Observable { + const params = status ? { status } : {}; + return this.http.get('/sponsordd/amendment-requests', { params }); + } + + approveAmendmentRequest(id: number): Observable { + return this.http.post(`/sponsordd/amendment-requests/${id}/approve`, {}); + } + + rejectAmendmentRequest(id: number, rejectionReason: string): Observable { + return this.http.post(`/sponsordd/amendment-requests/${id}/reject`, { rejectionReason }); + } + // ── Approver signature (stamped onto the printed DD) ── // Reuses Fineract's generic document-attachment API (entityType is a free-form string with // no per-type backend wiring required) rather than building dedicated signature storage. diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html index a4b311eee..3f107ddac 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html @@ -9,22 +9,16 @@

✉ Approval Email - +
@if (data.emailSent) { - - Sent - + Sent } @else { - - Not sent - + Not sent } @if (data.emailOpenedAt) { - - Seen - + Seen } @else { Not yet seen @@ -34,16 +28,15 @@

-

{{ data.emailSubject ?? '(No Subject)' }}

- +
C
- +
CBSNexora @@ -53,7 +46,7 @@
- to + to @if (data.emailTo) { {{ data.emailTo }} } @else { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts index e2a18cb2d..5954b11f9 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts @@ -58,8 +58,12 @@ export class ClientEditEmailDialogComponent { if (!isoDate) return '—'; const d = new Date(isoDate); return d.toLocaleString('en-IN', { - day: '2-digit', month: 'short', year: 'numeric', - hour: '2-digit', minute: '2-digit', hour12: true + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true }); } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts index 0b3b1f84c..cfe3ebddf 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts @@ -40,7 +40,6 @@ const FACILITY_LABELS: Record = { neftFacility: 'NEFT Facility', rtgsFacility: 'RTGS Facility', interBankTotalCapFacility: 'Inter-Bank Total Cap', - withdrawalFacility: 'Withdrawal Facility', depositFacilityKyc: 'Deposit Facility (KYC)', virtualDebitCard: 'Virtual Debit Card' }; diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index faecfc283..e37709044 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -218,6 +218,14 @@ Closure Requests + + + DD Approvals + + + + DD Reprint Requests +
diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 0b35387f7..636148c3d 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -213,6 +213,22 @@ const routes: Routes = [ path: 'closure-requests', component: ClosureRequestsComponent, data: { title: 'Closure Requests' } + }, + { + path: 'dd-issue-approvals', + loadComponent: () => + import('../sponsor-dd/dd-issue-approvals/dd-issue-approvals.component').then( + (m) => m.DdIssueApprovalsComponent + ), + data: { title: 'DD Issuance Approvals' } + }, + { + path: 'dd-reprint-approvals', + loadComponent: () => + import('../sponsor-dd/dd-reprint-approvals/dd-reprint-approvals.component').then( + (m) => m.DdReprintApprovalsComponent + ), + data: { title: 'DD Reprint Approvals' } } ] }, diff --git a/src/app/tds-reports/tds-statutory.compute.ts b/src/app/tds-reports/tds-statutory.compute.ts index decb83d85..bbb50e748 100644 --- a/src/app/tds-reports/tds-statutory.compute.ts +++ b/src/app/tds-reports/tds-statutory.compute.ts @@ -146,8 +146,7 @@ export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): Decl for (const row of declared) { const acceptedOn = row.declarationAcceptedOn as string; - const key = - cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`; + const key = cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`; if (!row.declarationUin) { missingUinCount++; @@ -182,20 +181,22 @@ export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): Decl const periods: DeclarationStatementPeriod[] = [...buckets.entries()] .sort((a, b) => a[0].localeCompare(b[0])) - .map(([ - key, - lines - ]) => { - const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year); - return { + .map( + ([ key, - label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key, - dueDate, - overdue: dueDate < asAt, - lines, - totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0) - }; - }); + lines + ]) => { + const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year); + return { + key, + label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key, + dueDate, + overdue: dueDate < asAt, + lines, + totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0) + }; + } + ); return { fy: year,