From 7f3c97a2051e69436e0851a6e1b5ca4fdc8659ae Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 14 Oct 2026 13:51:38 +0530 Subject: [PATCH 01/10] feat(cashier): GL withdrawal slips, GL account panel with parent GL, and Payment lock on token withdrawals Transaction Entry: - GL withdrawal slip: a GL Withdrawal Slip is always a debit. The clerk raises it as a request (GL, cash vault GL, office and currency ride on the payload), the manager approves it and the cashier pays it against its token; nothing posts directly from this screen any more (submitGlJournalEntry removed). Cashiers without an approved token are told to enter one. GL credit-only rules moved into isGlCreditOnly(), and extra rows are not allowed while a Withdrawal Slip is selected. - A/C NO. now starts the automatic account search after six characters. - Clerk: with AC. TP. = GL show a "GL Account Information" panel (code, name, type, usage, normal balance, parent GL, manual entries, status, description) instead of the empty client panels; the parent GL name is looked up from /glaccounts/{parentId} for GL only. - Cashier: once a token (withdrawal / closure request) is verified, TYPE is disabled and fixed to Payment, and onTypChange refuses to change it. Requires the backend change "GL withdrawal slips through request, approval and cashier payment". Co-Authored-By: Claude Sonnet 5.5 --- .../transaction-entry.component.html | 285 +++++++++--------- .../transaction-entry.component.ts | 230 +++++++------- 2 files changed, 261 insertions(+), 254 deletions(-) diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 586117016..deda8f0e9 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -117,6 +117,8 @@ @@ -1627,13 +1634,7 @@ class="ws-f__ctl" [(ngModel)]="row.acTyp" (click)="$event.stopPropagation()" - (change)=" - row.acTyp === 'GL' && - row.typ !== 'Disbursement' && - row.typ !== 'Fund Transfer' && - row.typ !== 'Enter Bank Transfer' && - (row.dbCr = 'Cr') - " + (change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')" > @for (a of acTypOptionsFor(row); track a) { @@ -1704,12 +1705,7 @@ type="button" class="ws-seg__btn ws-seg__btn--dr" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" - [disabled]=" - row.acTyp === 'GL' && - row.typ !== 'Disbursement' && - row.typ !== 'Fund Transfer' && - row.typ !== 'Enter Bank Transfer' - " + [disabled]="isGlCreditOnly(row)" (click)="$event.stopPropagation(); row.dbCr = 'Dr'" > DR @@ -1718,6 +1714,7 @@ type="button" class="ws-seg__btn ws-seg__btn--cr" [class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'" + [disabled]="row.typ === 'Withdrawal Slip'" (click)="$event.stopPropagation(); row.dbCr = 'Cr'" > CR @@ -2043,7 +2040,13 @@ @if (isClerk) { - + } + + + + + + @if (loading && !loaded) { +

Loading…

+ } @else if (rows.length === 0) { +
+ inbox +

No cash transfer waiting

+

There is no cash allocation waiting for approval.

+
+ } @else { +
+ + + + + + + + + + + + + + + @for (r of rows; track r.id) { + + + + + + + + + + + } + +
DateFromTo (Cashier)AmountNoteRaised ByRaised AtAction
{{ r.transfer_date || '—' }}{{ r.from_party || '—' }}{{ r.to_party || '—' }}₹{{ r.total_amount | number: '1.2-2' }}{{ r.note || '—' }}{{ r.maker_name || '—' }}{{ formatDateTime(r.maker_at) }} + +
+
+ } + + + + + @if (reviewing) { +
+
+ swap_horiz +
+
Review cash transfer
+
+ Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }} +
+
+ +
+ +
+
+ Amount to release + ₹{{ reviewing.total_amount | number: '1.2-2' }} +
+ +
+
+
Transfer date
+
{{ reviewing.transfer_date || '—' }}
+
+
+
From
+
{{ reviewing.from_party || '—' }}
+
+
+
To (cashier)
+
{{ reviewing.to_party || '—' }}
+
+
+
Notes and coins
+
+ {{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations +
+
+ @if (reviewing.note) { +
+
Note
+
{{ reviewing.note }}
+
+ } +
+ +
Denomination breakdown
+ + + + + + + + + + @for (l of linesOf(reviewing); track l.value) { + + + + + + } + + + + + + + + +
DenominationCountAmount
₹{{ l.value }}{{ l.count }}₹{{ l.amount | number: '1.2-2' }}
Total{{ piecesOf(reviewing) }}₹{{ reviewing.total_amount | number: '1.2-2' }}
+
+ +
+ + + +
+
+ } +
+ + + + @if (rejecting) { +
+
+ block +
+
Reject cash transfer
+
₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}
+
+ +
+
+ + +
+
+ + +
+
+ } +
diff --git a/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.scss b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.scss new file mode 100644 index 000000000..ae36b2e83 --- /dev/null +++ b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.scss @@ -0,0 +1,573 @@ +/** + * 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/. + */ + +// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table. +:host { + display: block; +} + +.cta-tab { + padding: 0; +} + +.cta-action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 16px; + background: #fff; + border-bottom: 1px solid #e2e8f0; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 22px; + width: 22px; + height: 22px; + color: #0f4f3c; + } + + &__title { + font-size: 13px; + font-weight: 700; + color: #0d1b2a; + } + + &__sub { + max-width: 640px; + margin-top: 1px; + font-size: 11px; + color: #64748b; + } +} + +.cta-counts { + display: flex; + gap: 8px; +} + +.cta-count { + padding: 2px 10px; + border-radius: 999px; + background: #f1f5f9; + font-size: 11px; + color: #475569; + + strong { + color: #0f4f3c; + } +} + +.cta-filter-bar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 6px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.cta-fb-sep { + flex-shrink: 0; + width: 1px; + height: 22px; + margin: 0 4px; + background: #e2e8f0; +} + +.cta-search-box { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + min-width: 240px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + + mat-icon { + flex-shrink: 0; + font-size: 17px; + width: 17px; + height: 17px; + color: #94a3b8; + } + + &:hover { + border-color: #cbd5e1; + } + + &:focus-within { + border-color: #0f4f3c; + box-shadow: 0 0 0 3px rgb(15 79 60 / 8%); + } + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-family: inherit; + font-size: 12px; + color: #0d1b2a; + + &::placeholder { + color: #94a3b8; + } + } + + &__clear { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 50%; + background: none; + color: #94a3b8; + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + color: inherit; + } + + &:hover { + color: #dc2626; + background: #fef2f2; + } + } +} + +.cta-filter-select { + height: 30px; + min-width: 140px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 7px; + background: #fff; + font-family: inherit; + font-size: 12px; + color: #0d1b2a; + cursor: pointer; + + &:focus { + outline: none; + border-color: #0f4f3c; + } +} + +// ── Buttons ───────────────────────────────────────────────────────────────── +.cta-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: 7px; + font-family: inherit; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &--refresh { + height: 30px; + width: 30px; + justify-content: center; + padding: 0; + margin-left: auto; + border-color: #e2e8f0; + background: #fff; + color: #475569; + } + + &--approve { + background: #0f4f3c; + color: #fff; + + &:hover:not(:disabled) { + background: #0a3d2e; + } + } + + &--reject { + border-color: #fecaca; + background: #fff; + color: #dc2626; + + &:hover:not(:disabled) { + background: #fef2f2; + } + } + + &--reject-solid { + background: #dc2626; + color: #fff; + } + + &--ghost { + border-color: #e2e8f0; + background: #fff; + color: #475569; + } +} + +// ── Table ─────────────────────────────────────────────────────────────────── +.cta-table-wrap { + overflow-x: auto; + background: #fff; +} + +.cta-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + + th { + padding: 7px 10px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.06em; + text-align: left; + text-transform: uppercase; + color: #94a3b8; + white-space: nowrap; + } + + td { + padding: 5px 10px; + border-bottom: 1px solid #eef2f7; + vertical-align: middle; + color: #0d1b2a; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .cta-th-action { + text-align: right; + } +} + +.cta-row:hover td { + background: #f8fafc; +} + +.cta-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.02em; +} + +.cta-voucher { + font-weight: 700; + color: #0f4f3c; +} + +.cta-strong { + font-weight: 700; +} + +.cta-sub { + margin-top: 1px; + font-size: 11px; + color: #64748b; +} + +.cta-none { + color: #cbd5e1; +} + +.cta-amount { + font-weight: 700; +} + +.cta-reason { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cta-dt { + white-space: nowrap; + color: #475569; +} + +.cta-kind { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + + &--unpass { + background: #fef3c7; + color: #92400e; + } + + &--correction { + background: #dbeafe; + color: #1e40af; + } +} + +.cta-action-cell { + display: flex; + justify-content: flex-end; + gap: 6px; + white-space: nowrap; +} + +// ── Empty ─────────────────────────────────────────────────────────────────── +.cta-empty { + padding: 28px 16px; + text-align: center; + color: #94a3b8; + + &__icon { + display: block; + margin: 0 auto 6px; + font-size: 30px; + width: 30px; + height: 30px; + opacity: 0.6; + } + + &__title { + margin: 0; + font-size: 12px; + font-weight: 700; + color: #64748b; + } + + &__text { + margin: 3px 0 0; + font-size: 11px; + } +} + +// ── Reject popup ──────────────────────────────────────────────────────────── +.cta-dlg { + font-size: 12px; + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + } + + &__ico { + flex: 0 0 32px; + height: 32px; + width: 32px; + border-radius: 9px; + background: #fef2f2; + color: #dc2626; + font-size: 18px; + line-height: 32px; + text-align: center; + } + + &__titles { + flex: 1; + min-width: 0; + } + + &__title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &__x { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + width: 28px; + border: 0; + border-radius: 50%; + background: transparent; + color: #64748b; + cursor: pointer; + + &:hover { + background: #e2e8f0; + } + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + } + + &__body { + padding: 12px 14px; + max-height: 64vh; + overflow-y: auto; + + textarea { + width: 100%; + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font: inherit; + font-size: 12px; + resize: vertical; + + &:focus { + outline: 0; + border-color: #0f4f3c; + box-shadow: 0 0 0 3px rgb(15 79 60 / 8%); + } + } + } + + &__foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + } +} + +.cta-label { + display: block; + margin-bottom: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #94a3b8; +} + +.cta-dlg__ico--ok { + background: #f0fdf4; + color: #1f7a4d; +} + +.cta-total { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 10px; + padding: 8px 12px; + border-radius: 10px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + color: #065f46; + font-size: 12px; + font-weight: 600; + + strong { + font-size: 18px; + font-weight: 800; + font-variant-numeric: tabular-nums; + } +} + +.cta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 6px; + margin-bottom: 12px; +} + +.cta-cell { + padding: 6px 10px; + border-radius: 8px; + background: #f8fafc; + border: 1px solid #eef2f7; + + &--wide { + grid-column: 1 / -1; + } +} + +.cta-value { + font-size: 12px; + font-weight: 600; + color: #0f172a; + word-break: break-word; +} + +.cta-mini { + width: 100%; + margin-top: 4px; + border-collapse: collapse; + font-size: 12px; + + th { + padding: 6px 10px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.05em; + text-align: left; + text-transform: uppercase; + color: #94a3b8; + } + + td { + padding: 5px 10px; + border-bottom: 1px solid #eef2f7; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + tfoot td { + border-top: 2px solid #e2e8f0; + border-bottom: 0; + font-weight: 800; + background: #f8fafc; + } +} diff --git a/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.ts b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.ts new file mode 100644 index 000000000..c3b1305e7 --- /dev/null +++ b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.ts @@ -0,0 +1,190 @@ +/** + * 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, TemplateRef, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { CashierService } from '../cashier.service'; + +/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */ +interface CashTransferRequest { + id: number; + transfer_date: string | null; + from_party: string | null; + to_party: string | null; + note: string | null; + total_amount: number; + maker_name: string | null; + maker_at: string | null; + denominations: { denominationValue: number; count: number }[]; +} + +/** + * Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash + * leaves the vault only when it is approved, by someone other than the person who raised it. + */ +@Component({ + selector: 'mifosx-cash-transfer-approvals', + templateUrl: './cash-transfer-approvals.component.html', + styleUrls: ['./cash-transfer-approvals.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatIconModule, + MatSnackBarModule + ] +}) +export class CashTransferApprovalsComponent implements OnInit { + private service = inject(CashierService); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + + @ViewChild('rejectDialog') rejectDialog!: TemplateRef; + @ViewChild('reviewDialog') reviewDialog!: TemplateRef; + private dialogRef: MatDialogRef | null = null; + private reviewRef: MatDialogRef | null = null; + + /** The request open in the review popup, with every note and coin it asks for. */ + reviewing: CashTransferRequest | null = null; + + all: CashTransferRequest[] = []; + loading = false; + loaded = false; + busyId: number | null = null; + searchTerm = ''; + + rejecting: CashTransferRequest | null = null; + rejectReason = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.service + .getCashTransferRequests('PENDING') + .pipe(catchError(() => of([] as CashTransferRequest[]))) + .subscribe((rows) => { + this.all = rows as CashTransferRequest[]; + this.loading = false; + this.loaded = true; + }); + } + + get rows(): CashTransferRequest[] { + const term = this.searchTerm.trim().toLowerCase(); + if (!term) return this.all; + return this.all.filter((r) => [ + r.from_party, + r.to_party, + r.maker_name, + r.note + ].some((v) => (v ?? '').toLowerCase().includes(term))); + } + + /** One line per denomination asked for, largest first, with what each line adds up to. */ + linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] { + return [...(r.denominations ?? [])] + .filter((d) => d.count > 0) + .sort((a, b) => b.denominationValue - a.denominationValue) + .map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count })); + } + + /** How many notes and coins the request asks for in all. */ + piecesOf(r: CashTransferRequest): number { + return this.linesOf(r).reduce((sum, l) => sum + l.count, 0); + } + + review(r: CashTransferRequest): void { + this.reviewing = r; + this.reviewRef?.close(); + this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false }); + this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null)); + } + + closeReview(): void { + this.reviewRef?.close(); + } + + /** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */ + rejectFromReview(r: CashTransferRequest): void { + this.reviewRef?.close(); + this.startReject(r); + } + + formatDateTime(value: string | null): string { + if (!value) return '—'; + const d = new Date(value); + return isNaN(d.getTime()) + ? value + : d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); + } + + approve(r: CashTransferRequest): void { + if (this.busyId) return; + this.busyId = r.id; + this.service.approveCashTransferRequest(r.id).subscribe({ + next: (res: any) => { + this.busyId = null; + this.reviewRef?.close(); + this.snackBar.open( + res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier', + 'Close', + { duration: 6000 } + ); + this.load(); + }, + error: (err) => { + this.busyId = null; + this.fail(err, 'Could not approve'); + } + }); + } + + startReject(r: CashTransferRequest): void { + this.rejecting = r; + this.rejectReason = ''; + this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false }); + this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null)); + } + + cancelReject(): void { + this.dialogRef?.close(); + } + + confirmReject(): void { + if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return; + const id = this.rejecting.id; + this.busyId = id; + this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({ + next: () => { + this.busyId = null; + this.dialogRef?.close(); + this.snackBar.open('Rejected', 'Close', { duration: 4000 }); + this.load(); + }, + error: (err) => { + this.busyId = null; + this.fail(err, 'Could not reject'); + } + }); + } + + private fail(err: any, fallback: string): void { + const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback; + this.snackBar.open(msg, 'Close', { duration: 8000 }); + } +} diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.html b/src/app/cashier/cash-transfer/cash-transfer.component.html index 4c09120a9..1b55ecea2 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.html +++ b/src/app/cashier/cash-transfer/cash-transfer.component.html @@ -13,7 +13,15 @@

Cash Transfer

Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).

- +
+ + +
@@ -155,7 +163,56 @@
Total to transfer: ₹{{ total | number: '1.2-2' }}
+ + + +
+
+ pending_actions +
+
My transfer requests
+
Cash leaves the vault only after a manager approves
+
+ +
+
+ @if (myRequests.length) { + + + + + + + + + + + + @for (r of myRequests; track r.id) { + + + + + + + + } + +
DateToAmountStatusApproved by
{{ r.transfer_date || '—' }}{{ r.to_party || '—' }}₹{{ r.total_amount | number: '1.2-2' }} + {{ r.status }} + @if (r.rejection_reason) { +
{{ r.rejection_reason }}
+ } +
{{ r.checker_name || '—' }}
+ } @else { +
You have not raised any cash transfer request yet.
+ } +
+
+
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.scss b/src/app/cashier/cash-transfer/cash-transfer.component.scss index 78dfe9ead..90546bcbc 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.scss +++ b/src/app/cashier/cash-transfer/cash-transfer.component.scss @@ -49,3 +49,131 @@ font-size: 12px; color: #991b1b; } + +.ct-requests { + padding: 0; + + &__title { + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + font-size: 13px; + font-weight: 700; + color: #0d1b2a; + } + + &__note { + margin-top: 2px; + font-size: 11px; + color: #64748b; + } +} + +.ct-status { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: #e2e8f0; + font-size: 11px; + font-weight: 700; + + &--PENDING { + background: #fef3c7; + color: #92400e; + } + + &--APPROVED { + background: #d1fae5; + color: #065f46; + } + + &--REJECTED { + background: #fee2e2; + color: #991b1b; + } +} + +.ct-head-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.ct-badge { + min-width: 18px; + padding: 0 6px; + border-radius: 999px; + background: #f59e0b; + color: #fff; + font-size: 11px; + font-weight: 700; + text-align: center; +} + +.ct-dlg { + font-size: 12px; + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + } + + &__ico { + flex: 0 0 32px; + height: 32px; + width: 32px; + border-radius: 9px; + background: #f0fdf4; + color: #1f7a4d; + font-size: 18px; + line-height: 32px; + text-align: center; + } + + &__titles { + flex: 1; + min-width: 0; + } + + &__title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &__x { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + width: 28px; + border: 0; + border-radius: 50%; + background: transparent; + color: #64748b; + cursor: pointer; + + &:hover { + background: #e2e8f0; + } + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + } + + &__body { + max-height: 60vh; + overflow-y: auto; + } +} diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts index d818ad5e2..ab9041425 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.ts +++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts @@ -6,9 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -39,6 +40,7 @@ interface Denom { CommonModule, FormsModule, MatIconModule, + MatDialogModule, MatSnackBarModule, MatFormFieldModule, MatSelectModule, @@ -48,6 +50,10 @@ interface Denom { export class CashTransferComponent implements OnInit { private service = inject(CashierService); private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + + @ViewChild('requestsDialog') requestsDialog!: TemplateRef; + private requestsRef: MatDialogRef | null = null; notes: Denom[] = []; coins: Denom[] = []; @@ -82,7 +88,34 @@ export class CashTransferComponent implements OnInit { vaultHasBreakdown = true; loadingVault = false; + /** The caller's own transfer requests, newest first, so a request can be followed to its approval. */ + myRequests: any[] = []; + + loadMyRequests(): void { + this.service + .getCashTransferRequests(undefined, true) + .pipe(catchError(() => of([] as any[]))) + .subscribe((rows) => (this.myRequests = rows.slice(0, 20))); + } + + /** How many of this person's requests are still waiting for a manager. */ + get pendingCount(): number { + return this.myRequests.filter((r) => r.status === 'PENDING').length; + } + + /** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */ + openRequests(): void { + this.loadMyRequests(); + this.requestsRef?.close(); + this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false }); + } + + closeRequests(): void { + this.requestsRef?.close(); + } + ngOnInit(): void { + this.loadMyRequests(); const d = businessToday(); this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service.getActiveDenominations().subscribe({ @@ -245,16 +278,11 @@ export class CashTransferComponent implements OnInit { .filter((d) => this.countFor(d) > 0) .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) })) }; - this.service.createCashTransfer(payload).subscribe({ - next: (res: any) => { - if (res?.glWarning) { - this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', { - duration: 8000, - panelClass: ['snack-warn'] - }); - } else { - this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 }); - } + this.service.createCashTransferRequest(payload).subscribe({ + next: () => { + // Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault. + this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 }); + this.loadMyRequests(); this.clear(); this.note = ''; this.fromParty = ''; diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index cfa8def18..3b792fd35 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -47,8 +47,21 @@ export class CashierService { } // ── Cash Transfer ── - createCashTransfer(data: any): Observable { - return this.http.post('/cashtransfer', data); + /** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */ + createCashTransferRequest(data: any): Observable { + return this.http.post('/cashtransfer/requests', data); + } + /** Requests by status (comma separated); mine narrows them to the ones the caller raised. */ + getCashTransferRequests(status?: string, mine = false): Observable { + let params = new HttpParams().set('mine', String(mine)); + if (status) params = params.set('status', status); + return this.http.get('/cashtransfer/requests', { params }); + } + approveCashTransferRequest(id: number): Observable { + return this.http.post(`/cashtransfer/requests/${id}/approve`, {}); + } + rejectCashTransferRequest(id: number, reason: string): Observable { + return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason }); } getCashTransfers(offset = 0, limit = 20): Observable { const params = new HttpParams().set('offset', offset).set('limit', limit); diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index deda8f0e9..ffc38955f 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -129,11 +129,16 @@
- + @for (b of branches; track b.id) { + + }
@if (row.typ === 'DD') { @@ -1584,11 +1589,16 @@
- + @for (b of branches; track b.id) { + + }
@if (row.typ === 'DD') { diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index a1ffb6bd6..413652c93 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1877,6 +1877,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ngOnInit(): void { const d = businessToday(); this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; + this.loadBranches(); this.addRow(); this.loadDenominations(); this.loadOpeningPosition(); @@ -2309,7 +2310,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // anyway, just without the extra click to get there. const row: TxnRow = { typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt', - br: 1, + br: this.defaultBranchId, trTp: '', acTyp: 'SD', acNo: '', @@ -2655,14 +2656,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005; } + /** + * The branches an entry can be posted against: the real offices of this installation. Read from the offices + * themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change. + */ + branches: { id: number; label: string }[] = []; + + private loadBranches(): void { + const own = this.authService.getCredentials(); + const ownBranch = own?.officeId + ? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }] + : []; + // The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway + // makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own + // office, which is the one branch it is offered. + const permissions = own?.permissions ?? []; + const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE'); + if (!mayReadOffices) { + this.branches = ownBranch; + return; + } + this.http + .get('/offices') + .pipe(catchError(() => of([]))) + .subscribe((list: any) => { + const offices: any[] = Array.isArray(list) ? list : []; + this.branches = + offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch; + }); + } + + private branchLabel(id: number, name: string | null | undefined): string { + return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`; + } + + /** The office the signed-in user works in, which a new entry is posted against unless they pick another. */ + get defaultBranchId(): number { + return this.authService.getCredentials()?.officeId ?? 1; + } + 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}`; + return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`; } /** diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss index ad8feebef..54431fc8a 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss @@ -16,7 +16,7 @@ $green: #10b981; $amber: #f59e0b; .ac-container { - padding: 20px 24px; + padding: 12px 20px; max-width: 1440px; margin: 0 auto; } @@ -25,40 +25,40 @@ $amber: #f59e0b; display: flex; align-items: center; justify-content: space-between; - margin-bottom: 16px; + margin-bottom: 10px; gap: 12px; flex-wrap: wrap; &__left { display: flex; align-items: center; - gap: 12px; + gap: 10px; } &__icon { - width: 44px; - height: 44px; + width: 32px; + height: 32px; display: inline-flex; align-items: center; justify-content: center; - border-radius: 12px; + border-radius: 9px; background: linear-gradient(135deg, $blue 0%, $purple 100%); color: #fff; - font-size: 18px; + font-size: 14px; box-shadow: 0 2px 8px rgb(29 78 216 / 25%); } &__title { margin: 0; - font-size: 20px; + font-size: 16px; font-weight: 800; color: $navy; line-height: 1.1; } &__sub { - margin-top: 2px; - font-size: 12.5px; + margin-top: 1px; + font-size: 11px; color: #64748b; } } @@ -68,26 +68,26 @@ $amber: #f59e0b; hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one click away instead of a scroll-and-hunt. */ .ac-tabs { - margin-bottom: 20px; + margin-bottom: 12px; background: #fff; border: 1px solid #e2e8f0; - border-radius: 14px; - padding: 6px; + border-radius: 10px; + padding: 4px; box-shadow: 0 1px 3px rgb(15 23 42 / 4%); display: flex; flex-wrap: wrap; - gap: 4px; + gap: 2px; } .ac-tab { display: inline-flex; align-items: center; - gap: 8px; - height: 38px; - padding: 0 18px; - border-radius: 10px; + gap: 6px; + height: 28px; + padding: 0 11px; + border-radius: 8px; font-family: Inter, 'DM Sans', Roboto, sans-serif; - font-size: 13px; + font-size: 11.5px; font-weight: 600; letter-spacing: 0.01em; color: #64748b; @@ -99,7 +99,7 @@ $amber: #f59e0b; cursor: pointer; &__ic { - font-size: 13px; + font-size: 11px; color: inherit; } @@ -118,7 +118,7 @@ $amber: #f59e0b; @media (width <= 768px) { .ac-tab { - padding: 0 14px; - font-size: 12px; + padding: 0 10px; + font-size: 11px; } } From ab8ed7e67e052e3ddae36214e3acaf4ab1d8cc42 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:28:27 +0530 Subject: [PATCH 03/10] feat(vouchers): voucher correction screens and a correction status on the voucher registers A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until the final approval. - Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the request and deposits separately. - Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are added to the same tab bar. - A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup) says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason. - The deposit taken off the wrong account no longer shows on its passbook or transaction list. Co-Authored-By: Claude Sonnet 5.5 --- src/app/cashier/cashier-routing.module.ts | 13 + .../cashier/day-book/day-book.component.html | 15 +- .../cashier/day-book/day-book.component.ts | 9 +- .../cashier/my-work/my-work.component.html | 13 + src/app/cashier/my-work/my-work.component.ts | 9 +- .../voucher-detail-dialog.component.ts | 18 +- ...oucher-correction-approvals.component.html | 170 +++++ ...oucher-correction-approvals.component.scss | 485 ++++++++++++++ .../voucher-correction-approvals.component.ts | 192 ++++++ src/app/cashier/voucher-correction.service.ts | 120 ++++ .../voucher-correction.component.html | 446 +++++++++++++ .../voucher-correction.component.scss | 599 ++++++++++++++++++ .../voucher-correction.component.ts | 307 +++++++++ .../voucher-report.component.html | 13 + .../voucher-report.component.scss | 5 + .../voucher-report.component.ts | 9 +- .../voucher-status.component.ts | 137 ++++ src/app/core/shell/shell.component.html | 18 + .../savings-account-view.resolver.ts | 23 +- .../checker-inbox-and-tasks.component.html | 18 + src/app/tasks/tasks-routing.module.ts | 16 + 21 files changed, 2626 insertions(+), 9 deletions(-) create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts create mode 100644 src/app/cashier/voucher-correction.service.ts create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.html create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.scss create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.ts create mode 100644 src/app/cashier/voucher-status/voucher-status.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 76947f25b..f4c7d8401 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -83,6 +83,13 @@ const routes: Routes = [ ), data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' } }, + { + // The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here. + path: 'voucher-correction', + loadComponent: () => + import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent), + data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' } + }, { path: 'closure-requests', loadComponent: () => @@ -291,6 +298,12 @@ const routes: Routes = [ component: ReconciliationComponent, data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' } }, + { + path: 'voucher-correction', + loadComponent: () => + import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent), + data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' } + }, { path: 'cash-history', component: CashHistoryComponent, diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html index 5c5215145..116009136 100644 --- a/src/app/cashier/day-book/day-book.component.html +++ b/src/app/cashier/day-book/day-book.component.html @@ -202,6 +202,7 @@ Amount Payment Status + Correction By Approved By Time @@ -231,6 +232,18 @@ {{ r.status }} + + + + @if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') { + — + } + {{ r.handledBy ?? '—' }} {{ r.approvedBy ?? '—' }} @@ -256,7 +269,7 @@ — } - + diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index 05fb8e475..59bac8801 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -14,6 +14,7 @@ import { Router } from '@angular/router'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; +import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component'; /** * Which part of the day's work a row came from. Set by the server, one per source register. @@ -26,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM'; interface DayBookRow { voucherNo: string | null; + /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */ + voucherStatus?: VoucherStatus; + revokedByVoucherNo?: string | null; + replacesVoucherNo?: string | null; + correctionNote?: string | null; section: DayBookSection; side: 'CR' | 'DR'; kind: string; @@ -58,7 +64,8 @@ interface DayBookRow { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ] }) export class DayBookComponent implements OnInit { diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html index e4c96e7db..e62edb863 100644 --- a/src/app/cashier/my-work/my-work.component.html +++ b/src/app/cashier/my-work/my-work.component.html @@ -146,6 +146,7 @@ Amount My part Status + Correction Time @@ -203,6 +204,18 @@ {{ statusLabel(r.verificationStatus) }} + + + + @if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') { + — + } + {{ r.createdAt | date: 'HH:mm' }} } diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts index 8c0cf4bd4..28ef84cc3 100644 --- a/src/app/cashier/my-work/my-work.component.ts +++ b/src/app/cashier/my-work/my-work.component.ts @@ -15,6 +15,7 @@ import { catchError, finalize, of } from 'rxjs'; import { CashierService } from '../cashier.service'; import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component'; import { businessToday } from 'app/core/utils/business-today'; +import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component'; /** What the signed-in user's part in a voucher was. */ export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; @@ -24,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV'; interface MyWorkRow { voucherNo: string; + /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */ + voucherStatus?: VoucherStatus; + revokedByVoucherNo?: string | null; + replacesVoucherNo?: string | null; + correctionNote?: string | null; side: VoucherSide | null; voucherType: string | null; amount: number | null; @@ -76,7 +82,8 @@ interface MyWorkReport { CommonModule, FormsModule, MatIconModule, - MatDialogModule + MatDialogModule, + VoucherStatusComponent ] }) export class MyWorkComponent implements OnInit { diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts index 93137c3c7..a50339751 100644 --- a/src/app/cashier/my-work/voucher-detail-dialog.component.ts +++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts @@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component'; export interface VoucherDetailDialogData { /** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog @@ -18,6 +19,10 @@ export interface VoucherDetailDialogData { * duplicated inside every dialog that could ever show a voucher. */ detail: { voucherNo: string; + voucherStatus?: VoucherStatus; + revokedByVoucherNo?: string | null; + replacesVoucherNo?: string | null; + correctionNote?: string | null; voucherType: string | null; amount: number | null; branchName: string | null; @@ -58,7 +63,8 @@ export interface VoucherDetailDialogData { CommonModule, MatDialogModule, MatButtonModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ], template: `
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
receipt_long
-

{{ detail.voucherNo }}

+

+ {{ detail.voucherNo }} + +

{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}

diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html new file mode 100644 index 000000000..f65551611 --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html @@ -0,0 +1,170 @@ + + +
+ +
+
+ undo +
+
Voucher Corrections
+
+ Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval + moves money. +
+
+
+
+ {{ unpassCount }} unpass + {{ correctionCount }} correction +
+
+ + +
+ + + + + + + +
+ + + @if (loading && !loaded) { +

Loading…

+ } @else if (rows.length === 0) { +
+ inbox +

No voucher correction waiting

+

There is no unpass request or correction entry waiting for review.

+
+ } @else { +
+ + + + + + + + + + + + + + + + @for (r of rows; track r.request.id) { + + + + + + + + + + + + } + +
Voucher NoRequestPosted To (Wrong)Correct AccountAmountReasonRaised ByRaised AtAction
{{ r.request.original_voucher_no }} + {{ kindLabel(r.kind) }} + +
{{ r.request.wrong_account_no }}
+
{{ r.request.wrong_client_name || '—' }}
+
+ @if (r.kind === 'correction') { +
{{ r.request.correct_account_no }}
+
{{ r.request.correct_client_name || '—' }}
+ } @else { + — + } +
₹{{ r.request.amount | number: '1.2-2' }}{{ r.request.reason || '—' }}{{ r.raisedBy || '—' }}{{ formatDateTime(r.raisedAt) }} + + +
+
+ } +
+ + + + @if (rejecting) { +
+
+ block +
+
+ Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }} +
+
{{ rejecting.request.original_voucher_no }}
+
+ +
+
+ + +
+
+ + +
+
+ } +
diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss new file mode 100644 index 000000000..c8fbb6097 --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss @@ -0,0 +1,485 @@ +/** + * 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/. + */ + +// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table. +:host { + display: block; +} + +.vca-tab { + padding: 0; +} + +.vca-action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 16px; + background: #fff; + border-bottom: 1px solid #e2e8f0; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 22px; + width: 22px; + height: 22px; + color: #0f4f3c; + } + + &__title { + font-size: 13px; + font-weight: 700; + color: #0d1b2a; + } + + &__sub { + max-width: 640px; + margin-top: 1px; + font-size: 11px; + color: #64748b; + } +} + +.vca-counts { + display: flex; + gap: 8px; +} + +.vca-count { + padding: 2px 10px; + border-radius: 999px; + background: #f1f5f9; + font-size: 11px; + color: #475569; + + strong { + color: #0f4f3c; + } +} + +.vca-filter-bar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 6px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.vca-fb-sep { + flex-shrink: 0; + width: 1px; + height: 22px; + margin: 0 4px; + background: #e2e8f0; +} + +.vca-search-box { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + min-width: 240px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + + mat-icon { + flex-shrink: 0; + font-size: 17px; + width: 17px; + height: 17px; + color: #94a3b8; + } + + &:hover { + border-color: #cbd5e1; + } + + &:focus-within { + border-color: #0f4f3c; + box-shadow: 0 0 0 3px rgb(15 79 60 / 8%); + } + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-family: inherit; + font-size: 12px; + color: #0d1b2a; + + &::placeholder { + color: #94a3b8; + } + } + + &__clear { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 50%; + background: none; + color: #94a3b8; + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + color: inherit; + } + + &:hover { + color: #dc2626; + background: #fef2f2; + } + } +} + +.vca-filter-select { + height: 30px; + min-width: 140px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 7px; + background: #fff; + font-family: inherit; + font-size: 12px; + color: #0d1b2a; + cursor: pointer; + + &:focus { + outline: none; + border-color: #0f4f3c; + } +} + +// ── Buttons ───────────────────────────────────────────────────────────────── +.vca-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: 7px; + font-family: inherit; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &--refresh { + height: 30px; + width: 30px; + justify-content: center; + padding: 0; + margin-left: auto; + border-color: #e2e8f0; + background: #fff; + color: #475569; + } + + &--approve { + background: #0f4f3c; + color: #fff; + + &:hover:not(:disabled) { + background: #0a3d2e; + } + } + + &--reject { + border-color: #fecaca; + background: #fff; + color: #dc2626; + + &:hover:not(:disabled) { + background: #fef2f2; + } + } + + &--reject-solid { + background: #dc2626; + color: #fff; + } + + &--ghost { + border-color: #e2e8f0; + background: #fff; + color: #475569; + } +} + +// ── Table ─────────────────────────────────────────────────────────────────── +.vca-table-wrap { + overflow-x: auto; + background: #fff; +} + +.vca-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + + th { + padding: 7px 10px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.06em; + text-align: left; + text-transform: uppercase; + color: #94a3b8; + white-space: nowrap; + } + + td { + padding: 5px 10px; + border-bottom: 1px solid #eef2f7; + vertical-align: middle; + color: #0d1b2a; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .vca-th-action { + text-align: right; + } +} + +.vca-row:hover td { + background: #f8fafc; +} + +.vca-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.02em; +} + +.vca-voucher { + font-weight: 700; + color: #0f4f3c; +} + +.vca-strong { + font-weight: 700; +} + +.vca-sub { + margin-top: 1px; + font-size: 11px; + color: #64748b; +} + +.vca-none { + color: #cbd5e1; +} + +.vca-amount { + font-weight: 700; +} + +.vca-reason { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vca-dt { + white-space: nowrap; + color: #475569; +} + +.vca-kind { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + + &--unpass { + background: #fef3c7; + color: #92400e; + } + + &--correction { + background: #dbeafe; + color: #1e40af; + } +} + +.vca-action-cell { + display: flex; + justify-content: flex-end; + gap: 6px; + white-space: nowrap; +} + +// ── Empty ─────────────────────────────────────────────────────────────────── +.vca-empty { + padding: 28px 16px; + text-align: center; + color: #94a3b8; + + &__icon { + display: block; + margin: 0 auto 6px; + font-size: 30px; + width: 30px; + height: 30px; + opacity: 0.6; + } + + &__title { + margin: 0; + font-size: 12px; + font-weight: 700; + color: #64748b; + } + + &__text { + margin: 3px 0 0; + font-size: 11px; + } +} + +// ── Reject popup ──────────────────────────────────────────────────────────── +.vca-dlg { + font-size: 12px; + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + } + + &__ico { + flex: 0 0 32px; + height: 32px; + width: 32px; + border-radius: 9px; + background: #fef2f2; + color: #dc2626; + font-size: 18px; + line-height: 32px; + text-align: center; + } + + &__titles { + flex: 1; + min-width: 0; + } + + &__title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &__x { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + width: 28px; + border: 0; + border-radius: 50%; + background: transparent; + color: #64748b; + cursor: pointer; + + &:hover { + background: #e2e8f0; + } + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + } + + &__body { + padding: 12px 14px; + + textarea { + width: 100%; + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font: inherit; + font-size: 12px; + resize: vertical; + + &:focus { + outline: 0; + border-color: #0f4f3c; + box-shadow: 0 0 0 3px rgb(15 79 60 / 8%); + } + } + } + + &__foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + } +} + +.vca-label { + display: block; + margin-bottom: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #94a3b8; +} diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts new file mode 100644 index 000000000..56fe1650c --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts @@ -0,0 +1,192 @@ +/** + * 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, TemplateRef, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service'; + +type Kind = 'unpass' | 'correction'; +type KindFilter = 'ALL' | Kind; + +/** One row of the approvals table: a request, and which of the two steps it is waiting at. */ +interface ApprovalRow { + kind: Kind; + request: VoucherCorrection; + raisedBy: string | null; + raisedAt: string | null; +} + +/** + * Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a + * voucher, and later the correction entry the cashier made. Only the second approval moves money. + */ +@Component({ + selector: 'mifosx-voucher-correction-approvals', + templateUrl: './voucher-correction-approvals.component.html', + styleUrls: ['./voucher-correction-approvals.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatIconModule, + MatSnackBarModule + ] +}) +export class VoucherCorrectionApprovalsComponent implements OnInit { + private service = inject(VoucherCorrectionService); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + + @ViewChild('rejectDialog') rejectDialog!: TemplateRef; + private dialogRef: MatDialogRef | null = null; + + all: ApprovalRow[] = []; + loading = false; + loaded = false; + busyId: number | null = null; + + kindFilter: KindFilter = 'ALL'; + searchTerm = ''; + + /** The row being rejected, and the reason typed for it. */ + rejecting: ApprovalRow | null = null; + rejectReason = ''; + + readonly kindOptions: { value: KindFilter; label: string }[] = [ + { value: 'ALL', label: 'All requests' }, + { value: 'unpass', label: 'Unpass requests' }, + { value: 'correction', label: 'Correction entries' } + ]; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.service + .list('UNPASS_PENDING,CORRECTION_PENDING') + .pipe(catchError(() => of([] as VoucherCorrection[]))) + .subscribe((rows) => { + this.all = rows.map((r) => + r.status === 'UNPASS_PENDING' + ? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at } + : { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at } + ); + this.loading = false; + this.loaded = true; + }); + } + + /** The table: the kind picked, narrowed by whatever is typed in the search box. */ + get rows(): ApprovalRow[] { + const term = this.searchTerm.trim().toLowerCase(); + return this.all.filter((r) => { + if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false; + if (!term) return true; + const q = r.request; + return [ + q.original_voucher_no, + q.wrong_account_no, + q.wrong_client_name, + q.correct_account_no, + q.correct_client_name, + r.raisedBy + ].some((v) => (v ?? '').toLowerCase().includes(term)); + }); + } + + get unpassCount(): number { + return this.all.filter((r) => r.kind === 'unpass').length; + } + + get correctionCount(): number { + return this.all.filter((r) => r.kind === 'correction').length; + } + + kindLabel(kind: Kind): string { + return kind === 'unpass' ? 'Unpass' : 'Correction'; + } + + formatDateTime(value: string | null): string { + if (!value) return '—'; + const d = new Date(value); + return isNaN(d.getTime()) + ? value + : d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); + } + + approve(row: ApprovalRow): void { + if (this.busyId) return; + const id = row.request.id; + this.busyId = id; + const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id); + call.subscribe({ + next: (res: any) => { + this.busyId = null; + this.snackBar.open( + row.kind === 'unpass' + ? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account` + : `Corrected. New voucher ${res?.newVoucherNo ?? ''}`, + 'Close', + { duration: 6000 } + ); + this.load(); + }, + error: (err) => { + this.busyId = null; + this.fail(err, 'Could not approve'); + } + }); + } + + startReject(row: ApprovalRow): void { + this.rejecting = row; + this.rejectReason = ''; + this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false }); + this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null)); + } + + cancelReject(): void { + this.dialogRef?.close(); + } + + confirmReject(): void { + if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return; + const row = this.rejecting; + const id = row.request.id; + this.busyId = id; + const reason = this.rejectReason.trim(); + const call = + row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason); + call.subscribe({ + next: () => { + this.busyId = null; + this.dialogRef?.close(); + this.snackBar.open('Rejected', 'Close', { duration: 4000 }); + this.load(); + }, + error: (err) => { + this.busyId = null; + this.fail(err, 'Could not reject'); + } + }); + } + + private fail(err: any, fallback: string): void { + const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback; + this.snackBar.open(msg, 'Close', { duration: 8000 }); + } +} diff --git a/src/app/cashier/voucher-correction.service.ts b/src/app/cashier/voucher-correction.service.ts new file mode 100644 index 000000000..d252a561a --- /dev/null +++ b/src/app/cashier/voucher-correction.service.ts @@ -0,0 +1,120 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** One row of a voucher correction, as the server keeps it (snake_case columns). */ +export interface VoucherCorrection { + id: number; + original_voucher_no: string; + original_txn_id: number; + wrong_savings_account_id: number; + wrong_account_no: string; + wrong_client_name: string | null; + amount: number; + txn_date: string | null; + status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED'; + reason: string | null; + rejection_reason: string | null; + unpass_by_name: string | null; + unpass_at: string | null; + unpass_approved_by_name: string | null; + correct_account_no: string | null; + correct_client_name: string | null; + correction_by_name: string | null; + correction_at: string | null; + correction_approved_by_name: string | null; + new_voucher_no: string | null; +} + +/** A cash deposit voucher of today that can still be unpassed. */ +export interface EligibleVoucher { + voucherNo: string; + amount: number; + createdAt: string; + cashier: string; + txnId: number; + savingsAccountId: number; + accountNo: string; + clientName: string | null; + paymentType: string; +} + +/** What the server knows about a savings account typed in as the correct one. */ +export interface AccountLookup { + id: number; + accountNo: string; + clientId: number | null; + clientName: string | null; + balance: number; + currencyCode: string; + canReceive: boolean; +} + +/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */ +@Injectable({ providedIn: 'root' }) +export class VoucherCorrectionService { + private http = inject(HttpClient); + private readonly base = '/vouchers/corrections'; + + eligible(search?: string): Observable { + let params = new HttpParams(); + if (search) params = params.set('search', search); + return this.http.get(`${this.base}/eligible`, { params }); + } + + unpass(voucherNo: string, reason: string): Observable { + return this.http.post(`${this.base}/unpass`, { voucherNo, reason }); + } + + list(status?: string, mine = false): Observable { + let params = new HttpParams().set('mine', String(mine)); + if (status) params = params.set('status', status); + return this.http.get(this.base, { params }); + } + + get(id: number): Observable { + return this.http.get(`${this.base}/${id}`); + } + + accountLookup(accountNo: string): Observable { + return this.http.get(`${this.base}/account-lookup`, { params: { accountNo } }); + } + + submitCorrection(id: number, correctAccountNo: string): Observable { + return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo }); + } + + /** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */ + close(id: number, reason: string): Observable { + return this.http.post(`${this.base}/${id}/close`, { reason }); + } + + approveUnpass(id: number): Observable { + return this.http.post(`${this.base}/${id}/unpass/approve`, {}); + } + + rejectUnpass(id: number, reason: string): Observable { + return this.http.post(`${this.base}/${id}/unpass/reject`, { reason }); + } + + approveCorrection(id: number): Observable { + return this.http.post(`${this.base}/${id}/correction/approve`, {}); + } + + rejectCorrection(id: number, reason: string): Observable { + return this.http.post(`${this.base}/${id}/correction/reject`, { reason }); + } + + /** Ids of the transactions of this account that a completed correction removed. */ + voidedTransactions(savingsAccountId: number): Observable { + return this.http.get(`${this.base}/voided-transactions`, { params: { savingsAccountId } }); + } +} diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.html b/src/app/cashier/voucher-correction/voucher-correction.component.html new file mode 100644 index 000000000..6c113b6d2 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.html @@ -0,0 +1,446 @@ + + +
+
+
+
CASHIER · VOUCHER CORRECTION
+

Voucher Correction

+

+ Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final + approval. +

+
+
+ + +
    +
  1. + 1 + Unpass the voucher +
  2. +
  3. + 2 + Manager approves +
  4. +
  5. + 3 + Enter the correct account +
  6. +
  7. + 4 + Manager approves, deposit moves +
  8. +
+ + + + + @if (tab === 'today') { +
+ + + +
+ +
+ + + + + + + + + + + + + + @for (v of vouchers; track v.voucherNo) { + + + + + + + + + + } + @if (!vouchers.length) { + + + + } + +
VoucherAccountCustomerModeCashierAmount
+ {{ v.voucherNo }} + {{ v.accountNo }}{{ v.clientName || '—' }}{{ v.paymentType }}{{ v.cashier }}₹{{ v.amount | number: '1.2-2' }} + +
+ {{ loadingVouchers ? 'hourglass_empty' : 'inbox' }} +
{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}
+
+
+ } + + + @if (tab === 'unpassed') { +
+ + + + + + + + + + + + + @for (u of unpassed; track u.id) { + + + + + + + + + } + @if (!unpassed.length) { + + + + } + +
VoucherPosted toCustomerReasonAmount
+ {{ u.original_voucher_no }} + {{ u.wrong_account_no }}{{ u.wrong_client_name || '—' }}{{ u.reason }}₹{{ u.amount | number: '1.2-2' }} + +
+ {{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }} +
+ {{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }} +
+
+
+ } + + + @if (tab === 'mine') { +
+ + + + + + + + + + + + + @for (m of mine; track m.id) { + + + + + + + + + } + @if (!mine.length) { + + + + } + +
VoucherPosted toCorrected toAmountStatusNew voucher
+ {{ m.original_voucher_no }} + {{ m.wrong_account_no }}{{ m.correct_account_no || '—' }}₹{{ m.amount | number: '1.2-2' }} + {{ statusLabel[m.status] }} + @if (m.rejection_reason) { +
{{ m.rejection_reason }}
+ } +
{{ m.new_voucher_no || '—' }}
+ {{ loadingMine ? 'hourglass_empty' : 'history' }} +
{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}
+
+
+ } +
+ + + + @if (selectedVoucher) { +
+
+ undo +
+
Unpass voucher
+
{{ selectedVoucher.voucherNo }}
+
+ +
+ +
+
+ Deposit amount + ₹{{ selectedVoucher.amount | number: '1.2-2' }} +
+ +
+
+
Voucher
+
{{ selectedVoucher.voucherNo }}
+
+
+
Created
+
{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}
+
+
+
Cashier
+
{{ selectedVoucher.cashier }}
+
+
+
Mode
+
{{ selectedVoucher.paymentType }}
+
+
+
Account
+
{{ selectedVoucher.accountNo }}
+
+
+
Customer
+
{{ selectedVoucher.clientName || '—' }}
+
+
+
Account balance now
+
+ @if (voucherAccount) { + ₹{{ voucherAccount.balance | number: '1.2-2' }} + } @else { + — + } +
+
+
+
Account status
+
+ @if (voucherAccount) { + {{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }} + } @else { + — + } +
+
+
+ + + + +
+ info + A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the + deposit stays on {{ selectedVoucher.accountNo }} till then. +
+
+ +
+ + +
+
+ } +
+ + + + @if (active) { +
+
+ edit_note +
+
Correction entry
+
{{ active.original_voucher_no }}
+
+ +
+ +
+
+ Amount to move + ₹{{ active.amount | number: '1.2-2' }} +
+ + +
+
+
Transaction date
+
{{ active.txn_date || '—' }}
+
+
+
Mode
+
Cash
+
+
+
Unpassed by
+
{{ active.unpass_by_name || '—' }}
+
+
+
Approved by
+
{{ active.unpass_approved_by_name || '—' }}
+
+
+
Reason
+
{{ active.reason }}
+
+
+ +
+ +
+
Posted to (wrong)
+
{{ active.wrong_account_no }}
+
{{ active.wrong_client_name || '—' }}
+
+ Balance: + @if (wrongAccount) { + ₹{{ wrongAccount.balance | number: '1.2-2' }} + @if (wrongHasBalance) { + check_circle covers the deposit + } @else { + cancel does not cover ₹{{ active.amount | number: '1.2-2' }} + } + } @else { + — + } +
+
+ + arrow_forward + + +
+
Correct account
+ + @if (lookingUp) { +
Looking up…
+ } + @if (correctAccount) { +
{{ correctAccount.clientName || '—' }}
+
+ Balance: ₹{{ correctAccount.balance | number: '1.2-2' }} + @if (correctOk) { + check_circle active, can receive + } +
+ } + @if (lookupError) { +
error {{ lookupError }}
+ } +
+
+ + @if (wrongAccount && !wrongHasBalance) { +
+ warning +
+ The customer has already used this money, so it cannot be taken back from + {{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account + from your own pocket as a fresh deposit (Transaction Entry), then press Close request. +
+
+ } +
+ +
+ + + +
+
+ } +
diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.scss b/src/app/cashier/voucher-correction/voucher-correction.component.scss new file mode 100644 index 000000000..3242e02c5 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.scss @@ -0,0 +1,599 @@ +/** + * 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/. + */ + +// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case. +.pg { + padding: 14px 20px; + gap: 12px; +} + +.pg-title { + font-size: 20px; + margin: 2px 0; +} + +.pg-sub { + font-size: 12px; +} + +.pg-btn { + height: 30px; + padding: 0 12px; + font-size: 12px; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.pg-table { + font-size: 12px; + + th { + padding: 8px 12px; + font-size: 10px; + } + + td { + padding: 7px 12px; + } +} + +// ── Steps ─────────────────────────────────────────────────────────────────── +.vc-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.vc-step { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + font-size: 12px; + color: #64748b; + + &__no { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 20px; + height: 20px; + border-radius: 50%; + background: #e2e8f0; + font-size: 11px; + font-weight: 700; + color: #475569; + } + + &--on { + border-color: #1f7a4d; + background: #f0fdf4; + color: #0f4f3c; + + .vc-step__no { + background: #1f7a4d; + color: #fff; + } + } +} + +// ── Tabs ──────────────────────────────────────────────────────────────────── +.vc-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid #e2e8f0; + flex-wrap: wrap; +} + +.vc-tab { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 7px 12px; + margin-bottom: -1px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + font: inherit; + font-size: 12px; + font-weight: 600; + color: #64748b; + cursor: pointer; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } + + &:hover { + color: #0f4f3c; + } + + &--on { + color: #0f4f3c; + border-bottom-color: #1f7a4d; + } +} + +.vc-count { + padding: 0 7px; + border-radius: 999px; + background: #f59e0b; + color: #fff; + font-size: 10px; + font-weight: 700; +} + +// ── Search ────────────────────────────────────────────────────────────────── +.vc-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.vc-search { + display: flex; + align-items: center; + gap: 6px; + flex: 1 1 280px; + max-width: 420px; + height: 30px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + + &:focus-within { + border-color: #1f7a4d; + box-shadow: 0 0 0 3px rgb(31 122 77 / 12%); + } + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + color: #94a3b8; + } + + input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + font: inherit; + font-size: 12px; + } +} + +// ── Table ─────────────────────────────────────────────────────────────────── +.vc-table { + tbody tr { + cursor: pointer; + } + + tr.vc-row--on td { + background: #f0fdf4; + + &:first-child { + box-shadow: inset 3px 0 0 #1f7a4d; + } + } + + .vc-col-action { + width: 1%; + text-align: right; + white-space: nowrap; + } + + .vc-clip { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pg-chip { + font-size: 11px; + padding: 2px 8px; + } +} + +.vc-btn-sm { + height: 26px; + padding: 0 10px; + font-size: 11px; +} + +.vc-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.02em; +} + +.vc-empty { + padding: 24px 12px; + text-align: center; + color: #94a3b8; + font-size: 12px; + + mat-icon { + display: block; + margin: 0 auto 4px; + font-size: 28px; + height: 28px; + width: 28px; + opacity: 0.6; + } +} + +// ── Popups ────────────────────────────────────────────────────────────────── +.vc-dlg { + display: flex; + flex-direction: column; + max-height: 86vh; + font-size: 12px; + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + } + + &__ico { + flex: 0 0 32px; + height: 32px; + width: 32px; + border-radius: 9px; + background: #f0fdf4; + color: #1f7a4d; + font-size: 18px; + line-height: 32px; + text-align: center; + } + + &__titles { + flex: 1; + min-width: 0; + } + + &__title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &__x { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + width: 28px; + border: 0; + border-radius: 50%; + background: transparent; + color: #64748b; + cursor: pointer; + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + + &:hover { + background: #e2e8f0; + } + } + + &__body { + padding: 12px 14px; + overflow-y: auto; + } + + &__amount { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 10px; + padding: 8px 12px; + border-radius: 10px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + color: #065f46; + font-size: 12px; + font-weight: 600; + + strong { + font-size: 18px; + font-weight: 800; + font-variant-numeric: tabular-nums; + } + } + + &__foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + } +} + +.vc-warn { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 10px; + padding: 8px 12px; + border-radius: 8px; + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; + font-size: 11px; + + mat-icon { + flex: 0 0 18px; + height: 18px; + width: 18px; + font-size: 18px; + } +} + +.vc-dlg__close-req { + margin-right: auto; + color: #b45309; +} + +.vc-label { + display: block; + margin-bottom: 3px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #94a3b8; +} + +.vc-reason { + width: 100%; + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font: inherit; + font-size: 12px; + resize: vertical; + + &:focus { + outline: 0; + border-color: #1f7a4d; + box-shadow: 0 0 0 3px rgb(31 122 77 / 12%); + } +} + +.vc-info { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 8px; + padding: 7px 10px; + border-radius: 8px; + background: #eff6ff; + color: #1e40af; + font-size: 11px; + + mat-icon { + flex: 0 0 16px; + height: 16px; + width: 16px; + font-size: 16px; + } +} + +.vc-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 6px; + margin-bottom: 10px; +} + +.vc-cell { + padding: 6px 10px; + border-radius: 8px; + background: #f8fafc; + border: 1px solid #eef2f7; +} + +.vc-cell--wide { + grid-column: 1 / -1; +} + +.vc-value { + font-size: 12px; + font-weight: 600; + color: #0f172a; + word-break: break-word; +} + +// ── Wrong → Correct ───────────────────────────────────────────────────────── +.vc-compare { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; + gap: 8px; + + @media (width <= 640px) { + grid-template-columns: 1fr; + } +} + +.vc-arrow { + align-self: center; + color: #94a3b8; + + @media (width <= 640px) { + transform: rotate(90deg); + justify-self: center; + } +} + +.vc-acct { + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + + &--wrong { + border-color: #fecaca; + background: #fef2f2; + } + + &--ok { + border-color: #a7f3d0; + background: #ecfdf5; + } + + &__tag { + margin-bottom: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; + } + + &__no { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__input { + width: 100%; + height: 32px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + font-size: 13px; + font-weight: 600; + + &:focus { + outline: 0; + border-color: #1f7a4d; + box-shadow: 0 0 0 3px rgb(31 122 77 / 12%); + } + } + + &__name { + margin-top: 4px; + font-size: 12px; + font-weight: 600; + color: #1e293b; + } + + &__bal { + margin-top: 3px; + font-size: 11px; + color: #475569; + } + + &__err { + margin-top: 6px; + } +} + +.vc-ok, +.vc-bad { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: 6px; + font-weight: 700; + + mat-icon { + font-size: 14px; + height: 14px; + width: 14px; + } +} + +.vc-ok { + color: #059669; +} + +.vc-bad { + color: #dc2626; +} + +.vc-note { + margin-top: 3px; + font-size: 11px; + color: #64748b; +} + +// ── Status ────────────────────────────────────────────────────────────────── +.vc-status { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: #e2e8f0; + font-size: 11px; + font-weight: 700; + + &--UNPASS_PENDING, + &--CORRECTION_PENDING { + background: #fef3c7; + color: #92400e; + } + + &--UNPASSED { + background: #dbeafe; + color: #1e40af; + } + + &--COMPLETED { + background: #d1fae5; + color: #065f46; + } + + &--CLOSED { + background: #f1f5f9; + color: #475569; + } + + &--UNPASS_REJECTED { + background: #fee2e2; + color: #991b1b; + } +} diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.ts b/src/app/cashier/voucher-correction/voucher-correction.component.ts new file mode 100644 index 000000000..db114d5c2 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.ts @@ -0,0 +1,307 @@ +/** + * 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, TemplateRef, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { + AccountLookup, + EligibleVoucher, + VoucherCorrection, + VoucherCorrectionService +} from '../voucher-correction.service'; + +type Tab = 'today' | 'unpassed' | 'mine'; + +/** + * Cashier side of correcting a cash deposit that went to the wrong account. + * + * Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves). + * Unpassed: open the approved voucher, whose details are fixed, change only the account number and save + * (needs a manager approval again; only then do the accounts change). + */ +@Component({ + selector: 'mifosx-voucher-correction', + templateUrl: './voucher-correction.component.html', + styleUrls: [ + '../cashier-shared.scss', + './voucher-correction.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatDialogModule, + MatSnackBarModule + ] +}) +export class VoucherCorrectionComponent implements OnInit { + private service = inject(VoucherCorrectionService); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + private auth = inject(AuthenticationService); + + /** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */ + readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS'); + readonly mayCorrect = this.auth.isCashierUser(); + + @ViewChild('unpassDialog') unpassDialog!: TemplateRef; + @ViewChild('correctionDialog') correctionDialog!: TemplateRef; + private dialogRef: MatDialogRef | null = null; + + tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine'; + + // Today vouchers + search = ''; + vouchers: EligibleVoucher[] = []; + loadingVouchers = false; + selectedVoucher: EligibleVoucher | null = null; + /** The account the voucher was posted to, for the balance and status shown in the popup. */ + voucherAccount: AccountLookup | null = null; + unpassReason = ''; + sending = false; + + // Unpassed vouchers waiting for the correct account + unpassed: VoucherCorrection[] = []; + loadingUnpassed = false; + active: VoucherCorrection | null = null; + wrongAccount: AccountLookup | null = null; + correctAccountNo = ''; + correctAccount: AccountLookup | null = null; + lookupError = ''; + lookingUp = false; + + // The cashier own requests + mine: VoucherCorrection[] = []; + loadingMine = false; + + readonly statusLabel: Record = { + UNPASS_PENDING: 'Unpass awaiting approval', + UNPASSED: 'Unpassed — enter correct account', + UNPASS_REJECTED: 'Unpass rejected', + CORRECTION_PENDING: 'Correction awaiting approval', + COMPLETED: 'Corrected', + CLOSED: 'Closed — cashier deposits separately' + }; + + ngOnInit(): void { + if (this.mayUnpass) this.loadVouchers(); + if (this.mayCorrect) this.loadUnpassed(); + if (this.tab === 'mine') this.loadMine(); + } + + setTab(tab: Tab): void { + this.tab = tab; + if (tab === 'today') this.loadVouchers(); + if (tab === 'unpassed') this.loadUnpassed(); + if (tab === 'mine') this.loadMine(); + } + + // ── Today vouchers ───────────────────────────────────────────────────────── + + loadVouchers(): void { + this.loadingVouchers = true; + this.service + .eligible(this.search.trim()) + .pipe(catchError(() => of([] as EligibleVoucher[]))) + .subscribe((rows) => { + this.vouchers = rows; + this.loadingVouchers = false; + if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) { + this.selectedVoucher = null; + } + }); + } + + pick(v: EligibleVoucher): void { + this.selectedVoucher = v; + this.unpassReason = ''; + this.voucherAccount = null; + this.service + .accountLookup(v.accountNo) + .pipe(catchError(() => of(null))) + .subscribe((a) => (this.voucherAccount = a)); + this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null)); + } + + closeUnpass(): void { + this.dialogRef?.close(); + } + + get canUnpass(): boolean { + return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending; + } + + unpass(): void { + if (!this.selectedVoucher || !this.canUnpass) return; + this.sending = true; + this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({ + next: () => { + this.sending = false; + this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', { + duration: 4000 + }); + this.dialogRef?.close(); + this.unpassReason = ''; + this.loadVouchers(); + }, + error: (err) => { + this.sending = false; + this.fail(err, 'Could not unpass the voucher'); + } + }); + } + + // ── Unpassed vouchers: enter the correct account ─────────────────────────── + + loadUnpassed(): void { + this.loadingUnpassed = true; + this.service + .list('UNPASSED') + .pipe(catchError(() => of([] as VoucherCorrection[]))) + .subscribe((rows) => { + this.unpassed = rows; + this.loadingUnpassed = false; + }); + } + + open(row: VoucherCorrection): void { + this.active = row; + this.correctAccountNo = ''; + this.correctAccount = null; + this.lookupError = ''; + this.wrongAccount = null; + // The account the deposit went to: its balance is what the "can this be taken back" check reads. + this.service + .accountLookup(row.wrong_account_no) + .pipe(catchError(() => of(null))) + .subscribe((a) => (this.wrongAccount = a)); + this.showDialog(this.correctionDialog, () => (this.active = null)); + } + + closeActive(): void { + this.dialogRef?.close(); + } + + onAccountInput(value: string): void { + this.correctAccountNo = value; + this.correctAccount = null; + this.lookupError = ''; + // Same rule as the Transaction Entry screen: searches on its own once six characters are typed. + if (value.trim().length >= 6) this.lookup(); + } + + lookup(): void { + const accountNo = this.correctAccountNo.trim(); + if (!accountNo || this.lookingUp) return; + this.lookingUp = true; + this.service.accountLookup(accountNo).subscribe({ + next: (a) => { + this.lookingUp = false; + // A late answer for a number no longer typed is dropped. + if (accountNo !== this.correctAccountNo.trim()) return; + this.correctAccount = a; + if (this.active && a.id === this.active.wrong_savings_account_id) { + this.lookupError = 'This is the account the deposit was already posted to.'; + } else if (!a.canReceive) { + this.lookupError = 'This account is not active, so it cannot receive the deposit.'; + } + }, + error: (err) => { + this.lookingUp = false; + this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.'; + } + }); + } + + /** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */ + get wrongHasBalance(): boolean { + return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount; + } + + get correctOk(): boolean { + return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError; + } + + get canSave(): boolean { + return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending; + } + + save(): void { + if (!this.active || !this.canSave) return; + this.sending = true; + this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({ + next: () => { + this.sending = false; + this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 }); + this.dialogRef?.close(); + this.loadUnpassed(); + }, + error: (err) => { + this.sending = false; + this.fail(err, 'Could not save the correction'); + } + }); + } + + /** + * Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier + * puts the amount into the right account from their own pocket as a fresh deposit instead. + */ + closeRequest(): void { + if (!this.active || this.sending) return; + const reason = this.wrongHasBalance + ? 'Closed by the cashier' + : 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately'; + this.sending = true; + this.service.close(this.active.id, reason).subscribe({ + next: () => { + this.sending = false; + this.snackBar.open('Request closed', 'Close', { duration: 4000 }); + this.dialogRef?.close(); + this.loadUnpassed(); + }, + error: (err) => { + this.sending = false; + this.fail(err, 'Could not close the request'); + } + }); + } + + // ── My requests ──────────────────────────────────────────────────────────── + + loadMine(): void { + this.loadingMine = true; + this.service + .list(undefined, true) + .pipe(catchError(() => of([] as VoucherCorrection[]))) + .subscribe((rows) => { + this.mine = rows; + this.loadingMine = false; + }); + } + + /** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */ + private showDialog(template: TemplateRef, onClose: () => void): void { + this.dialogRef?.close(); + this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false }); + this.dialogRef.afterClosed().subscribe(onClose); + } + + private fail(err: any, fallback: string): void { + const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback; + this.snackBar.open(msg, 'Close', { duration: 6000 }); + } +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html index 73ac94478..592554b20 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.html +++ b/src/app/cashier/voucher-report/voucher-report.component.html @@ -169,6 +169,7 @@ Debit Credit Amount + Correction User + + + @if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') { + — + } + {{ row.username || '—' }} @if (systemOnly) { diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss index 1fe7a3b97..d7e7cc51e 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.scss +++ b/src/app/cashier/voucher-report/voucher-report.component.scss @@ -92,3 +92,8 @@ text-transform: uppercase; color: #7c3aed; } + +.vr-active { + font-size: 11px; + color: #94a3b8; +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts index d6c148d91..3d04bd8a6 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.ts +++ b/src/app/cashier/voucher-report/voucher-report.component.ts @@ -14,6 +14,7 @@ import { MatIconModule } from '@angular/material/icon'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; +import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component'; /** * Which side of the register a screen shows. @@ -26,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL'; /** One voucher as the register shows it: the number, the two sides, and who raised it. */ interface VoucherRow { voucherNo: string; + /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */ + voucherStatus?: VoucherStatus; + revokedByVoucherNo?: string | null; + replacesVoucherNo?: string | null; + correctionNote?: string | null; voucherType: string | null; amount: number | null; createdAt: string | null; @@ -82,7 +88,8 @@ interface VoucherReport { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ] }) export class VoucherReportComponent implements OnInit { diff --git a/src/app/cashier/voucher-status/voucher-status.component.ts b/src/app/cashier/voucher-status/voucher-status.component.ts new file mode 100644 index 000000000..a436de861 --- /dev/null +++ b/src/app/cashier/voucher-status/voucher-status.component.ts @@ -0,0 +1,137 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +/** Where a voucher stands after a voucher correction. */ +export type VoucherStatus = + | 'ACTIVE' + | 'REVOKED' + | 'UNPASS_PENDING' + | 'UNPASSED' + | 'CORRECTION_PENDING' + | 'CORRECTED' + | 'CLOSED' + | null + | undefined; + +/** + * A small badge saying whether a voucher was revoked, is under correction, or replaced another one. + * Nothing is shown for an ordinary (active) voucher, so the registers read as before. + */ +@Component({ + selector: 'mifosx-voucher-status', + standalone: true, + imports: [CommonModule], + template: ` + @if (label) { + {{ label }} + @if (note) { +
{{ note }}
+ } + } + `, + styles: [ + ` + .vs { + display: inline-block; + margin-left: 6px; + padding: 1px 8px; + border-radius: 999px; + background: #e2e8f0; + color: #334155; + font-size: 10px; + font-weight: 700; + white-space: nowrap; + vertical-align: middle; + } + + .vs-note { + margin-top: 2px; + max-width: 280px; + font-size: 11px; + line-height: 1.3; + color: #64748b; + } + + .vs--REVOKED { + background: #fee2e2; + color: #991b1b; + } + + .vs--UNPASS_PENDING, + .vs--CORRECTION_PENDING { + background: #fef3c7; + color: #92400e; + } + + .vs--UNPASSED { + background: #dbeafe; + color: #1e40af; + } + + .vs--CLOSED { + background: #f1f5f9; + color: #475569; + } + + .vs--CORRECTED { + background: #d1fae5; + color: #065f46; + } + ` + ] +}) +export class VoucherStatusComponent { + @Input() status: VoucherStatus; + /** The voucher that replaced this one, when it is revoked. */ + @Input() revokedBy: string | null | undefined; + /** The voucher this one replaced, when it is the corrected one. */ + @Input() replaces: string | null | undefined; + /** Why: the reason the voucher was unpassed, or why the correction was closed. */ + @Input() note: string | null | undefined; + + get label(): string { + switch (this.status) { + case 'REVOKED': + return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked'; + case 'UNPASS_PENDING': + return 'Unpass pending'; + case 'UNPASSED': + return 'Unpassed'; + case 'CORRECTION_PENDING': + return 'Correction pending'; + case 'CLOSED': + return 'Closed — cashier deposits separately'; + case 'CORRECTED': + return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected'; + default: + return ''; + } + } + + get hint(): string { + switch (this.status) { + case 'REVOKED': + return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.'; + case 'UNPASS_PENDING': + return 'A manager has not yet approved unpassing this voucher.'; + case 'UNPASSED': + return 'Unpassed: waiting for the cashier to enter the correct account.'; + case 'CORRECTION_PENDING': + return 'The correct account is entered; waiting for a manager to approve.'; + case 'CLOSED': + return 'The correction was closed by the cashier; the voucher stays as it was.'; + case 'CORRECTED': + return 'This voucher was raised by a correction and replaces the earlier one.'; + default: + return ''; + } + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 420d10c4a..dab25a011 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -131,6 +131,14 @@ My Closure Requests + + + Voucher Correction + @if (clerkAccountLoaded) { diff --git a/src/app/savings/common-resolvers/savings-account-view.resolver.ts b/src/app/savings/common-resolvers/savings-account-view.resolver.ts index 264fa26ff..faf79efd1 100644 --- a/src/app/savings/common-resolvers/savings-account-view.resolver.ts +++ b/src/app/savings/common-resolvers/savings-account-view.resolver.ts @@ -11,10 +11,12 @@ import { Injectable, inject } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; /** rxjs Imports */ -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; /** Custom Services */ import { SavingsService } from '../savings.service'; +import { VoucherCorrectionService } from '../../cashier/voucher-correction.service'; /** * Savings Account data resolver. @@ -22,14 +24,29 @@ import { SavingsService } from '../savings.service'; @Injectable() export class SavingsAccountViewResolver { private savingsService = inject(SavingsService); + private voucherCorrectionService = inject(VoucherCorrectionService); /** - * Returns the Savings Account data. + * Returns the Savings Account data, without the deposits a completed voucher correction took off this account: + * the passbook and the transaction list read their rows from here, and a deposit that was posted to the wrong + * account and moved should not appear on it at all. * @param {ActivatedRouteSnapshot} route Route Snapshot * @returns {Observable} */ resolve(route: ActivatedRouteSnapshot): Observable { const savingAccountId = route.paramMap.get('savingAccountId'); - return this.savingsService.getSavingsAccountData(savingAccountId); + return this.savingsService.getSavingsAccountData(savingAccountId).pipe( + switchMap((account: any) => + this.voucherCorrectionService.voidedTransactions(Number(savingAccountId)).pipe( + catchError(() => of([] as number[])), + map((voided: number[]) => { + if (voided.length && Array.isArray(account?.transactions)) { + account.transactions = account.transactions.filter((txn: any) => !voided.includes(txn.id)); + } + return account; + }) + ) + ) + ); } } 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 65f82890f..d8b22a6c4 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 @@ -144,6 +144,24 @@ Withdrawal Requests + + + Cash Transfers + + + + Voucher Corrections + + import('../cashier/cash-transfer-approvals/cash-transfer-approvals.component').then( + (m) => m.CashTransferApprovalsComponent + ), + data: { title: 'Cash Transfers' } + }, + { + path: 'voucher-corrections', + loadComponent: () => + import('../cashier/voucher-correction-approvals/voucher-correction-approvals.component').then( + (m) => m.VoucherCorrectionApprovalsComponent + ), + data: { title: 'Voucher Corrections' } + }, { path: 'dividend-requests', component: DividendRequestsComponent, From 4d205672dfc76b0fcdfcfa292edddb04be114bcc Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 15:49:36 +0530 Subject: [PATCH 04/10] feat(approvals): live pending-count badges on every Approval Center tab and the Approvals menu The badges are pushed, not polled. ApprovalCountsService opens one Server-Sent Events stream (/approvals/counts/stream) through HttpClient so the login and tenant headers apply; the server writes a `counts` event the moment the numbers change. - Total badge on the Approvals menu, count badge on 26 Approval Center tabs (Reject Account and Terminated Account are lists, not requests: no badge). - Last numbers are kept per login in localStorage, so a page refresh shows them at once until the stream's first event makes them exact. - Stream recycles every few minutes and reconnects at once; after a failure it retries with a growing wait and reads /approvals/counts meanwhile. - NO_PROGRESS_BAR token: the background stream does not run the top progress bar. Co-Authored-By: Claude Sonnet 5.5 --- .../core/progress-bar/no-progress.token.ts | 16 ++ .../core/progress-bar/progress.interceptor.ts | 4 + src/app/core/shell/shell.component.html | 5 + src/app/core/shell/shell.component.scss | 15 ++ src/app/core/shell/shell.component.ts | 15 ++ src/app/tasks/approval-counts.service.ts | 193 ++++++++++++++++++ .../checker-inbox-and-tasks.component.html | 78 +++++++ .../checker-inbox-and-tasks.component.scss | 19 ++ .../checker-inbox-and-tasks.component.ts | 26 ++- 9 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 src/app/core/progress-bar/no-progress.token.ts create mode 100644 src/app/tasks/approval-counts.service.ts diff --git a/src/app/core/progress-bar/no-progress.token.ts b/src/app/core/progress-bar/no-progress.token.ts new file mode 100644 index 000000000..a4159c292 --- /dev/null +++ b/src/app/core/progress-bar/no-progress.token.ts @@ -0,0 +1,16 @@ +/** + * 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 { HttpContextToken } from '@angular/common/http'; + +/** + * Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }` + * on background calls the user did not ask for (such as the Approvals badge listening for changes), which would + * otherwise keep the bar running or make it flash on a screen that has not changed. + */ +export const NO_PROGRESS_BAR = new HttpContextToken(() => false); diff --git a/src/app/core/progress-bar/progress.interceptor.ts b/src/app/core/progress-bar/progress.interceptor.ts index 482b4ad04..7a09d70ff 100644 --- a/src/app/core/progress-bar/progress.interceptor.ts +++ b/src/app/core/progress-bar/progress.interceptor.ts @@ -16,6 +16,7 @@ import { finalize } from 'rxjs/operators'; /** Custom Services */ import { ProgressBarService } from './progress-bar.service'; +import { NO_PROGRESS_BAR } from './no-progress.token'; /** * Http Request interceptor to start/stop loading the progress bar. @@ -29,6 +30,9 @@ export class ProgressInterceptor implements HttpInterceptor { * and stop when a response or error is received. */ intercept(request: HttpRequest, next: HttpHandler): Observable> { + if (request.context.get(NO_PROGRESS_BAR)) { + return next.handle(request); + } this.progressBarService.increase(); return next.handle(request).pipe( finalize(() => { diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index dab25a011..f4463de65 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -465,6 +465,11 @@ *mifosxHasPermission="'MENU_TASKS'" > Approvals + @if (approvalTotal > 0) { + {{ + approvalTotal > 99 ? '99+' : approvalTotal + }} + } Cashier diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 95d3060f9..31acfac82 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -1082,3 +1082,18 @@ $gray-700: #334155; width: $bar; } } + +// How many requests are waiting under Approvals. +.ni-badge { + min-width: 16px; + height: 16px; + margin-left: 4px; + padding: 0 5px; + border-radius: 999px; + background: #f59e0b; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; +} diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts index 47a81d008..4474746ce 100644 --- a/src/app/core/shell/shell.component.ts +++ b/src/app/core/shell/shell.component.ts @@ -17,6 +17,7 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant- /** rxjs Imports */ import { Observable, Subscription } from 'rxjs'; +import { ApprovalCountsService } from '../../tasks/approval-counts.service'; import { map, filter } from 'rxjs/operators'; /** Custom Services */ @@ -68,6 +69,11 @@ export class ShellComponent implements OnInit, OnDestroy { private authService = inject(AuthenticationService); private clerkUiStateService = inject(ClerkUiStateService); private tenantMenuAccessService = inject(TenantMenuAccessService); + private approvalCounts = inject(ApprovalCountsService); + + /** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */ + approvalTotal = 0; + private approvalTotalSub?: Subscription; /** Subscription to breakpoint observer for handset. */ isHandset$: Observable = this.breakpointObserver @@ -144,6 +150,13 @@ export class ShellComponent implements OnInit, OnDestroy { * Subscribes to progress bar to update its mode. */ ngOnInit() { + // The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens. + this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => { + this.approvalTotal = total; + this.cdr.markForCheck(); + }); + this.approvalCounts.start(); + this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => { this.progressBarMode = mode; this.cdr.detectChanges(); @@ -368,6 +381,8 @@ export class ShellComponent implements OnInit, OnDestroy { * Unsubscribes from progress bar. */ ngOnDestroy() { + this.approvalTotalSub?.unsubscribe(); + this.approvalCounts.stop(); if (this.progressBar$) { this.progressBar$.unsubscribe(); } diff --git a/src/app/tasks/approval-counts.service.ts b/src/app/tasks/approval-counts.service.ts new file mode 100644 index 000000000..c28d15bc4 --- /dev/null +++ b/src/app/tasks/approval-counts.service.ts @@ -0,0 +1,193 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { HttpClient, HttpContext, HttpDownloadProgressEvent, HttpEvent, HttpEventType } from '@angular/common/http'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { AuthenticationService } from '../core/authentication/authentication.service'; +import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token'; +import { NO_PROGRESS_BAR } from '../core/progress-bar/no-progress.token'; + +/** What the server pushes: the total and, for each Approval Center tab with something waiting, its number. */ +interface ApprovalCounts { + total: number; + counts: Record; +} + +/** After a stream drops, the first wait before reconnecting; it doubles up to the cap. */ +const RETRY_MS = 2_000; +const RETRY_MAX_MS = 60_000; + +/** A stream that ends sooner than this after opening is treated as a failure. */ +const MIN_STREAM_MS = 3_000; + +/** + * How many requests wait on each Approval Center tab, and the total for the Approvals menu. + * + * The numbers are pushed, not polled: the service opens one Server-Sent Events stream, GET /approvals/counts/stream, and + * the server writes a `counts` event the moment the numbers change, so a request raised or actioned by anyone moves the + * badge within about a second with no reloads and no traffic while nothing changes. The server answers numbers only + * (no rows); a tab still loads its own list when it is opened, and applies its own table, status, office scope and + * permission, so a tab the login may not see is absent. + * + * The stream goes through HttpClient (not EventSource, which cannot send the login's headers), so the usual + * authentication and tenant headers apply. It is silent (see SILENT_HTTP_ERROR, NO_PROGRESS_BAR): no error popup and no + * progress bar. The server ends a stream every few minutes and the service reconnects at once; after a failure it keeps + * the last numbers and retries with a growing wait. + * + * A browser tab that is hidden keeps its stream: it is one idle connection, and the badge is right when it is shown. + */ +@Injectable({ providedIn: 'root' }) +export class ApprovalCountsService implements OnDestroy { + private http = inject(HttpClient); + private authenticationService = inject(AuthenticationService); + + private readonly countsSubject = new BehaviorSubject>({}); + private readonly totalSubject = new BehaviorSubject(0); + /** Pending count per tab key; a tab with nothing waiting is absent. */ + readonly counts$ = this.countsSubject.asObservable(); + /** Everything waiting across all the tabs the signed-in user can see. */ + readonly total$ = this.totalSubject.asObservable(); + + private active = false; + private failures = 0; + private stream: Subscription | null = null; + private retryTimer: ReturnType | null = null; + + /** Whether this login reaches the Approval Center at all, so nothing is asked for everyone else. */ + get canSeeApprovals(): boolean { + const permissions = this.authenticationService.getCredentials()?.permissions ?? []; + return permissions.includes('ALL_FUNCTIONS') || permissions.includes('MENU_TASKS'); + } + + /** Opens the stream (once; a second call changes nothing). */ + start(): void { + if (this.active || !this.canSeeApprovals) return; + this.active = true; + // After a page refresh the last numbers show at once; the stream's first event (a moment later) makes them exact. + this.restore(); + this.connect(); + } + + stop(): void { + this.active = false; + this.stream?.unsubscribe(); + this.stream = null; + if (this.retryTimer) clearTimeout(this.retryTimer); + this.retryTimer = null; + this.failures = 0; + } + + ngOnDestroy(): void { + this.stop(); + } + + private connect(): void { + if (!this.active) return; + // Text already handled, so each progress event is only read from where the last one ended. + let consumed = 0; + const startedAt = Date.now(); + this.stream = this.http + .get('/approvals/counts/stream', { + observe: 'events', + reportProgress: true, + responseType: 'text', + headers: { Accept: 'text/event-stream' }, + context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true) + }) + .subscribe({ + next: (event: HttpEvent) => { + if (event.type !== HttpEventType.DownloadProgress) return; + const text = (event as HttpDownloadProgressEvent).partialText ?? ''; + consumed = this.read(text, consumed); + }, + // The server closes a stream after a few minutes: connect again straight away. One that ended almost at once + // was not a stream at all (a proxy answering for it, say), so that is a failure and backs off. + complete: () => (Date.now() - startedAt < MIN_STREAM_MS ? this.retryLater() : this.connect()), + error: () => this.retryLater() + }); + } + + private retryLater(): void { + this.failures += 1; + // While the stream is down (an older server without it, a proxy that blocks it) the badge is still kept right by a + // plain read each time the retry comes round; the stream takes over again as soon as it connects. + this.http + .get('/approvals/counts', { + params: { fresh: 'true' }, + context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true) + }) + .subscribe({ + next: (answer) => { + this.countsSubject.next(answer.counts ?? {}); + this.totalSubject.next(answer.total ?? 0); + this.remember(answer); + }, + error: () => undefined + }); + const wait = Math.min(RETRY_MAX_MS, RETRY_MS * 2 ** (this.failures - 1)); + this.retryTimer = setTimeout(() => this.connect(), wait); + } + + /** Reads every complete event after `from` (events end with a blank line) and returns how far it got. */ + private read(text: string, from: number): number { + let position = from; + let end = text.indexOf('\n\n', position); + while (end !== -1) { + this.handle(text.slice(position, end)); + position = end + 2; + end = text.indexOf('\n\n', position); + } + return position; + } + + private handle(block: string): void { + let name = 'message'; + const data: string[] = []; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) name = line.slice(6).trim(); + else if (line.startsWith('data:')) data.push(line.slice(5).trimStart()); + } + // Comment lines (the server's keep-alive pings) carry no event and are ignored. + if (name !== 'counts' || data.length === 0) return; + try { + const answer = JSON.parse(data.join('\n')) as ApprovalCounts; + this.failures = 0; + this.countsSubject.next(answer.counts ?? {}); + this.totalSubject.next(answer.total ?? 0); + this.remember(answer); + } catch { + // A damaged event is skipped; the next change sends the numbers again. + } + } + + /** Storage is per login, so one user's numbers are never shown to the next person on the same browser. */ + private storageKey(): string { + return `approvalCounts:${this.authenticationService.getCredentials()?.username ?? ''}`; + } + + private remember(answer: ApprovalCounts): void { + try { + localStorage.setItem(this.storageKey(), JSON.stringify({ total: answer.total, counts: answer.counts })); + } catch { + // Storage can be blocked or full; the badge simply starts empty after a refresh. + } + } + + private restore(): void { + try { + const saved = JSON.parse(localStorage.getItem(this.storageKey()) ?? 'null') as ApprovalCounts | null; + if (saved && typeof saved.total === 'number' && saved.counts) { + this.countsSubject.next(saved.counts); + this.totalSubject.next(saved.total); + } + } catch { + // Unreadable or missing: start empty and wait for the first event. + } + } +} 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 d8b22a6c4..19e6096d1 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 @@ -31,6 +31,9 @@ > Checker Inbox + @if (count('checker-inbox')) { + {{ count('checker-inbox') }} + } Client Approval + @if (count('client-approval')) { + {{ count('client-approval') }} + } Loan Approval + @if (count('loan-approval')) { + {{ count('loan-approval') }} + } Loan Disbursal + @if (count('loan-disbursal')) { + {{ count('loan-disbursal') }} + } Reschedule Loan + @if (count('reschedule-loan')) { + {{ count('reschedule-loan') }} + } Pending Account + @if (count('pending-account')) { + {{ count('pending-account') }} + } Pending Share Accounts + @if (count('pending-share-accounts')) { + {{ count('pending-share-accounts') }} + } Product Approval + @if (count('product-approval')) { + {{ count('product-approval') }} + } Pending Account Edits + @if (count('pending-savings-account-edits')) { + {{ count('pending-savings-account-edits') }} + } Pending Client Edits + @if (count('pending-client-edits')) { + {{ count('pending-client-edits') }} + } Pending User Approvals + @if (count('pending-user-edits')) { + {{ count('pending-user-edits') }} + } Withdrawal Requests + @if (count('withdrawal-requests')) { + {{ count('withdrawal-requests') }} + } Cash Transfers + @if (count('cash-transfers')) { + {{ count('cash-transfers') }} + } Voucher Corrections + @if (count('voucher-corrections')) { + {{ count('voucher-corrections') }} + } Share Withdrawal Requests + @if (count('share-withdrawal-requests')) { + {{ count('share-withdrawal-requests') }} + } Dividend Requests + @if (count('dividend-requests')) { + {{ count('dividend-requests') }} + } Share Addition Requests + @if (count('share-addition-requests')) { + {{ count('share-addition-requests') }} + } Deposit Requests + @if (count('deposit-requests')) { + {{ count('deposit-requests') }} + } Disbursement & Transfers + @if (count('disbursement-requests')) { + {{ count('disbursement-requests') }} + } DDS Approvals + @if (count('dds-approvals')) { + {{ count('dds-approvals') }} + } Cheque Book Requests + @if (count('chequebook-requests')) { + {{ count('chequebook-requests') }} + } Closure Requests + @if (count('closure-requests')) { + {{ count('closure-requests') }} + } DD Approvals + @if (count('dd-issue-approvals')) { + {{ count('dd-issue-approvals') }} + } DD Reprint Requests + @if (count('dd-reprint-approvals')) { + {{ count('dd-reprint-approvals') }} + } DD Revoke Requests + @if (count('dd-revoke-approvals')) { + {{ count('dd-revoke-approvals') }} + } Risk Category Changes + @if (count('risk-change-approvals')) { + {{ count('risk-change-approvals') }} + } diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss index 54431fc8a..cec831d5c 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss @@ -103,11 +103,30 @@ $amber: #f59e0b; color: inherit; } + // How many requests wait on the tab. + &__count { + min-width: 17px; + height: 17px; + padding: 0 5px; + border-radius: 999px; + background: #f59e0b; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 17px; + text-align: center; + } + &:hover:not(.ac-tab--active) { background: #f1f5f9; color: $navy; } + &.ac-tab--active .ac-tab__count { + background: #fff; + color: $blue-d; + } + &.ac-tab--active { background: linear-gradient(135deg, $blue 0%, $blue-d 100%); color: #fff; diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.ts b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.ts index 99e6101f1..a14aa9a94 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.ts @@ -7,10 +7,12 @@ */ /** Angular Imports */ -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { RouterLinkActive, RouterOutlet } from '@angular/router'; +import { Subscription } from 'rxjs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ApprovalCountsService } from '../approval-counts.service'; /** * Approval Center — checker inbox + client/loan approval workflows. @@ -31,6 +33,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FaIconComponent ] }) -export class CheckerInboxAndTasksComponent { - constructor() {} +export class CheckerInboxAndTasksComponent implements OnInit, OnDestroy { + private counts = inject(ApprovalCountsService); + + /** How many requests wait on each tab, by the tab's route segment; a tab with none is absent. */ + pending: Record = {}; + private subscriptions = new Subscription(); + + ngOnInit(): void { + this.subscriptions.add(this.counts.counts$.subscribe((c) => (this.pending = c))); + // The same live counts the Approvals menu badge listens to; they move as requests are raised or actioned. + this.counts.start(); + } + + count(key: string): number { + return this.pending[key] ?? 0; + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } } From 6507e682033ed1391a0fd3a80b40be9b46be2ae7 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 16 Oct 2026 12:31:03 +0530 Subject: [PATCH 05/10] feat(cashier): FD is credit-only on the transaction entry, and a Bank Transfer can fund an FD - A Fixed Deposit row is credit-only on the clerk and cashier screens (isFdCreditOnly): its DR button is disabled, and Save refuses any FD row left on DR (for example by a Withdrawal Slip TYPE). Money leaves an FD only through its maturity or premature closure; the clerk's "Close this account" flow is the one exception. - Bank Transfer / Fund Transfer accepts an FD on the CR side, so an FD can be funded from the customer's own Savings or Current account. The server enforces the funding rules (first and only funding, exactly the principal, the source account's owner, freeze status and available balance). Requires the backend change "FD funding rules and no FD withdrawals from clerk/cashier requests". Co-Authored-By: Claude Opus 5.5 --- .../transaction-entry.component.html | 8 ++-- .../transaction-entry.component.ts | 38 +++++++++++++++++-- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index ffc38955f..c429ad732 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -184,7 +184,7 @@ class="ws-f__ctl" [(ngModel)]="row.acTyp" (click)="$event.stopPropagation()" - (change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')" + (change)="isCreditOnly(row) && (row.dbCr = 'Cr')" > @for (a of acTypOptionsFor(row); track a) { @@ -267,7 +267,7 @@ type="button" class="ws-seg__btn ws-seg__btn--dr" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" - [disabled]="isGlCreditOnly(row)" + [disabled]="isCreditOnly(row)" (click)="row.dbCr = 'Dr'" > DR @@ -1644,7 +1644,7 @@ class="ws-f__ctl" [(ngModel)]="row.acTyp" (click)="$event.stopPropagation()" - (change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')" + (change)="isCreditOnly(row) && (row.dbCr = 'Cr')" > @for (a of acTypOptionsFor(row); track a) { @@ -1715,7 +1715,7 @@ type="button" class="ws-seg__btn ws-seg__btn--dr" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" - [disabled]="isGlCreditOnly(row)" + [disabled]="isCreditOnly(row)" (click)="$event.stopPropagation(); row.dbCr = 'Dr'" > DR diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 413652c93..f0be400ff 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -2344,6 +2344,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ); } + /** + * A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with + * exactly its principal, and money leaves it only through its maturity or premature closure. The one + * exception is the clerk's own "Close this account" flow, which raises a closure request. + */ + isFdCreditOnly(row: TxnRow): boolean { + return row.acTyp === 'FD' && !this.pendingClosure; + } + + /** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */ + isCreditOnly(row: TxnRow): boolean { + return this.isGlCreditOnly(row) || this.isFdCreditOnly(row); + } + /** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */ /** * A cashier row that was filled from an approved token (withdrawal / account-closure) is cash @@ -5272,6 +5286,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } onSave(): void { + // Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment, + // collection or transfer debits an FD. It is paid out only through its maturity or premature closure + // (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly); + // this also catches a row set to DR by its TYPE, such as a Withdrawal Slip. + const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr'); + if (fdDebitRows.length > 0) { + this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [ + `FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`, + 'Money leaves a Fixed Deposit only through its maturity or premature closure.', + 'An FD can only be credited — once, with exactly its principal.' + ]); + return; + } + // Demand drafts commit here like everything else on the worksheet. Checked first because a DD // row shares its shape with the ordinary entries below — without this it would fall through and // post as a plain transaction, with the draft never raised and the charges silently dropped. @@ -5349,7 +5377,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - // Bank transfer: only savings (SD) or current (CA/CB) accounts allowed + // Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on + // the CR side only -- funding it from the customer's own account. The server checks the funding rules + // (first and only funding, exactly the principal, the source account's owner and available balance). const allowedTypes = new Set([ 'SD', 'CA', @@ -5357,13 +5387,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ]); const invalidAccounts = [ ...drRows, - ...crRows + ...crRows.filter((r) => r.acTyp !== 'FD') ].filter((r) => !allowedTypes.has(r.acTyp)); if (invalidAccounts.length > 0) { this.showAlert('error', 'Invalid Account Type', [ `Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`, - 'Only Savings (SD) and Current (CA) accounts are permitted.', - 'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.', + 'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).', + 'GL and SHR accounts are not allowed — use Disbursement/Collection instead.', 'RD installments are paid through a cashier Receipt or Auto Debit.' ]); return; From 55f5c1cf32c39cafe144b8978567a0622e133df4 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 16 Oct 2026 12:31:24 +0530 Subject: [PATCH 06/10] feat(ui): longer startup splash and updated loading animation The startup splash now holds for at least 4 seconds (progress 2% per tick up to 90%) instead of 2, and the loading animation is updated. Co-Authored-By: Claude Opus 5.5 --- src/assets/animations/loading_animation.json | 82 ++++++++++---------- src/index.html | 12 +-- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src/assets/animations/loading_animation.json b/src/assets/animations/loading_animation.json index 9255cda72..c1ec33b94 100644 --- a/src/assets/animations/loading_animation.json +++ b/src/assets/animations/loading_animation.json @@ -45,15 +45,17 @@ "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ - -17.052704811096184, - -15.0467209815979 + 0, + 0, + 0 ], "ix": 1 }, "s": { "a": 0, "k": [ - 362.228, - 362.228, + 394, + 394, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ - 188.2303284168625, - 195.49656352277756 + 250, + 250, + 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "shapes": [ { @@ -341,16 +343,16 @@ "g": { "p": 3, "k": { "a": 0, "k": [ 0, 1, - 0.4470588235294118, - 0, + 0.6, + 0.2, 0.485, - 0.9725490196078431, - 0.2235294117647059, - 0.5019607843137255, 1, - 0.9411764705882353, - 0, - 1 + 0.6, + 0.2, + 1, + 1, + 0.6, + 0.2 ], "ix": 8 } }, "t": 1, "a": { "a": 0, "k": 0 }, @@ -363,7 +365,7 @@ "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 9 }, - "w": { "a": 0, "k": 2, "ix": 10 } + "w": { "a": 0, "k": 2.5, "ix": 10 } }, { "ty": "fl", @@ -402,8 +404,8 @@ "o": { "a": 1, "k": [ - { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 }, - { "s": [360], "t": 90.0000036657751 } + { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [28], "t": 0 }, + { "s": [388], "t": 90.0000036657751 } ], "ix": 3 }, @@ -437,7 +439,7 @@ 250, 250, 0 - ], "ix": 2 }, "r": { "a": 0, "k": 60, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, + ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "shapes": [ { "ty": "gr", @@ -723,16 +725,16 @@ ], "ix": 5 }, "g": { "p": 3, "k": { "a": 0, "k": [ 0, - 0, - 0.7764705882352941, + 1, + 1, 1, 0.485, - 0.24705882352941178, - 0.38823529411764707, 1, 1, - 0.49411764705882355, - 0, + 1, + 1, + 1, + 1, 1 ], "ix": 8 } }, "t": 1, @@ -746,7 +748,7 @@ "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 9 }, - "w": { "a": 0, "k": 0.5, "ix": 10 } + "w": { "a": 0, "k": 2.5, "ix": 10 } }, { "ty": "fl", @@ -813,14 +815,14 @@ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ - 362.228, - 362.228, + 330, + 330, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ 250, 250, 0 - ], "ix": 2 }, "r": { "a": 0, "k": 30, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, + ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "shapes": [ { "ty": "gr", @@ -1106,17 +1108,17 @@ ], "ix": 5 }, "g": { "p": 3, "k": { "a": 0, "k": [ 0, - 1, - 0.8235294117647058, - 0, + 0.0784, + 0.7059, + 0.2118, 0.485, - 0.5019607843137255, - 0.8117647058823529, - 0.5019607843137255, + 0.0784, + 0.7059, + 0.2118, 1, - 0, - 0.8, - 1 + 0.0784, + 0.7059, + 0.2118 ], "ix": 8 } }, "t": 1, "a": { "a": 0, "k": 0 }, @@ -1129,7 +1131,7 @@ "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 9 }, - "w": { "a": 0, "k": 0.5, "ix": 10 } + "w": { "a": 0, "k": 2.5, "ix": 10 } }, { "ty": "fl", @@ -1168,8 +1170,8 @@ "o": { "a": 1, "k": [ - { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 }, - { "s": [360], "t": 90.0000036657751 } + { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [-28], "t": 0 }, + { "s": [332], "t": 90.0000036657751 } ], "ix": 3 }, diff --git a/src/index.html b/src/index.html index b63e3f055..66d51bc09 100644 --- a/src/index.html +++ b/src/index.html @@ -325,10 +325,10 @@ }; document.head.appendChild(script); - // Progress simulation stages (Guaranteed ~2 seconds duration on refresh) + // Progress simulation stages (Guaranteed ~4 seconds duration on refresh: 2% every 90ms reaches 90% in about 4s) var interval = setInterval(function () { if (progress < 90) { - progress += 5; + progress += 2; } else if (angularReady && progress < 100) { progress += 5; } @@ -351,7 +351,7 @@ } }, 90); - // Enforce minimum hold time (2000ms = 2 seconds) so refresh always shows the animation + // Enforce minimum hold time (4000ms = 4 seconds) so refresh always shows the animation setTimeout(function () { minTimePassed = true; if (angularReady) { @@ -359,7 +359,7 @@ if (bar) bar.style.width = '100%'; dismissSplash(); } - }, 2000); + }, 4000); function dismissSplash() { if (dismissed) return; @@ -387,12 +387,12 @@ } }; - // Safety fallback (Max 7s in case of backend network failure) + // Safety fallback (Max 9s in case of backend network failure) setTimeout(function () { angularReady = true; minTimePassed = true; dismissSplash(); - }, 7000); + }, 9000); })(); From 1daa8e60a0ca29a80507af1b898a42c122306dc6 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 20 Oct 2026 10:48:23 +0530 Subject: [PATCH 07/10] fix(cashier): keep the A/C TP kind on SD/FD/RD lookups in transaction entry SD, FD and RD share one /savingsaccounts list that returns a deposit account of any kind for a number. The lookup now keeps only the deposit type chosen in A/C TP, and when the number belongs to another kind the error says so, so the clerk can fix A/C TP. Co-Authored-By: Claude Sonnet 5.5 --- .../transaction-entry.component.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index f0be400ff..958d36dc1 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -4060,8 +4060,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .pipe(catchError(() => of(null))) .subscribe((res: any) => { if (token !== this.searchToken) return; // stale result — newer search started - const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []); + const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []); const f = cfg.matchField; + // SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP. + const allItems = + cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found; // 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 const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim()); @@ -4091,7 +4094,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } this.loadingAccount = false; - this.accountError = 'Account not found for: ' + acNo; + // The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP. + const otherKind = + cfg.depositTypeId != null + ? found.find( + (x: any) => + x?.depositType?.id !== cfg.depositTypeId && + (String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo)) + ) + : null; + this.accountError = otherKind + ? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)` + : `${acTyp} account not found for: ${acNo}`; }); } @@ -4155,6 +4169,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { * account type — used to keep Header GL accounts out of the results a clerk/cashier can pick * for a transaction; a Header can never receive a posting, so it should never be offered. */ extraListQueryParams?: string; + /** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit + * account of any kind for a number. This is the depositType.id the chosen A/C type accepts + * (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */ + depositTypeId?: number; } { const t = acTyp?.toUpperCase(); if (t === 'LA') { @@ -4235,6 +4253,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return { list: '/savingsaccounts', detail: '/fixeddepositaccounts', + depositTypeId: 200, nominee: 'FD', search: 'fixeddeposit', siAccountType: 2, @@ -4252,6 +4271,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return { list: '/savingsaccounts', detail: '/recurringdepositaccounts', + depositTypeId: 300, nominee: 'RD', search: 'recurringdeposit', siAccountType: 2, @@ -4269,6 +4289,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return { list: '/savingsaccounts', detail: '/savingsaccounts', + depositTypeId: 100, nominee: 'SB', search: 'saving', siAccountType: 2, From f804d356d72b6e1357fc5dba8d9a7e243358eb62 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 20 Oct 2026 10:48:37 +0530 Subject: [PATCH 08/10] fix(rd-products): save RD Interest Paid under expenses, as on FD and savings The backend finds the account by name in any group. Products saved while it was under liabilities are still read from there, so the name is not left behind as an "other" liability row. Co-Authored-By: Claude Sonnet 5.5 --- ...posit-product-accounting-step.component.ts | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts index 7f20525e7..4b2457882 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts @@ -175,18 +175,13 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { if (this.isCashOrAccrualAccounting()) { const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings; this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions); - // Interest Paid is a liability clearing account (Dr Payable / Cr Paid, then Dr Paid / Cr RD), saved under - // liabilities. Products saved before that kept it under expenses, so both groups are read. - const fromLiabilities = this.extractNamedGlMapping( - this.extractNamedGlMapping( - glMappings?.liabilities || [], - RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, - 'savingsInterestPaidAccountId', - this.liabilityAccountOptions - ), - SAVINGS_INTEREST_PAID_ACCOUNT_NAME, - 'savingsInterestPaidAccountId', - this.liabilityAccountOptions + // Interest Paid sits under expenses, as on the fixed deposit and savings products (the backend finds it by name + // in any group). Products saved while it was under liabilities are read from there too, so the name is not + // left behind as an "other" liability row; a liability GL chosen then has to be picked again from expenses. + const fromLiabilities = (glMappings?.liabilities || []).filter( + (mapping: any) => + mapping?.accountName !== RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME && + mapping?.accountName !== SAVINGS_INTEREST_PAID_ACCOUNT_NAME ); this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions); const fromExpenses = this.extractNamedGlMapping( @@ -277,15 +272,15 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { const otherGlMappings = { assets: map('assetsOtherGlMappings', this.assetAccountOptions), - liabilities: [ + liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions), + expenses: [ ...namedAsOtherGlMapping( 'savingsInterestPaidAccountId', RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, - this.liabilityAccountOptions + this.expenseAccountOptions ), - ...map('liabilitiesOtherGlMappings', this.liabilityAccountOptions) + ...map('expensesOtherGlMappings', this.expenseAccountOptions) ], - expenses: map('expensesOtherGlMappings', this.expenseAccountOptions), income: map('incomeOtherGlMappings', this.incomeAccountOptions) }; return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null; @@ -393,7 +388,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { }, // Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed // Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr - // the RD. Optional - a product naming neither posts straight to the control account as before. + // the RD. Optional - a product naming neither posts straight to the control account as before. Interest + // Paid itself is under Expenses. { key: 'interestPayableAccountId', label: 'Interest Payable Account', @@ -401,13 +397,6 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { controlName: 'interestPayableAccountId', options: this.liabilityAccountOptions }, - { - key: 'savingsInterestPaidAccountId', - label: 'Recurring Deposit Interest Paid', - inputLabel: 'Recurring deposit interest paid', - controlName: 'savingsInterestPaidAccountId', - options: this.liabilityAccountOptions - }, { key: 'escheatLiabilityId', label: 'Escheat Liability Account', @@ -433,6 +422,13 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { controlName: 'interestOnSavingsAccountId', options: this.expenseAccountOptions, required: true + }, + { + key: 'savingsInterestPaidAccountId', + label: 'Recurring Deposit Interest Paid', + inputLabel: 'Recurring deposit interest paid', + controlName: 'savingsInterestPaidAccountId', + options: this.expenseAccountOptions } ], otherMappingsControlName: 'expensesOtherGlMappings', From 3e5cc26e49d927391d6d5c00e2a8038bf03071f7 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 20 Oct 2026 16:44:47 +0530 Subject: [PATCH 09/10] feat(fixed-deposits): FD quote from the account engine, FD Special payout schedule popup, Special terms locked - Rate chart step (account opening): the rate, maturity date, maturity amount and interest now come from POST fixeddepositaccounts/account-preview for every FD type, so the quote matches the account once created (the old calculate-fd-interest call counted quarters from the deposit date and was off, e.g. FD DOUBLE by 36,979.23). The client-side formula fallback is removed: on an error the message is shown, nothing is estimated. - Dates are sent as the local calendar day (toISOString() turned 21 Oct at local midnight into 20 Oct). - FD Special: total interest paid out monthly, monthly payout, principal at maturity, maturity date, number of payouts, and "View payout schedule", which opens the existing Monthly Interest Payout Schedule dialog in a new preview mode (Days column, no paid / pending status). - Preview step: FD Special labels (total interest paid out monthly, principal at maturity). - FD product terms: choosing FD Special locks Interest Calculation Type to Simple, as Compounding and Posting are locked to Monthly (the backend refuses anything else). - Preview step: three existing `!= null` template checks rewritten for the eqeqeq lint rule, same meaning. Co-Authored-By: Claude Opus 5.5 --- ...nt-interest-rate-chart-step.component.html | 61 ++++- ...nt-interest-rate-chart-step.component.scss | 69 ++++++ ...ount-interest-rate-chart-step.component.ts | 219 +++++++++++------- ...eposit-account-preview-step.component.html | 12 +- ...-deposit-account-preview-step.component.ts | 5 + .../interest-schedule-dialog.component.html | 52 +++-- .../interest-schedule-dialog.component.ts | 15 +- .../fixed-deposits/fixed-deposits.service.ts | 10 + ...ed-deposit-product-terms-step.component.ts | 13 ++ 9 files changed, 351 insertions(+), 105 deletions(-) diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html index 8eab0d0bc..a38b4a58e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html @@ -331,12 +331,14 @@ {{ matchResult.appliedRate | number: '1.2-2' }}% p.a.
- Interest Earned + {{ + isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned' + }} ₹ {{ matchResult.interestEarned | number: '1.2-2' }}
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
- Monthly Payout + Monthly Payout (approx., by days in the month) ₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month @@ -351,13 +353,64 @@ } - ₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }} + ₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
+ @if (isSpecial && specialPreview) { +
+ Maturity Date + {{ + specialPreview.maturityDate | date: 'dd MMM yyyy' + }} +
+
+ Monthly payouts + {{ specialPreview.numberOfPayouts }} +
+
+ Principal + all payouts + ₹ {{ specialPreview.totalValueAtMaturity | number: '1.2-2' }} +
+ } + + @if (isSpecial && specialPreview) { +
+
+ Monthly Interest Payout Schedule + +
+
+ From {{ specialPreview.startDate | date: 'dd MMM yyyy' }} (the day the account is opened) to + {{ specialPreview.maturityDate | date: 'dd MMM yyyy' }}, worked out by the account engine from the + product. +
+
+ } } - @if (!matchResult.maturityAmount) { + @if (specialPreviewLoading) { +
+ + Working out the maturity from the product… +
+ } + @if (specialPreviewError) { +
+ + {{ specialPreviewError }} +
+ } + @if (!matchResult.maturityAmount && !specialPreviewLoading && !specialPreviewError) {
Enter a deposit amount above to see the maturity calculation. diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.scss index 79daea4d9..73759cd9c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.scss @@ -1229,3 +1229,72 @@ $red-light: #fee2e2; font-weight: 700; } } + +/* FD Special: payout schedule from the backend preview */ +.fdrc-schedule { + margin-top: 12px; + border: 1px solid #bbf7d0; + border-radius: 10px; + background: #fff; + overflow: hidden; + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 14px; + background: #f0fdf4; + font-size: 12px; + font-weight: 700; + color: #166534; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__note { + padding: 6px 14px; + font-size: 12px; + color: #475569; + } + + &__wrap { + max-height: 360px; + overflow: auto; + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 6px 14px; + border-top: 1px solid #e2e8f0; + text-align: left; + } + + th { + position: sticky; + top: 0; + background: #f8fafc; + font-size: 11px; + text-transform: uppercase; + color: #64748b; + } + + .num { + text-align: right; + } + + tfoot td { + font-weight: 700; + background: #f0fdf4; + } + } +} + +.fdrc-payout-hint--error { + color: #b91c1c; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts index 666f126d5..7bd845d30 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts @@ -36,12 +36,39 @@ import { FormsModule } from '@angular/forms'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FixedDepositsService } from '../../fixed-deposits.service'; +import { MatDialog } from '@angular/material/dialog'; +import { InterestScheduleDialogComponent } from '../../fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component'; import { ClientsService } from 'app/clients/clients.service'; import { SettingsService } from 'app/settings/settings.service'; import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; export type FdPeriodUnit = 'Days' | 'Months' | 'Years'; +/** + * yyyy-MM-dd of the calendar day as the user sees it. toISOString() gives the UTC day instead, which in India is the day + * before for any date at local midnight - so a deposit opened on the 21st was quoted from the 20th. + */ +function localIsoDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** FD quote from POST fixeddepositaccounts/account-preview (worked out by the account engine, every FD type). */ +export interface SpecialPayoutPreview { + fdType: string; + depositAmount: number; + annualInterestRate: number; + seniorCitizen: boolean; + startDate: string; + maturityDate: string; + numberOfPayouts: number; + fullMonthPayout: number; + totalInterest: number; + maturityAmount: number; + totalValueAtMaturity: number; + periods: { period: number; fromDate: string; toDate: string; days: number; interestAmount: number }[]; +} + export interface SlabMatchResult { slab: any; slabIndex: number; @@ -87,6 +114,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan private clientsService = inject(ClientsService); private settingsService = inject(SettingsService); private cdr = inject(ChangeDetectorRef); + private dialog = inject(MatDialog); @Input() fixedDepositsAccountTemplate: any; @Input() fixedDepositsAccountProductTemplate: any; @@ -163,6 +191,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan } matchResult: SlabMatchResult | null = null; + /** FD Special only: the backend's quote and payout schedule for the tenure and amount entered. */ + specialPreview: SpecialPayoutPreview | null = null; + specialPreviewLoading = false; + specialPreviewError: string | null = null; validationWarning: string | null = null; // ── FD Type labels ────────────────────────────────────────────────────── @@ -270,6 +302,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan calculateRate() { this.validationWarning = null; this.matchResult = null; + this.specialPreview = null; + this.specialPreviewError = null; if (!this.tenureValue || this.tenureValue <= 0) { this.validationWarning = 'Please enter a valid tenure value greater than 0.'; @@ -334,30 +368,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); if (this.principalAmount && this.principalAmount > 0) { - // Years and Months convert exactly; a Days tenure is sent as days and never converted. - let tenureInMonths: number | undefined; - let tenureInDays: number | undefined; - if (this.tenurePeriodUnit === 'Years') { - tenureInMonths = this.tenureValue * 12; - } else if (this.tenurePeriodUnit === 'Months') { - tenureInMonths = this.tenureValue; - } else { - // Sent as days, whatever the count. Converting to whole months silently dropped the - // remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the - // screen still said "200 Days" above the figure. The backend takes days directly and - // works the calendar out itself. - tenureInDays = enteredDays; - } - - // Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual) - const compMap: Record = { 1: 0, 4: 1, 5: 3, 6: 6, 7: 12 }; - const compId = this.fixedDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id ?? 4; - const compMonths = compMap[compId] ?? 1; - - // Map posting period type id → months - const postId = this.fixedDepositsAccountProductTemplate?.interestPostingPeriodType?.id ?? 4; - const postMonths = compMap[postId] ?? 1; - const emitResult = () => { if (frequencyId !== null) { this.tenureSelected.emit({ @@ -375,7 +385,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan // Get submittedOnDate — Input takes priority over template data let submittedOnDateStr: string; if (this.submittedOnDate) { - submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0]; + submittedOnDateStr = localIsoDate(this.submittedOnDate); } else { const submittedOnDateRaw = this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ?? @@ -384,63 +394,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan const d = Array.isArray(submittedOnDateRaw) ? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2]) : new Date(submittedOnDateRaw); - submittedOnDateStr = !isNaN(d.getTime()) - ? d.toISOString().split('T')[0] - : new Date().toISOString().split('T')[0]; + submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date()); } else { - submittedOnDateStr = new Date().toISOString().split('T')[0]; + submittedOnDateStr = localIsoDate(new Date()); } } - const daysInYearVal = - this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365; - - // Always use backend API for accurate calculation (including daily compounding with 0) - this.fixedDepositsService - .calculateFixedDepositInterest({ - principalAmount: this.principalAmount, - annualInterestRate: rate, - tenureInMonths, - tenureInDays, - interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend - interestPostingPeriodInMonths: postMonths, - submittedOnDate: submittedOnDateStr, - daysInYear: daysInYearVal - }) - .subscribe({ - next: (apiResult: any) => { - result.maturityAmount = apiResult.maturityAmount; - result.interestEarned = - apiResult.totalInterest ?? Math.round((apiResult.maturityAmount - this.principalAmount) * 100) / 100; - this.matchResult = { ...result }; - emitResult(); - }, - error: () => { - // Fallback if API fails: frontend formula - const r = rate / 100; - if (compMonths === 0) { - // Daily compounding fallback - result.maturityAmount = - Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100; - } else { - const n = 12 / compMonths; - const effectiveMonths = tenureInMonths ?? enteredDays / 30; - const rPerCompounding = rate / (n * 100); - // Whole compounding periods, then simple interest on the days left over. Flooring the - // periods and stopping there dropped the remainder entirely: a 200-day deposit - // compounding monthly quoted only six months of interest and lost the other twenty - // days, which is the depositor's money. - const wholePeriods = Math.floor(effectiveMonths / compMonths); - const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods); - const leftoverDays = enteredDays - wholePeriods * compMonths * 30; - const leftoverInterest = - leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0; - result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100; - } - result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100; - this.matchResult = { ...result }; - emitResult(); - } - }); + // The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout + // schedule - comes from the backend, which builds the would-be account from the product exactly as Submit does + // (its posting periods, compounding, days in year, rate card, senior-citizen rate) and runs the account's own + // interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated. + if (frequencyId !== null) { + this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult); + } } else { this.matchResult = result; if (frequencyId !== null) { @@ -457,6 +422,96 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan } } + /** The backend's payout schedule in the same popup the account itself uses, in its quote (preview) mode. */ + openSpecialSchedule(): void { + if (!this.specialPreview) return; + this.dialog.open(InterestScheduleDialogComponent, { + width: '760px', + data: { + preview: true, + currencyCode: this.fixedDepositsAccountProductTemplate?.currency?.code ?? 'INR', + note: + 'Worked out by the account engine from the product, starting ' + + this.specialPreview.startDate + + ' (the day the account is opened) and ending the day before maturity on ' + + this.specialPreview.maturityDate + + '. The first and last periods are part-months; each pays for the days it covers.', + schedule: this.specialPreview.periods.map((p) => ({ + fromDate: p.fromDate, + toDate: p.toDate, + days: p.days, + interestAmount: Number(p.interestAmount), + paid: false + })) + } + }); + } + + /** + * Asks the backend for the quote (POST fixeddepositaccounts/account-preview) with the same terms Submit sends: the + * product's own interest terms, and the senior-citizen rate the same way Submit overrides it. The rate, maturity + * amount and interest shown are the backend's, so they match the account once it is created. + */ + private loadAccountPreview( + result: SlabMatchResult, + rate: number, + submittedOnDateStr: string, + frequencyId: number, + emitResult: () => void + ): void { + const pt = this.fixedDepositsAccountProductTemplate ?? {}; + const body: any = { + clientId: pt.clientId ?? this.fixedDepositsAccountTemplate?.clientId, + productId: pt.depositProductId ?? pt.productId ?? this.fixedDepositsAccountTemplate?.productId, + submittedOnDate: submittedOnDateStr, + dateFormat: 'yyyy-MM-dd', + locale: 'en', + depositAmount: this.principalAmount, + depositPeriod: this.tenureValue, + depositPeriodFrequencyId: frequencyId + }; + if (pt.nominalAnnualInterestRate != null) body.nominalAnnualInterestRate = pt.nominalAnnualInterestRate; + if (pt.interestCompoundingPeriodType?.id != null) + body.interestCompoundingPeriodType = pt.interestCompoundingPeriodType.id; + if (pt.interestPostingPeriodType?.id != null) body.interestPostingPeriodType = pt.interestPostingPeriodType.id; + if (pt.interestCalculationType?.id != null) body.interestCalculationType = pt.interestCalculationType.id; + if (pt.interestCalculationDaysInYearType?.id != null) + body.interestCalculationDaysInYearType = pt.interestCalculationDaysInYearType.id; + if (this.isSeniorCitizen && rate > 0) { + body.isSeniorCitizen = true; + body.seniorCitizenInterestRate = rate; + body.nominalAnnualInterestRate = rate; + } + + this.specialPreview = null; + this.specialPreviewError = null; + this.specialPreviewLoading = true; + this.fixedDepositsService.accountPreview(body).subscribe({ + next: (preview: SpecialPayoutPreview) => { + this.specialPreviewLoading = false; + this.specialPreview = preview; + result.appliedRate = Number(preview.annualInterestRate); + result.interestEarned = Number(preview.totalInterest); + result.estimatedMonthlyPayout = preview.fullMonthPayout != null ? Number(preview.fullMonthPayout) : undefined; + result.maturityAmount = Number(preview.maturityAmount); + this.matchResult = { ...result }; + emitResult(); + this.cdr.markForCheck(); + }, + error: (e: any) => { + this.specialPreviewLoading = false; + this.specialPreviewError = + e?.error?.errors?.[0]?.defaultUserMessage || + e?.error?.defaultUserMessage || + 'Could not work out the maturity. Check the deposit details and try again.'; + result.maturityAmount = undefined; + result.interestEarned = undefined; + this.matchResult = { ...result }; + this.cdr.markForCheck(); + } + }); + } + private resolveFrequencyId(unit: FdPeriodUnit): number | null { const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? []; const unitLower = unit.toLowerCase(); @@ -471,12 +526,16 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan this.tenureValue = null; this.principalAmount = null; this.matchResult = null; + this.specialPreview = null; + this.specialPreviewError = null; this.validationWarning = null; this.tenurePeriodUnit = this.allowedPeriodUnits[0]; } onPeriodUnitChange() { this.matchResult = null; + this.specialPreview = null; + this.specialPreviewError = null; this.validationWarning = null; } @@ -524,6 +583,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan this.calculateRate(); } else { this.matchResult = null; + this.specialPreview = null; + this.specialPreviewError = null; } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html index 98644bdb4..5b13cf1f4 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html @@ -326,7 +326,7 @@
Inactive - @if (fixedDepositsAccountProductTemplate?.daysToInactive != null) { + @if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) { After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction } @else { Not configured @@ -340,7 +340,7 @@
Dormant - @if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) { + @if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) { {{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive } @else { Not configured @@ -354,7 +354,7 @@
Escheat - @if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) { + @if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) { {{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant } @else { Not configured @@ -458,13 +458,15 @@ {{ maturitySummary.tenureLabel }}
- Estimated Interest Earned + {{ + isSpecial ? 'Total Interest (paid out monthly)' : 'Estimated Interest Earned' + }} + {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
- Projected Maturity Amount + {{ isSpecial ? 'Principal at Maturity' : 'Projected Maturity Amount' }} {{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.ts index fc8c3130a..4f90460f5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.ts @@ -145,6 +145,11 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges { SPECIAL: 'FD Special (Monthly Interest Payout)' }; + /** FD Special pays its interest out monthly; the principal alone comes back at maturity. */ + get isSpecial(): boolean { + return this.fixedDepositsAccountProductTemplate?.fdType === 'SPECIAL'; + } + getFdTypeLabel(fdType: string): string { return this.fdTypeLabels[fdType] || fdType; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html index de34802f2..5426c51bd 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html @@ -11,15 +11,25 @@ {{ 'labels.heading.Monthly Interest Payout Schedule' | translate }} - {{ paidCount }} of {{ schedule.length }} periods paid so far + + @if (isPreview) { + {{ schedule.length }} monthly payouts + } @else { + {{ paidCount }} of {{ schedule.length }} periods paid so far + } +

- Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary - slightly with the number of days in that month. + @if (data.note) { + {{ data.note }} + } @else { + Computed by the actual interest engine for this account's full term -- the exact amount posted each month can + vary slightly with the number of days in that month. + }

@@ -28,8 +38,13 @@ {{ 'labels.inputs.From Date' | translate }} {{ 'labels.inputs.To Date' | translate }} + @if (isPreview) { + Days + } {{ 'labels.inputs.Interest Amount' | translate }} - {{ 'labels.inputs.Status' | translate }} + @if (!isPreview) { + {{ 'labels.inputs.Status' | translate }} + } @@ -37,28 +52,35 @@ {{ period.fromDate | dateFormat }} {{ period.toDate | dateFormat }} + @if (isPreview) { + {{ period.days }} + } {{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} - - @if (period.paid) { - - Paid - - } @else { - Pending - } - + @if (!isPreview) { + + @if (period.paid) { + + Paid + + } @else { + Pending + } + + } } - {{ 'labels.inputs.Total Interest Payable' | translate }} + {{ 'labels.inputs.Total Interest Payable' | translate }} {{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} - + @if (!isPreview) { + + } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts index 612cc78f3..39ec30f18 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts @@ -26,6 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; * a 24+ month term makes for a long table that would otherwise push every other card on the tab down * the page. Each row also shows whether that period's interest has actually posted yet, not just what * the engine projects it to be. + * + * Also opened from the account-opening screen with `preview: true`: the backend's quote for an FD Special + * not opened yet, so there is no paid / pending status, and each period shows its number of days. */ @Component({ selector: 'mifosx-interest-schedule-dialog', @@ -46,14 +49,22 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class InterestScheduleDialogComponent { dialogRef = inject>(MatDialogRef); data: { - schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>; + schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }>; currencyCode: string; + /** Account-opening quote: no paid / pending status. */ + preview?: boolean; + /** Shown above the table instead of the account note. */ + note?: string; } = inject(MAT_DIALOG_DATA); - get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> { + get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }> { return this.data.schedule ?? []; } + get isPreview(): boolean { + return !!this.data.preview; + } + get currencyCode(): string { return this.data.currencyCode; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index e5af75631..fb2bb9c33 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -154,6 +154,16 @@ export class FixedDepositsService { return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams }); } + /** + * FD quote while opening the account, for every FD type: the same body as create (clientId, productId, + * submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, interest terms, senior citizen ...). The + * backend builds the would-be account from the product, without saving it, and returns the applied rate, maturity + * date and amount Submit would store, and the interest periods (FD Special: the monthly payout schedule). + */ + accountPreview(body: any): Observable { + return this.http.post(`/fixeddepositaccounts/account-preview`, body); + } + /** * FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period * configured: backdated to the original maturity date within the grace window, or from today diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts index d1abb4ad6..339a12d51 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts @@ -129,12 +129,24 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges * (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the * same values here to avoid an admin configuring a combination that the backend will silently override * at account-creation time. + * + * Its interest is paid out every month, so nothing compounds on the principal: the Interest Calculation Type is + * locked to Simple as well. The backend refuses an FD Special product with any other value for these three + * (DepositProductDataValidator), so a request from anywhere else cannot save them either. */ private applyFdTypeConstraints() { if (!this.fixedDepositProductTermsForm) return; const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType'); const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType'); + const calculationTypeCtrl = this.fixedDepositProductTermsForm.get('interestCalculateSelector'); if (this.fdType === 'SPECIAL') { + const simple = (this.interestCalculationTypeOptionData || []).find( + (o: any) => String(o.value ?? o.name ?? '').toLowerCase() === 'simple' + ); + if (calculationTypeCtrl && simple) { + calculationTypeCtrl.setValue(simple.id); + calculationTypeCtrl.disable(); + } // Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums) const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4); if (postingCtrl && this.interestPostingPeriodTypeData) { @@ -154,6 +166,7 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges } else { postingCtrl?.enable(); compoundingCtrl?.enable(); + calculationTypeCtrl?.enable(); } } From 357f666421f067ffda5e3e3efd5d63b74d1c23c3 Mon Sep 17 00:00:00 2001 From: keshav Date: Tue, 20 Oct 2026 17:16:51 +0530 Subject: [PATCH 10/10] KYC management: stage-wise notices, pagination, RD base chart --- src/app/accounting/accounting.service.ts | 13 +- src/app/aml/aml.service.ts | 16 + .../cash-transfer/cash-transfer.component.ts | 11 +- .../transaction-entry.component.html | 54 +- .../transaction-entry.component.scss | 36 + .../transaction-entry.component.ts | 79 +- .../disbursement-review.component.html | 45 +- .../disbursement-review.component.scss | 51 + .../disbursement-review.component.ts | 16 + .../client-general-step.component.html | 12 +- .../client-general-step.component.ts | 57 +- .../client-identity-check.component.html | 211 ++ .../client-identity-check.component.scss | 157 + .../client-identity-check.component.ts | 308 ++ .../client-kyc-step.component.ts | 46 +- .../general-tab/general-tab.component.html | 94 +- .../general-tab/general-tab.component.scss | 63 + .../general-tab/general-tab.component.ts | 69 + src/app/clients/clients.component.html | 22 + src/app/clients/clients.component.scss | 12 + src/app/clients/clients.component.ts | 28 +- src/app/clients/clients.service.ts | 190 +- .../create-client.component.html | 7 + .../create-client/create-client.component.ts | 47 + .../clients/models/client-identity.model.ts | 34 + .../clients/utils/aadhaar-hash.util.spec.ts | 52 + src/app/clients/utils/aadhaar-hash.util.ts | 185 ++ src/app/clients/utils/name-case.util.spec.ts | 19 +- src/app/clients/utils/name-case.util.ts | 14 + src/app/core/http/cache.interceptor.ts | 8 +- src/app/core/shell/shell.component.html | 8 +- src/app/core/shell/shell.component.ts | 19 + ...urrent-account-details-step.component.html | 20 +- ...urrent-account-details-step.component.scss | 25 + .../current-account-details-step.component.ts | 51 + ...te-recurring-deposits-account.component.ts | 7 +- ...dit-recurring-deposit-account.component.ts | 4 +- ...ount-interest-rate-chart-step.component.ts | 8 +- .../general-tab/general-tab.component.html | 28 + .../general-tab/general-tab.component.ts | 31 + .../recurring-deposits.service.ts | 10 + .../directives/restrict-input.directive.ts | 7 +- src/app/fiu/fiu-data.ts | 2 + src/app/fiu/fiu.component.html | 372 ++- src/app/fiu/fiu.component.scss | 8 +- src/app/fiu/fiu.component.ts | 180 +- ...alm-bucket-breakdown-dialog.component.html | 852 +++++ ...alm-bucket-breakdown-dialog.component.scss | 1347 ++++++++ .../alm-bucket-breakdown-dialog.component.ts | 962 ++++++ .../alm-bucket-breakdown.config.ts | 463 +++ .../alm-bucket.util.ts | 209 ++ .../alm-maturity.service.ts | 125 + .../crr-slr-dialog.component.html | 310 +- .../crr-slr-dialog.component.scss | 141 +- .../crr-slr-dialog.component.ts | 323 +- .../reserve-transfer-dialog.component.html | 124 + .../reserve-transfer-dialog.component.scss | 223 ++ .../reserve-transfer-dialog.component.ts | 167 + .../home/dashboard/dashboard.component.html | 6 + .../home/dashboard/dashboard.component.scss | 14 + src/app/home/dashboard/dashboard.component.ts | 53 +- .../kyc-management.component.html | 2322 +++++++------ .../kyc-management.component.scss | 5 +- .../kyc-management.component.ts | 2912 ++++++++++++----- .../institution-profile.component.html | 6 +- .../institution-profile.component.ts | 6 +- .../organization-routing.module.ts | 7 + .../organization/organization.component.html | 20 + src/app/organization/organization.service.ts | 34 + .../reserve-fund-config.component.html | 359 ++ .../reserve-fund-config.component.scss | 143 + .../reserve-fund-config.component.ts | 308 ++ ...urrent-product-preview-step.component.html | 95 +- ...base-fd-interest-rate-chart.component.html | 26 +- .../base-fd-interest-rate-chart.component.ts | 81 +- ...e-recurring-deposit-product.component.html | 12 +- ...ate-recurring-deposit-product.component.ts | 44 +- ...t-recurring-deposit-product.component.html | 4 + ...dit-recurring-deposit-product.component.ts | 24 +- .../recurring-deposit-products.component.html | 9 - .../rd-interest-rates-tab.component.html | 4 + .../rd-interest-rates-tab.component.ts | 3 +- .../aml-cc-tracking.component.html | 141 + .../aml-cc-tracking.component.scss | 234 ++ .../aml-cc-tracking.component.ts | 95 + .../aml-flagged-transactions.component.html | 287 +- .../aml-flagged-transactions.component.scss | 28 +- .../aml-flagged-transactions.component.ts | 21 +- .../aml-recurring-alerts.component.scss | 6 + .../aml-rules/aml-rules.component.html | 25 + .../aml-rules/aml-rules.component.scss | 20 + .../aml-rules/aml-rules.component.ts | 43 +- .../form9-return/form9-bank-format.ts | 480 +++ .../form9-return/form9-return.component.html | 400 ++- .../form9-return/form9-return.component.scss | 126 + .../form9-return/form9-return.component.ts | 74 + .../rbi-circulars/rbi-circulars.service.ts | 39 + .../form1-statutory.component.scss | 307 ++ .../reserve-return/reserve-return.base.ts | 244 +- .../reserve-return.component.html | 260 +- .../create-savings-account.component.ts | 5 + ...w-journal-entry-transaction.component.html | 56 + ...w-journal-entry-transaction.component.scss | 92 + ...iew-journal-entry-transaction.component.ts | 40 +- .../gl-settings-dialog.component.ts | 145 +- .../deposit-requests.component.html | 26 +- .../deposit-requests.component.scss | 45 + src/environments/.env.ts | 4 +- 108 files changed, 15001 insertions(+), 2721 deletions(-) create mode 100644 src/app/clients/client-stepper/client-identity-check/client-identity-check.component.html create mode 100644 src/app/clients/client-stepper/client-identity-check/client-identity-check.component.scss create mode 100644 src/app/clients/client-stepper/client-identity-check/client-identity-check.component.ts create mode 100644 src/app/clients/models/client-identity.model.ts create mode 100644 src/app/clients/utils/aadhaar-hash.util.spec.ts create mode 100644 src/app/clients/utils/aadhaar-hash.util.ts create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.html create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.scss create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.ts create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown.config.ts create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket.util.ts create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-maturity.service.ts create mode 100644 src/app/home/dashboard/crr-slr-dialog/reserve-transfer-dialog.component.html create mode 100644 src/app/home/dashboard/crr-slr-dialog/reserve-transfer-dialog.component.scss create mode 100644 src/app/home/dashboard/crr-slr-dialog/reserve-transfer-dialog.component.ts create mode 100644 src/app/organization/reserve-fund-config/reserve-fund-config.component.html create mode 100644 src/app/organization/reserve-fund-config/reserve-fund-config.component.scss create mode 100644 src/app/organization/reserve-fund-config/reserve-fund-config.component.ts create mode 100644 src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.html create mode 100644 src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.scss create mode 100644 src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.ts create mode 100644 src/app/rbi-circulars/form9-return/form9-bank-format.ts diff --git a/src/app/accounting/accounting.service.ts b/src/app/accounting/accounting.service.ts index 2143fbd22..5bc382ab0 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -10,7 +10,8 @@ /** Angular Imports */ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; +import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token'; /** rxjs Imports */ import { Observable } from 'rxjs'; @@ -198,6 +199,16 @@ export class AccountingService { return this.http.get('/journalentries/ledger', { params: httpParams }); } + /** + * The whole transfer a ledger transaction belongs to, when it is one branch's part of a transfer between branches. + * An extra, informational lookup: if it fails the screen simply shows no banner, so a failure raises no error popup. + */ + getInterBranchTransfer(transactionId: string): Observable { + return this.http.get(`/accounting/interbranchtransfers/${transactionId}`, { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + /** * @param {string} transactionId Transaction ID to be reverted. * @param {string} comments Any comments. diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 047fed18c..a2b725230 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -142,6 +142,22 @@ export class AmlService { return this.http.get('/aml/ctr-reports/lines', { params }); } + /** + * Cash transactions of the threshold or more that no generated report contains yet, shaped like the lines above. + * They show on the FIU screen as soon as they are posted; generating the month is what files them. + */ + getCtrPendingLines(): Observable { + return this.http.get('/aml/ctr-reports/pending-lines'); + } + + /** + * How much came into and went out of each current / Cash Credit account over the last `days` days, with the + * customer's declared turnover and the sanctioned limit alongside. + */ + getCcTracking(days = 30): Observable { + return this.http.get('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) }); + } + /** month format: yyyy-MM */ generateCtrReport(month: string): Observable { return this.http.post('/aml/ctr-reports/generate', { month }); diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts index ab9041425..a8370b4ff 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.ts +++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts @@ -19,6 +19,7 @@ import { of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; +import { AuthenticationService } from '../../core/authentication/authentication.service'; interface Denom { id: number; @@ -54,6 +55,7 @@ export class CashTransferComponent implements OnInit { @ViewChild('requestsDialog') requestsDialog!: TemplateRef; private requestsRef: MatDialogRef | null = null; + private authenticationService = inject(AuthenticationService); notes: Denom[] = []; coins: Denom[] = []; @@ -139,7 +141,14 @@ export class CashTransferComponent implements OnInit { .getTellers() .pipe(catchError(() => of([] as any[]))) .subscribe((list) => { - this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); + // The vault and its cash are physical, so a branch moves cash only from its own tellers. The + // backend lists every teller below the user's office (Head Office sees all branches), which is + // right for reports but wrong here. + const myOfficeId = this.authenticationService.getCredentials()?.officeId; + const own = (list || []).filter( + (t: any) => !myOfficeId || t.officeId == null || Number(t.officeId) === Number(myOfficeId) + ); + this.tellers = own.map((t: any) => ({ id: t.id, name: t.name })); if (this.tellers.length === 1) { this.onTellerSelect(this.tellers[0].id); } diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index c429ad732..95295fbf3 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -132,12 +132,12 @@
@@ -722,7 +722,24 @@ } @else {
- Branch{{ officeName }} + Branch + @if (isOwnBranchAccount !== null) { + + {{ officeName }} + @if (!isOwnBranchAccount) { + Other branch + } + + } @else { + {{ officeName }} + } +
A/c No. - Branch {{ officeName ?? '—' }} + Branch + @if (isOwnBranchAccount !== null) { + + {{ officeName }} + @if (!isOwnBranchAccount) { + Other branch + } + + } @else { + {{ officeName }} + } + Product {{ @@ -1592,12 +1626,12 @@
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 06858c598..f56420d65 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -3462,3 +3462,39 @@ select.te-cell { box-shadow: none; } } + +/* The branch an account is held at, coloured by where the signed-in user works: green for their own branch, red for any other. */ +.te-branch-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + + &--mine { + background: #dcfce7; + border-color: #4ade80; + color: #166534; + } + + &--other { + background: #fca5a5; + border-color: #f87171; + color: #7f1d1d; + } +} + +.te-branch-chip__tag { + padding: 0 6px; + border-radius: 8px; + background: rgb(255 255 255 / 80%); + color: #7f1d1d; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 958d36dc1..59090265e 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -10,7 +10,8 @@ import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } fr import { CommonModule } from '@angular/common'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; +import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token'; import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators'; import { forkJoin, of, Subject } from 'rxjs'; import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; @@ -2710,7 +2711,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } branchNameFor(branchId: number): string { - return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`; + return ( + this.seenBranches.find((b) => b.id === branchId)?.name ?? + this.branches.find((b) => b.id === branchId)?.label ?? + `Branch ${branchId}` + ); } /** @@ -4349,6 +4354,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return false; } this.accountDetails = acc; + this.resolveAccountBranch(acc, acTyp, token, this.selectedRow); this.jointHolders = acc.jointHolders ?? []; // GL only: look up the parent (header) GL's name. Never runs for any other account type. this.glParent = null; @@ -4965,6 +4971,75 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ]; } + /** + * Whether the account on screen is held at the branch the signed-in user works at: true for their own branch, false for + * another branch's, null while either branch is not known (the branch is then shown plain, uncoloured). + */ + get isOwnBranchAccount(): boolean | null { + const mine = this.authService.getCredentials()?.officeId; + const accountOffice = this.accountDetails?.officeId ?? this.clientDetails?.officeId; + if (mine == null || accountOffice == null) return null; + return Number(mine) === Number(accountOffice); + } + + /** What the BRANCH selector offers: the offices this login may use, plus the branches of the accounts picked here. */ + get branchSelectOptions(): { id: number; name: string }[] { + const merged = this.branches.map( + (branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? { id: branch.id, name: branch.label } + ); + for (const seen of this.seenBranches) { + if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen); + } + return merged; + } + + /** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */ + private seenBranches: { id: number; name: string }[] = []; + + /** + * The branch an account is held at. The savings, FD and RD detail responses carry no office, so the screen fell back + * to the client's own branch and an account opened at another branch read as the client's. The client's account list + * names each account's branch, so it is read from there. A current account's own response already carries it. + */ + private resolveAccountBranch(acc: any, acTyp: string, token: number, row: TxnRow | null | undefined): void { + if (acc?.officeId != null && acc?.officeName) { + this.adoptAccountBranch({ id: Number(acc.officeId), name: String(acc.officeName) }, row); + return; + } + const type = (acTyp ?? '').toUpperCase(); + if (!acc?.id || !acc?.clientId || ![ + 'SD', + 'FD', + 'RD', + 'CA' + ].includes(type)) return; + this.http + .get(`/clients/${acc.clientId}/accounts`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) }) + .pipe(catchError(() => of(null))) + .subscribe((res) => { + // stale: another account was picked while this was loading + if (token !== this.searchToken || this.accountDetails?.id !== acc.id) return; + const list: any[] = (type === 'CA' ? res?.currentAccounts : res?.savingsAccounts) ?? []; + const match = list.find((a: any) => a.id === acc.id); + if (match?.officeId != null && match?.officeName) { + this.adoptAccountBranch({ id: Number(match.officeId), name: String(match.officeName) }, row); + } + }); + } + + private adoptAccountBranch(branch: { id: number; name: string }, row: TxnRow | null | undefined): void { + this.accountDetails = { ...this.accountDetails, officeId: branch.id, officeName: branch.name }; + // /offices lists only the signed-in user's own branches, or nothing for a user who may not read offices; the account's + // branch must still be selectable, under its real name + if (!this.seenBranches.some((b) => b.id === branch.id)) { + this.seenBranches = [ + ...this.seenBranches, + branch + ]; + } + if (row) row.br = branch.id; + } + get openingDate(): string { const raw = this.accountDetails?.timeline?.submittedOnDate ?? diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.html b/src/app/clerk/disbursement-review/disbursement-review.component.html index 7ad308fd8..2844568b5 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.html +++ b/src/app/clerk/disbursement-review/disbursement-review.component.html @@ -102,6 +102,27 @@
} + + @if (isInterBranch) { +
+ alt_route +
+
Inter-branch transfer
+
+ @if (branchNames(drEntries) && branchNames(crEntries)) { + Money moves from {{ branchNames(drEntries) }} to + {{ branchNames(crEntries) }}. + } @else { + These accounts are held at different branches: {{ branchNames(creditEntries) }}. + } + Each account is posted in its own branch's books. Check the accounts before approving. +
+
+
+ } + @if (isBankTransferMode) {
@@ -154,6 +175,7 @@ Account No Account Name Type + Branch {{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }} @@ -166,11 +188,16 @@ {{ accountTypeLabel(entry.accountType) }} + + {{ + entry.officeName || '—' + }} + ₹{{ entry.amount | number: '1.2-2' }} } - {{ isCollectionMode ? 'Total Debit' : 'Total Credit' }} + {{ isCollectionMode ? 'Total Debit' : 'Total Credit' }} ₹{{ totalCreditAmount() | number: '1.2-2' }} @@ -192,6 +219,7 @@ Account No Account Name Type + Branch Amount (DR) @@ -204,11 +232,16 @@ {{ accountTypeLabel(entry.accountType) }} + + {{ + entry.officeName || '—' + }} + ₹{{ entry.amount | number: '1.2-2' }} } - Total Debit + Total Debit ₹{{ totalDebitAmount() | number: '1.2-2' }} @@ -228,6 +261,7 @@ Account No Account Name Type + Branch Amount (CR) @@ -240,11 +274,16 @@ {{ accountTypeLabel(entry.accountType) }} + + {{ + entry.officeName || '—' + }} + ₹{{ entry.amount | number: '1.2-2' }} } - Total Credit + Total Credit ₹{{ totalCreditAmount() | number: '1.2-2' }} diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.scss b/src/app/clerk/disbursement-review/disbursement-review.component.scss index a95e0293a..d1c1c63ef 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.scss +++ b/src/app/clerk/disbursement-review/disbursement-review.component.scss @@ -534,3 +534,54 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%); font-family: 'Courier New', monospace; letter-spacing: 0.02em; } + +/* A leg's branch. Amber on every leg when the request moves money between branches, neutral otherwise. */ +.dri-branch-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 20px; + background: #f1f5f9; + border: 1px solid #cbd5e1; + font-size: 11px; + font-weight: 700; + color: #475569; + + &--cross { + background: #fffbeb; + border-color: #fcd34d; + color: #92400e; + } +} + +/* Shown above the summary when the accounts are at different branches. */ +.dri-interbranch { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 0 12px; + padding: 12px 16px; + border: 1px solid #fcd34d; + border-left: 4px solid #f59e0b; + border-radius: 8px; + background: #fffbeb; + color: #78350f; + + mat-icon { + flex-shrink: 0; + color: #d97706; + } + + &__title { + font-size: 13px; + font-weight: 800; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + &__text { + margin-top: 2px; + font-size: 13px; + line-height: 1.5; + } +} diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.ts b/src/app/clerk/disbursement-review/disbursement-review.component.ts index 82d96762f..f033916a5 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.ts +++ b/src/app/clerk/disbursement-review/disbursement-review.component.ts @@ -25,6 +25,9 @@ interface CreditEntry { accountType: string; amount: number; dbCr?: string; + /** The branch the account is held at; absent for a GL leg or when the server could not resolve it. */ + officeId?: number | null; + officeName?: string | null; } @Component({ @@ -167,6 +170,19 @@ export class DisbursementReviewComponent implements OnInit { return this.creditEntries.filter((e) => e.dbCr !== 'Dr'); } + /** + * True when the accounts in this request are held at more than one branch, so money moves between branches. The + * approver has to see that before approving: it is booked in each branch's own books. + */ + get isInterBranch(): boolean { + return new Set(this.creditEntries.filter((e) => e.officeId != null).map((e) => e.officeId)).size > 1; + } + + /** Distinct branch names among the given legs, in order, e.g. for "Head Office" or "Head Office, bhopal". */ + branchNames(entries: CreditEntry[]): string { + return [...new Set(entries.map((e) => e.officeName).filter((name): name is string => !!name))].join(', '); + } + totalCreditAmount(): number { const entries = this.isCollectionMode ? this.drEntries : this.crEntries; return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0); diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index dbe9d8b20..ea661314d 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -930,6 +930,10 @@ createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched ) { Enter valid 10-digit number (starts with 6-9) + } @else if (createClientForm.get('mobileNo')?.hasError('contactTaken')) { + This mobile number already belongs to another customer. Please enter a different number. } @@ -940,7 +944,11 @@ color="primary" type="button" class="verify-btn" - [disabled]="readOnly || createClientForm.get('mobileNo')?.value?.length !== 10" + [disabled]=" + readOnly || + createClientForm.get('mobileNo')?.value?.length !== 10 || + createClientForm.get('mobileNo')?.hasError('contactTaken') + " (click)="verifyWithDIP()" > @@ -1008,6 +1016,8 @@ createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched ) { Email is required + } @else if (createClientForm.get('emailAddress')?.hasError('contactTaken')) { + This email already belongs to another customer. Please enter a different email. } @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) { Enter a valid email (e.g. name@example.com) } diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index db39bae63..a0785425e 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -27,15 +27,16 @@ import { UntypedFormControl, ReactiveFormsModule, ValidatorFn, + AsyncValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; -import { Subject, Subscription, merge } from 'rxjs'; -import { filter, switchMap, takeUntil } from 'rxjs/operators'; +import { Subject, Subscription, merge, of, timer } from 'rxjs'; +import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { LegalFormId } from 'app/clients/models/legal-form.enum'; -import { toProperNameCase } from 'app/clients/utils/name-case.util'; +import { toPersonNameCase, toProperNameCase } from 'app/clients/utils/name-case.util'; import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service'; /** Custom Services */ @@ -936,6 +937,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges }; } + /** + * A mobile number or email that already belongs to another customer. This is a different person using a contact + * that is taken (a PAN or Aadhaar match is what means the same customer), so the answer is to enter another one. + * Runs only once the format validators pass, and a new keystroke cancels the previous call, which is what + * debounces it. The client being edited is excluded so its own number is not flagged. + */ + private contactTakenValidator(field: 'mobileNo' | 'emailAddress'): AsyncValidatorFn { + return (control: AbstractControl) => { + const value = (control.value ?? '').toString().trim(); + if (!value) { + return of(null); + } + return timer(400).pipe( + switchMap(() => + this.clientService.lookupClientIdentity({ [field]: value, excludeClientId: this.clientData?.id ?? null }) + ), + map((result) => (result?.contactConflicts?.[field] ? { contactTaken: true } : null)), + // A failed lookup must not block the form: the server still refuses a duplicate when the client is saved. + catchError(() => of(null)) + ); + }; + } + /** * Creates the client form. */ @@ -969,7 +993,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges Validators.required, Validators.pattern(/^[6-9]\d{9}$/), Validators.maxLength(10) - ] + ], + this.contactTakenValidator('mobileNo') ], alternateMobileNo: [ '', @@ -986,7 +1011,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges Validators.email, Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/), Validators.maxLength(100) - ] + ], + this.contactTakenValidator('emailAddress') ], dateOfBirth: [ '', @@ -1456,7 +1482,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges const control = this.createClientForm.get(controlName); if (control && control.value) { const originalValue = control.value; - const capitalized = toProperNameCase(originalValue); + // An entity's full name keeps its typed spelling (matched against the PAN name); a person's + // name parts are title-cased, the same as the Full Name line shows them. + const capitalized = + controlName === 'fullname' ? toProperNameCase(originalValue) : toPersonNameCase(originalValue); if (originalValue !== capitalized) { control.setValue(capitalized); } @@ -1925,12 +1954,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges /** Returns capitalized full name preview from first + middle + last */ get fullNamePreview(): string { - const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : ''); const salutationId = this.createClientForm?.get('salutationId')?.value; const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || ''; - const first = capitalize(this.createClientForm?.get('firstname')?.value || ''); - const middle = capitalize(this.createClientForm?.get('middlename')?.value || ''); - const last = capitalize(this.createClientForm?.get('lastname')?.value || ''); + const first = toPersonNameCase(this.createClientForm?.get('firstname')?.value); + const middle = toPersonNameCase(this.createClientForm?.get('middlename')?.value); + const last = toPersonNameCase(this.createClientForm?.get('lastname')?.value); const full = [ first, middle, @@ -2360,6 +2388,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges if (generalDetails.fullname) { generalDetails.fullname = toProperNameCase(generalDetails.fullname); } + for (const part of [ + 'firstname', + 'middlename', + 'lastname' + ]) { + if (generalDetails[part]) { + generalDetails[part] = toPersonNameCase(generalDetails[part]); + } + } if (generalDetails.submittedOnDate instanceof Date) { generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat); diff --git a/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.html b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.html new file mode 100644 index 000000000..48152f1f6 --- /dev/null +++ b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.html @@ -0,0 +1,211 @@ + + +
+
+ fingerprint +
+

Existing customer check

+

+ @if (isEntity) { + Enter the company's GSTIN or PAN. If either is already on file in any branch, it is the same customer and a + new one is not created. + } @else { + Enter PAN or Aadhaar. If either is already on file in any branch, it is the same customer and a new one is not + created. Nothing found? What you enter here is filled into the KYC step for you. + } +

+
+
+ + @if (form) { +
+ @if (isEntity) { + + GSTIN + + @if (form.get('gstin')?.hasError('pattern')) { + Invalid GSTIN. It has 15 characters, like 22AAAAA0000A1Z5 + } + + } + + + {{ isEntity ? 'Company PAN' : 'PAN' }} + + @if (form.get('pan')?.hasError('pattern')) { + Invalid PAN. Use 5 letters, 4 digits, 1 letter, like ABCDE1234F + } + + + @if (!isEntity) { + + Aadhaar + + @if (form.get('aadhaar')?.hasError('pattern')) { + Invalid Aadhaar. It must be exactly 12 digits + } @else { + Only a fingerprint of this number is sent, never the number + } + + } +
+ } + + @switch (status) { + @case ('checking') { +
+ + Checking all branches… +
+ } + @case ('not-found') { +
+ check_circle + + No existing customer found. You can continue; these details will be filled into the KYC step so you do not + type them again. + +
+ } + @case ('error') { +
+ warning + + Could not check right now. You can continue; the PAN, Aadhaar and GSTIN are checked again when the KYC is + saved. + + +
+ } + @case ('conflict') { +
+ report + + These details belong to different customers. Check what was typed. If they are correct, ask a + manager to review before continuing. + +
+ @for (m of matches; track m.clientId) { +
+ {{ m.displayName }} + CIF {{ m.cifNumber }} · {{ m.officeName }} + + @for (by of m.matchedBy; track by) { + {{ matchedByLabel(by) }} matches + } + +
+ } + } + @case ('matched') { + @if (match; as m) { +
+ person_search + This is an existing customer. A new client is not created for the same person. +
+
+
+ {{ m.displayName }} + + @for (by of m.matchedBy; track by) { + {{ matchedByLabel(by) }} matches + } + +
+
+
+
CIF
+
{{ m.cifNumber }}
+
+
+
Home branch
+
{{ m.officeName }}
+
+
+
Type
+
{{ legalFormLabel(m) }}
+
+ @if (m.panMasked) { +
+
PAN
+
{{ m.panMasked }}
+
+ } + @if (m.aadhaarMasked) { +
+
Aadhaar
+
{{ m.aadhaarMasked }}
+
+ } + @if (m.gstinMasked) { +
+
GSTIN
+
{{ m.gstinMasked }}
+
+ } +
+ + @if (m.visibleToCaller) { + + } @else { +

+ This customer is registered at {{ m.officeName }}. They stay a single customer (one CIF). Open their page, then choose which account to open for them at + this branch. They are listed under this branch once an account is opened here. +

+ + } +
+ } + } + } +
diff --git a/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.scss b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.scss new file mode 100644 index 000000000..54cd2a206 --- /dev/null +++ b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.scss @@ -0,0 +1,157 @@ +/** + * 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 { + display: block; +} + +.identity-check { + margin-bottom: 16px; + padding: 16px 20px; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 12px; + + &--blocking { + border-color: #fecaca; + background: #fffafa; + } + + &__header { + display: flex; + gap: 12px; + align-items: flex-start; + margin-bottom: 12px; + } + + &__icon { + flex: none; + color: var(--md-sys-color-primary, #1074b9); + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 600; + } + + &__hint { + margin: 2px 0 0; + font-size: 13px; + color: #6b7280; + } + + // PAN and GSTIN read in capitals even before the value is rewritten. + &__upper { + text-transform: uppercase; + } + + &__fields { + display: flex; + flex-wrap: wrap; + gap: 0 16px; + } + + &__field { + flex: 1 1 220px; + min-width: 200px; + } + + &__status { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 12px; + font-size: 13px; + border-radius: 8px; + + &--info { + background: #eff6ff; + color: #1e40af; + } + + &--ok { + background: #ecfdf5; + color: #047857; + } + + &--warn { + background: #fffbeb; + color: #92400e; + } + + &--error { + background: #fef2f2; + color: #991b1b; + } + } + + &__card { + margin-top: 10px; + padding: 12px 14px; + background: #fff; + border: 1px solid #fecaca; + border-radius: 10px; + } + + &__card-head { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + justify-content: space-between; + } + + &__meta { + margin-left: 8px; + font-size: 13px; + color: #6b7280; + } + + &__chips { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + } + + &__chip { + padding: 2px 8px; + font-size: 12px; + color: #991b1b; + background: #fee2e2; + border-radius: 999px; + } + + &__facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px 16px; + margin: 10px 0 0; + + dt { + font-size: 12px; + color: #6b7280; + } + + dd { + margin: 0; + font-size: 14px; + font-weight: 500; + } + } + + &__action { + margin-top: 12px; + } + + &__note { + margin: 12px 0 0; + font-size: 13px; + color: #374151; + } +} diff --git a/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.ts b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.ts new file mode 100644 index 000000000..6dabb99a6 --- /dev/null +++ b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.ts @@ -0,0 +1,308 @@ +/** + * 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, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + inject +} from '@angular/core'; +import { + AbstractControl, + FormGroupDirective, + NgForm, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { Router } from '@angular/router'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { Observable, Subject, merge, of } from 'rxjs'; +import { catchError, debounceTime, map, switchMap, takeUntil, tap } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { RestrictInputDirective } from '../../../directives/restrict-input.directive'; +import { ClientsService } from '../../clients.service'; +import { ClientIdentityLookupResult, ClientIdentityMatch } from '../../models/client-identity.model'; +import { aadhaarHash } from '../../utils/aadhaar-hash.util'; + +export type ClientIdentityStatus = 'idle' | 'checking' | 'not-found' | 'matched' | 'conflict' | 'error'; + +/** + * What the parent needs from the identity check: whether creation must stop, and the identifiers the officer entered + * so the KYC step can be filled in without typing them again. + */ +export interface ClientIdentityState { + status: ClientIdentityStatus; + /** Individual: the person's PAN. Entity: the company's PAN. */ + pan: string | null; + /** The 12 digits, held in memory only to pre-fill the KYC step. Never sent: only its hash is. */ + aadhaar: string | null; + gstin: string | null; + /** True when the customer already exists (or the identifiers disagree), so a new client must not be created. */ + blocking: boolean; +} + +/** + * Shows a field's error as soon as something has been typed in it, not only after the field is left: a PAN that is + * wrong should say so while it is being typed, and the form should not look fine in the meantime. + */ +class ShowErrorWhenDirty implements ErrorStateMatcher { + isErrorState(control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean { + return !!control && control.invalid && (control.dirty || control.touched); + } +} + +const PAN_PATTERN = /^[A-Za-z]{5}\d{4}[A-Za-z]$/; +const AADHAAR_PATTERN = /^\d{12}$/; +const GSTIN_PATTERN = /^\d{2}[A-Za-z]{5}\d{4}[A-Za-z][1-9A-Za-z]Z[0-9A-Za-z]$/; + +/** + * "Is this person already a customer of the bank?" — asked at the top of the General step, before anything is saved. + * + * Any ONE of PAN / Aadhaar (individual) or PAN / GSTIN (entity) matching an existing customer, in any branch, means + * the same customer, so a second client must not be created. When nothing matches, what was entered is carried to the + * KYC step so it does not have to be typed again. + */ +@Component({ + selector: 'mifosx-client-identity-check', + templateUrl: './client-identity-check.component.html', + styleUrls: ['./client-identity-check.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIcon, + MatProgressSpinner, + RestrictInputDirective + ] +}) +export class ClientIdentityCheckComponent implements OnInit, OnChanges, OnDestroy { + private formBuilder = inject(UntypedFormBuilder); + private clientsService = inject(ClientsService); + private router = inject(Router); + private authenticationService = inject(AuthenticationService); + + /** 1 = individual, 2 = entity (non-individual). */ + @Input() legalFormType = 1; + @Output() identityChange = new EventEmitter(); + + form: UntypedFormGroup; + readonly errorMatcher = new ShowErrorWhenDirty(); + status: ClientIdentityStatus = 'idle'; + matches: ClientIdentityMatch[] = []; + /** True while the customer is being introduced to this branch. */ + linking = false; + + private destroy$ = new Subject(); + private recheck$ = new Subject(); + + get isEntity(): boolean { + return this.legalFormType === 2; + } + + /** The first (and normally only) existing customer found. */ + get match(): ClientIdentityMatch | null { + return this.matches[0] ?? null; + } + + /** + * True while a field holds something that is not a valid PAN / Aadhaar / GSTIN. Such a value is never looked up or + * carried to the KYC step, so the parent must not let the officer carry on past it as if the check had passed. + */ + get hasInvalidInput(): boolean { + return !!this.form?.invalid; + } + + markAllAsTouched(): void { + this.form?.markAllAsTouched(); + } + + get state(): ClientIdentityState { + const v = this.normalized(); + return { + status: this.status, + pan: v.pan, + aadhaar: v.aadhaar, + gstin: v.gstin, + blocking: this.status === 'matched' || this.status === 'conflict' + }; + } + + ngOnInit(): void { + this.form = this.formBuilder.group({ + pan: [ + '', + Validators.pattern(PAN_PATTERN) + ], + aadhaar: [ + '', + Validators.pattern(AADHAAR_PATTERN) + ], + gstin: [ + '', + Validators.pattern(GSTIN_PATTERN) + ] + }); + + // A pause in typing (or a manual re-check) runs one lookup; typing again cancels the one in flight. + merge(this.form.valueChanges, this.recheck$) + .pipe( + debounceTime(400), + tap(() => this.startChecking()), + switchMap(() => this.lookup()), + takeUntil(this.destroy$) + ) + .subscribe((result) => this.applyResult(result)); + } + + ngOnChanges(changes: SimpleChanges): void { + // Switching individual <-> entity swaps which identifiers apply; what was typed for the other kind is stale. + if (changes['legalFormType'] && !changes['legalFormType'].firstChange) { + this.reset(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** Clears the fields and the result, e.g. when a new client creation session starts. */ + reset(): void { + this.form?.reset({ pan: '', aadhaar: '', gstin: '' }, { emitEvent: false }); + this.status = 'idle'; + this.matches = []; + this.identityChange.emit(this.state); + } + + recheck(): void { + this.recheck$.next(); + } + + /** + * PAN and GSTIN are always written in capitals, so what is typed is capitalised as it is typed. The caret is put + * back where it was, otherwise editing the middle of a PAN would throw it to the end. The shared restrict-input + * directive only capitalises when the field is left, which let lower-case show while typing. + */ + capitalise(controlName: 'pan' | 'gstin', event: Event): void { + const input = event.target as HTMLInputElement; + const upper = input.value.toUpperCase(); + if (upper !== input.value) { + const { selectionStart, selectionEnd } = input; + input.value = upper; + input.setSelectionRange(selectionStart, selectionEnd); + } + this.form.get(controlName)?.setValue(upper, { emitEvent: false }); + } + + /** Identifiers that are complete and well formed; anything half-typed is ignored rather than looked up. */ + private normalized(): { pan: string | null; aadhaar: string | null; gstin: string | null } { + const raw = this.form?.getRawValue() ?? { pan: '', aadhaar: '', gstin: '' }; + const pan = String(raw.pan ?? '') + .replace(/\s+/g, '') + .toUpperCase(); + const aadhaar = String(raw.aadhaar ?? '').replace(/[\s-]/g, ''); + const gstin = String(raw.gstin ?? '') + .replace(/\s+/g, '') + .toUpperCase(); + return { + pan: PAN_PATTERN.test(pan) ? pan : null, + aadhaar: !this.isEntity && AADHAAR_PATTERN.test(aadhaar) ? aadhaar : null, + gstin: this.isEntity && GSTIN_PATTERN.test(gstin) ? gstin : null + }; + } + + private startChecking(): void { + const v = this.normalized(); + if (!v.pan && !v.aadhaar && !v.gstin) { + this.status = 'idle'; + this.matches = []; + this.identityChange.emit(this.state); + return; + } + this.status = 'checking'; + this.matches = []; + this.identityChange.emit(this.state); + } + + private lookup(): Observable { + const v = this.normalized(); + if (!v.pan && !v.aadhaar && !v.gstin) { + return of(null); + } + return this.clientsService + .lookupClientIdentity({ panNumber: v.pan, aadhaarHash: aadhaarHash(v.aadhaar), gstinNumber: v.gstin }) + .pipe( + map((result): ClientIdentityLookupResult | 'error' => result), + catchError(() => of('error' as const)) + ); + } + + private applyResult(result: ClientIdentityLookupResult | 'error' | null): void { + if (result === null) { + // Nothing complete to look up; startChecking() already reset to idle. + return; + } + if (result === 'error') { + // Not blocking: the server checks again when the KYC is saved, so a failed lookup must not stop onboarding. + this.status = 'error'; + this.matches = []; + } else if (result.conflict) { + this.status = 'conflict'; + this.matches = result.matches; + } else if (result.matched) { + this.status = 'matched'; + this.matches = result.matches; + } else { + this.status = 'not-found'; + this.matches = []; + } + this.identityChange.emit(this.state); + } + + /** + * Starts opening an account at THIS branch for a customer registered at another one. The customer stays at the home + * branch (one CIF). This branch is introduced to them, which only lets it open their page; they are listed under + * this branch once an account is actually opened here. An account opened for a customer of another branch is held + * at the branch that opens it. + */ + openAccountHere(match: ClientIdentityMatch): void { + const officeId = this.authenticationService.getCredentials()?.officeId; + if (this.linking || officeId == null) { + return; + } + this.linking = true; + this.clientsService.linkClientToMyBranch(match.clientId).subscribe({ + next: () => { + this.linking = false; + this.router.navigate([ + '/clients', + match.clientId, + 'general' + ]); + }, + // The failure is shown by the global error handler. + error: () => (this.linking = false) + }); + } + + /** Legal form of the existing customer, for the card. */ + legalFormLabel(match: ClientIdentityMatch): string { + return match.legalFormEnum === 2 ? 'Non-Individual' : 'Individual'; + } + + matchedByLabel(by: string): string { + return by === 'AADHAAR' ? 'Aadhaar' : by; + } +} diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index cfca4fb8c..2c024b758 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -43,6 +43,8 @@ import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component'; import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component'; import { dataUrlToFile } from 'app/shared/utils/data-url.util'; +import { aadhaarHash } from '../../utils/aadhaar-hash.util'; +import { ClientIdentityState } from '../client-identity-check/client-identity-check.component'; import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; import { PanDeclarationDialogComponent, @@ -108,6 +110,32 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { @Input() verificationDateOfBirth: Date | null = null; /** Read-only mode — disables form editing until user clicks edit icon */ @Input() readOnly = false; + + /** + * The identifiers the officer already typed into the existing-customer check on the General step. They are filled + * in here so they are not typed twice, but only into fields that are still empty: whatever was entered on this + * step wins. Ignored while the check says the customer already exists, since no client is created then. + */ + @Input() set identityPrefill(identity: ClientIdentityState | null) { + if (!identity || identity.blocking) { + return; + } + const isEmpty = (name: string) => !String(this.kycForm?.get(name)?.value ?? '').trim(); + const patch: Record = {}; + if (this.isNonIndividual) { + if (identity.gstin && isEmpty('gstinNumber')) patch['gstinNumber'] = identity.gstin; + if (identity.pan && isEmpty('enterprisePan')) patch['enterprisePan'] = identity.pan; + } else { + if (identity.pan && isEmpty('panNumber')) { + patch['panNumber'] = identity.pan; + patch['hasPan'] = true; + } + if (identity.aadhaar && isEmpty('aadhaarNumber')) patch['aadhaarNumber'] = identity.aadhaar; + } + if (Object.keys(patch).length) { + this.kycForm.patchValue(patch); + } + } @Input() calculatedRisk: { category: number; reason: string; @@ -1756,7 +1784,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { kycType: this.kycTypeValue, riskCategory: this.currentRiskCategory, panNumber: pan, - aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000' + aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000', + ...this.aadhaarHashField(aadhaarRaw) }; // A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is @@ -2358,7 +2387,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { locale: 'en', kycType: this.kycTypeValue, riskCategory: this.currentRiskCategory, - aadhaarNumberMasked: aadhaarMasked + aadhaarNumberMasked: aadhaarMasked, + ...this.aadhaarHashField(aadhaar) }; const panVal = this.kycForm.value.panNumber; if (panVal) { @@ -2679,6 +2709,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { } } + /** + * `{ aadhaarHash }` for a full 12-digit Aadhaar, `{}` otherwise. The number itself is never sent: KYC calls are + * recorded server-side, so only its SHA-256 goes. + */ + private aadhaarHashField(aadhaar: string | null | undefined): { aadhaarHash?: string } { + const hash = aadhaarHash(aadhaar); + return hash ? { aadhaarHash: hash } : {}; + } + get kycData(): any { const formVal = this.kycForm.getRawValue(); const aadhaarRaw = formVal.aadhaarNumber || ''; @@ -2707,6 +2746,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { // For non-individual: Company PAN is used as panNumber for verification panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null, aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked, + // The fingerprint of the full number, so the server can tell two Aadhaar numbers apart (the masked form keeps + // only the last four digits). Absent when the field holds a masked value reloaded from the server. + ...(this.isNonIndividual ? {} : this.aadhaarHashField(aadhaarRaw)), // Only ever sent when this screen actually holds a value. The seeding status is derived by // the provider during PAN verification and written straight to the record, so sending an // empty one back would blank a field the server filled in — which reads as an officer diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.html b/src/app/clients/clients-view/general-tab/general-tab.component.html index 7902eb818..6a2cbab2e 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.html +++ b/src/app/clients/clients-view/general-tab/general-tab.component.html @@ -90,6 +90,21 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Last Active' | translate }} {{ element.lastActiveTransactionDate | dateFormat }} @@ -203,6 +218,21 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Closed Date' | translate }} {{ element.timeline.closedOnDate | dateFormat }} @@ -262,6 +292,21 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Last Active' | translate }} {{ element.lastActiveTransactionDate | dateFormat }} @@ -452,6 +497,21 @@ {{ 'labels.inputs.Fixed Deposit Product' | translate }} {{ element.productName }} + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Last Active' | translate }} {{ element.lastActiveTransactionDate | dateFormat }} @@ -551,6 +611,21 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Closed Date' | translate }} {{ element.timeline.closedOnDate | dateFormat }} @@ -611,10 +686,27 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + Status - {{ rdStatusLabel(element) }} + {{ + rdStatusLabel(element) + }} @if (element.timeline?.closedOnDate) {
on {{ element.timeline.closedOnDate | dateFormat }}
} diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.scss b/src/app/clients/clients-view/general-tab/general-tab.component.scss index 33fd52124..1c1f14729 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.scss +++ b/src/app/clients/clients-view/general-tab/general-tab.component.scss @@ -440,3 +440,66 @@ table[mat-table] { font-size: 11px; color: #64748b; } + +/* + * Branch an account is held at, as a pill coloured by where the signed-in user works: green for their own branch, red for + * any other. When the customer banks at several other branches the reds run from light to dark, one shade per branch. + */ +.branch-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + + .fa { + font-size: 12px; + } + + &--mine { + background: #dcfce7; + color: #166534; + border-color: #4ade80; + } + + &--other-0 { + background: #fee2e2; + color: #991b1b; + border-color: #fca5a5; + } + + &--other-1 { + background: #fca5a5; + color: #7f1d1d; + border-color: #f87171; + } + + &--other-2 { + background: #dc2626; + color: #fff; + border-color: #b91c1c; + } + + &--other-3 { + background: #991b1b; + color: #fff; + border-color: #7f1d1d; + } +} + +.branch-chip__home { + padding: 0 6px; + border-radius: 8px; + + /* fixed colours: the chip text turns white on the dark reds, which would vanish against this light tag */ + background: rgb(255 255 255 / 85%); + color: #1f2937; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.ts b/src/app/clients/clients-view/general-tab/general-tab.component.ts index 5382d6c2a..9a8db4816 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.ts +++ b/src/app/clients/clients-view/general-tab/general-tab.component.ts @@ -39,6 +39,7 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../pipes/format-number.pipe'; import { CurrencyPipe } from '@angular/common'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AuthenticationService } from '../../../core/authentication/authentication.service'; import { ReportsService } from 'app/reports/reports.service'; import { SettingsService } from 'app/settings/settings.service'; import { Subject } from 'rxjs'; @@ -151,6 +152,7 @@ export class GeneralTabComponent implements OnDestroy { private router = inject(Router); private reportsService = inject(ReportsService); private settingsService = inject(SettingsService); + private authenticationService = inject(AuthenticationService); /** Open Loan Accounts Columns */ openLoansColumns: string[] = [ @@ -177,6 +179,7 @@ export class GeneralTabComponent implements OnDestroy { openSavingsColumns: string[] = [ 'Account No', 'Saving Account', + 'Branch', 'Last Active', 'Balance', 'Actions' @@ -185,12 +188,14 @@ export class GeneralTabComponent implements OnDestroy { closedSavingsColumns: string[] = [ 'Account No', 'Saving Account', + 'Branch', 'Closed Date' ]; /** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */ recurringColumns: string[] = [ 'Account No', 'Saving Account', + 'Branch', 'Status', 'Last Active', 'Balance', @@ -336,12 +341,76 @@ export class GeneralTabComponent implements OnDestroy { /** Client Id */ clientid: any; + /** The customer's own (home) branch, to tell it from branches the customer banks at elsewhere. */ + clientOfficeId: number | null = null; + + /** How many red shades the branch chips have (general-tab.component.scss, .branch-chip--other-0 ... -3), light to dark. */ + private static readonly OTHER_BRANCH_SHADES = 4; + + /** The branch the signed-in user works at: accounts held here read green, every other branch red. */ + private get myOfficeId(): number | null { + const officeId = this.authenticationService.getCredentials()?.officeId; + return officeId == null ? null : Number(officeId); + } + + /** + * The other branches this customer banks at, lightest to darkest. Ordered by branch id so the shades stay put between + * visits; computed over every deposit account on the page so one branch has the same shade in every table. + */ + private otherBranchIds(): number[] { + const mine = this.myOfficeId; + const ids = new Set(); + for (const account of [ + ...this.savingAccounts, + ...this.currentAccounts + ]) { + const officeId = Number(account?.officeId); + if (Number.isFinite(officeId) && officeId !== mine) { + ids.add(officeId); + } + } + return [...ids].sort((a, b) => a - b); + } + + /** Green for an account at the signed-in user's branch; red for any other branch, a different shade for each. */ + branchChipClass(account: any): string { + const officeId = Number(account?.officeId); + if (officeId === this.myOfficeId) { + return 'branch-chip branch-chip--mine'; + } + const others = this.otherBranchIds(); + const rank = Math.max(others.indexOf(officeId), 0); + const last = GeneralTabComponent.OTHER_BRANCH_SHADES - 1; + // one other branch gets a mid red, several are spread from the lightest to the darkest + const shade = others.length <= 1 ? 1 : Math.round((rank * last) / (others.length - 1)); + return `branch-chip branch-chip--other-${shade}`; + } + + branchChipTitle(account: any): string { + const parts = [account?.officeName]; + if (Number(account?.officeId) === this.myOfficeId) { + parts.push('your branch'); + } + if (this.isHomeBranch(account)) { + parts.push('customer home branch'); + } + return parts.join(' - '); + } + + isHomeBranch(account: any): boolean { + return this.clientOfficeId != null && Number(account?.officeId) === this.clientOfficeId; + } + /** * @param {ActivatedRoute} route Activated Route * @param {ClientsService} clientService Clients Service * @param {Router} router Router */ constructor() { + // The customer's record is resolved on the parent route, not on this tab's own route data. + this.route.parent?.data.subscribe((data: any) => { + this.clientOfficeId = data?.clientViewData?.officeId ?? null; + }); this.route.data.subscribe( (data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => { this.clientAccountData = data.clientAccountsData; diff --git a/src/app/clients/clients.component.html b/src/app/clients/clients.component.html index 250023553..f56d8f9d0 100644 --- a/src/app/clients/clients.component.html +++ b/src/app/clients/clients.component.html @@ -50,6 +50,17 @@ /> + @if (isHeadOffice) { + + + All Branches + @for (office of offices; track office.id) { + {{ office.name }} + } + + + } + All Status @@ -92,6 +103,11 @@ row.legalForm?.id === 2 ? 'business' : 'person' }} {{ hideClientData ? maskName(row.displayName) : row.displayName }} + @if (!isHeadOffice && row.officeId && myOfficeId !== null && row.officeId !== myOfficeId) { + + Home: {{ row.officeName }} + + } @if (row._ckycDone) { verified } @@ -114,6 +130,12 @@
+ + + Branch + {{ row.officeName }} + + {{ 'labels.inputs.Status' | translate }} diff --git a/src/app/clients/clients.component.scss b/src/app/clients/clients.component.scss index b43c98496..ba9f068ab 100644 --- a/src/app/clients/clients.component.scss +++ b/src/app/clients/clients.component.scss @@ -7,3 +7,15 @@ */ @use '../shared/styles/list-layout' as *; + +// A customer registered at another branch that has been linked to this one. +.row-home-chip { + margin-left: 8px; + padding: 1px 8px; + font-size: 11px; + font-weight: 500; + color: #92400e; + background: #fef3c7; + border-radius: 999px; + white-space: nowrap; +} diff --git a/src/app/clients/clients.component.ts b/src/app/clients/clients.component.ts index d93a1ab2f..3291ab25e 100644 --- a/src/app/clients/clients.component.ts +++ b/src/app/clients/clients.component.ts @@ -38,6 +38,8 @@ import { MatIconButton, MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AuthenticationService } from '../core/authentication/authentication.service'; +import { OrganizationService } from '../organization/organization.service'; @Component({ selector: 'mifosx-clients', @@ -71,6 +73,15 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; }) export class ClientsComponent implements OnInit { private clientService = inject(ClientsService); + private authenticationService = inject(AuthenticationService); + private organizationService = inject(OrganizationService); + + /** Head Office sees every branch's clients, so it gets a Branch filter. A branch user only ever sees its own. */ + isHeadOffice = this.authenticationService.isHeadOfficeUser(); + offices: { id: number; name: string }[] = []; + officeFilter: number | null = null; + /** The caller's own branch, to tell a customer of another branch (linked here) from this branch's own. */ + myOfficeId = this.authenticationService.getCredentials()?.officeId ?? null; /** Returns true if client data masking is enabled */ get hideClientData(): boolean { @@ -90,6 +101,7 @@ export class ClientsComponent implements OnInit { 'displayName', 'cifNumber', 'legalForm', + 'officeName', 'status', 'profileCompletion', 'actions' @@ -119,6 +131,13 @@ export class ClientsComponent implements OnInit { @ViewChild(MatSort) sort: MatSort; ngOnInit() { + if (!this.isHeadOffice) { + this.displayedColumns = this.displayedColumns.filter((c) => c !== 'officeName'); + } else { + this.organizationService.getOffices().subscribe((offices: any[]) => { + this.offices = (offices || []).map((o) => ({ id: o.id, name: o.name })); + }); + } if (environment.preloadClients) { this.getClients(); } @@ -144,7 +163,8 @@ export class ClientsComponent implements OnInit { this.sortDirection, false, '', - true + true, + this.isHeadOffice ? this.officeFilter : null ) .subscribe( (data: any) => { @@ -233,6 +253,12 @@ export class ClientsComponent implements OnInit { this.dataSource.data = filtered; } + /** Branch filter is applied by the server so paging and the record count stay correct. */ + officeChanged() { + this.resetPaginator(); + this.getClients(); + } + pageChanged(event: PageEvent) { this.pageSize = event.pageSize; this.currentPage = event.pageIndex; diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index cd08181ce..89215b202 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -18,6 +18,7 @@ import { map, catchError } from 'rxjs/operators'; import { environment } from '../../environments/environment'; import { dataUrlToFile } from 'app/shared/utils/data-url.util'; +import { ClientIdentityLookupResult } from './models/client-identity.model'; /** * Clients service. @@ -742,9 +743,14 @@ export class ClientsService { sortDirection: string = '', activeOnly: boolean = false, legalFormType: string = '', - silent: boolean = false + silent: boolean = false, + officeId: number | null = null ) { const innerRequest: any = { text }; + // Branch filter (Head Office list). The server still limits results to the caller's office hierarchy. + if (officeId != null) { + innerRequest.officeId = officeId; + } if (activeOnly) { innerRequest.activeOnly = true; } @@ -868,6 +874,39 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/kyc`, kycData); } + /** + * "Is this person already a customer of the bank?" — across every branch, by PAN, Aadhaar fingerprint or GSTIN. + * Any ONE of them matching means the same customer. `contactConflicts` reports a phone/email already used by another + * client, which is never the same customer. Only the Aadhaar's SHA-256 is sent, never the number. + */ + /** + * Introduces a customer of another branch to the caller's branch, so the branch can see them and open an account + * for them there. The customer stays at the home branch. Safe to repeat. + */ + linkClientToMyBranch( + clientId: number + ): Observable<{ clientId: number; linked: boolean; officeId: number; officeName: string }> { + return this.http.post<{ clientId: number; linked: boolean; officeId: number; officeName: string }>( + '/clientidentity/link', + { + clientId + } + ); + } + + lookupClientIdentity(identity: { + panNumber?: string | null; + aadhaarHash?: string | null; + gstinNumber?: string | null; + mobileNo?: string | null; + emailAddress?: string | null; + excludeClientId?: number | null; + }): Observable { + return this.http.post('/clientidentity/lookup', identity, { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + /** * Screen a client name against the UN Security Council consolidated sanctions list. * @param name The name to screen @@ -1073,9 +1112,146 @@ export class ClientsService { */ saveKycContactLog( clientId: string | number, - data: { channel: string; notes: string; officerName?: string } + data: { channel: string; notes: string; officerName?: string; noticeType?: string }, + silent = false ): Observable { - return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data); + return this.http.post( + `/kyc/exceptions/${clientId}/contact-log`, + data, + silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {} + ); + } + + /** + * The subject and text a customer would receive for the stage they are at today. + * `type` is KYC or DORMANCY; the body comes back empty when they are at no such stage. + */ + previewKycNotice(clientId: string | number, type: string): Observable<{ subject?: string; body?: string }> { + return this.http.get<{ subject?: string; body?: string }>( + `/kyc/exceptions/${clientId}/contact-log/preview?type=${type}`, + { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + } + ); + } + + /** Queues notices to be sent in the background by the queue job. */ + enqueueKycNotices(body: { + items: { clientId: number; noticeType: string; channel: string; accountNo?: string; stageLabel?: string }[]; + notes?: string; + officerName?: string; + sendAt?: string; + }): Observable<{ queued: number; skipped: number; unreachable: number }> { + return this.http.post<{ queued: number; skipped: number; unreachable: number }>('/kyc/notice-queue', body); + } + + /** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */ + getKycNoticeQueue(status = ''): Observable { + return this.http.get(`/kyc/notice-queue${status ? `?status=${status}` : ''}`, { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + + actOnKycNotice(id: number, command: 'cancel' | 'retry'): Observable { + return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {}); + } + + /** What each of these customers has been sent so far: the latest notice, how many, and which channels are done. */ + getKycContactSummary(clientIds: number[]): Observable< + { + clientId: number; + count: number; + lastAt: string; + lastChannel: string; + lastStatus: string; + doneChannels: string[]; + balance?: number; + hasMobile?: boolean; + hasEmail?: boolean; + mobileMasked?: string | null; + emailMasked?: string | null; + }[] + > { + return this.http.get< + { + clientId: number; + count: number; + lastAt: string; + lastChannel: string; + lastStatus: string; + doneChannels: string[]; + hasMobile?: boolean; + hasEmail?: boolean; + mobileMasked?: string | null; + emailMasked?: string | null; + }[] + >('/kyc/contact-audit/clients', { + params: new HttpParams().set('ids', clientIds.join(',')), + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + + /** Notices per customer (counts, outcomes, the latest), most recently contacted first, a page at a time. */ + getKycContactAuditCustomers( + filter: { + from?: string; + to?: string; + channel?: string; + status?: string; + search?: string; + real?: boolean; + offset?: number; + limit?: number; + } = {} + ): Observable<{ total: number; rows: any[] }> { + return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit/customers', { + params: this.auditParams(filter), + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + + /** Notices per day (customers, notices, outcomes), newest day first. */ + getKycContactAuditDays( + filter: { from?: string; to?: string; channel?: string; status?: string; search?: string; real?: boolean } = {} + ): Observable { + return this.http.get('/kyc/contact-audit/days', { + params: this.auditParams(filter), + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + + /** Customer notices on record, newest first, a page at a time. */ + getKycContactAudit( + filter: { + from?: string; + to?: string; + channel?: string; + status?: string; + search?: string; + real?: boolean; + offset?: number; + limit?: number; + } = {} + ): Observable<{ total: number; rows: any[] }> { + return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit', { + params: this.auditParams(filter), + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + + private auditParams(filter: Record): HttpParams { + let params = new HttpParams(); + Object.entries(filter).forEach( + ([ + k, + v + ]) => { + if (v !== undefined && v !== null && v !== '') { + params = params.set(k, String(v)); + } + } + ); + return params; } getKycContactLog(clientId: string | number): Observable { @@ -1092,6 +1268,14 @@ export class ClientsService { return this.http.get('/kyc/exceptions/lapsed'); } + /** Clients whose re-KYC falls due within the next `days` days (default 30), soonest first. */ + getExpiringKyc(days = 30): Observable { + // The caller reports a failure in its own banner, so the global popup is suppressed. + return this.http.get(`/kyc/exceptions/expiring?days=${days}`, { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + /** * Savings accounts the dormancy job has marked. * diff --git a/src/app/clients/create-client/create-client.component.html b/src/app/clients/create-client/create-client.component.html index 039ada14f..b6937e631 100644 --- a/src/app/clients/create-client/create-client.component.html +++ b/src/app/clients/create-client/create-client.component.html @@ -38,6 +38,12 @@ }
} + @if (!createdClientId) { + + }
diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index ccd05c057..723fbffd6 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -23,6 +23,10 @@ import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india- import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component'; import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component'; import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component'; +import { + ClientIdentityCheckComponent, + ClientIdentityState +} from '../client-stepper/client-identity-check/client-identity-check.component'; import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component'; import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component'; @@ -60,6 +64,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ClientEntityKycStepComponent, ClientDatatableStepComponent, ClientKycStepComponent, + ClientIdentityCheckComponent, ClientPreviewStepComponent, ClientFamilyMembersStepComponent, MatProgressBarModule, @@ -79,6 +84,7 @@ export class CreateClientComponent { @ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent; @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent; @ViewChild('clientKyc') clientKycStep: ClientKycStepComponent; + @ViewChild(ClientIdentityCheckComponent) identityCheck: ClientIdentityCheckComponent; @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent; @ViewChildren('dtclient') clientDatatables: QueryList; @@ -87,6 +93,17 @@ export class CreateClientComponent { selectedTabIndex = 0; createdClientId = ''; savingClient = false; + /** + * Result of the existing-customer check at the top of the General step. While it says the customer already exists + * (or the identifiers disagree) a new client must not be created; otherwise the identifiers typed there are filled + * into the KYC step. + */ + identity: ClientIdentityState | null = null; + + onIdentityChange(state: ClientIdentityState): void { + this.identity = state; + } + /** Pre-fill data passed from "Create as Client" on a manual family member */ prefillData: any = null; @@ -138,6 +155,8 @@ export class CreateClientComponent { this.highestTabReached = 0; this.validationError = ''; this.prefillData = null; + this.identity = null; + this.identityCheck?.reset(); this.clientGeneralStep?.resetForNewClient(); this.clientKycStep?.resetForNewClient(); this.clientIndiaAddressStep?.resetForNewClient(); @@ -212,6 +231,10 @@ export class CreateClientComponent { const idx = this.selectedTabIndex; if (idx === 0) { + // The customer is already on the books (any branch), or the identifiers belong to different customers. + if (!this.createdClientId && (this.identity?.blocking || this.identity?.status === 'checking')) return false; + // A PAN / Aadhaar / GSTIN typed in the wrong format is neither looked up nor carried on, so it must be fixed or cleared. + if (!this.createdClientId && this.identityCheck?.hasInvalidInput) return false; if (!this.clientGeneralStep || !this.clientGeneralForm) return false; if (this.clientGeneralStep.sanctionsBlocked) return false; // A number the officer rejected on the DIP result must be replaced, not carried forward. @@ -274,6 +297,17 @@ export class CreateClientComponent { if (this.savingClient) return 'Saving — please wait…'; if (this.currentStepValid) return ''; const idx = this.selectedTabIndex; + if (idx === 0 && !this.createdClientId && this.identityCheck?.hasInvalidInput) { + return 'Correct the PAN / Aadhaar / GSTIN format in the Existing customer check, or clear the field.'; + } + if (idx === 0 && !this.createdClientId && this.identity?.status === 'checking') { + return 'Checking whether this customer already exists…'; + } + if (idx === 0 && !this.createdClientId && this.identity?.blocking) { + return this.identity.status === 'conflict' + ? 'The PAN / Aadhaar / GSTIN entered belong to different customers — correct them or ask a manager.' + : 'This customer already exists — a new client is not created for the same person. Open the existing customer instead.'; + } if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) { const disposition = this.clientGeneralStep.sanctionsDisposition; if (disposition === 'FALSE_POSITIVE') { @@ -358,6 +392,19 @@ export class CreateClientComponent { onNext() { // If on General tab and client not yet created — save first if (this.selectedTabIndex === 0 && !this.createdClientId) { + // Defensive re-check: the button is disabled while this holds, but a second client for the same person is the + // one mistake this screen exists to prevent. + if (this.identityCheck?.hasInvalidInput) { + this.identityCheck.markAllAsTouched(); + this.snackBar.open(this.currentStepBlockerMessage, 'Close', { duration: 6000 }); + return; + } + if (this.identity?.blocking) { + this.snackBar.open(this.currentStepBlockerMessage || 'This customer already exists.', 'Close', { + duration: 6000 + }); + return; + } if (!this.clientGeneralForm?.valid) { this.clientGeneralForm?.markAllAsTouched(); setTimeout(() => { diff --git a/src/app/clients/models/client-identity.model.ts b/src/app/clients/models/client-identity.model.ts new file mode 100644 index 000000000..e29ff8e54 --- /dev/null +++ b/src/app/clients/models/client-identity.model.ts @@ -0,0 +1,34 @@ +/** + * 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/. + */ + +/** An existing customer found by PAN / Aadhaar fingerprint / GSTIN. Identifiers are masked by the server. */ +export interface ClientIdentityMatch { + clientId: number; + displayName: string; + cifNumber: string; + officeId: number; + officeName: string; + legalFormEnum: number | null; + kycStatus: number; + /** True when the caller's branch can open this client; false when it sits in another branch. */ + visibleToCaller: boolean; + panMasked: string | null; + aadhaarMasked: string | null; + gstinMasked: string | null; + matchedBy: Array<'PAN' | 'AADHAAR' | 'GSTIN'>; +} + +export interface ClientIdentityLookupResult { + /** Any ONE of PAN / Aadhaar / GSTIN matched an existing customer: the same person or entity. */ + matched: boolean; + /** The identifiers point at different customers: bad data or fraud, not a customer to reuse. */ + conflict: boolean; + matches: ClientIdentityMatch[]; + /** A phone or email already used by another client. Never means "the same customer". */ + contactConflicts: { mobileNo: boolean; emailAddress: boolean }; +} diff --git a/src/app/clients/utils/aadhaar-hash.util.spec.ts b/src/app/clients/utils/aadhaar-hash.util.spec.ts new file mode 100644 index 000000000..48534e8df --- /dev/null +++ b/src/app/clients/utils/aadhaar-hash.util.spec.ts @@ -0,0 +1,52 @@ +/** + * 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 { createHash } from 'crypto'; +import { aadhaarHash, sha256Hex } from './aadhaar-hash.util'; + +describe('sha256Hex', () => { + it('matches the published SHA-256 test vectors', () => { + expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(sha256Hex('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe( + '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1' + ); + }); + + it('agrees with Node on 12-digit inputs, including ones that need a second block boundary', () => { + for (const value of [ + '000000000000', + '123456789012', + '999999999999', + '0'.repeat(55), + '0'.repeat(56), + '0'.repeat(64) + ]) { + expect(sha256Hex(value)).toBe(createHash('sha256').update(value).digest('hex')); + } + }); +}); + +describe('aadhaarHash', () => { + it('hashes a 12-digit number', () => { + expect(aadhaarHash('123456789012')).toBe(createHash('sha256').update('123456789012').digest('hex')); + }); + + it('ignores the spaces and hyphens people type between the groups', () => { + expect(aadhaarHash('1234 5678 9012')).toBe(aadhaarHash('123456789012')); + expect(aadhaarHash('1234-5678-9012')).toBe(aadhaarHash('123456789012')); + }); + + it('returns null for anything that is not a full number', () => { + expect(aadhaarHash('')).toBeNull(); + expect(aadhaarHash(null)).toBeNull(); + expect(aadhaarHash('XXXXXXXX1234')).toBeNull(); + expect(aadhaarHash('XXXXXXXX0000')).toBeNull(); + expect(aadhaarHash('12345678901')).toBeNull(); + }); +}); diff --git a/src/app/clients/utils/aadhaar-hash.util.ts b/src/app/clients/utils/aadhaar-hash.util.ts new file mode 100644 index 000000000..a3bd2fef7 --- /dev/null +++ b/src/app/clients/utils/aadhaar-hash.util.ts @@ -0,0 +1,185 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The comparable fingerprint of an Aadhaar number: SHA-256 of the 12 digits, as 64 lowercase hex characters. + * + * Only this fingerprint is sent to the server. The KYC create/update calls are recorded in an audit table and a log + * on the server, so the 12-digit number must not be in the request. + * + * A plain, unkeyed SHA-256 of 12 digits can be reversed by trying every number, so this is a lookup key and not + * protection against someone who can read the stored value. + * + * Written out in full instead of using `crypto.subtle` because that API is asynchronous and only exists in secure + * contexts, while the callers (a form-value getter, a blur handler) need the answer synchronously and the app may be + * served over plain HTTP. + */ + +const K = [ + 0x428a2f98, + 0x71374491, + 0xb5c0fbcf, + 0xe9b5dba5, + 0x3956c25b, + 0x59f111f1, + 0x923f82a4, + 0xab1c5ed5, + 0xd807aa98, + 0x12835b01, + 0x243185be, + 0x550c7dc3, + 0x72be5d74, + 0x80deb1fe, + 0x9bdc06a7, + 0xc19bf174, + 0xe49b69c1, + 0xefbe4786, + 0x0fc19dc6, + 0x240ca1cc, + 0x2de92c6f, + 0x4a7484aa, + 0x5cb0a9dc, + 0x76f988da, + 0x983e5152, + 0xa831c66d, + 0xb00327c8, + 0xbf597fc7, + 0xc6e00bf3, + 0xd5a79147, + 0x06ca6351, + 0x14292967, + 0x27b70a85, + 0x2e1b2138, + 0x4d2c6dfc, + 0x53380d13, + 0x650a7354, + 0x766a0abb, + 0x81c2c92e, + 0x92722c85, + 0xa2bfe8a1, + 0xa81a664b, + 0xc24b8b70, + 0xc76c51a3, + 0xd192e819, + 0xd6990624, + 0xf40e3585, + 0x106aa070, + 0x19a4c116, + 0x1e376c08, + 0x2748774c, + 0x34b0bcb5, + 0x391c0cb3, + 0x4ed8aa4a, + 0x5b9cca4f, + 0x682e6ff3, + 0x748f82ee, + 0x78a5636f, + 0x84c87814, + 0x8cc70208, + 0x90befffa, + 0xa4506ceb, + 0xbef9a3f7, + 0xc67178f2 +]; + +function rotr(x: number, n: number): number { + return (x >>> n) | (x << (32 - n)); +} + +/** SHA-256 of a string (UTF-8 encoded), as lowercase hex. */ +export function sha256Hex(text: string): string { + const bytes: number[] = []; + for (const ch of unescape(encodeURIComponent(text))) { + bytes.push(ch.charCodeAt(0)); + } + const bitLength = bytes.length * 8; + + bytes.push(0x80); + while (bytes.length % 64 !== 56) { + bytes.push(0); + } + // 64-bit big-endian message length; inputs here are tiny so the high word is always 0. + const high = Math.floor(bitLength / 0x100000000); + const low = bitLength >>> 0; + for (const word of [ + high, + low + ]) { + bytes.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff); + } + + const h = [ + 0x6a09e667, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19 + ]; + const w = new Array(64); + + for (let offset = 0; offset < bytes.length; offset += 64) { + for (let i = 0; i < 16; i++) { + const j = offset + i * 4; + w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3]; + } + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + + let [ + a, + b, + c, + d, + e, + f, + g, + hh + ] = h; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i] + w[i]) | 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) | 0; + hh = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + h[0] = (h[0] + a) | 0; + h[1] = (h[1] + b) | 0; + h[2] = (h[2] + c) | 0; + h[3] = (h[3] + d) | 0; + h[4] = (h[4] + e) | 0; + h[5] = (h[5] + f) | 0; + h[6] = (h[6] + g) | 0; + h[7] = (h[7] + hh) | 0; + } + + return h.map((x) => (x >>> 0).toString(16).padStart(8, '0')).join(''); +} + +/** + * The fingerprint for a 12-digit Aadhaar, or null when the value is not one (too short, masked, a placeholder). + * Spaces and hyphens people type between the groups are ignored. + */ +export function aadhaarHash(aadhaar: string | null | undefined): string | null { + const digits = (aadhaar ?? '').replace(/[\s-]/g, ''); + return /^\d{12}$/.test(digits) ? sha256Hex(digits) : null; +} diff --git a/src/app/clients/utils/name-case.util.spec.ts b/src/app/clients/utils/name-case.util.spec.ts index e58b929b0..c3e15a842 100644 --- a/src/app/clients/utils/name-case.util.spec.ts +++ b/src/app/clients/utils/name-case.util.spec.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { toProperNameCase } from './name-case.util'; +import { toPersonNameCase, toProperNameCase } from './name-case.util'; describe('toProperNameCase', () => { it('raises a lower-case first letter on every word', () => { @@ -53,3 +53,20 @@ describe('toProperNameCase', () => { expect(toProperNameCase(undefined)).toBe(''); }); }); + +describe('toPersonNameCase', () => { + it('title-cases every word, lowering mixed or upper case', () => { + expect(toPersonNameCase('keshav SIngh sisodiya')).toBe('Keshav Singh Sisodiya'); + expect(toPersonNameCase('KESHAV')).toBe('Keshav'); + }); + + it('keeps hyphen and apostrophe name prefixes', () => { + expect(toPersonNameCase('JEAN-marie')).toBe('Jean-Marie'); + expect(toPersonNameCase("D'SOUZA")).toBe("D'Souza"); + }); + + it('returns an empty string for blank or non-string input', () => { + expect(toPersonNameCase(' ')).toBe(''); + expect(toPersonNameCase(null)).toBe(''); + }); +}); diff --git a/src/app/clients/utils/name-case.util.ts b/src/app/clients/utils/name-case.util.ts index f127ff906..7ac74f7b4 100644 --- a/src/app/clients/utils/name-case.util.ts +++ b/src/app/clients/utils/name-case.util.ts @@ -62,3 +62,17 @@ export function toProperNameCase(value: string | null | undefined): string { } return collapsed.split(' ').map(capitalizeWord).join(' '); } + +/** + * Title case for an individual's first, middle and last name: each word's first letter raised, + * the rest lowered, so 'SIngh' and 'singh' both become 'Singh'. This is what the Full Name line on + * the general step has always shown; using it for the stored value too keeps the preview and the + * saved client the same as that line. Entity names stay on {@link toProperNameCase} — their + * spelling is matched against the registered PAN name. + */ +export function toPersonNameCase(value: string | null | undefined): string { + if (typeof value !== 'string') { + return ''; + } + return toProperNameCase(value.toLowerCase()); +} diff --git a/src/app/core/http/cache.interceptor.ts b/src/app/core/http/cache.interceptor.ts index d19b669c7..e5c8ed2b0 100644 --- a/src/app/core/http/cache.interceptor.ts +++ b/src/app/core/http/cache.interceptor.ts @@ -35,6 +35,7 @@ export class CacheInterceptor implements HttpInterceptor { '/rbireservereturn', '/rbireserveconfig', '/tdsconfig', + '/profitappropriationconfig', '/tds/deposit-interest', // Declarations and challans are entered on the very screens that read them back. '/tds/declarations', @@ -53,7 +54,12 @@ export class CacheInterceptor implements HttpInterceptor { // deposit may be closed at all - a stale copy would show a released lien as still blocking. '/liens', // The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken. - '/recurringdepositaccounts/dashboard' + '/recurringdepositaccounts/dashboard', + // Notes are also written by action screens (approve, close, deposit...), so a cached list would hide them. + '/notes', + // The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's + // own bucket edges from the query; a cached copy would show another date's or another setting's figures. + '/almreturn' ]; private isCacheable(url: string): boolean { diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index f4463de65..fb00db20c 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -2041,11 +2041,11 @@ - ALM (Asset Liability - Management) + ALM (Average Liabilities + and Assets) – Time Bucket Breakdown Balance Sheet (BR Act & Others) diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts index 4474746ce..49383469e 100644 --- a/src/app/core/shell/shell.component.ts +++ b/src/app/core/shell/shell.component.ts @@ -377,6 +377,25 @@ export class ShellComponent implements OnInit, OnDestroy { }); } + /** + * Opens the ALM Time-Bucket Breakdown: which balance-sheet item goes to which bucket of the + * Structural Liquidity Statement. Rules are editable and kept per browser. + */ + async openAlmBucketBreakdownDialog(): Promise { + const { AlmBucketBreakdownDialogComponent } = + await import('../../home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component'); + this.dialog.open(AlmBucketBreakdownDialogComponent, { + width: '94vw', + maxWidth: '1320px', + maxHeight: '92vh', + panelClass: 'crr-slr-dialog-panel', + autoFocus: false, + disableClose: false, + hasBackdrop: true, + backdropClass: 'alm-dlg-backdrop' + }); + } + /** * Unsubscribes from progress bar. */ diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html index 69528ae98..217353d2c 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html @@ -838,9 +838,23 @@ {{ c.clientCif || '—' }} - - {{ c.sharePercentage }}% - + @if (readOnly) { + + {{ c.sharePercentage }}% + + } @else { + + % + + }
+ @if (installmentPosition) { +
+ Next Installment Due + + @if (installmentPosition.nextDueDate) { + {{ installmentPosition.nextDueDate | dateFormat }} · + {{ installmentPosition.installmentAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} + } @else { + All installments paid + } + +
+
+ Installments Paid + {{ installmentPosition.paidInstallments }} of {{ installmentPosition.totalInstallments }} +
+ @if (installmentPosition.overdueInstallments > 0) { +
+ Overdue + + {{ installmentPosition.overdueInstallments }} installment(s) · + {{ installmentPosition.overdueAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + } @if ( recurringDepositsAccountData.timeline.closedOnDate || recurringDepositsAccountData.summary.totalWitddrawals ) { diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts index 11fd8779c..452887d69 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts @@ -46,11 +46,21 @@ export class GeneralTabComponent { totalLienAmount = 0; /** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */ lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null; + /** When the next installment is expected and how much, plus anything already overdue; null once fully paid. */ + installmentPosition: { + nextDueDate: string | null; + installmentAmount: number; + paidInstallments: number; + totalInstallments: number; + overdueInstallments: number; + overdueAmount: number; + } | null = null; constructor() { this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => { this.recurringDepositsAccountData = data.recurringDepositsAccountData; this.loadLiens(this.recurringDepositsAccountData?.id); + this.loadInstallmentPosition(this.recurringDepositsAccountData?.id); this.currency = this.recurringDepositsAccountData.currency; this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null; if (this.router.url.includes('clients')) { @@ -63,6 +73,27 @@ export class GeneralTabComponent { }); } + private loadInstallmentPosition(accountId: any) { + this.installmentPosition = null; + if (!accountId) { + return; + } + this.recurringDepositsService.getRecurringDepositInstallmentPosition(accountId).subscribe((response: any) => { + const row = (response?.accounts ?? []).find((a: any) => Number(a.id) === Number(accountId)); + if (!row) { + return; + } + this.installmentPosition = { + nextDueDate: row.nextDueDate || null, + installmentAmount: Number(row.installmentAmount) || 0, + paidInstallments: Number(row.paidInstallments) || 0, + totalInstallments: Number(row.totalInstallments) || 0, + overdueInstallments: Number(row.overdueInstallments) || 0, + overdueAmount: Number(row.overdueAmount) || 0 + }; + }); + } + private loadLiens(accountId: any) { this.liens = []; this.totalLienAmount = 0; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits.service.ts b/src/app/deposits/recurring-deposits/recurring-deposits.service.ts index cb399e41d..b1bc0b8c3 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits.service.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits.service.ts @@ -39,6 +39,16 @@ export class RecurringDepositsService { return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`); } + /** + * This deposit's installment position as of the business date (next due date, overdue count/amount), from the + * RD Dashboard endpoint - the same paid/overdue rule the auto-debit and penalty jobs use. Older backends ignore + * `accountId` and return every RD, so callers pick the row by id. + */ + getRecurringDepositInstallmentPosition(recurringDepositAccountId: any): Observable { + const httpParams = new HttpParams().set('accountId', recurringDepositAccountId); + return this.http.get('/recurringdepositaccounts/dashboard', { params: httpParams }); + } + /** * @param clientId Client Id * @param clientName Client Name diff --git a/src/app/directives/restrict-input.directive.ts b/src/app/directives/restrict-input.directive.ts index 176b32cbc..10e2df765 100644 --- a/src/app/directives/restrict-input.directive.ts +++ b/src/app/directives/restrict-input.directive.ts @@ -10,7 +10,7 @@ import { Directive, HostListener, Input, ElementRef, inject } from '@angular/cor import { NgControl } from '@angular/forms'; /** The kinds of field this knows how to hold to a shape. */ -export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar'; +export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar' | 'gstin' | 'tan'; interface Rule { /** Characters this kind will accept at all. */ @@ -27,7 +27,10 @@ const RULES: Record = { // A PAN is five letters, four digits, one letter - and is always written in capitals. pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }, // Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits. - aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true } + aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true }, + // GSTIN is fifteen letters and digits, TAN ten; both are always written in capitals. + gstin: { allowed: /[A-Za-z0-9]/, maxLength: 15, upper: true }, + tan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true } }; /** diff --git a/src/app/fiu/fiu-data.ts b/src/app/fiu/fiu-data.ts index f87077470..f02eeaf7a 100644 --- a/src/app/fiu/fiu-data.ts +++ b/src/app/fiu/fiu-data.ts @@ -54,6 +54,8 @@ export interface CtrTransaction { declaration: string; /** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */ customerName?: string; + /** True for a transaction that has crossed the threshold but is not yet in a generated CTR. */ + pendingFiling?: boolean; /** * CTR does not judge suspicion. It records only whether the source of funds was * evidenced at the counter (CTR filed, nothing further) or could not be, in which diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index 3c4946314..bc48a40f5 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -223,6 +223,15 @@
Loading the generated CTR lines…
} @else if (ctrLinesError) {
{{ ctrLinesError }}
+ } @else if (ctrPendingCount > 0) { +
+ + {{ ctrPendingCount }} cash transaction{{ ctrPendingCount > 1 ? 's' : '' }} of ₹10,00,000 or more + {{ ctrPendingCount > 1 ? 'are' : 'is' }} listed below automatically as soon as + {{ ctrPendingCount > 1 ? 'they are' : 'it is' }} posted. + {{ ctrPendingCount > 1 ? 'They are' : 'It is' }} not in a filed report yet — pick the month under “Generate + CTR” and run it to include {{ ctrPendingCount > 1 ? 'them' : 'it' }}. +
} @else if (!ctrTxns.length) {
@@ -342,27 +351,103 @@ Records Status Submitted On + Customers @for (r of amlCtrReports; track r.id) { - + {{ r.reportMonth }} - {{ r.generatedOn | date: 'medium' }} + {{ r.generatedOn ? (asDate(r.generatedOn) | date: 'medium') : '—' }} {{ r.recordCount }} {{ r.status }} - {{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }} + {{ r.submittedOn ? (asDate(r.submittedOn) | date: 'medium') : '—' }} + + + + } @empty { - No CTR reports generated yet. + No CTR reports generated yet. }
+ + + +
+ @if (ctrReportView; as report) { +
+
+

CTR report — {{ report.reportMonth }}

+

+ {{ report.status }} · {{ ctrReportLines.length }} record{{ ctrReportLines.length === 1 ? '' : 's' }} · + total ₹{{ ctrReportTotal | number: '1.2-2' }} +

+
+
+ + +
+
+ @if (ctrReportLinesLoading) { +
Loading the customers in this report…
+ } @else { +
+ + + + + + + + + + + + + + + @for (l of ctrReportLines; track $index) { + + + + + + + + + + + } @empty { + + + + } + +
CustomerCustomer ID / CKYCAccountBranchDateDirectionTypeAmount
+ {{ l.clientName }} +
+ {{ l.individual === false ? 'Entity' : 'Individual' }} +
+
{{ l.ckycNumber || l.clientExternalId || '—' }}{{ l.accountNo || '—' }}{{ l.branchName || '—' }}{{ l.transactionDate ? (asDate(l.transactionDate) | date: 'dd MMM yyyy') : '—' }}{{ l.direction || '—' }}{{ l.connectedGroup ? 'Connected group (month total)' : 'Single transaction' }}₹{{ l.amount | number: '1.2-2' }}
This report names no customers.
+
+ } + } +
+
} @@ -581,146 +666,153 @@
} - @if (selectedStrCase) { -
-
-
-

STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}

-

- Alert #{{ selectedStrCase.alertId }} · - {{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client - @if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) { - Risk category auto-escalated + + +

+ @if (selectedStrCase) { +
+
+
+

STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}

+

+ Alert #{{ selectedStrCase.alertId }} · + {{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client + @if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) { + Risk category auto-escalated + } +

+
+ +
+ +
+ {{ selectedStrCase.status }} + {{ strCaseOutcome(selectedStrCase) }} +
+ +
+
+ + +
+
+ + +
+ +
+
+ Principal Officer verification — mark each point as found + + {{ answeredCount }} / {{ strChecklist.length }} answered + +
+ + + + + + + + + + @for (item of strChecklist; track item.key) { + + + + + + } + +
Verification pointGenuineNot satisfied
+
{{ item.label }}
+
{{ item.hint }}
+
+ + + +
+
+ + +
+ @if (!allAnswered) { + + + Answer every verification point to determine the outcome — + {{ strChecklist.length - answeredCount }} still pending. + + } @else if (derivedConclusion === 'NOT_SUSPICIOUS') { + + + Not suspicious. All {{ strChecklist.length }} points found genuine — the case + will be closed and nothing filed with FIU-IND. + + } @else { + + + Suspicious — STR must be filed. + {{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied: + {{ failedFindings[0].label }} + @if (failedFindings.length > 1) { + and {{ failedFindings.length - 1 }} more + } + . + + } +
+ + + + @if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') { +
+ + + +
} -

-
- -
- -
- {{ selectedStrCase.status }} - {{ strCaseOutcome(selectedStrCase) }} -
- -
-
- - -
-
- - -
- -
-
- Principal Officer verification — mark each point as found - - {{ answeredCount }} / {{ strChecklist.length }} answered -
- - - - - - - - - - @for (item of strChecklist; track item.key) { - - - - - - } - -
Verification pointGenuineNot satisfied
-
{{ item.label }}
-
{{ item.hint }}
-
- - - -
- - -
- @if (!allAnswered) { - - - Answer every verification point to determine the outcome — - {{ strChecklist.length - answeredCount }} still pending. - - } @else if (derivedConclusion === 'NOT_SUSPICIOUS') { - - - Not suspicious. All {{ strChecklist.length }} points found genuine — the case will be - closed and nothing filed with FIU-IND. - - } @else { - - - Suspicious — STR must be filed. - {{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied: - {{ failedFindings[0].label }} - @if (failedFindings.length > 1) { - and {{ failedFindings.length - 1 }} more - } - . - - } -
- - - - @if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') { -
- - - -
- } -
+ }
- } +
diff --git a/src/app/fiu/fiu.component.scss b/src/app/fiu/fiu.component.scss index f8f0390c8..bef42a0dc 100644 --- a/src/app/fiu/fiu.component.scss +++ b/src/app/fiu/fiu.component.scss @@ -6,7 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -:host { +:host, +.fiu-dialog { --f-navy: #0b1e3d; --f-blue: #1d4ed8; --f-blue-l: #eff6ff; @@ -1858,3 +1859,8 @@ white-space: normal; font-size: 12.5px; } + +/* The STR case popup renders in the overlay, outside :host, so it carries the page's colour variables itself. */ +.fiu-dialog { + padding: 4px; +} diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index e918bce81..3b20699df 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -6,7 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject, ViewChild, TemplateRef } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { forkJoin, of } from 'rxjs'; +import { exportWorkbook } from 'app/cashier/cashier-export'; +import { catchError } from 'rxjs/operators'; import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -131,6 +136,134 @@ export class FiuComponent implements OnInit { private sanctionsLogsService = inject(SanctionsLogsService); private authenticationService = inject(AuthenticationService); private route = inject(ActivatedRoute); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + /** The STR case investigation, shown in a popup rather than a panel pushed in under the case list. */ + @ViewChild('strCaseDialog') strCaseDialogTpl!: TemplateRef; + private strCaseDialogRef: MatDialogRef | null = null; + + /** The CTR report whose customers are open in the popup, and the lines behind it. */ + @ViewChild('ctrReportDialog') ctrReportDialogTpl!: TemplateRef; + private ctrReportDialogRef: MatDialogRef | null = null; + ctrReportView: any = null; + ctrReportLines: any[] = []; + ctrReportLinesLoading = false; + + /** Opens a generated CTR report with every customer and transaction it names. */ + openCtrReport(report: any): void { + this.ctrReportView = report; + this.ctrReportLines = []; + this.ctrReportLinesLoading = true; + this.amlService.getCtrReportLines(report.id).subscribe({ + next: (lines) => { + this.ctrReportLines = lines || []; + this.ctrReportLinesLoading = false; + }, + error: () => { + this.ctrReportLinesLoading = false; + this.toast(`Could not load the customers in the ${report.reportMonth} report`); + } + }); + this.ctrReportDialogRef?.close(); + this.ctrReportDialogRef = this.dialog.open(this.ctrReportDialogTpl, { + width: '1280px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false + }); + this.ctrReportDialogRef.afterClosed().subscribe(() => { + this.ctrReportView = null; + this.ctrReportDialogRef = null; + }); + } + + closeCtrReport(): void { + this.ctrReportDialogRef?.close(); + } + + /** Total of the amounts in the open report, so the popup states what the filing adds up to. */ + get ctrReportTotal(): number { + return this.ctrReportLines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0); + } + + /** + * Excel workbook of a CTR report with the customer behind every line. Fetches the lines itself unless the popup + * already holds them, so it works straight from the reports table as well as from inside the popup. + */ + exportCtrReport(report: any): void { + const build = (lines: any[]) => this.buildCtrReportWorkbook(report, lines); + if (this.ctrReportView?.id === report.id && !this.ctrReportLinesLoading) { + build(this.ctrReportLines); + return; + } + this.amlService.getCtrReportLines(report.id).subscribe({ + next: (lines) => build(lines || []), + error: () => this.toast(`Could not load the ${report.reportMonth} report to export it`) + }); + } + + private async buildCtrReportWorkbook(report: any, lines: any[]): Promise { + const total = lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0); + await exportWorkbook( + [ + { + sheetName: `CTR ${report.reportMonth}`, + title: `Cash Transaction Report — ${report.reportMonth}`, + subtitle: `${report.status} · ${lines.length} record${lines.length === 1 ? '' : 's'} · total ₹${total.toLocaleString('en-IN')} · generated ${new Date().toLocaleString('en-IN')}`, + headers: [ + 'Report Month', + 'Customer', + 'Customer ID / CKYC Ref', + 'CKYC Number', + 'Customer Type', + 'Account No', + 'Branch', + 'Account Status', + 'Account Opened', + 'Transaction ID', + 'Transaction Date', + 'Direction', + 'Line Type', + 'Amount (₹)' + ], + rows: lines.map((l) => [ + l.reportMonth || report.reportMonth, + l.clientName || '', + l.clientExternalId || '', + l.ckycNumber || '', + l.individual === false ? 'Entity' : 'Individual', + l.accountNo || '', + l.branchName || '', + l.accountStatus || '', + toIsoDate(l.accountOpenedOn) || '', + l.transactionId ?? null, + toIsoDate(l.transactionDate) || '', + l.direction || '', + l.connectedGroup ? 'Connected group — month total' : 'Single transaction', + Number(l.amount) || 0 + ]), + widths: [ + 12, + 28, + 22, + 18, + 14, + 20, + 18, + 16, + 16, + 14, + 16, + 12, + 28, + 18 + ] + } + ], + `CTR-${report.reportMonth}` + ); + } /** Active tab */ activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; @@ -145,6 +278,8 @@ export class FiuComponent implements OnInit { */ ctrTxns: CtrTransaction[] = []; ctrLinesLoading = false; + /** Reportable cash transactions already posted but not yet in a generated CTR, listed automatically. */ + ctrPendingCount = 0; ctrLinesError = ''; /** * ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2. @@ -682,10 +817,23 @@ export class FiuComponent implements OnInit { loadCtrLines(): void { this.ctrLinesLoading = true; this.ctrLinesError = ''; - this.amlService.getCtrReportLines().subscribe({ - next: (lines) => { + // The pending list is an extra: if it cannot be read, the generated lines still show. + forkJoin({ + generated: this.amlService.getCtrReportLines(), + pending: this.amlService.getCtrPendingLines().pipe(catchError(() => of([] as any[]))) + }).subscribe({ + next: ({ generated, pending }) => { this.ctrLinesLoading = false; - this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1)); + const generatedRows = (generated || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1)); + const pendingRows = (pending || []).map((l: any, i: number) => ({ + ...this.toCtrTransaction(l, generatedRows.length + i + 1), + pendingFiling: true + })); + this.ctrPendingCount = pendingRows.length; + this.ctrTxns = [ + ...generatedRows, + ...pendingRows + ]; // Cached so the compliance flags an officer sets below survive a reload, keyed on the rows // that actually came back rather than on a seeded set. this.applyStoredCtrCompliance(); @@ -693,6 +841,7 @@ export class FiuComponent implements OnInit { error: (err: any) => { this.ctrLinesLoading = false; this.ctrTxns = []; + this.ctrPendingCount = 0; this.ctrLinesError = err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.'; @@ -820,6 +969,17 @@ export class FiuComponent implements OnInit { this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || ''; this.fileReferenceNo = strCase.strReferenceNo || ''; this.resetChecklist(); + this.strCaseDialogRef?.close(); + this.strCaseDialogRef = this.dialog.open(this.strCaseDialogTpl, { + width: '1100px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false + }); + this.strCaseDialogRef.afterClosed().subscribe(() => { + this.selectedStrCase = null; + this.strCaseDialogRef = null; + }); } /** Plain-language outcome for a live STR case, shown in the investigation panel. */ @@ -837,6 +997,7 @@ export class FiuComponent implements OnInit { } closeStrCaseDetail(): void { + this.strCaseDialogRef?.close(); this.selectedStrCase = null; } @@ -871,8 +1032,9 @@ export class FiuComponent implements OnInit { ? `STR case #${updated.id} concluded suspicious — file with FIU-IND by ${updated.fiuDueDate}` : `STR case #${updated.id} concluded not suspicious — no filing required` ); - this.selectedStrCase = updated; this.loadAmlStrCases(); + // Concluded: close the popup so the case's new status is what the officer sees in the list. + this.closeStrCaseDetail(); }, error: this.apiError(`Concluding STR case #${this.selectedStrCase.id}`) }); @@ -889,8 +1051,8 @@ export class FiuComponent implements OnInit { this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe({ next: (updated) => { this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`); - this.selectedStrCase = updated; this.loadAmlStrCases(); + this.closeStrCaseDetail(); }, error: this.apiError(`Filing STR case #${this.selectedStrCase.id}`) }); @@ -1784,6 +1946,12 @@ export class FiuComponent implements OnInit { } toast(msg: string): void { + // The page's own toast sits behind the popup's backdrop, so while the STR case popup is open the message would be + // there but unseen - which read as the action having done nothing. A snackbar is drawn above the popup. + if (this.strCaseDialogRef || this.ctrReportDialogRef) { + this.snackBar.open(msg, 'OK', { duration: 5000 }); + return; + } this.toastMsg = msg; this.toastVisible = true; if (this.toastTimer) clearTimeout(this.toastTimer); diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.html b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.html new file mode 100644 index 000000000..4a8fec04d --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.html @@ -0,0 +1,852 @@ + + +
+
+
+
+
+

ALM Time-Bucket Breakdown

+

Day-wise breakdown of FD / RD / Loan from the as-on date

+
+
+
+ @if (view === 'config') { + + + } + +
+
+ +
+ + +
+ +
+ @if (view === 'config') { +

+   Each line shows which bucket its money goes to. You can change the + % and the rule. Changes are saved in this browser straight away. Hover over a line name to see + why it is slotted that way. +

+ + @if (problemCount > 0) { +

+ {{ problemCount }} line(s) have a % total that is not 100 or is not filled in yet. See the red or yellow + totals below. +

+ } + } + + @if (view === 'config') { + +
+ + +
+ @if (loading) { + Loading data from accounts… + } @else if (loadError) { + {{ loadError }} + } @else if (breakdown) { + FD, RD and Loan data as on {{ breakdown.asOnDate }}. + } +
+
+ } + + @if (view === 'days') { +
+

1. Select

+
+ + +
+ + @if (selectBy === 'types') { +

Tick one or more. You can pick from both sides together.

+ @for (grp of pickGroups; track grp.side) { +
+
+ {{ grp.title }} +
+
+ @for (t of grp.items; track t.value) { + + } +
+
+ } +
+ + +
+ } @else { + + + @if (searching) { +

Searching…

+ } @else if (searchError) { +

{{ searchError }}

+ } @else if (searchText.trim().length < 2) { +

Type at least 2 characters of an account number or a customer name.

+ } @else if (!searchResults.length) { +

No accounts found.

+ } + + @if (searchResults.length) { +
+ @for (hit of searchResults; track hit.kind + '-' + hit.id) { + + } +
+ } + + @if (picked.length) { +
+ Selected ({{ picked.length }}) + @for (p of picked; track p.kind + '-' + p.id) { + + {{ p.kind }} {{ p.accountNo }} + + + } + +
+ } + } + +

2. Date and unit

+
+ + + +
+ + @if (reportShown && selectBy === 'types') { +
+

+ {{ typeColumns.length }} account {{ typeColumns.length === 1 ? 'type' : 'types' }} + as on {{ asOnIso }} · Amounts in {{ unitWord }} +

+ @if (loading) { +

Loading data from accounts…

+ } + @if (loadError) { +

{{ loadError }}

+ } + @if (breakdown) { + @for (c of typeColumns; track c.value) { + @if (c.notice) { +

{{ c.label }}: {{ c.notice }}

+ } + } +
+ + + + + + + @for (c of typeColumns; track c.value) { + + } + @if (typeHasLiability && typeHasAsset) { + + + } @else if (typeColumns.length > 1) { + + } + + + + @for (r of typeMatrix; track r.label) { + + + + + @for (v of r.cells; track $index) { + + } + @if (typeHasLiability && typeHasAsset) { + + + } @else if (typeColumns.length > 1) { + + } + + } + + + + + @for (c of typeColumns; track c.value) { + + } + @if (typeHasLiability && typeHasAsset) { + + + } @else if (typeColumns.length > 1) { + + } + + +
BucketDaysDates + {{ c.label }} + Liabilities totalAssets totalTotal
{{ r.label }}{{ r.days }}{{ r.dates }}{{ amountText(v) }}{{ amountText(r.liab) }}{{ amountText(r.asset) }}{{ amountText(r.liab + r.asset) }}
Total{{ amountText(c.total) }}{{ amountText(liabilityGrandTotal) }}{{ amountText(assetGrandTotal) }}{{ amountText(liabilityGrandTotal + assetGrandTotal) }}
+
+

+ Deposits and term loans are slotted by their own dates: a deposit once on its maturity date, a loan once + for each unpaid instalment on its due date. Anything already due goes in the first bucket (a loan + instalment follows the Overdue rule). CC / OD and NPA loans are divided by the bucket-wise % set for + them in the Configuration table. Deposit amounts are balances, loan amounts are principal; interest is a + separate line of the statement. +

+ } +
+ } + + @if (reportShown && selectBy === 'accounts') { +
+

+ {{ picked.length }} {{ picked.length === 1 ? 'account' : 'accounts' }} + as on {{ asOnIso }} · Amounts in {{ unitWord }} +

+ @if (reportLoading) { +

Loading…

+ } + @if (reportError) { +

{{ reportError }}

+ } + @if (report; as rep) { + + + + + + + + + + + + + @for (r of reportBuckets; track r.label) { + + + + + + + + + } + + + + + + + + + +
BucketDaysDatesAmount%Entries
{{ r.label }}{{ r.days }}{{ r.dates }}{{ amountText(r.amount) }}{{ r.amount ? (r.pct | number: '1.0-1') + '%' : '' }}{{ r.count || '' }}
Total{{ amountText(rep.total) }}{{ rep.total ? '100%' : '' }}{{ rep.count }}
+

+ A deposit counts once, on its maturity date; a loan counts once for each unpaid instalment, on its due + date. Anything already due goes in the first bucket (a loan instalment follows the Overdue rule). + Deposit amounts are balances, loan amounts are the principal of each instalment; interest is a separate + line of the statement. +

+ +

Each entry

+
+ + + + + + + + + + + + + + + @for (r of rep.rows; track $index) { + + + + + + + + + + + } @empty { + + + + } + +
AccountCustomerProductDue / maturity dateDaysBucketStatusAmount
+ {{ r.kind }} {{ r.accountNo }} + @if (r.installment) { + instalment {{ r.installment }} + } + {{ r.customer }} + {{ r.product }} + @if (r.note) { +
{{ r.note }}
+ } +
{{ r.date ? (r.date | date: 'dd-MMM-yyyy') : '—' }}{{ r.days === null ? '' : r.days }}{{ bucketName(r.bucket) }}{{ r.status }}{{ amountText(r.amount) }}
Nothing to show for these accounts on this date.
+
+ } +
+ } +
+ } @else { +
+
+

Bucket ranges

+ +
+

+ @if (rangeEdit) { + Change the Start / End of a bucket, or add and remove buckets. When you change the End of a bucket, the + Start of the next one and its heading change with it. + } @else { + Start and End of each bucket. Click Edit to change, add or remove buckets. + } +

+ + + + + + + + + @if (rangeEdit) { + + } + + + + @for (b of cfg.buckets; track $index) { + + + + + + + @if (rangeEdit) { + + } + + } + +
#StartEndHeadingDays / Dates
{{ $index + 1 }} + @if (fromOf($index); as f) { + @if (rangeEdit) { + + + + + } @else { + {{ f.n }} {{ unitLetter(f.unit) }} + } + } + + @if (b.upTo; as t) { + @if (rangeEdit) { + + + + + } @else { + {{ t.n }} {{ unitLetter(t.unit) }} + } + } @else { + end + } + + {{ b.label }} + @if (rangeProblem($index); as problem) { +
{{ problem }}
+ } +
+ @if (windows[$index]; as w) { + {{ dayRange(w) }} · {{ dateRange(w) }} + } + + @if (cfg.buckets.length > 2) { + + } +
+ + @if (rangeEdit) { + +
+ + Add a bucket ending at: + + + + + + + It is placed in the right position automatically and splits the bucket it falls inside into two. + +
+ } + +
+ + @if (dayCheck; as c) { +
+ @if (c.due) { + Already due ({{ -c.days }} days ago) → {{ c.bucket }} + } @else { + {{ c.days }} days left → {{ c.bucket }} + } +
+ } @else { +
Pick a maturity date and its bucket will show here.
+ } +
+
+ + + @for (sec of sections; track sec.side) { +
+ + + + + + + @if (editMode) { + + } + + + @for (b of cfg.buckets; track $index) { + + } + + + + @for (item of itemsOf(sec.side); track item.id) { + @if (item.header) { + + + + + @if (editMode) { + + } + + } @else { + + + + + + @if (item.rule === 'maturity') { + @if (autoAmounts(item); as amounts) { + + @for (v of amounts; track $index) { + + } + + } @else { + + + } + } @else if (item.rule === 'fixed') { + + @for (b of cfg.buckets; track $index) { + + } + } @else { + @for (b of cfg.buckets; track $index) { + + } + + } + + @if (editMode) { + + } + + } + } + +
{{ sec.title }}RESIDUAL MATURITYTotalLine
{{ b.label }}
+ @if (editMode) { + + } @else { + {{ item.ref }} + } + + @if (editMode) { + + } @else { + {{ item.name }} + } + + +
+ @if (editMode) { + + } @else { + {{ item.ref }} + } + + @if (editMode) { + + } @else { + {{ item.name }} + } + + + @if (item.rule === 'maturity' || item.rule === 'behavioural') { + + + } + {{ amountText(v) }} + {{ amountText(autoTotal(amounts)) }} + + @if (!item.source) { + This line has to be filled by hand: it has no account source yet + } @else if (loading) { + Data aa raha hai… + } @else { + No data came from the accounts + } + — + {{ share(item, $index) !== null ? '100%' : '' }} + + + + {{ rowState(item) === 'todo' ? 'fill in' : rowTotal(item) + '%' }} + + + +
+ @if (editMode) { + + } +
+ } + + @if (editMode) { +
+
Bucket names and day span
+ @for (b of cfg.buckets; track $index) { +
+ {{ $index + 1 }} + + +
+ @if (b.upTo) { + + + } @else { + No end + } + +
+
+ } + + @if (cfg.overdue; as o) { +
Overdue (due but not received): which bucket
+
+ + +
+ } +
+ } + + + @if (breakdown; as b) { +
+

Shown separately (not slotted by date)

+
    + @for (n of b.notSlotted; track n.key) { +
  • + {{ n.label }} — {{ n.accounts }} loan, outstanding + {{ amountText(n.outstanding) }} +
    {{ n.why }}
    +
  • + } +
+
    + @for (note of b.notes; track note) { +
  • {{ note }}
  • + } +
+
+ } + + +
+

What the report will show

+
+
Statement of Structural Liquidity as on {{ asOnIso }}
+
( Amounts in {{ unitWord }} )
+
+
    +
  • + Two tables, OUTFLOWS and INFLOWS, with the lines above. Each line’s amount + sits in its bucket, with a Total in the last column. +
  • +
  • A. TOTAL OUTFLOWS — the sum of all outflow lines, bucket by bucket.
  • +
  • B. TOTAL INFLOWS — the sum of all inflow lines, bucket by bucket.
  • +
  • C. MISMATCH (B − A) — inflow minus outflow, in each bucket.
  • +
  • + D. CUMULATIVE MISMATCH — running total of C, from the first bucket up to that bucket. +
  • +
  • E. C as % to A — mismatch divided by outflow, times 100.
  • + @for (l of cfg.limits; track $index) { +
  • + {{ cfg.buckets[l.bucket]?.label }}: negative mismatch must not exceed {{ l.pct }}%. The report shows it in red when it does. +
  • + } +
+

+ Where the amount comes from: By due date lines take their data from FD, RD and Loan accounts. + Fixed, Split and By study lines take the ledger (GL) + balance and apply the % above. Lines with no register have to be filled in by hand. +

+
+ } +
+
diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.scss b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.scss new file mode 100644 index 000000000..71feb0c7f --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.scss @@ -0,0 +1,1347 @@ +/** + * 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 { + --g50: #f8fafc; + --g100: #f1f5f9; + --g200: #e2e8f0; + --g300: #cbd5e1; + --g500: #64748b; + --g700: #334155; + --g900: #0f172a; + --indigo: #4f46e5; + --indigo-l: #e0e7ff; + --liab: #b91c1c; + --liab-l: #fee2e2; + --asset: #15803d; + --asset-l: #dcfce7; + --warn: #b45309; + --warn-l: #fef3c7; + + /* The filed statement's header yellow */ + --sheet-head: #fff200; + --sheet-line: #1e293b; + + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: var(--g900); +} + +.bkd { + background: #fff; + border-radius: 14px; + display: flex; + flex-direction: column; + overflow: hidden; + max-height: 90vh; +} + +/* ── Header ── */ +.bkd-head { + padding: 16px 20px; + border-bottom: 1px solid var(--g200); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + background: linear-gradient(180deg, var(--g50), #fff); +} + +.bkd-head-left { + display: flex; + align-items: flex-start; + gap: 13px; +} + +.bkd-head-ic { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + flex: 0 0 auto; + border-radius: 10px; + background: var(--indigo-l); + color: var(--indigo); + font-size: 17px; +} + +.bkd-title { + margin: 0; + font-size: 17px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.bkd-sub { + margin: 3px 0 0; + font-size: 12px; + color: var(--g500); +} + +.bkd-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.bkd-close { + border: 0; + background: var(--g100); + width: 30px; + height: 30px; + border-radius: 8px; + cursor: pointer; + color: var(--g700); +} + +.bkd-body { + padding: 18px 20px 22px; + overflow: auto; + width: 100%; + box-sizing: border-box; +} + +/* ── Buttons ── */ +.bkd-btn { + border: 1px solid var(--g300); + background: #fff; + color: var(--g700); + border-radius: 8px; + padding: 7px 12px; + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + + &:hover { + background: var(--g50); + } +} + +.bkd-btn-on { + background: var(--indigo); + border-color: var(--indigo); + color: #fff; + + &:hover { + background: var(--indigo); + } +} + +.bkd-btn-sm { + padding: 4px 9px; + font-size: 12px; +} + +.bkd-x { + border: 0; + background: var(--g100); + width: 24px; + height: 24px; + border-radius: 6px; + cursor: pointer; + color: var(--g700); +} + +.bkd-note { + margin: 0 0 14px; + padding: 9px 12px; + background: var(--g50); + border: 1px solid var(--g200); + border-radius: 8px; + font-size: 12.5px; + line-height: 1.5; + color: var(--g700); +} + +.bkd-warn { + color: var(--warn); + font-size: 12px; + margin: 8px 0 0; +} + +.bkd-banner { + padding: 8px 12px; + background: var(--warn-l); + border-radius: 8px; + margin: 0 0 14px; +} + +.bkd-none { + color: var(--g500); +} + +.bkd-empty { + font-size: 12.5px; + color: var(--g500); +} + +.bkd-dim { + opacity: 0.45; +} + +/* ── The configuration table, like the filed statement ── */ +.bkd-sheet-wrap { + margin-bottom: 20px; + overflow-x: auto; +} + +.bkd-sheet { + width: 100%; + min-width: 1180px; + border-collapse: collapse; + font-size: 12.5px; + + th, + td { + border: 1px solid var(--sheet-line); + padding: 0; + } + + thead th { + background: var(--sheet-head); + font-weight: 700; + text-align: center; + padding: 6px 4px; + line-height: 1.25; + } + + .s-title { + text-align: left; + vertical-align: bottom; + padding: 6px 8px; + font-size: 13px; + } + + .s-span { + font-size: 13px; + } + + .s-bucket { + width: 88px; + font-size: 11.5px; + } + + .s-total-h { + width: 62px; + } + + .s-tools-h { + width: 70px; + } +} + +.bkd-sheet tbody td { + padding: 3px 6px; + vertical-align: middle; +} + +.s-ref { + width: 40px; + text-align: right; + font-weight: 700; + color: var(--g700); + + input { + width: 36px; + padding: 2px 3px; + text-align: right; + } +} + +.s-name { + min-width: 270px; + font-weight: 600; + + input { + width: 100%; + } +} + +.s-sub { + padding-left: 18px !important; + font-weight: 500; +} + +.s-rule { + width: 104px; + + select { + width: 100%; + padding: 3px 4px; + font-size: 11.5px; + } +} + +.s-head td { + background: var(--g100); + font-weight: 700; +} + +.s-head-fill { + background: var(--g100); +} + +.s-cell { + text-align: center; + + input { + width: 100%; + min-width: 0; + border: 0; + background: transparent; + text-align: center; + padding: 4px 2px; + font: inherit; + font-size: 12.5px; + appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } + + &:focus { + outline: 2px solid var(--indigo); + background: #fff; + } + } +} + +.s-cell-on { + background: var(--asset-l); + + input { + font-weight: 700; + color: var(--asset); + } +} + +/* Figures the accounts put in a due-date line on their own */ +.s-auto { + text-align: right; + padding-right: 8px !important; + font-variant-numeric: tabular-nums; + font-weight: 600; + background: var(--asset-l); + color: var(--asset); + white-space: nowrap; +} + +.s-auto-zero { + background: transparent; + color: var(--g300); + font-weight: 400; +} + +.s-auto-total { + text-align: right; + background: var(--g50); +} + +.s-src { + margin-top: 3px; + width: 100%; + font-size: 11px !important; +} + +.bkd-toolbar { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 14px; + + label { + display: grid; + gap: 3px; + width: 180px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); + } +} + +.bkd-toolbar-state { + font-size: 12.5px; + color: var(--g500); + padding-bottom: 6px; +} + +.bkd-ok { + color: var(--asset); +} + +.bkd-apart-box { + margin: 0 0 12px; + padding: 10px 14px; + border: 1px solid var(--g200); + border-radius: 10px; + background: var(--g50); +} + +/* A fixed line moves by clicking the bucket it should go to */ +.s-pick { + cursor: pointer; + font-weight: 700; + color: var(--asset); + user-select: none; + + &:hover:not(.s-cell-on) { + background: var(--indigo-l); + } + + &:focus-visible { + outline: 2px solid var(--indigo); + outline-offset: -2px; + } +} + +/* A line carrying percentages: the boxes read as boxes, not as a coloured display */ +.bkd-sheet .s-cell-input input { + border: 1px solid var(--g300); + background: #fff; + border-radius: 4px; + margin: 1px 0; + + &:hover { + border-color: var(--indigo); + } + + &::placeholder { + color: var(--g300); + } +} + +.s-due { + text-align: center; + background: repeating-linear-gradient(45deg, var(--g50), var(--g50) 5px, var(--g100) 5px, var(--g100) 10px); + color: var(--g700); + font-size: 12px; +} + +.s-tot { + text-align: center; + font-weight: 700; + white-space: nowrap; +} + +.s-tot-ok { + color: var(--asset); +} + +.s-tot-bad { + background: var(--liab-l); + color: var(--liab); +} + +.s-tot-todo { + background: var(--warn-l); + color: var(--warn); +} + +.s-off td { + opacity: 0.45; +} + +.s-tools { + text-align: center; + white-space: nowrap; +} + +.s-incl { + display: inline-flex; + align-items: center; + margin-right: 4px; + cursor: pointer; +} + +.s-add { + margin-top: 8px; +} + +/* ── Report preview ── */ +.bkd-report { + margin-top: 8px; +} + +.bkd-h3 { + margin: 0 0 8px; + font-size: 15px; + font-weight: 700; +} + +.bkd-h4 { + margin: 18px 0 6px; + font-size: 13px; + font-weight: 700; +} + +.bkd-report-card { + border: 1px solid var(--sheet-line); + padding: 8px 12px; + text-align: center; + max-width: 560px; + + .r-title { + font-weight: 700; + font-size: 13.5px; + } + + .r-sub { + font-size: 12px; + color: var(--g700); + } +} + +.bkd-report-list { + margin: 12px 0 0; + padding-left: 18px; + font-size: 12.5px; + line-height: 1.7; + color: var(--g700); +} + +.bkd-limit { + color: var(--liab); +} + +/* ── Live data, collapsed until wanted ── */ +.bkd-live { + margin-top: 22px; + border: 1px solid var(--g200); + border-radius: 10px; + padding: 10px 14px 14px; + + summary { + cursor: pointer; + font-size: 13px; + font-weight: 700; + color: var(--g700); + } +} + +.bkd-tbl { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + font-size: 11.5px; + font-weight: 700; + color: var(--g500); + padding: 6px 8px; + border-bottom: 1px solid var(--g200); + } + + td { + padding: 8px; + border-bottom: 1px solid var(--g100); + vertical-align: middle; + } +} + +.bkd-num { + text-align: right !important; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.bkd-b-name { + font-weight: 600; +} + +.bkd-b-hint { + font-size: 11px; + color: var(--g500); +} + +.bkd-days-intro, +.bkd-days-note { + margin: 0 0 10px; + font-size: 12.5px; + color: var(--g700); + line-height: 1.5; +} + +.bkd-days-note { + margin: 8px 0 0; + color: var(--g500); +} + +.bkd-asof, +.bkd-calc label { + display: grid; + gap: 3px; + width: 190px; + margin: 12px 0 10px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); +} + +.bkd-calc { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + margin-top: 16px; + padding: 12px; + border: 1px solid var(--g200); + border-radius: 10px; + background: var(--g50); + + label { + margin: 0; + } +} + +.bkd-calc-out { + font-size: 13.5px; + padding-bottom: 6px; +} + +.bkd-mat-wrap { + margin-top: 12px; + overflow-x: auto; + border: 1px solid var(--g200); + border-radius: 10px; +} + +.bkd-mat { + min-width: 920px; + font-size: 12.5px; + + th small { + display: block; + font-weight: 500; + color: var(--g500); + } + + td.bkd-num, + th.bkd-num { + width: auto; + white-space: nowrap; + } +} + +.bkd-mat-item { + min-width: 190px; + text-align: left; +} + +.bkd-mat-sec td { + background: var(--g100); + font-weight: 700; + font-size: 12px; +} + +.bkd-mat-liab td { + color: var(--liab); +} + +.bkd-mat-asset td { + color: var(--asset); +} + +.bkd-mat-total { + font-weight: 700; + background: var(--g50); +} + +.bkd-apart, +.bkd-notes { + margin: 0; + padding-left: 18px; + font-size: 12.5px; + line-height: 1.6; + color: var(--g700); +} + +.bkd-notes { + margin-top: 12px; + color: var(--g500); +} + +/* ── Editing buckets ── */ +.bkd-bucket-edit { + margin: 0 0 20px; + padding: 12px; + background: var(--g50); + border: 1px solid var(--g200); + border-radius: 8px; + display: grid; + gap: 8px; +} + +.bkd-bucket-edit-row { + display: grid; + grid-template-columns: 24px 1.4fr 1.4fr 230px; + gap: 8px; + align-items: center; +} + +.bkd-step-n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--indigo-l); + color: var(--indigo); + font-size: 11.5px; + font-weight: 700; +} + +.bkd-editor-sub { + font-size: 11.5px; + font-weight: 700; + color: var(--g700); +} + +.bkd-span { + display: flex; + align-items: center; + gap: 6px; + + /* stylelint-disable-next-line selector-no-qualifying-type */ + input[type='number'] { + width: 64px; + } + + select { + width: 62px; + } + + .bkd-check { + white-space: nowrap; + } +} + +.bkd-check { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; +} + +.bkd-overdue-title { + margin-top: 8px; +} + +.bkd-overdue { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + + label { + display: grid; + gap: 3px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); + } +} + +/* :where() keeps these defaults at zero specificity, so the table's own cell inputs always win. */ +/* stylelint-disable-next-line selector-no-qualifying-type */ +:where(input[type='text'], input[type='number'], input[type='date'], select, textarea) { + font: inherit; + font-size: 12.5px; + padding: 6px 8px; + border: 1px solid var(--g300); + border-radius: 6px; + background: #fff; + color: var(--g900); + min-width: 0; + box-sizing: border-box; +} + +/* stylelint-disable-next-line selector-no-qualifying-type */ +:where(input[type='text'], select) { + width: 100%; +} + +/* stylelint-disable-next-line selector-no-qualifying-type */ +:where(input[type='checkbox']) { + width: auto; +} + +@media (width <= 700px) { + .bkd-bucket-edit-row, + .bkd-overdue { + grid-template-columns: 1fr; + } +} + +/* ── Tabs ── */ +.bkd-tabs { + display: flex; + gap: 4px; + padding: 0 20px; + border-bottom: 1px solid var(--g200); + background: #fff; +} + +.bkd-tab { + border: 0; + background: none; + padding: 10px 14px; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--g500); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + + &:hover { + color: var(--g900); + } + + &.on { + color: var(--indigo); + border-bottom-color: var(--indigo); + } +} + +/* ── Day breakdown of one product ── */ +.bkd-focus { + margin-bottom: 12px; + + label { + display: grid; + gap: 3px; + width: 260px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); + } +} + +.bkd-focus-tbl { + max-width: 980px; + + tfoot td { + font-weight: 700; + border-top: 1px solid var(--g300); + border-bottom: 0; + } + + td.bkd-num { + font-weight: 600; + } +} + +/* ── Bucket ranges (From / To) ── */ +.bkd-h4-first { + margin-top: 0; +} + +.bkd-range { + max-width: 980px; +} + +.bkd-range-n { + width: 28px; + color: var(--g500); + font-weight: 700; +} + +.bkd-span-edit { + display: inline-flex; + gap: 4px; + align-items: center; + + /* stylelint-disable-next-line selector-no-qualifying-type */ + input[type='number'] { + width: 60px; + } + + select { + width: 62px; + } +} + +/* ── Accounts behind the chosen line ── */ +.bkd-acc-head { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + margin: 14px 0 8px; +} + +.bkd-acc-sum { + font-size: 12.5px; + color: var(--g700); +} + +.bkd-bad { + color: var(--liab); + font-weight: 700; +} + +.bkd-acc-cell { + white-space: nowrap; +} + +.bkd-link { + border: 0; + background: none; + color: var(--indigo); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 0 0 0 6px; +} + +.bkd-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0; +} + +.bkd-chip { + border: 1px solid var(--g300); + background: #fff; + border-radius: 999px; + padding: 3px 10px; + font: inherit; + font-size: 12px; + cursor: pointer; + + &.on { + background: var(--indigo-l); + border-color: var(--indigo); + color: var(--indigo); + font-weight: 700; + } +} + +.bkd-acc-wrap { + max-height: 360px; + overflow: auto; + border: 1px solid var(--g200); + border-radius: 10px; +} + +.bkd-acc { + font-size: 12.5px; + + thead th { + position: sticky; + top: 0; + background: var(--g50); + } +} + +/* ── Adding a bucket ── */ +.bkd-addbucket { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0 4px; + padding: 8px 10px; + border: 1px dashed var(--g300); + border-radius: 8px; +} + +.bkd-addbucket-title { + font-size: 12.5px; + font-weight: 600; + color: var(--g700); +} + +/* ── Bucket ranges: header with its own Edit button, read-only values ── */ +.bkd-range-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; + + .bkd-h4 { + margin: 0; + } +} + +.bkd-range-val { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* ── Choosing what to break down ── */ +.bkd-pick-group { + margin-bottom: 12px; +} + +.bkd-pick-title { + font-size: 12px; + font-weight: 700; + margin-bottom: 6px; +} + +.bkd-pick-liab { + color: var(--liab); +} + +.bkd-pick-asset { + color: var(--asset); +} + +.bkd-pick-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.bkd-pick-opt { + max-width: 340px; + text-align: left; + border: 1px solid var(--g300); + background: #fff; + border-radius: 8px; + padding: 7px 11px; + font: inherit; + font-size: 12.5px; + line-height: 1.35; + color: var(--g900); + cursor: pointer; + + &:hover { + border-color: var(--indigo); + } + + &.on { + border-color: var(--indigo); + background: var(--indigo-l); + color: var(--indigo); + font-weight: 600; + } +} + +.bkd-pick-ref { + font-weight: 700; + margin-right: 4px; + color: var(--g500); +} + +.bkd-pick-opt.on .bkd-pick-ref { + color: var(--indigo); +} + +.bkd-pick-nodata { + margin-left: 6px; + font-size: 10.5px; + font-weight: 600; + color: var(--warn); + background: var(--warn-l); + padding: 1px 6px; + border-radius: 999px; +} + +.bkd-submit-row { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + + label { + display: grid; + gap: 3px; + width: 180px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); + } +} + +.bkd-submit { + padding: 8px 26px; + font-size: 13px; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.bkd-report-out { + margin-top: 22px; + padding-top: 14px; + border-top: 2px solid var(--g200); +} + +.bkd-report-title { + margin-top: 0; + font-size: 15px; + + small { + display: block; + margin-top: 2px; + font-size: 12px; + font-weight: 500; + color: var(--g500); + } +} + +.bkd-setup { + margin-bottom: 20px; + padding: 12px 14px 14px; + border: 1px solid var(--g200); + border-radius: 10px; + background: var(--g50); +} + +/* ── Editing the picker ── */ +.bkd-pick-wrap { + display: inline-flex; + align-items: stretch; +} + +.bkd-pick-del { + border: 1px solid var(--g300); + border-left: 0; + background: var(--g100); + border-radius: 0 8px 8px 0; + padding: 0 8px; + color: var(--liab); + cursor: pointer; + + &:hover { + background: var(--liab-l); + } +} + +.bkd-pick-wrap:has(.bkd-pick-del) .bkd-pick-opt { + border-radius: 8px 0 0 8px; +} + +.bkd-pick-edit { + margin: 4px 0 12px; + padding: 10px 12px; + border: 1px dashed var(--g300); + border-radius: 8px; + background: var(--g50); + display: grid; + gap: 10px; +} + +.bkd-pick-acc { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + font-size: 12.5px; + + select { + min-width: 260px; + } +} + +.bkd-pick-add label { + display: grid; + gap: 3px; + max-width: 460px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); +} + +.bkd-pick-checks { + display: flex; + flex-wrap: wrap; + gap: 8px 22px; +} + +/* ── Searching and picking accounts ── */ +.bkd-search { + display: flex; + gap: 8px; + max-width: 720px; + + /* stylelint-disable-next-line selector-no-qualifying-type */ + input[type='text'] { + flex: 1; + padding: 9px 11px; + font-size: 13px; + } + + select { + width: 150px; + } +} + +.bkd-hits { + display: grid; + gap: 6px; + max-width: 720px; + margin-top: 10px; + max-height: 300px; + overflow: auto; +} + +.bkd-hit { + display: grid; + grid-template-columns: 46px 1fr auto 74px; + align-items: center; + gap: 10px; + text-align: left; + border: 1px solid var(--g300); + background: #fff; + border-radius: 8px; + padding: 8px 10px; + font: inherit; + font-size: 12.5px; + cursor: pointer; + + &:hover { + border-color: var(--indigo); + } + + &.picked { + background: var(--indigo-l); + border-color: var(--indigo); + } +} + +.bkd-hit-kind { + font-size: 10.5px; + font-weight: 700; + text-align: center; + padding: 2px 0; + border-radius: 5px; + background: var(--g100); + color: var(--g700); +} + +.bkd-hit-main small { + display: block; + color: var(--g500); + font-size: 11.5px; +} + +.bkd-hit-amt { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.bkd-hit-state { + font-size: 11.5px; + font-weight: 700; + color: var(--indigo); + text-align: right; +} + +.bkd-picked { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 8px; + margin-top: 12px; + font-size: 12.5px; +} + +.bkd-pill-sel { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 10px; + border-radius: 999px; + background: var(--indigo-l); + color: var(--indigo); + font-weight: 600; + + button { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0 6px; + } +} + +/* ── Switch between kinds of account and individual accounts ── */ +.bkd-modes { + display: inline-flex; + border: 1px solid var(--g300); + border-radius: 8px; + overflow: hidden; + margin-bottom: 12px; +} + +.bkd-mode { + border: 0; + background: #fff; + padding: 7px 16px; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: var(--g700); + cursor: pointer; + + & + & { + border-left: 1px solid var(--g300); + } + + &.on { + background: var(--indigo); + color: #fff; + } +} + +.bkd-pick-box { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-right: 8px; + border: 1px solid var(--g300); + border-radius: 4px; + background: #fff; + font-size: 11px; + line-height: 1; + vertical-align: -3px; +} + +.bkd-pick-opt.on .bkd-pick-box { + border-color: var(--indigo); + background: var(--indigo); + color: #fff; +} + +.bkd-pick-actions { + display: flex; + gap: 6px; + margin: -4px 0 8px; +} + +.bkd-matrix-wrap { + max-height: none; +} + +.bkd-matrix { + font-size: 12.5px; + min-width: 640px; +} diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.ts b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.ts new file mode 100644 index 000000000..d819d7d0c --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.ts @@ -0,0 +1,962 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Subject, catchError, debounceTime, of, switchMap } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { businessTodayIso } from 'app/core/utils/business-today'; +import { + AlmBucket, + AlmConfig, + AlmItem, + AlmRule, + AlmSide, + AlmPlacement, + AlmSource, + AlmSpan, + AlmUnit, + RULE_OPTIONS, + SHARE_SOURCES, + SOURCE_OPTIONS, + defaultConfig +} from './alm-bucket-breakdown.config'; +import { + BucketWindow, + addSpan, + bucketIndexFor, + bucketLabel, + bucketWindows, + dayRangeText, + daysBetween, + formatDate, + fromSpanOf, + parseIsoDate, + setFromSpan +} from './alm-bucket.util'; +import { AccountHit, AccountRow, AlmMaturityService, MaturityBreakdown, SelectedReport } from './alm-maturity.service'; + +type AmountUnit = 'rupee' | 'lakh' | 'crore'; + +/** How a line's shares stand: whether the bank still has something to fill in. */ +type RowState = 'spread' | 'ok' | 'todo' | 'bad'; + +/** Which of the server's account groups each source adds together. */ +const SOURCE_GROUPS: Record<'FD_RD' | 'FD' | 'RD' | 'LOAN', string[]> = { + FD_RD: [ + 'FD', + 'RD' + ], + FD: ['FD'], + RD: ['RD'], + LOAN: ['LOAN'] +}; + +/** The two tables of the statement, in the order it prints them. */ +const SECTIONS: { side: AlmSide; title: string }[] = [ + { side: 'liability', title: 'OUTFLOWS' }, + { side: 'asset', title: 'INFLOWS' } +]; + +/** + * Where the browser keeps the bank's edits. Same arrangement as the OSS GL mapping: there is no + * backend table for this yet, so the setting belongs to the browser it was made in. The suffix is + * the shape of what is stored; a different shape starts again from RBI's defaults. + */ +const STORAGE_KEY = 'alm_bucket_breakdown_config_v2'; + +/** + * ALM Time-Bucket Breakdown — the configuration of the Structural Liquidity Statement. + * + * One table laid out like the statement the bank files: the same numbered Outflow and Inflow lines + * down the side, the same residual-maturity buckets across the top. Each line says how its balance + * is slotted — wholly in one bucket, by the due date of each account, or by percentages the bank + * sets. The statement itself is computed from this configuration. + * + * Rules start from RBI's Urban Co-operative Bank defaults but none is fixed: every line, bucket and + * percentage can be edited, and "Reset" returns to RBI's. + */ +@Component({ + selector: 'mifosx-alm-bucket-breakdown-dialog', + standalone: true, + templateUrl: './alm-bucket-breakdown-dialog.component.html', + styleUrls: ['./alm-bucket-breakdown-dialog.component.scss'], + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatTooltipModule, + FaIconComponent + ] +}) +export class AlmBucketBreakdownDialogComponent { + private dialogRef = inject(MatDialogRef); + private service = inject(AlmMaturityService); + private destroyRef = inject(DestroyRef); + + readonly ruleOptions = RULE_OPTIONS; + readonly sourceOptions = SOURCE_OPTIONS; + readonly sections = SECTIONS; + + cfg: AlmConfig = this.load(); + /** Which tab is open: the day breakdown of one product, or the statement's configuration table. */ + view: 'days' | 'config' = 'days'; + /** Lines can be renamed, added, removed and left out of the report only while this is on. */ + editMode = false; + + /** The date the buckets are counted from — the business date, not the browser's clock. */ + asOnIso = businessTodayIso(); + /** Maturity typed into the day calculator. */ + maturityIso = ''; + + /** Amounts come back in rupees; the bank reads them in lakhs unless told otherwise. */ + readonly units: { value: AmountUnit; label: string; divisor: number }[] = [ + { value: 'rupee', label: '₹ (rupaye)', divisor: 1 }, + { value: 'lakh', label: 'Lakh', divisor: 100_000 }, + { value: 'crore', label: 'Crore', divisor: 10_000_000 } + ]; + unit: AmountUnit = 'lakh'; + + /** FD / RD / loan totals per bucket for the as-on date, from the server. */ + breakdown: MaturityBreakdown | null = null; + loading = false; + loadError = ''; + /** Only the newest request's answer may land; a slow earlier one must not overwrite it. */ + private loadSeq = 0; + + constructor() { + this.watchSearch(); + this.loadBreakdown(); + } + + // ── The configuration table ── + + /** The lines of one table, headings included, in statement order. */ + itemsOf(side: AlmSide): AlmItem[] { + return this.cfg.items.filter((i) => i.side === side); + } + + /** Columns across a table: reference, line, rule, every bucket, total, and the row tools while editing. */ + get columnCount(): number { + return 3 + this.cfg.buckets.length + 1 + (this.editMode ? 1 : 0); + } + + /** Share of a line's balance that goes to one bucket, or null when none does. */ + share(item: AlmItem, bucket: number): number | null { + const placed = item.placements.filter((p) => p.bucket === bucket); + if (!placed.length) { + return null; + } + const explicit = placed.filter((p) => p.pct !== null && p.pct !== undefined && `${p.pct}` !== ''); + if (explicit.length) { + return explicit.reduce((sum, p) => sum + Number(p.pct), 0); + } + // No share written down: a line with one bucket puts the whole balance there. + return item.placements.length === 1 ? 100 : null; + } + + /** Set the share going to one bucket; empty or zero takes the bucket out of the line. */ + setShare(item: AlmItem, bucket: number, raw: number | string | null): void { + const value = raw === null || raw === '' ? 0 : Math.max(0, Math.min(100, Number(raw))); + const rest = item.placements.filter((p) => p.bucket !== bucket); + if (value > 0 && !Number.isNaN(value)) { + const kept = item.placements.find((p) => p.bucket === bucket); + rest.push({ bucket, pct: value, label: kept?.label }); + } + item.placements = rest.sort((a, b) => a.bucket - b.bucket); + this.changed(); + } + + /** + * The amounts a due-date line gets from its accounts, in rupees; null while there is nothing to + * show (no source chosen, not loaded yet, or the server could not answer). + */ + autoAmounts(item: AlmItem): number[] | null { + return item.rule === 'maturity' ? this.itemAmounts(item) : null; + } + + /** + * What a line has in each bucket, in rupees. Deposits and term loans come bucket by bucket from + * their own dates; cash-credit and NPA loans come as one outstanding figure that the line's own + * percentages divide. Null when the line has no accounts behind it, or nothing has loaded yet. + */ + itemAmounts(item: AlmItem | null): number[] | null { + return item?.source ? this.amountsOf(item.source, item) : null; + } + + /** + * What one kind of account has in each bucket. For cash-credit and NPA loans the percentages come + * from the statement line that names that kind, and are zero until that line has them. + */ + private amountsOf(source: AlmSource, line: AlmItem | null): number[] | null { + if (!this.breakdown) { + return null; + } + if (SHARE_SOURCES.includes(source)) { + const outstanding = this.apartLine(source)?.outstanding ?? 0; + return this.cfg.buckets.map((_b, i) => (outstanding * (line ? (this.share(line, i) ?? 0) : 0)) / 100); + } + const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS]; + const groups = this.breakdown.groups.filter((g) => keys?.includes(g.key)); + if (!groups.length) { + return null; + } + return this.cfg.buckets.map((_b, i) => groups.reduce((sum, g) => sum + (g.buckets[i] ?? 0), 0)); + } + + /** The loans the server reports apart from the date buckets: CC/OD, or NPA. */ + private apartLine(source: AlmSource): { outstanding: number; accounts: number } | undefined { + const key = source === 'CCOD' ? 'REVOLVING' : 'NPA'; + return this.breakdown?.notSlotted.find((n) => n.key === key); + } + + /** Accounts behind a line, for the tooltip and the day view's total. */ + autoAccounts(item: AlmItem | null): number { + const source = item?.source; + if (!source || !this.breakdown) { + return 0; + } + if (SHARE_SOURCES.includes(source)) { + return this.apartLine(source)?.accounts ?? 0; + } + const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS] ?? []; + return this.breakdown.groups.filter((g) => keys.includes(g.key)).reduce((sum, g) => sum + g.accounts, 0); + } + + autoTotal(amounts: number[]): number { + return amounts.reduce((sum, v) => sum + v, 0); + } + + // ── Picking kinds of account ── + + /** Whether the day-wise breakdown is by kind of account, or for individual accounts found by search. */ + selectBy: 'types' | 'accounts' = 'types'; + + /** The kinds of account that can be picked, on the liability and the asset side. */ + readonly pickTypes: { value: AlmSource; label: string; side: AlmSide }[] = [ + { value: 'FD', label: 'Fixed Deposits (FD)', side: 'liability' }, + { value: 'RD', label: 'Recurring Deposits (RD)', side: 'liability' }, + { value: 'LOAN', label: 'Term Loans', side: 'asset' }, + { value: 'CCOD', label: 'CC / OD Loans', side: 'asset' }, + { value: 'NPA', label: 'NPA Loans', side: 'asset' } + ]; + + /** The kinds picked; any number of them, from either side. */ + pickedTypes: AlmSource[] = [ + 'FD', + 'RD' + ]; + + get pickGroups(): { side: AlmSide; title: string; items: { value: AlmSource; label: string; side: AlmSide }[] }[] { + return [ + { + side: 'liability', + title: 'Liabilities: deposits (customers)', + items: this.pickTypes.filter((t) => t.side === 'liability') + }, + { side: 'asset', title: 'Assets: loans (customers)', items: this.pickTypes.filter((t) => t.side === 'asset') } + ]; + } + + setSelectBy(mode: 'types' | 'accounts'): void { + this.selectBy = mode; + this.reportShown = false; + } + + isTypePicked(value: AlmSource): boolean { + return this.pickedTypes.includes(value); + } + + /** Tick or untick a kind. Any number can be ticked together; a report already open is put away. */ + toggleType(value: AlmSource): void { + this.pickedTypes = this.isTypePicked(value) ? this.pickedTypes.filter((v) => v !== value) : [ + ...this.pickedTypes, + value + ]; + this.reportShown = false; + } + + selectAllTypes(): void { + this.pickedTypes = this.pickTypes.map((t) => t.value); + this.reportShown = false; + } + + clearTypes(): void { + this.pickedTypes = []; + this.reportShown = false; + } + + /** One column per kind picked: what it has in every bucket, and a note where something is still to be filled in. */ + get typeColumns(): { + value: AlmSource; + label: string; + side: AlmSide; + amounts: number[]; + total: number; + notice: string; + }[] { + return this.pickTypes + .filter((t) => this.isTypePicked(t.value)) + .map((t) => { + const amounts = + this.amountsOf(t.value, this.cfg.items.find((i) => !i.header && i.source === t.value) ?? null) ?? + this.cfg.buckets.map(() => 0); + const total = this.autoTotal(amounts); + const outstanding = SHARE_SOURCES.includes(t.value) ? (this.apartLine(t.value)?.outstanding ?? 0) : 0; + const notice = + outstanding > 0 && total === 0 + ? `The bucket-wise % for this kind are not filled in yet, so nothing is divided. Fill them in the Configuration table (outstanding is ${this.amountText(outstanding)}).` + : ''; + return { value: t.value, label: t.label, side: t.side, amounts, total, notice }; + }); + } + + /** One row per bucket across the picked kinds, with the liability and asset sides added up separately. */ + get typeMatrix(): { label: string; days: string; dates: string; cells: number[]; liab: number; asset: number }[] { + const cols = this.typeColumns; + return this.windows.map((w, i) => { + const cells = cols.map((c) => c.amounts[i] ?? 0); + return { + label: this.cfg.buckets[i]?.label ?? w.label, + days: dayRangeText(w), + dates: this.dateRange(w), + cells, + liab: cols.reduce((sum, c, k) => sum + (c.side === 'liability' ? cells[k] : 0), 0), + asset: cols.reduce((sum, c, k) => sum + (c.side === 'asset' ? cells[k] : 0), 0) + }; + }); + } + + get liabilityGrandTotal(): number { + return this.typeColumns.reduce((sum, c) => sum + (c.side === 'liability' ? c.total : 0), 0); + } + + get assetGrandTotal(): number { + return this.typeColumns.reduce((sum, c) => sum + (c.side === 'asset' ? c.total : 0), 0); + } + + get typeHasLiability(): boolean { + return this.typeColumns.some((c) => c.side === 'liability'); + } + + get typeHasAsset(): boolean { + return this.typeColumns.some((c) => c.side === 'asset'); + } + + // ── Picking accounts one by one ── + + /** What is typed in the search box. */ + searchText = ''; + /** Which kind of account the search looks in: all, or one kind. */ + searchKind: '' | 'FD' | 'RD' | 'LOAN' = ''; + searchResults: AccountHit[] = []; + searching = false; + searchError = ''; + /** The accounts picked so far. */ + picked: AccountHit[] = []; + private searchTyped = new Subject(); + + /** Whether the report of the picked accounts is on screen: only after Submit, and until the picks or the date change. */ + reportShown = false; + report: SelectedReport | null = null; + reportLoading = false; + reportError = ''; + /** Only the newest request's answer may land; a slow earlier one must not overwrite it. */ + private reportSeq = 0; + + /** Set up the search so it waits for typing to pause, and a slow answer to an old search never replaces a newer one. */ + private watchSearch(): void { + this.searchTyped + .pipe( + debounceTime(300), + switchMap(() => + this.service.searchAccounts(this.searchText.trim(), this.searchKind).pipe( + catchError((err) => { + this.searchError = + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + 'Search failed. The backend needs a restart with the new endpoint.'; + return of({ results: [] as AccountHit[] }); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((found) => { + this.searching = false; + this.searchResults = found.results; + }); + } + + /** Typing or changing the kind: search again once typing pauses. */ + onSearchInput(): void { + this.searchError = ''; + if (this.searchText.trim().length < 2) { + this.searchResults = []; + this.searching = false; + return; + } + this.searching = true; + this.searchTyped.next(); + } + + /** Whether an account is already picked. */ + isPicked(hit: AccountHit): boolean { + return this.picked.some((p) => p.kind === hit.kind && p.id === hit.id); + } + + pick(hit: AccountHit): void { + if (!this.isPicked(hit)) { + this.picked = [ + ...this.picked, + hit + ]; + this.reportShown = false; + } + } + + unpick(hit: AccountHit): void { + this.picked = this.picked.filter((p) => !(p.kind === hit.kind && p.id === hit.id)); + this.reportShown = false; + } + + clearPicked(): void { + this.picked = []; + this.reportShown = false; + } + + /** Show the day-wise breakdown of the picked accounts for the chosen date. */ + submitReport(): void { + if (this.selectBy === 'types') { + if (this.pickedTypes.length) { + this.reportShown = true; + this.loadBreakdown(); + } + return; + } + if (!this.picked.length) { + return; + } + this.reportShown = true; + this.loadReport(); + } + + /** Whether Submit has anything to submit. */ + get canSubmit(): boolean { + return this.selectBy === 'types' ? this.pickedTypes.length > 0 : this.picked.length > 0; + } + + /** Fetch the report of the picked accounts; also called again when the bucket setting changes under an open report. */ + private loadReport(): void { + const seq = ++this.reportSeq; + this.reportError = ''; + if (!parseIsoDate(this.asOnIso)) { + this.report = null; + this.reportError = 'The as-on date is not valid.'; + return; + } + if (this.bucketSpecInvalid) { + this.report = null; + this.reportError = 'Only the last bucket may have no end; every other bucket needs an end.'; + return; + } + this.reportLoading = true; + this.service + .getSelected(this.picked.map((p) => `${p.kind}:${p.id}`).join(','), { + asOnDate: this.asOnIso, + buckets: this.edgeSpec, + overdueRecentBucket: this.cfg.overdue?.recent ?? 0, + overdueOldBucket: this.cfg.overdue?.old ?? 2 + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + if (seq === this.reportSeq) { + this.reportLoading = false; + this.report = result; + } + }, + error: (err) => { + if (seq === this.reportSeq) { + this.reportLoading = false; + this.report = null; + this.reportError = + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + 'No data came back. The backend needs a restart with the new endpoint.'; + } + } + }); + } + + /** A new as-on date means a new report: put the old one away until Submit is pressed again. */ + onAsOnChange(): void { + this.reportShown = false; + this.loadBreakdown(); + } + + /** The report by bucket: what the picked accounts put in each one, with the days and dates of the bucket. */ + get reportBuckets(): { label: string; days: string; dates: string; amount: number; count: number; pct: number }[] { + const rows = this.report?.rows ?? []; + const total = rows.reduce((sum, r) => sum + r.amount, 0); + return this.windows.map((w, i) => { + const inBucket = rows.filter((r) => r.bucket === i); + const amount = inBucket.reduce((sum, r) => sum + r.amount, 0); + return { + label: this.cfg.buckets[i]?.label ?? w.label, + days: dayRangeText(w), + dates: this.dateRange(w), + amount, + count: inBucket.length, + pct: total ? (amount / total) * 100 : 0 + }; + }); + } + + bucketName(index: number | null): string { + return index === null ? '' : (this.cfg.buckets[index]?.label ?? ''); + } + + // ── The bucket ranges, as a From / To table ── + + /** Where a bucket starts, as the table writes it; null for the first, which always starts on day 1. */ + fromOf(index: number): AlmSpan | null { + return fromSpanOf(this.cfg.buckets, index); + } + + /** Change where a bucket starts. That is where the one before it ends, so that one moves too. */ + setFrom(index: number, change: { n?: number | string; unit?: AlmUnit }): void { + const current = this.fromOf(index); + if (!current) { + return; + } + const n = change.n !== undefined ? Number(change.n) : current.n; + this.applyRange(index - 1, () => + setFromSpan(this.cfg.buckets, index, { n: n > 0 ? n : 1, unit: change.unit ?? current.unit }) + ); + } + + /** Change where a bucket ends. */ + setTo(index: number, change: { n?: number | string; unit?: AlmUnit }): void { + const current = this.cfg.buckets[index]?.upTo; + if (!current) { + return; + } + const n = change.n !== undefined ? Number(change.n) : current.n; + this.applyRange(index, () => { + this.cfg.buckets[index].upTo = { n: n > 0 ? n : 1, unit: change.unit ?? current.unit }; + }); + } + + /** + * Apply a change to the end of one bucket, and keep the headings of the two buckets it touches in + * step — but only a heading still worded the way the table would word it; one the bank has typed + * over is left alone. + */ + private applyRange(endingIndex: number, apply: () => void): void { + const touched = [ + endingIndex, + endingIndex + 1 + ].filter((i) => i >= 0 && i < this.cfg.buckets.length); + const wasGenerated = touched.map((i) => this.cfg.buckets[i].label === bucketLabel(this.cfg.buckets, i)); + apply(); + touched.forEach((i, k) => { + if (wasGenerated[k]) { + this.cfg.buckets[i].label = bucketLabel(this.cfg.buckets, i); + } + }); + this.bucketsChanged(); + } + + /** The end typed into the "add a bucket" form. */ + newEnd: { n: number; unit: AlmUnit } = { n: 7, unit: 'day' }; + + /** Start, End, adding and removing buckets work only while this is on, so a stray click cannot change them. */ + rangeEdit = false; + + toggleRangeEdit(): void { + this.rangeEdit = !this.rangeEdit; + } + + /** 'd', 'm' or 'y' — a unit the way the range table writes it. */ + unitLetter(unit: AlmUnit): string { + return unit === 'day' ? 'd' : unit === 'month' ? 'm' : 'y'; + } + + /** + * Add a bucket ending where the form says. It lands in date order and takes the front part of the + * bucket it falls inside, so what was placed there stays with the later part; lines, the overdue + * rule and limits keep pointing at the buckets they pointed at. + */ + addBucket(): void { + const end: AlmSpan = { n: Math.max(1, Number(this.newEnd.n) || 1), unit: this.newEnd.unit }; + const buckets = this.cfg.buckets; + const reference = parseIsoDate(this.asOnIso) ?? new Date(); + const endTime = addSpan(reference, end).getTime(); + if (buckets.some((b) => b.upTo && addSpan(reference, b.upTo).getTime() === endTime)) { + window.alert('A bucket already ends there.'); + return; + } + let position = buckets.findIndex((b) => !b.upTo || addSpan(reference, b.upTo).getTime() > endTime); + if (position < 0) { + position = buckets.length; + } + const laterWasGenerated = position < buckets.length && buckets[position].label === bucketLabel(buckets, position); + this.shiftBucketRefs((i) => (i >= position ? i + 1 : i)); + // A bucket put in front becomes the first and starts on day 1; the old first now starts where it ends. + if (position === 0 && buckets[0]) { + buckets[0].startSpan = undefined; + } + buckets.splice(position, 0, { label: '', hint: '', upTo: end }); + buckets[position].label = bucketLabel(buckets, position); + if (laterWasGenerated && position + 1 < buckets.length) { + buckets[position + 1].label = bucketLabel(buckets, position + 1); + } + this.bucketsChanged(); + } + + /** + * Remove a bucket. Whatever was placed in it moves to the next one (to the one before, if it was + * the last, which then runs on without end), so no line loses its money. + */ + removeBucket(index: number): void { + const buckets = this.cfg.buckets; + if (buckets.length <= 2) { + return; + } + if (!window.confirm(`Remove the "${buckets[index].label}" bucket? Its money will move to the next bucket.`)) { + return; + } + const last = index === buckets.length - 1; + const target = last ? index - 1 : index + 1; + const targetWasGenerated = buckets[target].label === bucketLabel(buckets, target); + this.shiftBucketRefs((i) => { + const moved = i === index ? target : i; + return moved > index ? moved - 1 : moved; + }); + const removedStart = buckets[index].startSpan; + buckets.splice(index, 1); + // Removing the first bucket makes the next one the first, which keeps the statement's start. + if (index === 0 && removedStart) { + buckets[0].startSpan = removedStart; + } + if (last) { + buckets[index - 1].upTo = null; + } + const now = last ? index - 1 : index; + if (targetWasGenerated) { + buckets[now].label = bucketLabel(buckets, now); + } + this.mergeShares(); + this.bucketsChanged(); + } + + /** Re-point everything that names a bucket by its position: lines, the overdue rule, the limits. */ + private shiftBucketRefs(map: (index: number) => number): void { + this.cfg.items.forEach((item) => item.placements.forEach((p) => (p.bucket = map(p.bucket)))); + if (this.cfg.overdue) { + this.cfg.overdue.recent = map(this.cfg.overdue.recent); + this.cfg.overdue.old = map(this.cfg.overdue.old); + } + this.cfg.limits.forEach((l) => (l.bucket = map(l.bucket))); + } + + /** After two buckets became one, a line's two shares there become one share, and a limit is kept once. */ + private mergeShares(): void { + this.cfg.items.forEach((item) => { + const merged = new Map(); + for (const p of item.placements) { + const have = merged.get(p.bucket); + if (!have) { + merged.set(p.bucket, { ...p }); + } else if (have.pct != null || p.pct != null) { + have.pct = (Number(have.pct) || 0) + (Number(p.pct) || 0); + } + } + item.placements = [...merged.values()].sort((a, b) => a.bucket - b.bucket); + }); + const seen = new Set(); + this.cfg.limits = this.cfg.limits.filter((l) => !seen.has(l.bucket) && !!seen.add(l.bucket)); + } + + /** A bucket that ends before it starts, or no later than the one before. Empty when the range is fine. */ + rangeProblem(index: number): string { + const w = this.windows[index]; + if (!w || !w.to) { + return ''; + } + return w.to < w.from ? 'End is before Start: keep it later than the end of the previous bucket.' : ''; + } + + /** Move a whole line to one bucket: its balance all goes there and nowhere else. */ + setFixed(item: AlmItem, bucket: number): void { + const label = item.placements.find((p) => p.bucket === bucket)?.label; + item.placements = [{ bucket, pct: 100, label }]; + this.changed(); + } + + /** What a line's shares add up to, or null for a line that follows due dates. */ + rowTotal(item: AlmItem): number | null { + if (item.rule === 'maturity') { + return null; + } + return this.cfg.buckets.reduce((sum, _b, i) => sum + (this.share(item, i) ?? 0), 0); + } + + rowState(item: AlmItem): RowState { + const total = this.rowTotal(item); + if (total === null) { + return 'spread'; + } + if (total === 0) { + return 'todo'; + } + return Math.abs(total - 100) < 0.005 ? 'ok' : 'bad'; + } + + /** Shares that do not make up the whole balance, over every line that is in the report. */ + get problemCount(): number { + return this.cfg.items.filter( + (i) => !i.header && i.included !== false && [ + 'bad', + 'todo' + ].includes(this.rowState(i)) + ).length; + } + + /** Changing a line's rule: due-date lines follow each account's date, the rest carry shares. */ + ruleChanged(item: AlmItem, rule: AlmRule): void { + item.rule = rule; + item.spreads = rule === 'maturity'; + if (rule === 'fixed') { + // A fixed line sits in one bucket: keep the one that held the most, or start in the first. + const biggest = [...item.placements].sort((a, b) => (Number(b.pct) || 0) - (Number(a.pct) || 0))[0]; + item.placements = [{ bucket: biggest?.bucket ?? 0, pct: 100, label: biggest?.label }]; + } + this.changed(); + } + + // ── Editing the lines themselves ── + + toggleEdit(): void { + this.editMode = !this.editMode; + } + + addLine(side: AlmSide): void { + const items = this.itemsOf(side); + const last = items[items.length - 1]; + this.cfg.items.splice(this.cfg.items.indexOf(last) + 1, 0, { + id: `u${Date.now()}`, + side, + ref: '', + level: 0, + name: side === 'liability' ? 'Nayi liability' : 'Nayi asset', + rule: 'fixed', + reason: '', + spreads: false, + placements: [{ bucket: 0, pct: 100 }] + }); + this.changed(); + } + + removeLine(item: AlmItem): void { + if (!window.confirm(`"${item.name}" hata dein?`)) { + return; + } + this.cfg.items = this.cfg.items.filter((i) => i.id !== item.id); + this.changed(); + } + + /** Open-ended means "and beyond" — only the last bucket should be. */ + setOpenEnded(bucket: AlmBucket, open: boolean): void { + bucket.upTo = open ? null : { n: 5, unit: 'year' }; + this.bucketsChanged(); + } + + /** Called after any edit — the change is kept right away, there is no separate save step. */ + changed(): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.cfg)); + } catch { + /* private window or blocked storage — the edit still holds for this session */ + } + } + + reset(): void { + if (!window.confirm('All settings will go back to the RBI defaults and your changes will be lost. Continue?')) { + return; + } + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* nothing stored to remove */ + } + this.cfg = defaultConfig(); + this.loadBreakdown(); + } + + close(): void { + this.dialogRef.close(); + } + + // ── Days from the as-on date ── + + /** Every bucket as dates, for the chosen as-on date. */ + get windows(): BucketWindow[] { + const asOn = parseIsoDate(this.asOnIso); + return asOn ? bucketWindows(asOn, this.cfg.buckets) : []; + } + + /** Day-by-day text for a window: 'Din 1 se 14'. */ + dayRange(w: BucketWindow): string { + return dayRangeText(w); + } + + dateRange(w: BucketWindow): string { + return w.to ? `${formatDate(w.from)} to ${formatDate(w.to)}` : `${formatDate(w.from)} onwards`; + } + + /** What the day calculator says for the maturity typed in; null until both dates are valid. */ + get dayCheck(): { days: number; bucket: string; due: boolean } | null { + const asOn = parseIsoDate(this.asOnIso); + const maturity = parseIsoDate(this.maturityIso); + if (!asOn || !maturity) { + return null; + } + const days = daysBetween(asOn, maturity); + return { + days, + due: days <= 0, + bucket: this.cfg.buckets[bucketIndexFor(asOn, maturity, this.cfg.buckets)]?.label ?? '' + }; + } + + // ── Live FD / RD / loan totals for the as-on date ── + + /** The bank's bucket edges as the server reads them: '14d,28d,3m,6m,1y,3y,5y'. */ + private get edgeSpec(): string { + const letter = { day: 'd', month: 'm', year: 'y' }; + return this.cfg.buckets + .filter((b) => b.upTo) + .map((b) => `${b.upTo?.n}${letter[b.upTo?.unit ?? 'day']}`) + .join(','); + } + + /** Only the last bucket may be open-ended, and every other one needs an end. */ + private get bucketSpecInvalid(): boolean { + const last = this.cfg.buckets.length - 1; + return this.cfg.buckets.some((b, i) => !b.upTo && i < last) || !!this.cfg.buckets[last]?.upTo; + } + + /** Fetch the fixed-term breakdown for the chosen as-on date and the current bucket setting. */ + loadBreakdown(): void { + const seq = ++this.loadSeq; + this.loadError = ''; + if (!parseIsoDate(this.asOnIso)) { + this.breakdown = null; + this.loadError = 'The as-on date is not valid.'; + return; + } + if (this.bucketSpecInvalid) { + this.breakdown = null; + this.loadError = 'Only the last bucket may have no end; every other bucket needs an end.'; + return; + } + this.loading = true; + this.service + .getBreakdown({ + asOnDate: this.asOnIso, + buckets: this.edgeSpec, + overdueRecentBucket: this.cfg.overdue?.recent ?? 0, + overdueOldBucket: this.cfg.overdue?.old ?? 2 + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + if (seq !== this.loadSeq) { + return; + } + this.loading = false; + this.breakdown = result; + }, + error: (err) => { + if (seq !== this.loadSeq) { + return; + } + this.loading = false; + this.breakdown = null; + this.loadError = + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + 'No data came back. The backend needs a restart with the new endpoint.'; + } + }); + } + + /** A bucket edge or the overdue rule changed: the server must slot everything again. */ + bucketsChanged(): void { + this.changed(); + this.loadBreakdown(); + if (this.reportShown && this.selectBy === 'accounts') { + this.loadReport(); + } + } + + /** The unit as the statement's heading words it: 'Lakhs of Rupees'. */ + get unitWord(): string { + return { rupee: 'Rupees', lakh: 'Lakhs of Rupees', crore: 'Crores of Rupees' }[this.unit]; + } + + /** An amount in rupees as the chosen unit; a dash when there is nothing in it. */ + amountText(rupees: number): string { + if (!rupees) { + return '–'; + } + const divisor = this.units.find((u) => u.value === this.unit)?.divisor ?? 1; + return (rupees / divisor).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + /** The saved edits if they are intact, otherwise RBI's defaults. */ + private load(): AlmConfig { + const fallback = defaultConfig(); + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') as AlmConfig | null; + if ( + saved && + Array.isArray(saved.buckets) && + saved.buckets.length >= 2 && + Array.isArray(saved.items) && + Array.isArray(saved.limits) + ) { + const overdue = (saved.overdue ??= fallback.overdue ?? { recent: 0, old: 2 }); + // Buckets can be added and removed, so anything pointing past the last one is cut back. + const last = saved.buckets.length - 1; + overdue.recent = Math.min(overdue.recent, last); + overdue.old = Math.min(overdue.old, last); + saved.limits = saved.limits.filter((l) => l.bucket <= last); + saved.items.forEach((item) => { + item.placements = (item.placements ?? []).filter((p) => p.bucket <= last); + }); + // Lines saved before due-date lines could name their accounts take the default's, by id. + saved.items.forEach((item) => { + if (item.source === undefined) { + item.source = fallback.items.find((d) => d.id === item.id)?.source ?? ''; + } + }); + return saved; + } + } catch { + /* unreadable — start from the defaults */ + } + return fallback; + } +} diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown.config.ts b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown.config.ts new file mode 100644 index 000000000..e1b94439b --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown.config.ts @@ -0,0 +1,463 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The Structural Liquidity Statement of an Urban Co-operative Bank (non-scheduled — 8 residual-maturity + * buckets, 1-14 days not split), laid out row for row like the statement the bank files: the same + * numbered Outflow and Inflow lines, the same buckets. + * + * Everything in this file is only the DEFAULT. It follows RBI's Urban Co-operative Banks (Asset + * Liability Management) circular of 17 September 2008 and the 2025 Directions, and where the bank's + * own filed statement slots a line differently (Interest Payable, Provisions, Other Liabilities) it + * follows the bank's statement. The screen works on a copy the bank can edit and falls back to these + * values on "Reset"; percentages in particular are a benchmark that a bank may replace with its own + * behavioural study approved by the Board / ALCO. + */ + +export type AlmSide = 'liability' | 'asset'; + +/** + * How the bucket is decided. + * - fixed: always the same bucket(s). + * - maturity: follows the due date of each account/instalment, so it spreads over the buckets. + * - split: one balance divided between buckets by share. + * - behavioural: the bank's own past-data study decides. + */ +export type AlmRule = 'fixed' | 'maturity' | 'split' | 'behavioural'; + +/** + * Which accounts feed a due-date line on their own. Empty means nothing does yet and the line has to + * be filled by hand. FD_RD is the two deposit kinds added together, as RBI's "Term Deposits" head is. + */ +export type AlmSource = 'FD' | 'RD' | 'FD_RD' | 'LOAN' | 'CCOD' | 'NPA' | ''; + +export const SOURCE_OPTIONS: { value: AlmSource; label: string }[] = [ + { value: '', label: 'None (fill by hand)' }, + { value: 'FD_RD', label: 'FD + RD accounts (by maturity)' }, + { value: 'FD', label: 'FD accounts only (by maturity)' }, + { value: 'RD', label: 'RD accounts only (by maturity)' }, + { value: 'LOAN', label: 'Term Loan accounts (by instalment)' }, + { value: 'CCOD', label: 'CC / OD loan accounts (outstanding × %)' }, + { value: 'NPA', label: 'NPA loan accounts (outstanding × %)' } +]; + +/** Sources whose accounts give one outstanding figure that the line's own percentages then divide. */ +export const SHARE_SOURCES: AlmSource[] = [ + 'CCOD', + 'NPA' +]; + +export type AlmUnit = 'day' | 'month' | 'year'; + +/** How far out a bucket reaches, counted from the as-on date. */ +export interface AlmSpan { + n: number; + unit: AlmUnit; +} + +export interface AlmBucket { + /** The column heading, as the statement prints it. */ + label: string; + /** One line a branch user can follow. */ + hint: string; + /** + * Last day the bucket takes, counted from the as-on date. Calendar months and years are used + * as they are, so '3 months' from 30 June is 30 September. Null means no upper end. + */ + upTo: AlmSpan | null; + /** + * Only on the first bucket: where it starts, when that is not day 1. Every later bucket starts where + * the one before it ends. Money due before this start still counts in the first bucket, since + * nothing may fall outside the statement. + */ + startSpan?: AlmSpan; +} + +export interface AlmPlacement { + /** Index into the bucket list. */ + bucket: number; + /** Share of the balance that goes to this bucket, 0-100; blank when the whole line goes there. */ + pct?: number | null; + /** Short name for the portion, e.g. 'Core'. */ + label?: string; +} + +export interface AlmItem { + id: string; + side: AlmSide; + /** The statement's own numbering: '1.', '3.', 'I)'. */ + ref: string; + /** 0 for a numbered head, 1 for a line under a head. */ + level: 0 | 1; + /** A heading only, such as '3. Deposits': it carries no figure of its own. */ + header?: boolean; + /** Left out of the report when false; shown when absent or true. */ + included?: boolean; + name: string; + rule: AlmRule; + /** Plain-language reason, in the words a branch manager would use. */ + reason: string; + /** True when each account falls in the bucket of its own due date. */ + spreads: boolean; + /** For a due-date line: the accounts whose maturities fill it. */ + source?: AlmSource; + placements: AlmPlacement[]; +} + +/** Prudential cap on the net cumulative negative mismatch, as a % of cumulative outflow in that bucket. */ +export interface AlmLimit { + bucket: number; + pct: number; +} + +/** + * Where an instalment or receivable that is already due and unpaid is slotted — the bank's Overdue + * Receivables rule. Both are bucket positions: 'recent' for one that fell due less than a month ago, + * 'old' for anything older. + */ +export interface AlmOverdueRule { + recent: number; + old: number; +} + +export interface AlmConfig { + buckets: AlmBucket[]; + items: AlmItem[]; + limits: AlmLimit[]; + overdue?: AlmOverdueRule; +} + +export const RULE_OPTIONS: { value: AlmRule; label: string; help: string }[] = [ + { value: 'fixed', label: 'Fixed', help: 'Always in the same bucket' }, + { value: 'maturity', label: 'By due date', help: 'Each account by its own maturity' }, + { value: 'split', label: 'Split', help: 'Balance divided by percentages' }, + { value: 'behavioural', label: 'By study', help: 'Percentages from the bank’s own study' } +]; + +/** Column headings as the filed statement prints them, with the span each one covers. */ +export const DEFAULT_BUCKETS: AlmBucket[] = [ + { label: '1 to 14 days', hint: 'Needed straight away', upTo: { n: 14, unit: 'day' } }, + { label: '15 to 28 days', hint: 'After the next two weeks', upTo: { n: 28, unit: 'day' } }, + { label: '29 days and upto 3 months', hint: 'Within this quarter', upTo: { n: 3, unit: 'month' } }, + { label: 'Over 3 months and upto 6 months', hint: 'Next quarter', upTo: { n: 6, unit: 'month' } }, + { label: 'Over 6 months and upto 1 year', hint: 'Within the year', upTo: { n: 1, unit: 'year' } }, + { label: 'Over 1 year and upto 3 years', hint: 'Medium term', upTo: { n: 3, unit: 'year' } }, + { label: 'Over 3 years and upto 5 years', hint: 'Long term', upTo: { n: 5, unit: 'year' } }, + { label: 'Over 5 years', hint: 'Permanent items', upTo: null } +]; + +/** Non-scheduled UCB: negative mismatch in 1-14 and 15-28 days must stay within 20% of outflows. */ +export const DEFAULT_LIMITS: AlmLimit[] = [ + { bucket: 0, pct: 20 }, + { bucket: 1, pct: 20 } +]; + +/** Buckets by position, so the lines below read as the bucket they mean. */ +const B = { d14: 0, d28: 1, m3: 2, m6: 3, y1: 4, y3: 5, y5: 6, over5: 7 }; + +const whole = (bucket: number): AlmPlacement[] => [{ bucket, pct: 100 }]; + +type Seed = Omit & { level?: 0 | 1; spreads?: boolean }; + +const head = (side: AlmSide, ref: string, name: string): Seed => ({ + side, + ref, + level: 0, + header: true, + name, + rule: 'fixed', + reason: '', + placements: [] +}); + +const line = ( + side: AlmSide, + ref: string, + name: string, + rule: AlmRule, + reason: string, + placements: AlmPlacement[] = [], + level: 0 | 1 = 1, + source: AlmSource = '' +): Seed => ({ side, ref, level, name, rule, reason, placements, spreads: rule === 'maturity', source }); + +const SEED: Seed[] = [ + // ── OUTFLOWS (liabilities) ── + line( + 'liability', + '1.', + 'Capital', + 'fixed', + 'This is the bank’s own money and is never paid back, so it goes in the farthest bucket.', + whole(B.over5), + 0 + ), + line( + 'liability', + '2.', + 'Reserves & Surplus', + 'fixed', + 'The bank’s own money, never paid back. Farthest bucket.', + whole(B.over5), + 0 + ), + head('liability', '3.', 'Deposits'), + line( + 'liability', + 'I)', + 'Current Deposits', + 'split', + 'A part can be withdrawn at any time (volatile), so it goes in 1-14 days. The rest is stable (core), so it goes in 1-3 years. Change the % from the bank’s own study.', + [ + { bucket: B.d14, pct: 10, label: 'Volatile' }, + { bucket: B.y3, pct: 90, label: 'Core' } + ] + ), + line( + 'liability', + 'II)', + 'Saving Bank Deposits', + 'split', + 'Same as Current. RBI’s benchmark is about 10% volatile. Change the % from the bank’s own study.', + [ + { bucket: B.d14, pct: 10, label: 'Volatile' }, + { bucket: B.y3, pct: 90, label: 'Core' } + ] + ), + line( + 'liability', + 'III)', + 'Term Deposits (FD and RD)', + 'maturity', + 'The maturity date of each FD and RD decides its bucket. An FD that is already due goes in the first bucket.', + [], + 1, + 'FD_RD' + ), + line('liability', 'IV)', 'Certificates of Deposit', 'maturity', 'By maturity date.'), + head('liability', '4.', 'Borrowings'), + line('liability', 'I.', 'Call and short Notice', 'maturity', 'The bucket of the day it has to be repaid.'), + line('liability', 'II.', 'Inter-Bank (Term)', 'maturity', 'The bucket of the day it has to be repaid.'), + line('liability', 'III.', 'Refinances', 'maturity', 'The bucket of the day it has to be repaid.'), + line('liability', 'IV.', 'Others (specify)', 'maturity', 'The bucket of the day it has to be repaid.'), + head('liability', '5.', 'Other Liabilities & Provisions'), + line('liability', 'I.', 'DD / Bills Payable', 'fixed', 'These are paid quickly, so 1-14 days.', whole(B.d14)), + line('liability', 'II.', 'Branch Adjustments', 'fixed', 'Settled between branches, usually quickly.', whole(B.d14)), + line( + 'liability', + 'III.', + 'Provisions', + 'fixed', + 'In your filed statement provisions are in 1-14 days. Change it according to the purpose of the provision.', + whole(B.d14) + ), + line( + 'liability', + 'IV.', + 'Other Liabilities', + 'fixed', + 'Amounts that are not to be paid out (like fees received in advance) go in the farthest bucket. Your filed statement has them in over 5 years.', + whole(B.over5) + ), + line( + 'liability', + '6.', + 'Unavailed portion of Cash Credit / Overdraft / Demand Loan component of Working Capital', + 'behavioural', + 'How much of the limit customers will use comes from the bank’s past history. RBI: fill the % in the buckets within 12 months.', + [], + 0 + ), + line( + 'liability', + '7.', + 'Letters of Credit / Guarantees', + 'behavioural', + 'How much of the LC / guarantee will devolve comes from the bank’s history, net of margin. Fill in the %.', + [], + 0 + ), + line('liability', '8.', 'Repos', 'maturity', 'The bucket of the day it has to be repaid.', [], 0), + line('liability', '9.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0), + line('liability', '10.', 'Swaps (Sell/Buy/Maturing forwards)', 'maturity', 'By maturity date.', [], 0), + line( + 'liability', + '11.', + 'Interest Payable', + 'fixed', + 'Interest still to be paid as per the books. Your filed statement has it in 1-14 days. RBI: the bucket of the date it is due.', + whole(B.d14), + 0 + ), + line( + 'liability', + '12.', + 'Others (specify)', + 'fixed', + 'Anything else. Your filed statement has it in over 5 years.', + whole(B.over5), + 0 + ), + + // ── INFLOWS (assets) ── + line( + 'asset', + '1.', + 'Cash', + 'fixed', + 'Cash is with the bank right now, so it is available straight away.', + whole(B.d14), + 0 + ), + line( + 'asset', + '2.', + 'Balances with RBI', + 'behavioural', + 'Balance above the CRR/SLR requirement is in 1-14 days. The required part follows the maturity of the bank’s deposits (DTL). Fill in the %.', + [], + 0 + ), + head('asset', '3.', 'Balances with other Banks'), + line( + 'asset', + 'I)', + 'Current Account', + 'fixed', + 'What can be withdrawn goes in 1-14 days. RBI: the minimum balance that has to be kept goes in 1-3 years; split it if you want.', + whole(B.d14) + ), + line( + 'asset', + 'II)', + 'Money at Call and short Notice, Term Deposits and other placements with other banks including DCCBs and SCBs', + 'maturity', + 'By the maturity date of the FD / call money placed with the bank.' + ), + line( + 'asset', + '4.', + 'Investments (including those under Repos but excluding Reverse Repos)', + 'maturity', + 'By the maturity of the bond / G-Sec, leaving out the part needed to maintain SLR.', + [], + 0 + ), + head('asset', '5.', 'Advances (Performing)'), + line( + 'asset', + 'I.', + 'Bills Purchased and Discounted (including bills under DUPN)', + 'maturity', + 'By the due date of the bill.' + ), + line( + 'asset', + 'II.', + 'Cash Credits, Overdrafts and Loans repayable on demand', + 'behavioural', + 'The part that comes back quickly goes in a short bucket; the part that always stays (core) goes in 1-3 years. Fill in the % from the bank’s study.', + [], + 1, + 'CCOD' + ), + line( + 'asset', + 'III.', + 'Term Loans', + 'maturity', + 'Each EMI goes in the bucket of its own due date, not the whole loan at once.', + [], + 1, + 'LOAN' + ), + line( + 'asset', + '6.', + 'NPAs (Advances and Investments)', + 'behavioural', + 'Sub-standard NPA in 3-5 years; doubtful and loss in the farthest bucket (net of provision). Fill in the % as the bank sees fit.', + [], + 0, + 'NPA' + ), + line( + 'asset', + '7.', + 'Fixed Assets', + 'fixed', + 'Things like buildings and furniture, which do not bring in money quickly.', + whole(B.over5), + 0 + ), + head('asset', '8.', 'Other Assets'), + line( + 'asset', + 'I.', + 'Branch Adjustments', + 'fixed', + 'Debit balance of branch adjustments, usually settled quickly.', + whole(B.d14) + ), + line('asset', 'II.', 'Leased Assets', 'maturity', 'By the date of the lease’s interim cash flows.'), + line( + 'asset', + 'III.', + 'Others', + 'fixed', + 'Things that are not real money (intangibles, non-cash) go in the farthest bucket. Your filed statement has them in over 5 years.', + whole(B.over5) + ), + line('asset', '9.', 'Reverse Repos', 'maturity', 'The bucket of the day the money comes back.', [], 0), + line('asset', '10.', 'Swaps (Buy/Sell)/Maturing forwards', 'maturity', 'By maturity date.', [], 0), + line('asset', '11.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0), + line( + 'asset', + '12.', + 'Interest Receivable', + 'fixed', + 'Interest still to be received. Your filed statement has it in over 5 years. RBI: the bucket of the date it is due.', + whole(B.over5), + 0 + ), + line( + 'asset', + '13.', + 'Export Refinance from RBI', + 'fixed', + 'Limit that has not been used yet can be drawn straight away.', + whole(B.d14), + 0 + ), + line( + 'asset', + '14.', + 'Others (specify)', + 'behavioural', + 'Anything else. Fill in the % as the bank’s study gives.', + [], + 0 + ) +]; + +/** A fresh, independent copy of the RBI defaults — callers are free to mutate it. */ +export function defaultConfig(): AlmConfig { + return { + buckets: DEFAULT_BUCKETS.map((b) => ({ ...b })), + limits: DEFAULT_LIMITS.map((l) => ({ ...l })), + // RBI (2008 UCB circular): under a month overdue is expected within 14 days; older goes to 29 days-3 months. + overdue: { recent: 0, old: 2 }, + items: SEED.map((s, i) => ({ + ...s, + level: s.level ?? 1, + id: `d${i + 1}`, + spreads: s.spreads ?? false, + placements: s.placements.map((p) => ({ ...p })) + })) + }; +} diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket.util.ts b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket.util.ts new file mode 100644 index 000000000..bd3ef6914 --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket.util.ts @@ -0,0 +1,209 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { AlmBucket, AlmSpan } from './alm-bucket-breakdown.config'; + +/** + * The day arithmetic behind the Structural Liquidity Statement: how many days a maturity is from the + * as-on date, and which bucket that lands it in. The statement and this screen both use it, so what + * the screen shows is what the return will do. + * + * Dates are plain calendar days ('yyyy-MM-dd'), never timestamps, so the browser's time zone and + * daylight saving cannot move a maturity into the next bucket. + */ + +const MS_PER_DAY = 86_400_000; + +/** Where the first bucket starts unless told otherwise: the day after the as-on date. */ +const DAY_ONE: AlmSpan = { n: 1, unit: 'day' }; + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' +]; + +/** One bucket laid over a calendar, for a given as-on date. */ +export interface BucketWindow { + label: string; + /** First day the bucket takes. */ + from: Date; + /** Last day the bucket takes, or null when it runs on without end. */ + to: Date | null; + /** Days from the as-on date to the first / last day. */ + fromDays: number; + toDays: number | null; +} + +/** 'yyyy-MM-dd' to a local calendar date; null when it is not a real date. */ +export function parseIsoDate(iso: string | null | undefined): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? ''); + if (!m) { + return null; + } + const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return date.getFullYear() === Number(m[1]) && date.getMonth() === Number(m[2]) - 1 && date.getDate() === Number(m[3]) + ? date + : null; +} + +/** '30-Jun-2026'. */ +export function formatDate(date: Date): string { + return `${String(date.getDate()).padStart(2, '0')}-${MONTHS[date.getMonth()]}-${date.getFullYear()}`; +} + +/** Whole days from one calendar date to another; negative when `to` is earlier. */ +export function daysBetween(from: Date, to: Date): number { + const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate()); + const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()); + return Math.round((b - a) / MS_PER_DAY); +} + +/** + * The date a span reaches. Months and years are calendar months and years, and a day that does not + * exist in the target month falls back to that month's last day (31 Jan + 1 month = 28/29 Feb). + */ +export function addSpan(from: Date, span: AlmSpan): Date { + if (span.unit === 'day') { + return new Date(from.getFullYear(), from.getMonth(), from.getDate() + span.n); + } + const months = span.unit === 'year' ? span.n * 12 : span.n; + const target = new Date(from.getFullYear(), from.getMonth() + months, 1); + const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate(); + return new Date(target.getFullYear(), target.getMonth(), Math.min(from.getDate(), lastDay)); +} + +/** + * Index of the bucket a maturity falls in. A maturity on or before the as-on date is already due, so + * it is slotted in the first bucket rather than falling out of the statement. + */ +export function bucketIndexFor(asOn: Date, maturity: Date, buckets: AlmBucket[]): number { + if (!buckets.length || maturity <= asOn) { + return 0; + } + for (let i = 0; i < buckets.length; i++) { + const upTo = buckets[i].upTo; + if (!upTo || maturity <= addSpan(asOn, upTo)) { + return i; + } + } + return buckets.length - 1; +} + +/** Each bucket as a run of calendar days from the as-on date, in bucket order. */ +export function bucketWindows(asOn: Date, buckets: AlmBucket[]): BucketWindow[] { + let from = addSpan(asOn, fromSpanOf(buckets, 0) ?? DAY_ONE); + return buckets.map((b) => { + const to = b.upTo ? addSpan(asOn, b.upTo) : null; + const window: BucketWindow = { + label: b.label, + from, + to, + fromDays: daysBetween(asOn, from), + toDays: to ? daysBetween(asOn, to) : null + }; + if (to) { + from = new Date(to.getFullYear(), to.getMonth(), to.getDate() + 1); + } + return window; + }); +} + +/** 'Days 1 to 14', 'Days 29 to 92', 'Day 1827 onwards'. */ +export function dayRangeText(w: BucketWindow): string { + return w.toDays === null ? `Day ${w.fromDays} onwards` : `Days ${w.fromDays} to ${w.toDays}`; +} + +// ── The bucket ranges as a From / To table ── + +/** '14 d', '3 m', '1 y' — how a span is written in the From / To table. */ +export function spanText(span: AlmSpan): string { + return `${span.n} ${span.unit === 'day' ? 'd' : span.unit === 'month' ? 'm' : 'y'}`; +} + +/** '14 days', '1 month', '5 years' — a span in words, as the statement's headings put it. */ +function spanWords(span: AlmSpan): string { + const word = span.unit === 'day' ? 'day' : span.unit === 'month' ? 'month' : 'year'; + return `${span.n} ${word}${span.n === 1 ? '' : 's'}`; +} + +/** + * Where bucket `index` starts, as it is written in the table: the first starts on day 1, a bucket + * after one that ends in days starts the day after (14 d then 15 d), and one after a month or year + * starts "over" it (3 m then 3 m). Null for the first bucket, whose start is fixed. + */ +export function fromSpanOf(buckets: AlmBucket[], index: number): AlmSpan | null { + if (index === 0) { + return { ...(buckets[0]?.startSpan ?? DAY_ONE) }; + } + const previous = buckets[index - 1].upTo; + if (!previous) { + return null; + } + return previous.unit === 'day' ? { n: previous.n + 1, unit: 'day' } : { ...previous }; +} + +/** Move the start of bucket `index`, which is the end of the one before it. */ +export function setFromSpan(buckets: AlmBucket[], index: number, from: AlmSpan): void { + const n = Number(from.n) || 1; + if (index === 0) { + // Day 1 is the ordinary start, so it is stored as no override at all. + buckets[0].startSpan = n === 1 && from.unit === 'day' ? undefined : { n, unit: from.unit }; + return; + } + buckets[index - 1].upTo = from.unit === 'day' ? { n: Math.max(1, n - 1), unit: 'day' } : { n, unit: from.unit }; +} + +/** + * The column heading a range deserves, worded the way the filed statement words them: + * '1 to 14 days', '29 days and upto 3 months', 'Over 3 months and upto 6 months', 'Over 5 years'. + */ +export function bucketLabel(buckets: AlmBucket[], index: number): string { + const to = buckets[index]?.upTo ?? null; + const previous = index > 0 ? buckets[index - 1].upTo : null; + if (index === 0) { + const start = buckets[0].startSpan; + if (start) { + if (start.unit === 'day') { + return !to + ? `Over ${spanWords(start)}` + : to.unit === 'day' + ? `${start.n} to ${to.n} days` + : `${start.n} days and upto ${spanWords(to)}`; + } + return to ? `Over ${spanWords(start)} and upto ${spanWords(to)}` : `Over ${spanWords(start)}`; + } + if (to?.unit === 'day' && to.n === 1) { + return 'Next day'; + } + return !to ? 'Over 0 days' : to.unit === 'day' ? `1 to ${to.n} days` : `1 day and upto ${spanWords(to)}`; + } + if (!previous) { + return buckets[index].label; + } + if (previous.unit === 'day') { + const from = previous.n + 1; + if (!to) { + return `Over ${spanWords(previous)}`; + } + if (to.unit === 'day' && to.n === from) { + return `Day ${from}`; + } + return to.unit === 'day' ? `${from} to ${to.n} days` : `${from} days and upto ${spanWords(to)}`; + } + return to ? `Over ${spanWords(previous)} and upto ${spanWords(to)}` : `Over ${spanWords(previous)}`; +} diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-maturity.service.ts b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-maturity.service.ts new file mode 100644 index 000000000..1a23634da --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-maturity.service.ts @@ -0,0 +1,125 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** One product's total, split by bucket, in rupees. */ +export interface MaturityGroup { + key: string; + label: string; + side: 'liability' | 'asset'; + accounts: number; + total: number; + buckets: number[]; +} + +/** Loans reported apart from the date buckets, with the reason they are not slotted by date. */ +export interface NotSlotted { + key: string; + label: string; + accounts: number; + outstanding: number; + why: string; +} + +export interface MaturityBreakdown { + asOnDate: string; + bucketCount: number; + groups: MaturityGroup[]; + notSlotted: NotSlotted[]; + notes: string[]; +} + +/** What the screen asks the server for: the date, the bank's own bucket edges and its overdue rule. */ +export interface MaturityQuery { + asOnDate: string; + /** Edges like '14d,28d,3m,6m,1y,3y,5y'. */ + buckets: string; + overdueRecentBucket: number; + overdueOldBucket: number; +} + +/** One account found by a search, to be picked. */ +export interface AccountHit { + /** FD, RD or LOAN. */ + kind: 'FD' | 'RD' | 'LOAN'; + id: number; + accountNo: string; + customer: string | null; + product: string; + /** Maturity date of a deposit; null for a loan. */ + date: string | null; + /** Deposit balance, or loan principal outstanding. */ + amount: number; +} + +/** One deposit, or one unpaid loan instalment, of the accounts picked. */ +export interface AccountRow { + id: number; + accountNo: string; + customer: string | null; + product: string; + kind: string; + /** Loan instalment number, for term loans. */ + installment?: number; + /** Maturity date of a deposit, or due date of an instalment. */ + date: string | null; + /** Days from the as-on date to that date; negative once it has passed. */ + days: number | null; + status: string; + /** Position of the bucket it falls in. */ + bucket: number | null; + amount: number; + /** Why the row needs a second look, for an NPA or a cash-credit / overdraft loan. */ + note?: string | null; +} + +export interface SelectedReport { + asOnDate: string; + bucketCount: number; + count: number; + total: number; + rows: AccountRow[]; +} + +/** Fixed-term deposits and loans of the bank's customers, slotted into residual-maturity buckets. */ +@Injectable({ providedIn: 'root' }) +export class AlmMaturityService { + private http = inject(HttpClient); + + getBreakdown(query: MaturityQuery): Observable { + const params = new HttpParams() + .set('asOnDate', query.asOnDate) + .set('buckets', query.buckets) + .set('overdueRecentBucket', query.overdueRecentBucket) + .set('overdueOldBucket', query.overdueOldBucket); + return this.http.get('/almreturn/maturity-breakdown', { params }); + } + + /** Find accounts by account number or customer name; at least two characters. A kind of FD, RD or LOAN narrows it. */ + searchAccounts(q: string, kind: string): Observable<{ results: AccountHit[] }> { + let params = new HttpParams().set('q', q); + if (kind) { + params = params.set('kind', kind); + } + return this.http.get<{ results: AccountHit[] }>('/almreturn/maturity-breakdown/search', { params }); + } + + /** The day-wise breakdown of the picked accounts, given as KIND:id pairs like FD:12,RD:5,LOAN:33. */ + getSelected(accounts: string, query: MaturityQuery): Observable { + const params = new HttpParams() + .set('accounts', accounts) + .set('asOnDate', query.asOnDate) + .set('buckets', query.buckets) + .set('overdueRecentBucket', query.overdueRecentBucket) + .set('overdueOldBucket', query.overdueOldBucket); + return this.http.get('/almreturn/maturity-breakdown/selected', { params }); + } +} diff --git a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html index e331d938c..dea6ded43 100644 --- a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html +++ b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html @@ -15,7 +15,7 @@ RBI Liabilities Register

Demand & Time Liabilities

-

NDTL · CRR/SLR Computation Base · As per RBI Master Circular

+

What the bank owes, and what it must hold against it

+
-
Total Demand Liabilities
-
{{ crore(totalDemand) }}Cr
-
Payable on demand
+
Demand liabilities
+
{{ tile(totalDemand) }}
+
Repayable on demand
-
Total Time Liabilities
-
{{ crore(totalTime) }}Cr
-
Payable on maturity
+
Time liabilities
+
{{ tile(totalTime) }}
+
Repayable on maturity
-
Assets with Banking System
-
- {{ crore(totalBankingAssets) }}Cr +
Held with other banks
+ +
+ {{ tile(totalBankingAssets) }} +
+ +
+ {{ bankingAssetsNet ? 'Reduces the base below' : 'More than we owe banks — base unchanged' }}
-
↓ Deducted from NDTL
-
Net Demand & Time Liabilities (NDTL)
-
{{ crore(ndtl) }}Cr
-
After deducting banking assets
-
-
- -
CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}
-
{{ crore(crrRequired) }}Cr
-
SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}
+
NDTL — the base
+
{{ tile(ndtl) }}
+
Demand + time, net of bank balances
@@ -94,26 +97,33 @@ 02 Time Liabilities + +
@if (activeTab === 'demand') { -
Amounts payable on demand — RBI Schedule II
+
Money the bank must repay whenever a customer asks for it.
- - - - + + + + @@ -167,17 +177,17 @@ @if (activeTab === 'time') { -
Amounts payable after a specified period — RBI Schedule III
+
Money the bank repays only when the deposit matures.
# Liability ItemAccount CodeOpening (₹ Cr)Current (₹ Cr)Change (₹ Cr)ItemOpeningCurrentChange Status
- - - - + + + + @@ -231,17 +241,20 @@ @if (activeTab === 'assets') { -
Deducted from gross liabilities to arrive at NDTL — RBI Master Circular
+
+ What the bank keeps with other banks. This comes off what it owes them — and only off that, so where it is the + larger of the two the base is left as it is. +
# Liability ItemAccount CodeOpening (₹ Cr)Current (₹ Cr)Change (₹ Cr)ItemOpeningCurrentChange Status
- - - - + + + + @@ -275,7 +288,12 @@ @@ -289,20 +307,18 @@ @if (activeTab === 'slr') { -
- SLR = (Liquid Assets / NDTL) × 100 — RBI Sec 24(2A) of Banking Regulation Act, 1949 -
+
Cash, bank balances and securities the bank holds against its SLR requirement.
# Asset ItemAccount CodeOpening (₹ Cr)Current (₹ Cr)Change (₹ Cr)ItemOpeningCurrentChange Status
- Total Assets with Banking System ( Deducted from NDTL ) + Total Assets with Banking System + {{ + bankingAssetsNet + ? '( Deducted from NDTL )' + : '( Netted against item I, which is smaller — no deduction )' + }} {{ totalOf(bankingAssets, 'openingValue') }} {{ totalOf(bankingAssets, 'currentValue') }}
- + - - - + + + @@ -348,119 +364,141 @@
# Liquid AssetAccount CodeItem CategoryOpening (₹ Cr)Current (₹ Cr)Change (₹ Cr)OpeningCurrentChange Status
- +
-
SLR Computation — (Liquid Assets / NDTL) × 100
+
How the SLR ratio is worked out
-
Liquid Assets
-
₹13,238.80
-
6 asset categories
+
Assets we hold
+
{{ money(totalLiquidAssets) }}
+
{{ liquidAssets.length }} categories
÷
-
NDTL
-
₹69,221.30
-
Net Demand & Time
-
-
×
-
-
100
-
100
-
 
+
NDTL base
+
{{ money(baseNdtl) }}
+
as at {{ baseDateStr || '—' }}
=
-
Actual SLR
+
SLR held
{{ slrActualPercent }}%
-
Min required: {{ slrRate }}%
+
minimum {{ slrRate }}%
- -
-
-
SLR Compliance Status
-
- Actual: {{ slrActualPercent }}% · Required: {{ slrRate }}.00% · NDTL: ₹{{ - ndtl | number: '1.2-2' - }} - Cr -
-
-
-
-
SLR Required ({{ slrRate }}%)
-
₹{{ slrRequired | number: '1.2-2' }} Cr
-
-
-
Liquid Assets Held
-
₹{{ totalLiquidAssets | number: '1.2-2' }} Cr
-
-
-
Surplus
-
₹{{ slrSurplus | number: '1.2-2' }} Cr
-
-
-
+ } - -
-
- NDTL Computation — Formula Breakdown · Data as of {{ reportingDateStr }} + +
+
+
Are we meeting the requirement?
+
+ Charged on the NDTL of {{ money(baseNdtl) }} as at {{ baseDateStr || reportingDateStr }} +
-
-
-
Demand Liabilities
-
₹{{ totalDemand | number: '1.2-2' }}
-
16 line items
-
-
+
-
-
Time Liabilities
-
₹{{ totalTime | number: '1.2-2' }}
-
13 line items
-
-
−
-
-
Assets with Banking
-
₹{{ totalBankingAssets | number: '1.2-2' }}
-
3 line items
-
-
=
-
-
NDTL
-
₹{{ ndtl | number: '1.2-2' }}
-
CRR & SLR base
+ +
+ @for (r of positionRatios(); track r.name) { +
+ +
{{ r.name }}{{ r.rate === null ? ' — rate not set' : ' @ ' + r.rate + '%' }}
+
{{ money(r.required) }}
+
+ held {{ money(r.maintained) }} + + · {{ r.met ? '+' + money(r.surplus) : 'short ' + money(r.shortfall) }} + +
+
+ } +
+
Total Reserve
+
{{ money(totalReserve) }}
+
CRR + SLR required
- -
-
-
CRR & SLR Reserve Requirements — RBI Computation on NDTL
-
- Based on NDTL of ₹{{ ndtl | number: '1.2-2' }} Cr · Data as of {{ reportingDateStr }} (45-day lag) -
+ + @if (activeTab === 'held') { +
+ Where the cash reserve is held, with each account's balance on the business date + {{ position?.onDate || '—' }}. The requirement below is charged on {{ position?.baseDate || '—' }}, a fortnight back — RBI's lag is a rule, so today's own liabilities settle nothing yet.
-
-
-
CRR @ {{ crrRate }}.00%
-
₹{{ crrRequired | number: '1.2-2' }} Cr
+ + @if (position) { + +
+ @for (r of positionRatios(); track r.name) { +
+
{{ r.name }}{{ r.rate === null ? '' : ' @ ' + r.rate + '%' }}
+
+ Required{{ money(r.required) }} Maintained{{ money(r.maintained) }} + {{ r.met ? 'Surplus' : 'Shortfall' }} + {{ money(r.met ? r.surplus : r.shortfall) }} +
+
+ }
-
-
SLR @ {{ slrRate }}.00%
-
₹{{ slrRequired | number: '1.2-2' }} Cr
+ +
+ + + + + + + + + + + + @for (a of position.accounts; track a.glAccountId) { + + + + + + + + } + +
#AccountForm 1 headBalance today 
{{ $index + 1 }} +
{{ a.name }}
+
{{ a.glCode }}
+
+ {{ a.item }} · {{ a.head }} + + @if (!a.countsTowardMaintained) { + not counted on the return + } + {{ money(a.balance) }} + +
-
-
Total Reserve
-
₹{{ totalReserve | number: '1.2-2' }} Cr
-
-
-
+ } + } + @if (crrSlr.crrGap) { +
{{ crrSlr.crrGap.text }}
+ }
SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }} {{ crrSlr.slrRequired }}
+ @if (crrSlr.slrGap) { +
{{ crrSlr.slrGap.text }}
+ }
diff --git a/src/app/home/dashboard/dashboard.component.scss b/src/app/home/dashboard/dashboard.component.scss index 1cde1703d..45b183bd3 100644 --- a/src/app/home/dashboard/dashboard.component.scss +++ b/src/app/home/dashboard/dashboard.component.scss @@ -899,3 +899,17 @@ $purple: #7c3aed; text-align: center; } } + +// What is held against a requirement, under the requirement itself. Surplus is quiet; a shortfall is +// red, because on a compliance card it is the one line that must not be read past. +.metric-gap { + margin: -2px 0 8px; + text-align: right; + font-size: 11px; + color: #137333; +} + +.metric-gap--short { + color: #c5221f; + font-weight: 700; +} diff --git a/src/app/home/dashboard/dashboard.component.ts b/src/app/home/dashboard/dashboard.component.ts index c0605e700..90ad070a7 100644 --- a/src/app/home/dashboard/dashboard.component.ts +++ b/src/app/home/dashboard/dashboard.component.ts @@ -61,8 +61,19 @@ export class DashboardComponent implements OnInit { slrRate: number | null; crrRequired: string; slrRequired: string; + /** Surplus, or the shortfall, per ratio. A shortfall is its own amount, not a negative surplus. */ + crrGap: { text: string; short: boolean } | null; + slrGap: { text: string; short: boolean } | null; status: 'ok' | 'short' | 'unknown'; - } = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' }; + } = { + crrRate: null, + slrRate: null, + crrRequired: '—', + slrRequired: '—', + crrGap: null, + slrGap: null, + status: 'unknown' + }; /** Array of all user activities */ userActivity: string[]; @@ -93,8 +104,27 @@ export class DashboardComponent implements OnInit { /** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */ private loadCrrSlr(): void { - const crore = (v: number | null | undefined): string => - v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`; + // Rupees, not crore. Rounding to crore on a bank this size collapsed the figures to "₹2 Cr" + // and "₹13 Cr" — two requirements a crore apart both read the same, and a tile a treasury desk + // glances at said nothing it could act on. + // Scaled to the Indian crore/lakh so the tile holds its figure however large the bank grows. + // Plain rupees ran to nineteen characters on a seventy-crore requirement and overflowed the + // card; rounding to crore outright collapsed two requirements a crore apart to the same "₹2 + // Cr". This keeps four or five characters and two decimals of precision at any scale. + const money = (v: number | null | undefined): string => { + if (v === null || v === undefined) { + return '—'; + } + const abs = Math.abs(v); + const sign = v < 0 ? '-' : ''; + if (abs >= 1e7) { + return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`; + } + if (abs >= 1e5) { + return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`; + } + return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`; + }; // The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at // the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never @@ -107,8 +137,8 @@ export class DashboardComponent implements OnInit { } this.crrSlr.crrRate = data?.crrPercentage ?? null; this.crrSlr.slrRate = data?.slrPercentage ?? null; - this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]); - this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]); + this.crrSlr.crrRequired = money(data?.partB?.required?.[latest]); + this.crrSlr.slrRequired = money(data?.partC?.required?.[latest]); // Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a // bank short on SLR is not within limits because its cash reserve happens to be ample. @@ -116,6 +146,19 @@ export class DashboardComponent implements OnInit { const crrNeed = data?.partB?.required?.[latest]; const slrHeld = data?.partC?.maintained?.[latest]; const slrNeed = data?.partC?.required?.[latest]; + // Each ratio's own standing, under its requirement: surplus where it is met, and the + // shortfall — in red, named as one — where it is not. A single "Shortfall" tag said the bank + // was short without saying which ratio or by how much, which is the first thing asked. + const gap = (held: number | null | undefined, need: number | null | undefined) => { + if (held === null || held === undefined || need === null || need === undefined) { + return null; + } + const short = held < need; + return { text: `${short ? 'Short' : 'Surplus'} ${money(Math.abs(held - need))}`, short }; + }; + this.crrSlr.crrGap = gap(crrHeld, crrNeed); + this.crrSlr.slrGap = gap(slrHeld, slrNeed); + if ([ crrHeld, crrNeed, diff --git a/src/app/kyc-management/kyc-management.component.html b/src/app/kyc-management/kyc-management.component.html index 893e1e000..81072f03f 100644 --- a/src/app/kyc-management/kyc-management.component.html +++ b/src/app/kyc-management/kyc-management.component.html @@ -12,22 +12,17 @@ - + - +
-
-
-
47
-
Expiring in 30 days
-
▲ 12 this month
+ @if (alertDateOpen) { +
+
+ +
+
+ {{ alertFilter ? alertLabels[alertFilter] : 'Accounts' }} — {{ alertDateHeading }}: + {{ alertDateOpen | date: 'dd MMM yyyy' }} +
+
+ {{ alertDateRows.length }} account(s) — tick the ones to notify together, or use Notify on a row +
+
+ {{ bulkTargets.length }} customer(s) selected + +
+ + + + + + + @if (!alertFilter) { + + } + + + + + + + + + + @for (r of pagedDayRows; track $index) { + + + + + @if (!alertFilter) { + + } + + + + + + + + } @empty { + + + + } + +
+ + Customer / accountContactState{{ alertDateHeading }}BalanceNext stageLast notice sentNext to sendAction
+ + +
{{ r.name }}
+
{{ r.account }} · {{ r.branch }}
+
+ @if (contactOf(r.clientId); as ni) { + @if (ni.hasMobile || ni.hasEmail) { +
+ @if (ni.hasMobile) { + Mobile + } + @if (ni.hasEmail) { + Email + } +
+ } @else { + No mobile / email +
Reach by call or visit
+ } + } @else { + … + } +
+ {{ r.stage }} + +
{{ r.since | date: 'dd MMM yyyy' }}
+
{{ r.days }} {{ r.daysLabel }}
+
+ {{ + (r.balance ?? alertNoticeInfo[r.clientId]?.balance) == null + ? '—' + : '₹' + (r.balance ?? alertNoticeInfo[r.clientId]?.balance | number: '1.2-2') + }} + +
{{ r.next }}
+ @if (r.nextDate) { +
{{ r.nextDate | date: 'dd MMM yyyy' }}
+ } +
+
+ {{ lastNoticeText(r.clientId) }} +
+ @if (alertNoticeInfo[r.clientId]?.count) { +
{{ alertNoticeInfo[r.clientId].count }} notice(s) on record
+
+ @for (c of doneChannelsFor(r.clientId, r.stageKey); track c) { + ✓ {{ auditChannelLabels[c] || c }} + } +
+ @if (earlierStages(r.clientId, r.stageKey).length) { +
+ Earlier notices: {{ earlierStages(r.clientId, r.stageKey).join(', ') }} +
+ } + } +
+
+ {{ nextNoticeStep(r) }} +
+ @if (queuedChannels(r.clientId).length) { +
+ Waiting in queue: {{ queuedChannels(r.clientId).join(', ') }} +
+ } + @if (nextAutoReminder(r); as auto) { +
Automatic: {{ auto.date | date: 'dd MMM yyyy' }} — {{ auto.label }}
+ } +
+ +
+ + + @if (hasNoContact(r.clientId)) { + + } + + @if (r.stageKey !== 'ESCHEAT') { +
+ +
+ } +
+
+ {{ statusLoading ? 'Loading…' : 'No account matches these filters.' }} +
+ @if (alertDateRows.length > 0) { +
+ + {{ dayEffPage * daySize + 1 }}–{{ min(alertDateRows.length, (dayEffPage + 1) * daySize) }} of + {{ alertDateRows.length }} accounts + + Rows per page + + Page {{ dayEffPage + 1 }} of {{ dayCount() }} + + +
+ }
-
-
23
-
Debit Frozen
-
Action required
+ } + +
+
+ + + + + +
-
-
8
-
Inoperative
-
Escalating
+ +
+
+
{{ expiringRows.length }}
+
Expiring in 30 days
+
Re-KYC falls due soon
+
+
+
{{ expiryTotals.grace }}
+
Overdue — Grace Period
+
KYC lapsed, still operative
+
+
+
{{ expiryTotals.partialFreeze }}
+
Debit Freeze
+
Debits blocked
+
+
+
{{ expiryTotals.inoperative }}
+
Inoperative
+
KYC lapsed {{ expiryPolicy.inoperativeDays }}d+
+
+
+
{{ dormancyTotals.inactive }}
+
Inactive
+
+ No customer txn {{ dormancyPolicy.inactiveDays }}d +
+
+
+
{{ dormancyTotals.dormant }}
+
Dormant
+
+ No customer txn {{ dormancyPolicy.dormantDays }}d +
+
+
+
{{ dormancyTotals.escheat }}
+
Escheat (DEAF)
+
RBI DEAF remittance
+
-
-
3
-
Dormant
-
RBI reportable
+ + @if (alertFiltersActive) { +
+
+
{{ alertFilter ? alertLabels[alertFilter] : 'Matching accounts' }}
+ + {{ alertDateGroups.length }} day(s) · {{ alertList.length }} account(s) · click a day to see who is on + it + @if (alertUnreachableCount) { + · + {{ alertUnreachableCount }} customer(s) have no mobile or email + } + + +
+ + + + + + + + + + + + + + + @for (g of pagedDateGroups; track g.date) { + + + + + + + + + + + } @empty { + + + + } + +
+ + {{ alertDateHeading }}CustomersAccountsNotifiedThen moves toBalanceAction
+ + + {{ g.date | date: 'dd MMM yyyy' }} + + {{ g.customers }} + {{ g.rows.length }} + @if (!g.loaded) { + … + } @else if (g.notified === g.customers) { + All {{ g.customers }} notified + } @else if (g.notified === 0) { + None notified yet + } @else { + {{ g.notified }} of {{ g.customers }} notified + } + @if (g.loaded && g.earlier) { +
+ {{ g.earlier }} earlier notice(s) sent at previous stages +
+ } + @if (g.loaded && g.unreachable) { +
+ {{ g.unreachable }} cannot be reached by SMS / email +
+ } +
+
{{ g.after.label }}
+ @if (g.after.date) { +
on {{ g.after.date | date: 'dd MMM yyyy' }}
+ } +
+ {{ g.balance ? '₹' + (g.balance | number: '1.0-0') : '—' }} + + +
+ {{ statusLoading ? 'Loading…' : 'No account matches these filters.' }} +
+ @if (alertDateGroups.length > 0) { +
+ + {{ dateEffPage * dateSize + 1 }}–{{ min(alertDateGroups.length, (dateEffPage + 1) * dateSize) }} of + {{ alertDateGroups.length }} day(s) + + Rows per page + + Page {{ dateEffPage + 1 }} of {{ dateCount() }} + + +
+ } +
+ } +
+
+ } + + + @if (activeSection === 'notif') { +
+
+ Notification Scheduler +
+ +
-
-
18
-
Notices Today
-
SMS + Email
+
+ +
+
+
{{ schedulerRows.length }}
+
Customers in scope
+
Expiring in 30 days + lapsed
+
+
+
{{ schedulerDueCount }}
+
Next notice due
+
SMS, Email or WhatsApp to send
+
+
+
{{ schedulerNeverContacted }}
+
Never contacted
+
No notice on record
+
+
+
{{ schedSelected.size }}
+
Selected
+
Ready to dispatch
- + + + - - - - +
-
-
Accounts Requiring Action
- Showing {{ alertRows.length }} of 81 · Click row for client details +
Notice queue — sent in the background
+ + Waiting {{ queueCounts['PENDING'] + queueCounts['SENDING'] }} · Sent {{ queueCounts['SENT'] }} · Failed + {{ queueCounts['FAILED'] }} · Cancelled {{ queueCounts['CANCELLED'] }} + +
+ @if (queueError) { +
{{ queueError }}
+ } - - - - - - - - - + + + + + + + + - @for (row of alertRows; track row.id) { - + @for (g of queueGroups; track g.date) { + - - - + - - - - - + } @empty { + + }
CustomerRiskExpiry DateDays LeftNoticesStatePending ActionNext StageDateCustomersNoticesWaitingSentFailedCancelledAction
-
{{ row.name }}
-
{{ row.account }}
+ {{ g.date | date: 'dd MMM yyyy' }}
- {{ row.riskLabel.split(' ')[0] }} + + {{ g.customers }} {{ row.expiry }} -
- {{ row.daysLeft > 0 ? row.daysLeft : '—' }} -
-
-
-
+
{{ g.rows.length }} + {{ g.waiting }} - {{ row.noticesSent }}/3 + + {{ g.sent }} - {{ stateLabel(row.state) }} + + {{ g.failed }} - {{ row.noticePending }} + + {{ g.cancelled }} - {{ row.nextStage }} + + - +
+ {{ + queueLoading + ? 'Loading…' + : 'Nothing has been queued yet. Select customers on the Alert Dashboard and use Notify selected.' + }}
-
- } - - @if (activeSection === 'notif') { -
-
- Notification Scheduler -
- -
-
- -
-
-
-
-
Escalation Ladder — one channel at a time
- - {{ reKycCompletedCount }} of {{ reKycSteps.length }} dispatched · Expiry: 30 Apr 2027 · Window: 31 Mar – - 30 Apr - -
-
-
- - @for (s of reKycSteps; track s.step; let i = $index) { -
-
-
{{ s.step }}
-
-
-
- @if (isFirstOfNotice(i)) { -
Notice {{ s.notice }}
+ @if (schedShow) { + + + + + + + + + + + + + + @for (s of schedulerRows; track s.row.clientId) { + + + + + + + + + + } @empty { + + + } -
-
-
!
-
-
-
-
Debit Freeze — if no action
-
30 April 2027 · Auto-triggered on expiry
-
- Auto-triggered - Credits allowed - Debits blocked -
-
-
-
-
-
!
-
-
-
-
Inoperative — +60 days
-
30 June 2027 · No compliance
-
All txns blocked
-
-
-
-
!
-
-
Dormant — +150 days
-
30 September 2027
-
- Dormant - RBI reporting -
-
-
- - - - -
-
-
-
-
Channel Sequence
-
- -
- @for (s of reKycSteps; track s.step) { -
-
{{ s.step }}. {{ s.channel }}
-
{{ s.detail }}
-
- {{ reKycStatusLabel(s.status) }} -
-
- } -
-
- -
-
-
-
India Post Notice
-
-
-
-
Dispatch Date
-
10 April 2027
-
-
-
Reg. Post Docket No.
-
RP-BPL-20270410-443
-
-
-
Status
-
In Transit
-
- -
-
- -
-
-
-
Home Visit
-
-
-
Scheduled Date
- -
Officer
- - -
-
-
+ +
+ + CustomerKYCLadderNext stepLast contactAction
+ @if (s.next?.sends) { + } -
Step {{ s.step }} — {{ s.channel }}
-
- {{ s.date }} · {{ s.offset }} · - {{ reKycStatusLabel(s.status) }} +
+
{{ s.row.clientName }}
+
{{ s.row.accountNo }}
+
+
{{ s.row.expiryDate | date: 'dd MMM yyyy' }}
+
+ {{ + s.row.daysOverdue < 0 + ? -s.row.daysOverdue + ' days left' + : s.row.daysOverdue + ' days overdue' + }}
-
- {{ s.channel }} - {{ s.detail }} +
+
+ @for (st of s.steps; track st.label) { + + {{ st.state === 'done' ? '✓ ' : '' }}{{ st.label }} + + }
- @if (s.status === 'PENDING') { -
- - -
+
{{ s.next ? s.next.label : 'Ladder complete' }}{{ s.row.lastContactDate ? (s.row.lastContactDate | date: 'dd MMM yyyy') : 'Never' }} + @if (s.next?.sends) { + + } @else if (s.next) { + } - - +
+ {{ statusLoading ? 'Loading…' : 'No customer is expiring or lapsed.' }} +
+ }
} - + @if (activeSection === 'audit') {
Audit Inventory
- -
- + + - + + + + + + - - - + + +
-
-
-
-
KYC Notice Log
- Click row for client details -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#Account / CustomerNoticeChannelDispatchedReference / DocketDeliveryClient ActionOfficer
001 -
Rajesh K. Sharma
-
ACC-BPL-2024-00847
-
Notice 1SMS + Email31 Mar 2027MSG-2027-0331-001✓ DeliveredAwaitedScheduler
002 -
Rajesh K. Sharma
-
ACC-BPL-2024-00847
-
Notice 2Reg. Post + IVR10 Apr 2027RP-BPL-20270410-443In TransitAwaitedScheduler
003 -
Priya Malhotra
-
ACC-BPL-2021-00231
-
Notice 1SMS + Email10 Mar 2027MSG-2027-0310-003✓ DeliveredNo ResponseScheduler
004 -
Priya Malhotra
-
ACC-BPL-2021-00231
-
Notice 2Reg. Post20 Mar 2027RP-BPL-20270320-091✓ DeliveredNo ResponseScheduler
005 -
Priya Malhotra
-
ACC-BPL-2021-00231
-
Notice 3Home Visit30 Mar 2027VIS-BPL-20270330-012✓ DoneRefusedA. Mishra
006 -
Suresh Patel
-
ACC-BPL-2019-00112
-
Notice 3Video KYC5 Feb 2027VKYC-2027-0205-007No ResponseNo ActionSystem
-
- Showing 6 of 12 -
- - -
-
+
+ + + +
-
- } - - @if (activeSection === 'status') { -
-
- Account Status Management - + +
-
-
-
Active
-
1,847
-
KYC valid
+ @if (auditError) { +
+
{{ auditError }}
-
-
Alert Window
-
47
-
Expiring < 30d
-
-
-
Debit Freeze
-
23
-
Debits blocked
-
-
-
Inoperative
-
8
-
All txns blocked
-
-
-
Dormant
-
3
-
RBI reportable
-
-
+ } -
+ @if (auditDay) { +
-
-
Auto-Transition Rules
+ +
+
Notices sent on {{ auditDay | date: 'dd MMM yyyy' }}
+
+ {{ auditDayTotal }} notice(s) · click a row for the message the customer received +
+
+
-
-
-
- Active → Debit FreezeEnabled -
-
- KYC expires + 3 notices sent + no response → debits blocked, credits allowed -
-
- Window: 31 Mar – 30 Apr - 3/3 notices -
-
-
-
- Debit Freeze → InoperativeEnabled -
-
+60 days from expiry, still unresolved → all transactions blocked
-
Apr 30 – Jun 30
-
-
-
- Inoperative → DormantEnabled -
-
+150 days from expiry → dormant, RBI Dormant Account reporting triggered
-
- Jun 30 – Sep 30 - RBI notification -
+ @if (auditDayError) { +
{{ auditDayError }}
+ } + + + + + + + + + + + + + + + @for (r of auditDayRows; track r.id; let i = $index) { + + + + + + + + + + + @if (auditExpanded === r.id) { + + + + + } + } @empty { + + + + } + +
#Customer / accountChannelTimeSent toReferenceDeliveryOfficer
{{ auditDayPage * auditPageSize + i + 1 }} +
{{ r.clientName }}
+
{{ r.accountNo }}
+
{{ auditChannelLabels[r.channel] || r.channel }}{{ r.contactedAt | date: 'HH:mm' }}{{ r.destinationMasked || '—' }}{{ r.providerReference || '—' }} + + {{ auditStatusLabels[r.deliveryStatus] || r.deliveryStatus }} + + {{ r.officerName || '—' }}
+
Note
+
{{ r.notes }}
+ @if (r.messageBody) { +
Message sent to the customer
+
{{ r.messageBody }}
+ } + @if (r.failureReason) { +
Failure reason
+
{{ r.failureReason }}
+ } +
+ {{ auditDayLoading ? 'Loading…' : 'No notice matches these filters on this day.' }} +
+
+ Page {{ auditDayPage + 1 }} of {{ auditDayPageCount() }} + + +
+
+ } @else if (auditView === 'days') { + +
+
+
{{ auditTotals.notifiedCustomers }}
+
Customers notified
+
+ of {{ auditTotals.customers }} tried, summed per day
+
+
{{ auditTotals.notices }}
+
Notices on record
+
{{ auditDays.length }} day(s)
+
+
+
{{ auditTotals.delivered }}
+
Delivered
+
Reached the customer
+
+
+
{{ auditTotals.failed }}
+
Failed
+
Gateway refused
+
+
+
{{ auditTotals.noContact }}
+
No contact detail
+
No mobile / email on file
+
+
+
{{ auditTotals.recorded }}
+
Recorded
+
Calls and visits
+
-
-
Manual Override
- Maker-Checker +
Notices sent, day by day
+ {{ auditDays.length }} day(s) · click a day to see who was sent what
-
-
Account
- -
Current State
-
Debit Freeze
-
Override Action
- -
Reason
- -
- - -
-
-
⚠ Maker-Checker Required
-
- All manual changes require second officer approval and are logged in audit inventory. -
-
+ + + + + + + + + + + + + + + + @for (d of auditDays; track d.date) { + + + + + + + + + + + + } @empty { + + + + } + +
DateCustomers notifiedCustomers triedNoticesDeliveredFailedNo contact detailRecorded (call / visit)Action
+ {{ d.date | date: 'dd MMM yyyy' }} + + {{ + d.notifiedCustomers ?? 0 + }} + + {{ d.customers }} + {{ d.notices }} + {{ d.delivered }} + + {{ d.failed }} + + + {{ d.noContact + d.notConfigured }} + + + {{ d.recorded }} + + +
+ {{ auditLoading ? 'Loading…' : 'No notice matches these filters.' }} +
+
+ } @else { + +
+
+
Customers contacted
+ {{ auditCustTotal }} customer(s) · most recently contacted first · History shows everything done for + one +
+ + + + + + + + + + + + + + + @for (c of auditCustomers; track c.clientId) { + + + + + + + + + + + } @empty { + + + + } + +
Customer / accountNoticesDeliveredFailedNot sentRecordedLatestAction
+
{{ c.clientName }}
+
{{ c.accountNo }}
+
+ {{ c.notices }} + + {{ c.delivered }} + + {{ c.failed }} + + {{ c.notSent }} + + {{ c.recorded }} + +
{{ c.lastAt | date: 'dd MMM yyyy, HH:mm' }}
+
+ {{ auditChannelLabels[c.lastChannel] || c.lastChannel }} · + {{ auditStatusLabels[c.lastStatus] || c.lastStatus }} +
+
+ +
+ {{ auditCustLoading ? 'Loading…' : 'No customer matches these filters.' }} +
+
+ Page {{ auditCustPage + 1 }} of {{ auditCustPageCount() }} + +
-
- -
-
-
-
Recent State Transitions
- Click row for client details -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AccountFromToTriggerDateByAction
-
Priya Malhotra
-
ACC-BPL-2021-00231
-
ActiveDebit FreezeAuto — KYC Expired10 Apr 2027Scheduler - -
-
Suresh Patel
-
ACC-BPL-2019-00112
-
Debit FreezeInoperativeAuto — 60d elapsed15 May 2027Scheduler - -
-
Kavitha Nair
-
ACC-BPL-2018-00088
-
InoperativeDormantAuto — 150d elapsed1 Jun 2027Scheduler - -
-
+ }
} @@ -976,442 +1375,397 @@ }
- - @if (modalOpen && current) { -
-
+ + @if (queueDetail; as d) { +
+
-
{{ current.av }}
-
{{ current.name }}
-
{{ current.meta }}
+
Notices queued on {{ d.date | date: 'dd MMM yyyy' }}
+
+ {{ d.customers }} customer(s) · {{ d.rows.length }} notice(s) — Waiting {{ d.waiting }} · Sent + {{ d.sent }} · Failed {{ d.failed }} · Cancelled {{ d.cancelled }} +
- {{ stateLabel(current.state) }} - {{ current.riskLabel }} -
✕
+
✕
-
-
- - - - - + +
+ @if (queueFailed(d).length) { +
+
+ Not delivered — contact these {{ queueFailed(d).length }} customer(s) manually +
+ + + + + + + + + + + + @for (q of queueFailed(d); track q.id) { + + + + + + + + } + +
CustomerAccountChannelWhy it failedAction
+
{{ q.clientName }}
+
{{ q.accountNo }}{{ q.channel }}{{ q.resultMessage || '—' }} + +
+
+ } + + + + + + + + + + + + + + + + + @for (q of pgSlice('queue', queueOther(d)); track q.id; let i = $index) { + + + + + + + + + + + + + } + +
#CustomerAccountNoticeChannelQueuedSend atStatusResultAction
{{ pgFrom('queue') + i + 1 }} +
{{ q.clientName }}
+
{{ q.accountNo }}{{ q.stageLabel || (q.noticeType === 'DORMANCY' ? 'Account notice' : 'Re-KYC notice') }}{{ q.channel }}{{ q.createdAt | date: 'HH:mm' }}{{ q.scheduledFor | date: 'dd MMM, HH:mm' }} + + {{ + q.status === 'PENDING' + ? 'Waiting' + : q.status === 'SENDING' + ? 'Sending' + : q.status === 'SENT' + ? 'Sent' + : q.status === 'FAILED' + ? 'Failed' + : 'Cancelled' + }} + + @if (q.attempts > 1 && q.status === 'PENDING') { +
retry {{ q.attempts }}
+ } +
{{ q.resultMessage || '—' }} + @if (q.status === 'PENDING') { + + } @else if (q.status === 'FAILED') { + + } +
+ +
+ + +
+
+ } + + + @if (bulkOpen) { +
+
+
+
+
Notify selected customers
+
+ {{ bulkDone && bulkQueued ? bulkQueued.queued : bulkTargets.length }} customers +
+
✕
+
- - @if (modalTab === 'overview') { -
Compliance Journey
-
- @for (step of getStepperState(current); track step.label) { -
-
{{ stepIcon(step.state) }}
-
{{ step.label }}
+
+ @if (!bulkDone && !bulkSending) { +
+ + +
+ + A notice is sent to each customer once, even if several of their accounts are ticked. +
+ @if (bulkUnreachable) { +
+ {{ bulkUnreachable }} of the ticked customer(s) have no + {{ bulkChannel === 'EMAIL' ? 'email address' : 'mobile number' }} + on file and are left out. Use the "No mobile / email" filter to find them and reach them by call or + visit. +
+ } + @if (bulkAlreadyDone) { +
+ {{ bulkAlreadyDone }} of the ticked customer(s) already received a {{ bulkChannel }} notice. + +
+ } +
+ + +
+ @if (bulkError) { +
{{ bulkError }}
+ } + @for (g of bulkNoticeSummary; track g.label) { +
+ {{ g.count }} customer(s): {{ g.label }} — worded for the stage each one is at.
}
- -
-
⚡
-
-
{{ stateLabel(current.state) }} — {{ current.daysLeft }} days to expiry
-
- Next pending: {{ current.noticePending }}. Next stage: {{ current.nextStage }}. -
-
+
+ +
- -
-
-
{{ current.noticesSent }}
-
Notices Sent
-
-
-
{{ noticesPending(current) }}
-
Notices Pending
-
-
-
{{ current.actionTaken.length }}
-
Actions Taken
-
-
-
{{ current.pendingActions.length }}
-
Pending Actions
-
+
+ +
- -
-
-
KYC Information
-
-
-
PAN
-
{{ current.kyc.pan }}
-
-
-
PAN Status
-
- {{ current.kyc.panStatus }} -
-
-
-
Aadhaar
-
{{ current.kyc.aadhaar }}
-
-
-
Aadhaar Status
-
- {{ current.kyc.aadhaarStatus }} -
-
-
-
KYC Validity
-
{{ current.kyc.validity }}
-
-
-
Expiry Date
-
{{ current.expiry }}
-
-
+ @if (bulkTargets.length > bulkLimit) { +
+ {{ bulkTargets.length }} customers selected — send at most {{ bulkLimit }} at a time.
-
-
Contact Details
-
-
-
Mobile
-
{{ current.contact.mobile }}
-
-
-
Email
-
{{ current.contact.email }}
-
-
-
Address
-
{{ current.contact.address }}
-
-
-
Branch
-
{{ current.contact.branch }}
-
-
+ } + } @else { + @if (bulkSending) { +
Queuing…
+ } @else if (bulkQueued) { +
+ + {{ bulkQueued.queued }} notice(s) queued — they are sent in the background. + @if (bulkQueued.skipped) { + {{ bulkQueued.skipped }} already waiting in the queue, so not queued again. + } + @if (bulkQueued.unreachable) { + {{ bulkQueued.unreachable }} have no mobile number / email, so nothing was queued for them. + } + Follow them in the Notification Scheduler tab.
-
+ } } +
- - @if (modalTab === 'timeline') { -
Notice & Escalation Timeline
-
- @for (item of current.actionTaken; track $index) { -
-
✓
-
-
{{ item }}
-
Completed
-
-
- } - @for (item of current.pendingActions; track $index) { -
-
!
-
-
{{ item }}
-
Pending action
-
-
- } -
+ +
+
+ } - - @if (modalTab === 'notices') { -
All Notices Dispatched
+ + @if (schedOpen) { +
+
+
+
+
Dispatch next notice
+
+ {{ schedDone && schedQueued ? schedQueued.queued : schedTargets.length }} customers +
+
+
✕
+
+ +
+ @if (!schedDone && !schedSending) { - - - - - - - + + - @for (n of current.notices; track n.ref; let i = $index) { + @for (t of pgSlice('sched', schedTargets); track t.row.clientId) { - - - - - - - - } - @if (noticesPending(current) > 0) { - - + }
#NoticeChannelDateReferenceDeliveryResponseCustomerGoes out as
{{ i + 1 }} - {{ - n.no - }} - {{ n.channel }}{{ n.date }} - {{ n.ref }} - - {{ n.delivery }} - - {{ n.response }} -
- ⏳ {{ noticesPending(current) }} notice(s) still pending dispatch + {{ t.row.clientName }} +
{{ t.row.accountNo }}
{{ t.next?.label }}
- } - - - @if (modalTab === 'evidence') { - @if (!current.evidence) { -
-
📄
-
No evidence collected yet for this client.
-
Evidence is captured during branch visits or video KYC sessions.
-
- } @else { -
- {{ current.evidence.type === 'visit' ? 'Home Visit Evidence' : 'Video KYC Session Log' }} -
-
-
-
Date
-
{{ current.evidence.date }}
-
-
-
Officer / Agent
-
{{ current.evidence.officer }}
-
- @if (current.evidence.type === 'visit') { -
-
GPS Coordinates
-
{{ current.evidence.gps }}
-
-
-
Location
-
{{ current.evidence.location }}
-
+ +
+ + +
+
+ + +
+ @if (schedError) { +
{{ schedError }}
+ } + @if (schedTargets.length > bulkLimit) { +
Send at most {{ bulkLimit }} at a time.
+ } + } @else { + @if (schedSending) { +
Queuing…
+ } @else if (schedQueued) { +
+ + {{ schedQueued.queued }} notice(s) queued — they are sent in the background and appear in the queue + below. + @if (schedQueued.skipped) { + {{ schedQueued.skipped }} already waiting, so not queued again. + } + @if (schedQueued.unreachable) { + {{ schedQueued.unreachable }} have no mobile number / email, so nothing was queued for them. } -
-
Reference / Docket
-
{{ current.evidence.docket || current.evidence.ref }}
-
-
-
Outcome
-
No Compliance
-
-
- @if (current.evidence.type === 'visit') { -
Map Placeholder
-
- {{ current.evidence.gps }} -
{{ current.evidence.location }}
-
-
Photo Evidence
-
Visit photo uploaded · Tap to view
- } -
Officer Notes
-
{{ current.evidence.outcome }}
-
- -
} } +
- - @if (modalTab === 'action') { -
Available Actions
-
- @for (action of current.pendingActions; track action) { -
-
-
{{ action }}
-
Initiate via available channels
-
- -
- } -
+
} - - @if (homeVisitOpen && current) { -
-
+ + @if (historyFor; as h) { +
+
-
{{ current.av }}
-
Schedule Home Visit
-
{{ current.name }} · {{ current.account }}
+
{{ h.name }} — what has been done
+
+ {{ h.account }} · {{ (historyRows[h.clientId] || []).length }} action(s), newest first +
-
✕
+
✕
-
-
-
Visit Address
-
{{ current.name }}
-
{{ current.contact.address }}
-
Branch: {{ current.contact.branch }}
-
- -
- - -
- -
- - -
- -
- - -
+ + +
-
-
- } - - - @if (vkycOpen && current) { -
-
-
-
{{ current.av }}
-
-
Send Video KYC Link
-
{{ current.name }} · {{ current.account }}
-
-
✕
-
- -
-
-
Customer Contact
-
{{ current.name }}
-
- {{ current.contact.mobile }} -
-
- {{ current.contact.email }} -
-
- -
- -
- - - -
-
- -
- - -
- -
- - -
Default: 48 hours (RBI Video-based KYC norms)
-
- -
- - -
- -
- - -
-
- -
@@ -1459,6 +1813,15 @@ } @else {
Records an action you already took. Nothing is sent to the customer.
} + @if (selectedChannelSends && contactPreview) { +
+ {{ contactPreview.subject }} + {{ contactPreview.body }} +
+ } @if (selectedChannelUnavailable) {
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on @@ -1468,12 +1831,8 @@
- - + +
@@ -1665,3 +2024,38 @@
{{ toastMsg }}
+ + + + @if (total > 0) { +
+ {{ pgFrom(key) + 1 }}–{{ min(total, pgFrom(key) + pgSize(key)) }} of {{ total }} + Rows per page + + Page {{ pgPage(key, total) + 1 }} of {{ pgCount(key, total) }} + + +
+ } +
diff --git a/src/app/kyc-management/kyc-management.component.scss b/src/app/kyc-management/kyc-management.component.scss index 697f974b9..c39b27c06 100644 --- a/src/app/kyc-management/kyc-management.component.scss +++ b/src/app/kyc-management/kyc-management.component.scss @@ -108,7 +108,6 @@ .km-main { padding: 20px 24px; - max-width: 1400px; } .km-page-head { @@ -1778,3 +1777,7 @@ border-radius: 2px; } } + +.km-rep { + margin-top: 24px; +} diff --git a/src/app/kyc-management/kyc-management.component.ts b/src/app/kyc-management/kyc-management.component.ts index 8da0f15d6..b006ffaae 100644 --- a/src/app/kyc-management/kyc-management.component.ts +++ b/src/app/kyc-management/kyc-management.component.ts @@ -6,12 +6,37 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, HostListener, OnDestroy, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ClientsService } from '../clients/clients.service'; import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export'; +import { firstValueFrom, forkJoin, from, of } from 'rxjs'; +import { catchError, concatMap, map } from 'rxjs/operators'; +import { toIsoDate } from 'app/core/utils/api-date'; +import { businessTodayIso } from 'app/core/utils/business-today'; +import { + ALWAYS_ALLOWED, + KYC_EXPIRY_POLICY, + KycContactRow, + KycExpiryRow, + KycExpirySummary, + buildExpiryRow, + channelLabel, + expirySummary, + sortWorklist +} from '../kyc-expiry/kyc-expiry.compute'; +import { + DORMANCY_POLICY, + DormancyRow, + DormancySummary, + dormancySummary, + nextStage, + sortByIdle, + stageFromSubStatus +} from '../dormancy-report/dormancy-report.compute'; interface KmNotice { no: string; @@ -99,6 +124,15 @@ interface KmClient { hasEmail?: boolean; } +interface KmBulkTarget { + clientId: number; + name: string; + account: string; + mobile: string; + email?: string; + noticeType: string; +} + @Component({ selector: 'mifosx-kyc-management', templateUrl: './kyc-management.component.html', @@ -109,458 +143,18 @@ interface KmClient { FaIconComponent ] }) -export class KycManagementComponent implements OnInit { +export class KycManagementComponent implements OnInit, OnDestroy { private clientsService = inject(ClientsService); - activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts'; + private router = inject(Router); + activeSection: 'alerts' | 'status' | 'notif' | 'audit' | 'exceptions' | 'expiry' | 'dormancy' = 'alerts'; - modalOpen = false; - modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview'; + /** Customer the contact / resolve dialogs are open for. */ current: KmClient | null = null; toastMsg = ''; toastVisible = false; private toastTimer: any; - /** Home Visit scheduling dialog */ - homeVisitOpen = false; - homeVisitOfficer = ''; - homeVisitDateTime = ''; - homeVisitNote = ''; - bankOfficers = [ - 'Amit Sharma — Branch Officer (Bhopal Main)', - 'Neha Verma — KYC Officer (Bhopal Main)', - 'Ravi Kulkarni — Field Officer (Bhopal Zone-2)', - 'Sunita Patel — Compliance Officer (Bhopal Main)', - 'Manoj Tiwari — Field Officer (Indore Zone-1)' - ]; - scheduledHomeVisits: { client: string; address: string; officer: string; dateTime: string; note: string }[] = []; - - /** Video KYC link dialog */ - vkycOpen = false; - vkycOfficer = ''; - vkycExpiry = ''; - vkycChannels = { sms: true, email: true, whatsapp: false }; - vkycMessage = ''; - vkycSessionId = ''; - sentVkycLinks: { - client: string; - mobile: string; - email: string; - officer: string; - expiry: string; - channels: string[]; - sessionId: string; - link: string; - }[] = []; - - clients: Record = { - rajesh: { - id: 'rajesh', - av: 'RS', - name: 'Rajesh Kumar Sharma', - meta: 'ACC-BPL-2024-00847 · Savings A/c · Bhopal Main', - account: 'ACC-BPL-2024-00847', - state: 'alert', - risk: 'low', - riskLabel: 'Low Risk', - expiry: '30 Apr 2027', - daysLeft: 15, - kyc: { - type: 'Individual', - initiated: '4 Apr 2026', - lastVerified: '4 Apr 2026', - validity: '10 Years', - pan: 'AQRPV8345E', - aadhaar: 'XXXXXXXX6039', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-99041', - email: 'r****@gmail.com', - address: 'House No. 42, Sector 5, Bhopal MP 462001', - branch: 'Bhopal Main' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '31 Mar 2027', - ref: 'MSG-2027-0331-001', - delivery: 'Delivered', - response: 'No Action' - }, - { - no: 'N2', - channel: 'Reg. Post + IVR', - date: '10 Apr 2027', - ref: 'RP-BPL-20270410-443', - delivery: 'In Transit', - response: 'Pending' - } - ], - noticesSent: 2, - noticePending: 'Notice 3', - nextStage: 'Debit Freeze', - pendingActions: [ - 'Send Notice 3 (due 20 Apr 2027)', - 'Schedule Home Visit', - 'Send Video KYC link' - ], - actionTaken: [ - 'Notice 1 delivered', - 'Notice 2 dispatched via Reg. Post' - ], - evidence: null - }, - priya: { - id: 'priya', - av: 'PM', - name: 'Priya Malhotra', - meta: 'ACC-BPL-2021-00231 · Savings A/c · Bhopal Main', - account: 'ACC-BPL-2021-00231', - state: 'freeze', - risk: 'mid', - riskLabel: 'Mid Risk', - expiry: '10 Apr 2027', - daysLeft: 0, - kyc: { - type: 'Individual', - initiated: '10 Apr 2019', - lastVerified: '10 Apr 2019', - validity: '8 Years', - pan: 'BKJPV1234A', - aadhaar: 'XXXXXXXX7721', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-55321', - email: 'p****@yahoo.com', - address: 'Flat 12B, Arera Colony, Bhopal MP 462016', - branch: 'Bhopal Main' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '10 Mar 2027', - ref: 'MSG-2027-0310-003', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N2', - channel: 'Reg. Post', - date: '20 Mar 2027', - ref: 'RP-BPL-20270320-091', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N3', - channel: 'Home Visit', - date: '30 Mar 2027', - ref: 'VIS-BPL-20270330-012', - delivery: 'Visit Done', - response: 'Refused' - } - ], - noticesSent: 3, - noticePending: 'None — all 3 sent', - nextStage: 'Inoperative by 30 Jun 2027', - pendingActions: [ - 'Account in Debit Freeze', - 'Inoperative transition due 30 Jun 2027', - 'Maker-checker unfreeze pending' - ], - actionTaken: [ - 'Notice 1 delivered', - 'Notice 2 delivered via Reg. Post', - 'Notice 3 — Branch visit done, client refused', - 'Debit Freeze applied 10 Apr 2027' - ], - evidence: { - type: 'visit', - date: '30 Mar 2027', - officer: 'Anupam Mishra', - gps: '23.2599° N, 77.4126° E', - location: 'Arera Colony, Bhopal', - outcome: 'Client refused to sign self-declaration form. Informed about debit freeze implications.', - docket: 'VIS-BPL-20270330-012' - } - }, - suresh: { - id: 'suresh', - av: 'SP', - name: 'Suresh Patel', - meta: 'ACC-BPL-2019-00112 · Savings A/c · Bhopal South', - account: 'ACC-BPL-2019-00112', - state: 'inop', - risk: 'high', - riskLabel: 'High Risk', - expiry: '15 Mar 2027', - daysLeft: 0, - kyc: { - type: 'Individual', - initiated: '15 Mar 2025', - lastVerified: '15 Mar 2025', - validity: '2 Years', - pan: 'CXRTS5678B', - aadhaar: 'XXXXXXXX2211', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-88765', - email: 's****@gmail.com', - address: 'Village Ratibad, Bhopal MP 462044', - branch: 'Bhopal South' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '13 Feb 2027', - ref: 'MSG-2027-0213-006', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N2', - channel: 'Reg. Post + IVR', - date: '23 Feb 2027', - ref: 'RP-BPL-20270223-044', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N3', - channel: 'Video KYC', - date: '5 Mar 2027', - ref: 'VKYC-2027-0305-007', - delivery: 'No Response', - response: 'No Action' - } - ], - noticesSent: 3, - noticePending: 'None', - nextStage: 'Dormant by 30 Sep 2027', - pendingActions: [ - 'Account Inoperative — all txns blocked', - 'Dormant transition due 30 Sep 2027', - 'RBI Dormant Account reporting' - ], - actionTaken: [ - 'Notice 1 delivered', - 'Notice 2 delivered via Reg. Post', - 'Notice 3 — Video KYC invite sent (no response)', - 'Debit Freeze 15 Mar 2027', - 'Inoperative 15 May 2027' - ], - evidence: { - type: 'vkyc', - date: '5 Mar 2027', - officer: 'System', - ref: 'VKYC-2027-0305-007', - outcome: 'Video KYC session link sent. Client did not join within 48 hours. Session auto-expired.' - } - }, - kavitha: { - id: 'kavitha', - av: 'KN', - name: 'Kavitha Nair', - meta: 'ACC-BPL-2018-00088 · Savings A/c · Bhopal East', - account: 'ACC-BPL-2018-00088', - state: 'dormant', - risk: 'mid', - riskLabel: 'Mid Risk', - expiry: '1 Jan 2027', - daysLeft: 0, - kyc: { - type: 'Individual', - initiated: '1 Jan 2019', - lastVerified: '1 Jan 2019', - validity: '8 Years', - pan: 'DVJNK9012C', - aadhaar: 'XXXXXXXX4492', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-11092', - email: 'k****@rediff.com', - address: 'Plot 7, Awadhpuri, Bhopal MP 462010', - branch: 'Bhopal East' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '1 Dec 2026', - ref: 'MSG-2026-1201-009', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N2', - channel: 'Reg. Post', - date: '11 Dec 2026', - ref: 'RP-BPL-20261211-077', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N3', - channel: 'Home Visit', - date: '22 Dec 2026', - ref: 'VIS-BPL-20261222-019', - delivery: 'Not Found', - response: 'Not Found at Address' - } - ], - noticesSent: 3, - noticePending: 'None', - nextStage: 'Dormant (Final) — RBI Reporting', - pendingActions: [ - 'RBI Dormant Account Report submission', - 'Branch Manager approval for any reversal', - 'Regional Manager sign-off required' - ], - actionTaken: [ - 'Notice 1–3 sent, all no response', - 'Debit Freeze 1 Jan 2027', - 'Inoperative 2 Mar 2027', - 'Dormant 1 Jun 2027', - 'RBI report auto-generated' - ], - evidence: { - type: 'visit', - date: '22 Dec 2026', - officer: 'Rekha Sharma', - gps: '23.2491° N, 77.4384° E', - location: 'Awadhpuri, Bhopal', - outcome: - 'Client not found at registered address. Neighbours confirmed relocation. Address verification required.', - docket: 'VIS-BPL-20261222-019' - } - }, - amit: { - id: 'amit', - av: 'AS', - name: 'Amit Singh', - meta: 'ACC-BPL-2024-01103 · Savings A/c · Bhopal Main', - account: 'ACC-BPL-2024-01103', - state: 'alert', - risk: 'low', - riskLabel: 'Low Risk', - expiry: '5 May 2027', - daysLeft: 20, - kyc: { - type: 'Individual', - initiated: '5 May 2016', - lastVerified: '5 May 2016', - validity: '10 Years', - pan: 'EKJPS4321D', - aadhaar: 'XXXXXXXX8834', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-22334', - email: 'a****@gmail.com', - address: 'C-22, Shivaji Nagar, Bhopal MP 462016', - branch: 'Bhopal Main' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '5 Apr 2027', - ref: 'MSG-2027-0405-011', - delivery: 'Delivered', - response: 'No Action' - } - ], - noticesSent: 1, - noticePending: 'Notice 2 & 3', - nextStage: 'Debit Freeze if no action', - pendingActions: [ - 'Send Notice 2 (due 15 Apr 2027)', - 'Send Notice 3 (due 25 Apr 2027)', - 'Branch visit if needed' - ], - actionTaken: ['Notice 1 delivered 5 Apr 2027'], - evidence: null - }, - meena: { - id: 'meena', - av: 'MJ', - name: 'Meena Joshi', - meta: 'ACC-BPL-2022-00567 · Savings A/c · Bhopal North', - account: 'ACC-BPL-2022-00567', - state: 'urgent', - risk: 'high', - riskLabel: 'High Risk', - expiry: '28 Apr 2027', - daysLeft: 3, - kyc: { - type: 'Individual', - initiated: '28 Apr 2025', - lastVerified: '28 Apr 2025', - validity: '2 Years', - pan: 'FJLMN6789E', - aadhaar: 'XXXXXXXX3309', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-77654', - email: 'm****@gmail.com', - address: 'Lane 4, Shyamla Hills, Bhopal MP 462002', - branch: 'Bhopal North' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '29 Mar 2027', - ref: 'MSG-2027-0329-014', - delivery: 'Delivered', - response: 'No Action' - }, - { - no: 'N2', - channel: 'Reg. Post + IVR', - date: '8 Apr 2027', - ref: 'RP-BPL-20270408-088', - delivery: 'Delivered', - response: 'No Response' - } - ], - noticesSent: 2, - noticePending: 'URGENT: Notice 3', - nextStage: 'Debit Freeze in 3 days', - pendingActions: [ - 'URGENT: Send Notice 3 NOW', - 'Schedule Home Visit immediately', - 'Consider Video KYC as last resort' - ], - actionTaken: [ - 'Notice 1 delivered', - 'Notice 2 delivered via Reg. Post + IVR' - ], - evidence: null - } - }; - - alertRows: KmClient[] = []; exceptionRows: KmClient[] = []; pendingExceptions: KmClient[] = []; resolvedExceptions: KmClient[] = []; @@ -572,7 +166,10 @@ export class KycManagementComponent implements OnInit { contactDialogOpen = false; contactChannel = 'PHONE_CALL'; contactNotes = ''; - contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)'; + contactOfficer = ''; + /** Set when the dialog was opened from the dashboard: the stage-specific notice the customer gets. */ + contactNoticeType = ''; + contactPreview: { subject: string; body: string } | null = null; /** In-flight guard — a double-click must not send the customer two notices. */ contactSaving = false; /** Populated when the save itself failed (not when the save worked but delivery didn't). */ @@ -609,9 +206,2043 @@ export class KycManagementComponent implements OnInit { showAadhaarResolution = false; showGeneralResolution = false; + // ── Live status data (Account Status / Notification Scheduler / Audit Inventory) ────────────── + + statusLoading = false; + statusError = ''; + expiryRows: KycExpiryRow[] = []; + /** Re-KYC due within the next 30 days — not lapsed yet, so kept apart from {@link expiryRows}. */ + expiringRows: KycExpiryRow[] = []; + dormancyRows: DormancyRow[] = []; + expiryTotals: KycExpirySummary = expirySummary([]); + dormancyTotals: DormancySummary = dormancySummary([], businessTodayIso()); + readonly expiryPolicy = KYC_EXPIRY_POLICY; + readonly dormancyPolicy = DORMANCY_POLICY; + readonly alwaysAllowed = ALWAYS_ALLOWED; + + /** Every outreach attempt on record across the lapsed customers, newest first. */ + get auditRows(): { row: KycExpiryRow; log: KycContactRow }[] { + return this.expiryRows + .flatMap((row) => row.contacts.map((log) => ({ row, log }))) + .sort((a, b) => (b.log.date || '').localeCompare(a.log.date || '')); + } + + /** Reads the same sources as the Expired KYC and Dormancy reports, so the figures cannot differ. */ + /** Card picked on the Alert Dashboard; its accounts are listed beneath the cards. */ + /** The page opens on the Expiring in 30 days card. */ + alertFilter: 'EXPIRING' | 'GRACE' | 'PARTIAL_FREEZE' | 'INOPERATIVE' | 'INACTIVE' | 'DORMANT' | 'ESCHEAT' | null = + 'EXPIRING'; + + readonly alertLabels: Record = { + EXPIRING: 'Expiring in 30 days', + GRACE: 'Overdue — Grace Period', + PARTIAL_FREEZE: 'Debit Freeze', + INOPERATIVE: 'Inoperative', + INACTIVE: 'Inactive', + DORMANT: 'Dormant', + ESCHEAT: 'Escheat (DEAF)' + }; + + selectAlert(key: NonNullable): void { + this.alertFilter = this.alertFilter === key ? null : key; + this.datePage = 0; + this.leaveAlertDay(); + this.loadReachForList(); + } + + /** + * A day's list is its own step in the browser history. The browser's Back button, or the Back button on the page, both end + * up here and return to the list of days instead of leaving the KYC page. + */ + @HostListener('window:popstate') + onBrowserBack(): void { + const state = history.state || {}; + if (this.alertDateOpen && !state.kmDay) { + this.alertDateOpen = null; + } + if (this.auditDay && !state.kmAuditDay) { + this.auditDay = null; + this.auditDayRows = []; + } + } + + /** Closes the open day on the Alert Dashboard, taking its history step with it so Back does not need pressing twice. */ + private leaveAlertDay(): void { + if (!this.alertDateOpen) { + return; + } + this.alertDateOpen = null; + if (history.state?.kmDay) { + history.back(); + } + } + + private leaveAuditDay(): void { + if (!this.auditDay) { + return; + } + this.auditDay = null; + this.auditDayRows = []; + if (history.state?.kmAuditDay) { + history.back(); + } + } + + /** The two full reports live on their own pages; the dashboard links to them. */ + openReport(path: string): void { + this.router.navigate([path]); + } + + /** Filters above the cards; they narrow the list of accounts beneath them. */ + alertSearch = ''; + alertRisk = 0; + alertContact: '' | 'NONE' | 'CONTACTED' | 'ACK' = ''; + /** Whether a customer has a mobile number or email address to be sent anything at all. */ + alertReach: '' | 'NONE' | 'OK' = ''; + + get alertFiltersActive(): boolean { + return !!(this.alertFilter || this.alertSearch.trim() || this.alertRisk || this.alertContact || this.alertReach); + } + + resetAlertFilters(): void { + this.leaveAlertDay(); + this.alertFilter = null; + this.alertSearch = ''; + this.alertRisk = 0; + this.alertContact = ''; + this.alertReach = ''; + } + + // ── The list by date: how many fell on each day, and who ────────────────── + + /** The day open in the popup. */ + alertDateOpen: string | null = null; + + /** What the date means for the card in view: KYC expiry for the KYC states, last customer transaction otherwise. */ + get alertDateHeading(): string { + const f = this.alertFilter; + if (!f) { + return 'Date'; + } + return f === 'INACTIVE' || f === 'DORMANT' || f === 'ESCHEAT' ? 'Last customer transaction' : 'KYC expiry date'; + } + + /** The matching accounts grouped by that date, the most urgent (oldest or soonest) day first. */ + get alertDateGroups(): { + date: string; + rows: KycManagementComponent['alertList']; + customers: number; + balance: number; + /** Customers that something has been done for (a notice delivered, or a call / visit recorded). */ + notified: number; + /** Notices sent to these customers at stages before this one. */ + earlier: number; + /** Customers with neither a mobile number nor an email address. */ + unreachable: number; + /** False until the notice history of every customer on the day has come back. */ + loaded: boolean; + /** The card these customers move to next, and the day they get there. */ + after: { label: string; date: string }; + }[] { + const byDate = new Map(); + for (const r of this.alertList) { + const day = r.since || ''; + byDate.set(day, [ + ...(byDate.get(day) || []), + r + ]); + } + return [...byDate.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map( + ([ + date, + rows + ]) => { + const ids = [...new Set(rows.map((r) => r.clientId))]; + return { + date, + rows, + customers: ids.length, + // A KYC row carries no balance of its own; it is the customer's total, counted once per customer. + balance: ids.reduce( + (n, id) => + n + Number(this.alertNoticeInfo[id]?.balance ?? rows.find((r) => r.clientId === id)?.balance ?? 0), + 0 + ), + notified: ids.filter((id) => this.doneChannelsFor(id, rows[0]?.stageKey).length > 0).length, + earlier: ids.reduce((n, id) => n + this.earlierNotices(id, rows[0]?.stageKey), 0), + unreachable: ids.filter((id) => { + const c = this.alertNoticeInfo[id]; + return !!c && c.hasMobile !== undefined && !c.hasMobile && !c.hasEmail; + }).length, + loaded: ids.every((id) => this.alertNoticeInfo[id] !== undefined), + after: this.nextCardFor(date, rows[0]) + }; + } + ); + } + + /** + * Where a customer goes once this stage runs out. A KYC that expires moves to the grace period on its expiry date, then to + * the debit freeze after the grace days and to inoperative after the inoperative days; other stages carry their own next step. + */ + private nextCardFor( + date: string, + row?: KycManagementComponent['alertList'][number] + ): { label: string; date: string } { + const plus = (days: number): string => { + const x = new Date(date + 'T00:00:00Z'); + return new Date(x.getTime() + days * 86400000).toISOString().slice(0, 10); + }; + switch (row?.stageKey) { + case 'EXPIRING': + return { label: this.alertLabels['GRACE'], date }; + case 'GRACE': + return { label: this.alertLabels['PARTIAL_FREEZE'], date: plus(this.expiryPolicy.graceDays) }; + case 'PARTIAL_FREEZE': + return { label: this.alertLabels['INOPERATIVE'], date: plus(this.expiryPolicy.inoperativeDays) }; + default: + return { label: row?.next || '—', date: row?.nextDate || '' }; + } + } + + /** + * The channels a customer has been reached by at the stage they are at now. A notice sent at an earlier stage (the 30-day + * warning, once the KYC has expired) does not count, since the customer is owed the new one. Before the backend sends + * the stage split, every notice counts as before. + */ + doneChannelsFor(clientId: number, stageKey?: string): string[] { + const info = this.alertNoticeInfo[clientId]; + if (!info) { + return []; + } + if (!info.doneByStage || !stageKey) { + return info.doneChannels || []; + } + return [ + ...new Set([ + ...(info.doneByStage[stageKey] || []), + ...(info.doneByStage['ANY'] || []) + ]) + ]; + } + + /** The earlier stages at which this customer was already sent a notice, by name and how many. */ + earlierStages(clientId: number, stageKey?: string): string[] { + const info = this.alertNoticeInfo[clientId]; + const by = info?.noticesByStage || {}; + if (!stageKey) { + return []; + } + return Object.keys(by) + .filter((k) => k !== stageKey && k !== 'ANY' && by[k] > 0) + .map((k) => (this.alertLabels[k] || k) + (by[k] > 1 ? ' ×' + by[k] : '')); + } + + /** How many notices this customer was sent at stages before the one they are at now. */ + earlierNotices(clientId: number, stageKey?: string): number { + const by = this.alertNoticeInfo[clientId]?.noticesByStage || {}; + return Object.keys(by) + .filter((k) => !!stageKey && k !== stageKey && k !== 'ANY') + .reduce((n, k) => n + by[k], 0); + } + + /** Page sizes offered under the lists; the lists are cut into pages so a bank with a million customers never draws them all at once. */ + readonly pageSizes = [ + 10, + 25, + 50, + 100 + ]; + + /** Page and size of each pop-up list, by name. */ + private pg: Record = {}; + + private pgState(key: string): { page: number; size: number } { + return (this.pg[key] ||= { page: 0, size: 10 }); + } + + pgSize(key: string): number { + return this.pgState(key).size; + } + + pgCount(key: string, total: number): number { + return Math.max(1, Math.ceil(total / this.pgSize(key))); + } + + /** The page in view, kept inside the list when it shrinks. */ + pgPage(key: string, total: number): number { + return Math.min(this.pgState(key).page, this.pgCount(key, total) - 1); + } + + pgFrom(key: string): number { + return this.pgState(key).page * this.pgSize(key); + } + + pgSlice(key: string, list: T[]): T[] { + const s = this.pgState(key); + s.page = Math.min(s.page, Math.max(0, Math.ceil(list.length / s.size) - 1)); + return list.slice(s.page * s.size, s.page * s.size + s.size); + } + + pgMove(key: string, by: number, total: number): void { + this.pgState(key).page = Math.min(Math.max(this.pgPage(key, total) + by, 0), this.pgCount(key, total) - 1); + } + + pgSetSize(key: string, size: number): void { + const s = this.pgState(key); + s.size = size; + s.page = 0; + } + + min = Math.min; + dateSize = 25; + daySize = 25; + private datePage = 0; + private dayPage = 0; + + /** The page in view, kept inside the list when a filter shrinks it. */ + get dateEffPage(): number { + return Math.min(this.datePage, this.dateCount() - 1); + } + + get dayEffPage(): number { + return Math.min(this.dayPage, this.dayCount() - 1); + } + + dateCount(): number { + return Math.max(1, Math.ceil(this.alertDateGroups.length / this.dateSize)); + } + + dayCount(): number { + return Math.max(1, Math.ceil(this.alertDateRows.length / this.daySize)); + } + + datePMove(by: number): void { + this.datePage = Math.min(Math.max(this.dateEffPage + by, 0), this.dateCount() - 1); + } + + datePSize(n: number): void { + this.dateSize = n; + this.datePage = 0; + } + + dayPMove(by: number): void { + this.dayPage = Math.min(Math.max(this.dayEffPage + by, 0), this.dayCount() - 1); + } + + dayPSize(n: number): void { + this.daySize = n; + this.dayPage = 0; + } + + get pagedDateGroups(): KycManagementComponent['alertDateGroups'] { + const from = this.dateEffPage * this.dateSize; + return this.alertDateGroups.slice(from, from + this.dateSize); + } + + get pagedDayRows(): KycManagementComponent['alertList'] { + const from = this.dayEffPage * this.daySize; + return this.alertDateRows.slice(from, from + this.daySize); + } + + get alertDateRows(): KycManagementComponent['alertList'] { + return this.alertDateGroups.find((g) => g.date === this.alertDateOpen)?.rows || []; + } + + openAlertDate(date: string): void { + this.alertDateOpen = date; + this.dayPage = 0; + // The day gets its own step in the browser history, so the browser's Back button returns to the list, not out of the page. + history.pushState({ ...history.state, kmDay: date }, ''); + this.loadAlertNotices(); + this.loadQueue(); + } + + /** What the customers in the open day have been sent, so each row can say what went last and what is next. */ + alertNoticeInfo: Record< + number, + { + count: number; + lastAt: string; + lastChannel: string; + lastStatus: string; + doneChannels: string[]; + doneByStage?: Record; + noticesByStage?: Record; + balance?: number; + hasMobile?: boolean; + hasEmail?: boolean; + mobileMasked?: string | null; + emailMasked?: string | null; + } + > = {}; + alertNoticeLoaded = false; + + loadAlertNotices(): void { + const ids = [...new Set(this.alertDateRows.map((r) => r.clientId))]; + this.alertNoticeLoaded = false; + this.fetchNoticeInfo(ids); + } + + /** What each customer has been sent and whether they have a mobile number or email, fetched 500 at a time. */ + private fetchNoticeInfo(ids: number[]): void { + if (!ids.length) { + this.alertNoticeLoaded = true; + return; + } + const chunks: number[][] = []; + for (let i = 0; i < ids.length; i += 500) { + chunks.push(ids.slice(i, i + 500)); + } + forkJoin( + chunks.map((c) => this.clientsService.getKycContactSummary(c).pipe(catchError(() => of([] as any[])))) + ).subscribe((parts) => { + const info: KycManagementComponent['alertNoticeInfo'] = { ...this.alertNoticeInfo }; + parts.forEach((rows) => (rows || []).forEach((r: any) => (info[r.clientId] = r))); + this.alertNoticeInfo = info; + this.alertNoticeLoaded = true; + }); + } + + /** Looks up contact details for the customers on the list in view, so the ones who cannot be reached by SMS or email show. */ + loadReachForList(): void { + const ids = [...new Set(this.alertListBase.map((r) => r.clientId))].filter( + (id) => this.alertNoticeInfo[id]?.hasMobile === undefined + ); + this.fetchNoticeInfo(ids); + } + + /** True once the customer's details are known and there is neither a mobile number nor an email on file. */ + hasNoContact(clientId: number): boolean { + const c = this.alertNoticeInfo[clientId]; + return !!c && c.hasMobile !== undefined && !c.hasMobile && !c.hasEmail; + } + + contactOf(clientId: number): KycManagementComponent['alertNoticeInfo'][number] | undefined { + return this.alertNoticeInfo[clientId]; + } + + /** True when the customer is known to have nowhere to send this channel (no mobile for SMS / WhatsApp, no email for Email). */ + unreachableFor(clientId: number, channel: string): boolean { + const c = this.alertNoticeInfo[clientId]; + if (!c || c.hasMobile === undefined) { + return false; + } + return channel === 'EMAIL' ? !c.hasEmail : !c.hasMobile; + } + + /** Customers on the list in view with neither a mobile number nor an email address. */ + get alertUnreachableCount(): number { + const seen = new Set(); + this.alertListBase.forEach((r) => { + const c = this.alertNoticeInfo[r.clientId]; + if (c && c.hasMobile !== undefined && !c.hasMobile && !c.hasEmail) { + seen.add(r.clientId); + } + }); + return seen.size; + } + + /** Who the open history popup is about, and each customer's notices once loaded. */ + historyKey: string | null = null; + historyLoading = false; + historyFor: { clientId: number; name: string; account: string } | null = null; + historyRows: Record< + number, + { when: string; action: string; result: string; delivered: boolean; officer: string; notes: string; to: string }[] + > = {}; + + openHistory(r: { clientId: number; name: string; account: string }): void { + this.historyFor = { clientId: r.clientId, name: r.name, account: r.account }; + this.historyKey = String(r.clientId); + this.historyLoading = true; + this.clientsService.getKycContactLog(r.clientId).subscribe({ + next: (logs: any[]) => { + this.historyRows = { + ...this.historyRows, + [r.clientId]: (logs || []) + .map((l) => ({ + when: this.logDateTime(l?.contactedAt), + action: l?.channelLabel || l?.channel || 'Contact', + result: l?.deliveryMessage || l?.deliveryStatus || '', + delivered: !!l?.delivered || l?.deliveryStatus === 'NOT_APPLICABLE', + officer: (l?.officerName || '').split(' — ')[0], + notes: l?.notes || '', + to: l?.destinationMasked || '' + })) + .reverse() + }; + this.historyLoading = false; + }, + error: () => { + this.historyRows = { ...this.historyRows, [r.clientId]: [] }; + this.historyLoading = false; + } + }); + } + + closeHistory(): void { + this.historyKey = null; + this.historyFor = null; + } + + private logDateTime(value: any): string { + const d = Array.isArray(value) + ? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0) + : new Date(value); + return isNaN(d.getTime()) + ? '—' + : d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + /** "18 Oct, 10:02 · SMS · Delivered", or "None yet". */ + lastNoticeText(clientId: number): string { + const i = this.alertNoticeInfo[clientId]; + if (!i) { + return this.alertNoticeLoaded ? 'None yet' : '…'; + } + const when = (i.lastAt || '').replace('T', ' ').slice(0, 16); + return ( + when + + ' · ' + + (this.auditChannelLabels[i.lastChannel] || i.lastChannel) + + ' · ' + + (this.auditStatusLabels[i.lastStatus] || i.lastStatus) + ); + } + + /** + * The next step on the ladder for this customer: the first channel not yet done that can actually reach them. A channel the + * customer has no number or address for is skipped, since it would only come back as "not sent". + */ + nextNoticeStep(r: { clientId: number; mobile: string; email?: string; stageKey?: string }): string { + if (!this.alertNoticeLoaded) { + return '…'; + } + const done = this.doneChannelsFor(r.clientId, r.stageKey); + const ladder: { key: string; label: string; reachable: boolean }[] = [ + { key: 'SMS', label: 'Send SMS', reachable: !!r.mobile }, + { key: 'EMAIL', label: 'Send Email', reachable: r.email === undefined || !!r.email }, + { key: 'WHATSAPP', label: 'Send WhatsApp', reachable: !!r.mobile }, + { key: 'PHONE_CALL', label: 'Phone call', reachable: true }, + { key: 'BRANCH_VISIT', label: 'Branch visit', reachable: true }, + { key: 'FIELD_VISIT', label: 'Field visit', reachable: true } + ]; + const next = ladder.find((s) => !done.includes(s.key) && s.reachable); + return next ? next.label : 'Ladder complete'; + } + + /** The date of the next reminder the nightly job will send for this account, and why. */ + nextAutoReminder(r: { stageKey: string; since: string; nextDate?: string }): { date: string; label: string } | null { + const today = businessTodayIso(); + const add = (iso: string, days: number) => { + const d = new Date(iso + 'T00:00:00Z'); + return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10); + }; + if (r.stageKey === 'INACTIVE' || r.stageKey === 'DORMANT') { + if (!r.nextDate) { + return null; + } + const target = r.stageKey === 'INACTIVE' ? 'dormant' : 'closed to DEAF'; + const options = [ + { date: add(r.nextDate, -7), label: '7 days before it becomes ' + target }, + { date: add(r.nextDate, -1), label: '1 day before it becomes ' + target } + ]; + return options.find((o) => o.date > today) || null; + } + if (r.stageKey === 'ESCHEAT' || !r.since) { + return null; + } + // KYC: reminders fall on fixed days from the expiry date. + const marks: { day: number; label: string }[] = [ + { day: -30, label: '30 days before the KYC expires' }, + { day: -15, label: '15 days before the KYC expires' }, + { day: -7, label: '7 days before the KYC expires' }, + { day: -1, label: '1 day before the KYC expires' }, + { day: 0, label: 'the day the KYC expires' }, + { day: 83, label: '7 days before debits stop' }, + { day: 89, label: '1 day before debits stop' }, + { day: 90, label: 'the day debits stop' }, + { day: 173, label: '7 days before the account turns inoperative' }, + { day: 179, label: '1 day before the account turns inoperative' }, + { day: 180, label: 'the day the account turns inoperative' } + ]; + const next = marks.map((m) => ({ date: add(r.since, m.day), label: m.label })).find((m) => m.date > today); + return next || null; + } + + closeAlertDate(): void { + this.leaveAlertDay(); + } + + /** True when every customer of that day is ticked. */ + alertDateChecked(g: { rows: KycManagementComponent['alertList'] }): boolean { + return g.rows.length > 0 && g.rows.every((r) => this.alertSelected.has(r.clientId)); + } + + /** Ticks or clears every customer of that day at once. */ + toggleAlertDate(g: { rows: KycManagementComponent['alertList'] }): void { + if (this.alertDateChecked(g)) { + g.rows.forEach((r) => this.alertSelected.delete(r.clientId)); + } else { + g.rows.forEach((r) => this.alertSelected.add(r.clientId)); + } + } + + /** True when every customer on every day of the list is ticked. */ + get alertAllDaysSelected(): boolean { + const rows = this.alertList; + return rows.length > 0 && rows.every((r) => this.alertSelected.has(r.clientId)); + } + + /** Ticks or clears every customer on every day, for one batch. */ + toggleAllDays(): void { + const rows = this.alertList; + if (this.alertAllDaysSelected) { + rows.forEach((r) => this.alertSelected.delete(r.clientId)); + } else { + rows.forEach((r) => this.alertSelected.add(r.clientId)); + } + } + + get alertDateAllSelected(): boolean { + const rows = this.alertDateRows; + return rows.length > 0 && rows.every((r) => this.alertSelected.has(r.clientId)); + } + + toggleAlertDateAll(): void { + const rows = this.alertDateRows; + if (this.alertDateAllSelected) { + rows.forEach((r) => this.alertSelected.delete(r.clientId)); + } else { + rows.forEach((r) => this.alertSelected.add(r.clientId)); + } + } + + /** Accounts matching the chosen card and filters — same sources as the card counts. */ + get alertList(): KycManagementComponent['alertListBase'] { + const base = this.alertListBase; + if (!this.alertReach) { + return base; + } + return base.filter((r) => { + const c = this.alertNoticeInfo[r.clientId]; + // Not known yet: nobody is hidden on a guess. + if (!c || c.hasMobile === undefined) { + return true; + } + const reachable = !!(c.hasMobile || c.hasEmail); + return this.alertReach === 'NONE' ? !reachable : reachable; + }); + } + + /** The matching accounts before the contact-detail filter, which needs the customers' details fetched. */ + get alertListBase(): { + clientId: number; + stageKey: string; + mobile: string; + email?: string; + name: string; + account: string; + branch: string; + stage: string; + since: string; + days: number; + daysLabel: string; + next: string; + nextDate: string; + balance: number | null; + }[] { + if (!this.alertFiltersActive) { + return []; + } + const needle = this.alertSearch.trim().toLowerCase(); + const matches = (name: string, account: string) => + !needle || name.toLowerCase().includes(needle) || (account || '').toLowerCase().includes(needle); + const wanted = (stage: string) => !this.alertFilter || this.alertFilter === stage; + const keyOf = (r: KycExpiryRow) => (r.stage === 'DUE_SOON' ? 'EXPIRING' : r.stage); + + // Risk and outreach are properties of the KYC record; a dormancy row has neither, so it drops + // out whenever one of those filters is set rather than being shown as if it matched. + const kycOnly = !!(this.alertRisk || this.alertContact); + + const kyc = [ + ...this.expiringRows, + ...this.expiryRows + ] + .filter((r) => wanted(keyOf(r)) && matches(r.clientName, r.accountNo)) + .filter((r) => !this.alertRisk || r.riskCategory === this.alertRisk) + .filter( + (r) => + !this.alertContact || + (this.alertContact === 'NONE' && r.contactCount === 0) || + (this.alertContact === 'CONTACTED' && r.contactCount > 0) || + (this.alertContact === 'ACK' && r.acknowledged) + ) + .map((r) => ({ + clientId: r.clientId, + stageKey: keyOf(r), + mobile: r.mobile, + email: r.email as string | undefined, + name: r.clientName, + account: r.accountNo, + branch: r.branchCode, + stage: this.alertLabels[keyOf(r)] || r.meta.label, + since: r.expiryDate, + days: Math.abs(r.daysOverdue), + daysLabel: r.daysOverdue < 0 ? 'days left' : 'days overdue', + next: r.nextActionLabel, + nextDate: r.nextActionDate, + balance: null as number | null + })); + + const dormant = kycOnly + ? [] + : this.dormancyRows + .filter((r) => wanted(r.stage) && matches(r.clientName, r.accountNo)) + .map((r) => ({ + clientId: r.clientId, + stageKey: r.stage as string, + mobile: r.mobile, + email: undefined as string | undefined, + name: r.clientName, + account: r.accountNo, + branch: r.branchCode, + stage: this.alertLabels[r.stage] || r.stage, + since: r.lastCustomerTxnDate, + days: r.daysIdle, + daysLabel: 'days idle', + next: r.nextStageLabel || '—', + nextDate: r.nextStageDate, + balance: r.balance as number | null + })); + + return [ + ...kyc, + ...dormant + ]; + } + + /** Which family of notice a stage gets: a re-KYC notice, or one about an account that has gone quiet. */ + noticeTypeFor(stageKey?: string): string { + return [ + 'INACTIVE', + 'DORMANT', + 'ESCHEAT' + ].includes(stageKey || '') ? 'DORMANCY' : stageKey ? 'KYC' : ''; + } + + /** Notify: records and sends a notice through the same contact-log API the KYC screens use. */ + openAlertNotify(r: { + clientId: number; + name: string; + account: string; + branch: string; + mobile: string; + email?: string; + stageKey?: string; + }): void { + const av = r.name + .split(' ') + .map((n) => n[0]) + .join('') + .substring(0, 2) + .toUpperCase(); + const stub: KmClient = { + id: String(r.clientId), + av, + name: r.name, + meta: r.account, + state: 'alert', + risk: 'low', + riskLabel: '', + expiry: '', + daysLeft: 0, + account: r.account, + kyc: { + type: '', + initiated: '', + lastVerified: '', + validity: '', + pan: '', + aadhaar: '', + panStatus: '', + aadhaarStatus: '', + panLink: '' + }, + contact: { + // The list row may not carry the number or address (the dormancy list has no email), so fall back to what is on file. + mobile: r.mobile || this.alertNoticeInfo[r.clientId]?.mobileMasked || '—', + email: r.email || this.alertNoticeInfo[r.clientId]?.emailMasked || '—', + address: '', + branch: r.branch + }, + notices: [], + noticesSent: 0, + noticePending: '', + nextStage: '', + pendingActions: [], + actionTaken: [], + evidence: null, + hasMobile: !!(r.mobile || this.alertNoticeInfo[r.clientId]?.hasMobile), + // The dormancy list carries no email; leave it unknown rather than claiming there is none. + hasEmail: this.alertNoticeInfo[r.clientId]?.hasEmail ?? (r.email === undefined ? undefined : !!r.email), + contactHistory: [] + }; + this.openContactDialog(stub); + this.contactNoticeType = this.noticeTypeFor(r.stageKey); + this.loadNoticePreview(r.clientId); + } + + /** What the customer would receive, shown before the officer sends it. */ + loadNoticePreview(clientId: number): void { + this.contactPreview = null; + if (!this.contactNoticeType) { + return; + } + this.clientsService.previewKycNotice(clientId, this.contactNoticeType).subscribe({ + next: (n) => (this.contactPreview = n?.body ? { subject: n.subject || '', body: n.body } : null), + error: () => (this.contactPreview = null) + }); + } + + /** + * Activating a restricted account means renewing the customer's KYC: the server lifts the KYC + * block and revives inactive / dormant savings accounts when the re-KYC is completed, and does + * nothing for a plain transaction. So the KYC screen is where this happens. + */ + openAlertKyc(clientId: number): void { + this.router.navigate([ + '/clients', + clientId, + 'kyc' + ]); + } + + openAlertClient(clientId: number): void { + this.router.navigate([ + '/clients', + clientId, + 'general' + ]); + } + + // ── Bulk notify ──────────────────────────────────────────────────────────── + + /** Customers ticked in the list, by client id — a notice goes to the customer, not to an account. */ + alertSelected = new Set(); + bulkOpen = false; + bulkChannel = 'SMS'; + bulkNotes = ''; + bulkOfficer = ''; + bulkSending = false; + bulkDone = false; + /** When to send; blank means as soon as the queue job next runs (within about a minute). */ + bulkSendAt = ''; + bulkError = ''; + bulkQueued: { queued: number; skipped: number; unreachable: number } | null = null; + bulkResults: { name: string; account: string; ok: boolean; message: string }[] = []; + /** Kept modest so one click cannot flood the SMS gateway. */ + readonly bulkLimit = 50; + /** Only the channels that actually transmit; record-only channels need notes written per customer. */ + readonly bulkChannels = [ + { value: 'SMS', label: 'SMS' }, + { value: 'EMAIL', label: 'Email' }, + { value: 'WHATSAPP', label: 'WhatsApp Message' } + ]; + + toggleAlertRow(clientId: number): void { + if (this.alertSelected.has(clientId)) { + this.alertSelected.delete(clientId); + } else { + this.alertSelected.add(clientId); + } + } + + get alertAllSelected(): boolean { + const rows = this.alertList; + return rows.length > 0 && rows.every((r) => this.alertSelected.has(r.clientId)); + } + + toggleAlertAll(): void { + const rows = this.alertList; + if (this.alertAllSelected) { + rows.forEach((r) => this.alertSelected.delete(r.clientId)); + } else { + rows.forEach((r) => this.alertSelected.add(r.clientId)); + } + } + + /** One entry per customer: two accounts of the same customer must not get two notices. */ + /** Everyone ticked, before any are left out as already notified. */ + private selectedTargets(): KmBulkTarget[] { + const seen = new Set(); + const targets: KmBulkTarget[] = []; + for (const r of this.alertList) { + if (this.alertSelected.has(r.clientId) && !seen.has(r.clientId)) { + seen.add(r.clientId); + targets.push({ + clientId: r.clientId, + name: r.name, + account: r.account, + mobile: r.mobile, + email: r.email, + noticeType: this.noticeTypeFor(r.stageKey) + }); + } + } + return targets; + } + + /** Leave out customers who already received this channel, so nobody is sent the same notice twice. */ + bulkSkipDone = true; + + hasDone(clientId: number, channel: string): boolean { + const stageKey = this.alertListBase.find((r) => r.clientId === clientId)?.stageKey; + return ( + this.doneChannelsFor(clientId, stageKey).includes(channel) || this.queuedChannels(clientId).includes(channel) + ); + } + + /** Channels this customer already has a notice waiting on in the queue. */ + queuedChannels(clientId: number): string[] { + return this.queueRows + .filter((q) => q.clientId === clientId && (q.status === 'PENDING' || q.status === 'SENDING')) + .map((q) => q.channel); + } + + get bulkTargets(): KmBulkTarget[] { + const all = this.selectedTargets(); + if (!this.bulkOpen) { + return all; + } + // Someone with nowhere to send it is always left out; someone already sent it is left out unless the officer says otherwise. + return all.filter( + (t) => + !this.unreachableFor(t.clientId, this.bulkChannel) && + !(this.bulkSkipDone && this.hasDone(t.clientId, this.bulkChannel)) + ); + } + + /** How many of the ticked customers have no mobile number / email for the chosen channel. */ + get bulkUnreachable(): number { + return this.selectedTargets().filter((t) => this.unreachableFor(t.clientId, this.bulkChannel)).length; + } + + /** How many of the ticked customers already have this channel done. */ + get bulkAlreadyDone(): number { + return this.selectedTargets().filter((t) => this.hasDone(t.clientId, this.bulkChannel)).length; + } + + /** Fetches what these customers have been sent, for any not already known. */ + private ensureNoticeInfo(ids: number[]): void { + this.fetchNoticeInfo(ids.filter((id) => this.alertNoticeInfo[id]?.hasMobile === undefined)); + } + + /** How many of the selected customers get each kind of message, so the officer sees what is going out. */ + get bulkNoticeSummary(): { label: string; count: number }[] { + const kyc = this.bulkTargets.filter((t) => t.noticeType === 'KYC').length; + const dormancy = this.bulkTargets.filter((t) => t.noticeType === 'DORMANCY').length; + return [ + { label: 'Re-KYC notice (expiry, freeze or inoperative dates)', count: kyc }, + { label: 'Account notice (inactive, dormant or closed)', count: dormancy } + ].filter((x) => x.count > 0); + } + + get bulkSendDisabled(): boolean { + return ( + this.bulkSending || + !this.bulkNotes.trim() || + this.bulkTargets.length === 0 || + this.bulkTargets.length > this.bulkLimit + ); + } + + openBulk(): void { + this.ensureNoticeInfo(this.selectedTargets().map((t) => t.clientId)); + this.bulkSkipDone = true; + this.bulkChannel = 'SMS'; + this.bulkNotes = ''; + this.bulkOfficer = ''; + this.bulkResults = []; + this.bulkDone = false; + this.bulkSendAt = ''; + this.bulkError = ''; + this.bulkQueued = null; + this.bulkOpen = true; + } + + closeBulk(): void { + if (this.bulkSending) { + return; + } + this.bulkOpen = false; + } + + /** Hands the selected customers to the queue; the queue job sends them in the background. */ + sendBulk(): void { + if (this.bulkSendDisabled) { + return; + } + this.bulkSending = true; + this.clientsService + .enqueueKycNotices({ + items: this.bulkTargets.map((t) => ({ + clientId: t.clientId, + noticeType: t.noticeType, + channel: this.bulkChannel, + accountNo: t.account, + stageLabel: t.noticeType === 'DORMANCY' ? 'Account notice' : 'Re-KYC notice' + })), + notes: this.bulkNotes.trim(), + officerName: this.bulkOfficer.trim() || undefined, + sendAt: this.bulkSendAt || undefined + }) + .subscribe({ + next: (r) => { + this.bulkSending = false; + this.bulkDone = true; + this.bulkQueued = r; + this.alertSelected.clear(); + this.loadAlertNotices(); + this.loadQueue(); + }, + error: (err) => { + this.bulkSending = false; + this.bulkError = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + 'Could not queue the notices.'; + } + }); + } + + // ── Notification scheduler ───────────────────────────────────────────────── + + /** + * The escalation ladder an officer works down for each customer. The first three transmit; the rest + * are things the branch does and records, so they are listed but never sent from here. + */ + readonly schedulerLadder: { key: string; label: string; sends: boolean; notice?: number }[] = [ + { key: 'SMS', label: 'SMS', sends: true, notice: 1 }, + { key: 'EMAIL', label: 'Email', sends: true, notice: 2 }, + { key: 'WHATSAPP', label: 'WhatsApp', sends: true, notice: 3 }, + { key: 'PHONE_CALL', label: 'Phone Call', sends: false }, + { key: 'BRANCH_VISIT', label: 'Branch Visit', sends: false }, + { key: 'FIELD_VISIT', label: 'Field Visit', sends: false } + ]; + + schedScope: 'ALL' | 'EXPIRING' | 'LAPSED' = 'ALL'; + schedSearch = ''; + schedSelected = new Set(); + schedOpen = false; + schedOfficer = ''; + schedNote = ''; + schedSending = false; + schedDone = false; + schedError = ''; + schedQueued: { queued: number; skipped: number; unreachable: number } | null = null; + schedResults: { name: string; account: string; step: string; ok: boolean; message: string }[] = []; + + /** One line per customer with the ladder worked out from the outreach already on record. */ + get schedulerRows(): { + row: KycExpiryRow; + steps: { label: string; state: 'done' | 'next' | 'todo' }[]; + next: { key: string; label: string; sends: boolean; notice?: number } | null; + }[] { + const needle = this.schedSearch.trim().toLowerCase(); + const source = + this.schedScope === 'EXPIRING' ? this.expiringRows : this.schedScope === 'LAPSED' ? this.expiryRows : [ + ...this.expiryRows, + ...this.expiringRows + ]; + return source + .filter( + (r) => !needle || r.clientName.toLowerCase().includes(needle) || r.accountNo.toLowerCase().includes(needle) + ) + .map((row) => { + // A send that failed does not count: the customer was not reached, so the same rung is due again. + const done = (s: { key: string; sends: boolean }) => + row.contacts.some((c) => c.channel === s.key && (c.delivered || !s.sends)); + let nextFound = false; + let next: { key: string; label: string; sends: boolean; notice?: number } | null = null; + const steps = this.schedulerLadder.map((s) => { + if (done(s)) { + return { label: s.label, state: 'done' as const }; + } + if (!nextFound) { + nextFound = true; + next = s; + return { label: s.label, state: 'next' as const }; + } + return { label: s.label, state: 'todo' as const }; + }); + return { row, steps, next }; + }); + } + + /** Customers whose next rung is a notice the system can send now. */ + get schedulerDueCount(): number { + return this.schedulerRows.filter((r) => r.next?.sends).length; + } + + get schedulerNeverContacted(): number { + return this.schedulerRows.filter((r) => r.row.contactCount === 0).length; + } + + toggleSched(clientId: number): void { + if (this.schedSelected.has(clientId)) { + this.schedSelected.delete(clientId); + } else { + this.schedSelected.add(clientId); + } + } + + /** The customer list opens on request; nothing is listed for notice until the officer asks. */ + schedShow = false; + + get schedAllSelected(): boolean { + const rows = this.schedulerRows.filter((r) => r.next?.sends); + return rows.length > 0 && rows.every((r) => this.schedSelected.has(r.row.clientId)); + } + + toggleSchedAll(): void { + const rows = this.schedulerRows.filter((r) => r.next?.sends); + if (this.schedAllSelected) { + rows.forEach((r) => this.schedSelected.delete(r.row.clientId)); + } else { + rows.forEach((r) => this.schedSelected.add(r.row.clientId)); + } + } + + /** The ticked customers, each with the notice that is due for them. */ + get schedTargets(): KycManagementComponent['schedulerRows'] { + return this.schedulerRows.filter((r) => r.next?.sends && this.schedSelected.has(r.row.clientId)); + } + + get schedSendDisabled(): boolean { + return this.schedSending || this.schedTargets.length === 0 || this.schedTargets.length > this.bulkLimit; + } + + openSched(): void { + this.schedOfficer = ''; + this.schedNote = ''; + this.schedResults = []; + this.schedDone = false; + this.schedError = ''; + this.schedQueued = null; + this.schedOpen = true; + } + + /** Row-level shortcut: dispatch just this customer's next notice. */ + dispatchOne(clientId: number): void { + this.schedSelected = new Set([clientId]); + this.openSched(); + } + + closeSched(): void { + if (!this.schedSending) { + this.schedOpen = false; + } + } + + /** Queues each selected customer's next notice; the queue job sends them in the background. */ + dispatchScheduled(): void { + if (this.schedSendDisabled) { + return; + } + this.schedSending = true; + this.clientsService + .enqueueKycNotices({ + items: this.schedTargets.map((t) => ({ + clientId: t.row.clientId, + noticeType: 'KYC', + channel: t.next!.key, + accountNo: t.row.accountNo, + stageLabel: 'Re-KYC notice ' + (t.next!.notice ?? '') + ' (' + t.next!.label + ')' + })), + notes: this.schedNote.trim() || 'Re-KYC reminder — scheduled notice', + officerName: this.schedOfficer.trim() || undefined + }) + .subscribe({ + next: (r) => { + this.schedSending = false; + this.schedDone = true; + this.schedQueued = r; + this.schedSelected.clear(); + this.loadQueue(); + }, + error: (err) => { + this.schedSending = false; + this.schedError = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + 'Could not queue the notices.'; + } + }); + } + + // ── Wording for channels and delivery outcomes, used wherever a notice is described ── + + readonly auditChannelLabels: Record = { + SMS: 'SMS', + EMAIL: 'Email', + WHATSAPP: 'WhatsApp', + PHONE_CALL: 'Phone Call', + BRANCH_VISIT: 'Branch Visit', + FIELD_VISIT: 'Field Visit' + }; + readonly auditStatusLabels: Record = { + SENT: 'Delivered', + FAILED: 'Failed', + SKIPPED_NO_DESTINATION: 'No contact detail', + PROVIDER_NOT_CONFIGURED: 'Gateway not set up', + NOT_APPLICABLE: 'Recorded' + }; + + // ── Audit inventory: every notice on record, a day at a time ─────────────── + + /** One line per day, worked out by the server so a very large log is never loaded whole. */ + auditDays: { + date: string; + notices: number; + customers: number; + notifiedCustomers: number; + delivered: number; + failed: number; + noContact: number; + notConfigured: number; + recorded: number; + }[] = []; + auditFrom = ''; + auditTo = ''; + auditChannel = ''; + auditStatus = ''; + auditSearch = ''; + auditLoading = false; + auditError = ''; + + /** The day open in the popup, and the page of its notices on screen. */ + auditDay: string | null = null; + auditDayRows: { + id: number; + clientId: number; + clientName: string; + accountNo: string; + channel: string; + notes: string; + officerName: string; + deliveryStatus: string; + destinationMasked: string; + provider: string; + providerReference: string; + failureReason: string; + messageBody: string; + contactedAt: string; + }[] = []; + auditDayTotal = 0; + auditDayPage = 0; + readonly auditPageSize = 50; + auditDayLoading = false; + auditDayError = ''; + auditExpanded: number | null = null; + + get auditNoticeTotal(): number { + return this.auditDays.reduce((n, d) => n + d.notices, 0); + } + + /** Leave out attempts that went nowhere (no mobile / email, no gateway) unless the officer asks for them. */ + auditShowAll = false; + auditView: 'days' | 'customers' = 'days'; + auditCustomers: { + clientId: number; + clientName: string; + accountNo: string; + notices: number; + delivered: number; + failed: number; + notSent: number; + recorded: number; + lastAt: string; + lastChannel: string; + lastStatus: string; + }[] = []; + auditCustTotal = 0; + auditCustPage = 0; + auditCustLoading = false; + + private auditFilter(): { + from?: string; + to?: string; + channel?: string; + status?: string; + search?: string; + real?: boolean; + } { + return { + from: this.auditFrom, + to: this.auditTo, + channel: this.auditChannel, + status: this.auditStatus, + search: this.auditSearch.trim(), + real: !this.auditShowAll + }; + } + + setAuditView(view: 'days' | 'customers'): void { + this.auditView = view; + this.leaveAuditDay(); + if (view === 'customers') { + this.auditCustPage = 0; + this.loadAuditCustomers(); + } + } + + loadAuditCustomers(): void { + this.auditCustLoading = true; + this.clientsService + .getKycContactAuditCustomers({ + ...this.auditFilter(), + offset: this.auditCustPage * this.auditPageSize, + limit: this.auditPageSize + }) + .subscribe({ + next: (r) => { + this.auditCustomers = r?.rows || []; + this.auditCustTotal = r?.total || 0; + this.auditCustLoading = false; + }, + error: () => { + this.auditCustLoading = false; + this.auditError = 'Could not load the customers.'; + } + }); + } + + auditCustPageCount(): number { + return Math.max(1, Math.ceil(this.auditCustTotal / this.auditPageSize)); + } + + moveAuditCustPage(by: number): void { + this.auditCustPage = Math.min(Math.max(this.auditCustPage + by, 0), this.auditCustPageCount() - 1); + this.loadAuditCustomers(); + } + + loadAudit(): void { + this.auditLoading = true; + this.auditError = ''; + this.clientsService.getKycContactAuditDays(this.auditFilter()).subscribe({ + next: (days) => { + this.auditDays = days || []; + this.auditLoading = false; + }, + error: () => { + this.auditLoading = false; + this.auditError = 'Could not load the notice log.'; + } + }); + } + + /** Totals across the days on screen. Customers is a sum of each day's, so a customer reached on two days counts twice. */ + get auditTotals(): { + notifiedCustomers: number; + customers: number; + notices: number; + delivered: number; + failed: number; + noContact: number; + recorded: number; + } { + const sum = (pick: (d: KycManagementComponent['auditDays'][number]) => number) => + this.auditDays.reduce((n, d) => n + pick(d), 0); + return { + notifiedCustomers: sum((d) => d.notifiedCustomers ?? 0), + customers: sum((d) => d.customers), + notices: sum((d) => d.notices), + delivered: sum((d) => d.delivered), + failed: sum((d) => d.failed), + noContact: sum((d) => d.noContact + d.notConfigured), + recorded: sum((d) => d.recorded) + }; + } + + /** Apply the filters: back to the day list, freshly counted. */ + applyAudit(): void { + this.leaveAuditDay(); + this.loadAudit(); + if (this.auditView === 'customers') { + this.auditCustPage = 0; + this.loadAuditCustomers(); + } + } + + /** The last N days up to today. Notices are stamped with the real date, so this uses the computer's date. */ + setAuditRange(days: number): void { + const iso = (d: Date) => + d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - (days - 1)); + this.auditFrom = iso(from); + this.auditTo = iso(to); + this.applyAudit(); + } + + resetAudit(): void { + this.auditFrom = ''; + this.auditTo = ''; + this.auditChannel = ''; + this.auditStatus = ''; + this.auditSearch = ''; + this.applyAudit(); + } + + openAuditDay(date: string): void { + this.auditDay = date; + history.pushState({ ...history.state, kmAuditDay: date }, ''); + this.auditDayPage = 0; + this.auditExpanded = null; + this.loadAuditDay(); + } + + closeAuditDay(): void { + this.leaveAuditDay(); + } + + /** The popup applies the same channel, status and search as the day list, narrowed to its one day. */ + loadAuditDay(): void { + if (!this.auditDay) { + return; + } + this.auditDayLoading = true; + this.auditDayError = ''; + this.clientsService + .getKycContactAudit({ + ...this.auditFilter(), + from: this.auditDay, + to: this.auditDay, + offset: this.auditDayPage * this.auditPageSize, + limit: this.auditPageSize + }) + .subscribe({ + next: (r) => { + this.auditDayRows = r?.rows || []; + this.auditDayTotal = r?.total || 0; + this.auditDayLoading = false; + }, + error: () => { + this.auditDayLoading = false; + this.auditDayError = 'Could not load the notices for this day.'; + } + }); + } + + auditDayPageCount(): number { + return Math.max(1, Math.ceil(this.auditDayTotal / this.auditPageSize)); + } + + moveAuditDayPage(by: number): void { + this.auditDayPage = Math.min(Math.max(this.auditDayPage + by, 0), this.auditDayPageCount() - 1); + this.auditExpanded = null; + this.loadAuditDay(); + } + + auditStatusClass(status: string): string { + return status === 'SENT' + ? 'km-b-gr' + : status === 'NOT_APPLICABLE' + ? 'km-b-gy' + : status === 'FAILED' + ? 'km-b-re' + : 'km-b-am'; + } + + /** Fetches notices a page at a time for a workbook, up to a cap so one click cannot pull a whole year. */ + private async fetchAuditRows( + filter: { from?: string; to?: string; channel?: string; status?: string; search?: string }, + cap: number + ): Promise<{ rows: KycManagementComponent['auditDayRows']; total: number }> { + const rows: KycManagementComponent['auditDayRows'] = []; + let total = 0; + while (rows.length < cap) { + const page = await firstValueFrom( + this.clientsService.getKycContactAudit({ + ...filter, + offset: rows.length, + limit: Math.min(500, cap - rows.length) + }) + ); + total = page?.total || 0; + const batch = page?.rows || []; + rows.push(...batch); + if (!batch.length || rows.length >= total) { + break; + } + } + return { rows, total }; + } + + private readonly auditHeaders = [ + '#', + 'Date & time', + 'Customer', + 'Account', + 'Channel', + 'Delivery', + 'Sent to', + 'Reference', + 'Officer', + 'Note', + 'Failure reason', + 'Message sent' + ]; + private readonly auditWidths = [ + 6, + 20, + 28, + 20, + 14, + 18, + 22, + 22, + 22, + 40, + 30, + 80 + ]; + private readonly auditDayHeaders = [ + 'Date', + 'Customers notified', + 'Customers tried', + 'Notices', + 'Delivered', + 'Failed', + 'No contact detail', + 'Gateway not set up', + 'Recorded (call / visit)' + ]; + + private auditNoticeSheetRows(rows: KycManagementComponent['auditDayRows']): (string | number | null)[][] { + return rows.map((r, i) => [ + i + 1, + (r.contactedAt || '').replace('T', ' ').slice(0, 19), + r.clientName, + r.accountNo, + this.auditChannelLabels[r.channel] || r.channel, + this.auditStatusLabels[r.deliveryStatus] || r.deliveryStatus, + r.destinationMasked || '', + r.providerReference || '', + r.officerName || '', + r.notes || '', + r.failureReason || '', + r.messageBody || '' + ]); + } + + private auditDaySheetRows(): (string | number | null)[][] { + return this.auditDays.map((d) => [ + d.date, + d.notifiedCustomers ?? 0, + d.customers, + d.notices, + d.delivered, + d.failed, + d.noContact, + d.notConfigured, + d.recorded + ]); + } + + /** The day list as a workbook: one line per day with its counts. */ + async exportAudit(): Promise { + if (!this.auditDays.length) { + this.toast('There are no notices to export.'); + return; + } + try { + await exportWorkbook( + [ + { + sheetName: 'Notices per day', + title: 'KYC & account notices sent to customers — per day', + subtitle: this.auditNoticeTotal + ' notice(s)', + headers: this.auditDayHeaders, + rows: this.auditDaySheetRows(), + widths: [ + 14, + 18, + 16, + 12, + 12, + 12, + 20, + 20, + 24 + ] + } + ], + 'KYC-Notices-Per-Day-' + businessTodayIso() + ); + this.toast('Downloaded.'); + } catch { + this.toast('Could not build the file. Please try again.'); + } + } + + /** Every notice of the day open in the popup, with the exact text sent. */ + async exportAuditDay(): Promise { + if (!this.auditDay) { + return; + } + try { + const { rows, total } = await this.fetchAuditRows( + { ...this.auditFilter(), from: this.auditDay, to: this.auditDay }, + 5000 + ); + await exportWorkbook( + [ + { + sheetName: 'Notice log', + title: 'Notices sent on ' + this.auditDay, + subtitle: rows.length + (rows.length < total ? ' of ' + total : '') + ' notice(s)', + headers: this.auditHeaders, + rows: this.auditNoticeSheetRows(rows), + widths: this.auditWidths + } + ], + 'KYC-Notices-' + this.auditDay + ); + this.toast( + rows.length < total ? 'Downloaded the first ' + rows.length + ' of ' + total + ' notices.' : 'Downloaded.' + ); + } catch { + this.toast('Could not build the file. Please try again.'); + } + } + + /** The workbook handed to an inspector: a summary, the per-day figures, then the notices with the exact text sent. */ + async exportRbiReport(): Promise { + if (!this.auditDays.length) { + this.toast('There are no notices to report.'); + return; + } + const sum = (pick: (d: KycManagementComponent['auditDays'][number]) => number) => + this.auditDays.reduce((n, d) => n + pick(d), 0); + const period = + (this.auditFrom || this.auditDays[this.auditDays.length - 1].date) + + ' to ' + + (this.auditTo || this.auditDays[0].date); + const summary: (string | number | null)[][] = [ + [ + 'Notices on record', + sum((d) => d.notices) + ], + [ + 'Delivered', + sum((d) => d.delivered) + ], + [ + 'Failed', + sum((d) => d.failed) + ], + [ + 'No contact detail on file', + sum((d) => d.noContact) + ], + [ + 'Gateway not set up', + sum((d) => d.notConfigured) + ], + [ + 'Recorded (call / visit)', + sum((d) => d.recorded) + ], + [ + 'Days with notices', + this.auditDays.length + ] + ]; + try { + const { rows, total } = await this.fetchAuditRows(this.auditFilter(), 2000); + await exportWorkbook( + [ + { sheetName: 'Summary', title: 'Customer notice audit — summary', subtitle: 'Period: ' + period, headers: [ + 'Measure', + 'Count' + ], rows: summary, widths: [ + 40, + 14 + ] }, + { + sheetName: 'Per day', + title: 'Customer notice audit — per day', + subtitle: 'Period: ' + period, + headers: this.auditDayHeaders, + rows: this.auditDaySheetRows(), + widths: [ + 14, + 18, + 16, + 12, + 12, + 12, + 20, + 20, + 24 + ] + }, + { + sheetName: 'Notice log', + title: 'Customer notice audit — notices', + subtitle: + 'Period: ' + period + (rows.length < total ? ' · newest ' + rows.length + ' of ' + total + ' shown' : ''), + headers: this.auditHeaders, + rows: this.auditNoticeSheetRows(rows), + widths: this.auditWidths + } + ], + 'RBI-KYC-Notice-Audit-' + businessTodayIso() + ); + this.toast('RBI audit report downloaded.'); + } catch { + this.toast('Could not build the report. Please try again.'); + } + } + + /** Alert Dashboard: the list on screen, as a workbook. */ + async exportAlerts(): Promise { + const rows = this.alertList; + if (!rows.length) { + this.toast('Pick a card or a filter first — there is no list to export.'); + return; + } + try { + await exportWorkbook( + [ + { + sheetName: 'Accounts', + title: this.alertFilter ? this.alertLabels[this.alertFilter] : 'Accounts needing attention', + subtitle: rows.length + ' account(s) as on ' + businessTodayIso(), + headers: [ + 'Customer', + 'Account', + 'Branch', + 'State', + 'Date', + 'Days', + 'Balance', + 'Next stage', + 'Next date' + ], + rows: rows.map((r) => [ + r.name, + r.account, + r.branch, + r.stage, + r.since, + r.days + ' ' + r.daysLabel, + r.balance, + r.next, + r.nextDate || '' + ]), + widths: [ + 28, + 22, + 18, + 24, + 14, + 20, + 16, + 30, + 14 + ] + } + ], + 'KYC-Alerts-' + businessTodayIso() + ); + this.toast('Downloaded.'); + } catch { + this.toast('Could not build the file. Please try again.'); + } + } + + // ── Notice queue (what officers submitted, and how it went) ─────────────── + + queueRows: { + id: number; + clientId: number; + clientName: string; + accountNo: string; + noticeType: string; + channel: string; + stageLabel: string; + status: string; + attempts: number; + resultMessage: string; + scheduledFor: string; + createdAt: string; + processedAt: string; + }[] = []; + queueFilter = ''; + queueLoading = false; + queueError = ''; + private queueTimer: any; + + get queueCounts(): Record { + const counts: Record = { PENDING: 0, SENDING: 0, SENT: 0, FAILED: 0, CANCELLED: 0 }; + this.queueRows.forEach((r) => (counts[r.status] = (counts[r.status] || 0) + 1)); + return counts; + } + + get queueView(): KycManagementComponent['queueRows'] { + return this.queueFilter ? this.queueRows.filter((r) => r.status === this.queueFilter) : this.queueRows; + } + + /** The queue grouped by the day it was submitted, newest day first, with a count per outcome. */ + get queueGroups(): { + date: string; + rows: KycManagementComponent['queueRows']; + customers: number; + waiting: number; + sent: number; + failed: number; + cancelled: number; + }[] { + const byDate = new Map(); + for (const r of this.queueView) { + const day = (r.createdAt || '').slice(0, 10); + byDate.set(day, [ + ...(byDate.get(day) || []), + r + ]); + } + return [...byDate.entries()] + .sort((a, b) => b[0].localeCompare(a[0])) + .map( + ([ + date, + rows + ]) => ({ + date, + rows, + customers: new Set(rows.map((r) => r.clientId)).size, + waiting: rows.filter((r) => r.status === 'PENDING' || r.status === 'SENDING').length, + sent: rows.filter((r) => r.status === 'SENT').length, + failed: rows.filter((r) => r.status === 'FAILED').length, + cancelled: rows.filter((r) => r.status === 'CANCELLED').length + }) + ); + } + + /** The day whose notices are open in the popup. */ + queueDetailDate: string | null = null; + + get queueDetail(): KycManagementComponent['queueGroups'][number] | null { + return this.queueDetailDate ? this.queueGroups.find((g) => g.date === this.queueDetailDate) || null : null; + } + + openQueueDate(date: string): void { + this.queueDetailDate = date; + } + + /** Notices that could not be delivered; the officer reaches these customers by hand. */ + queueFailed(d: { rows: any[] }): any[] { + return d.rows.filter((q) => q.status === 'FAILED'); + } + + /** Everything else that was queued that day. */ + queueOther(d: { rows: any[] }): any[] { + return d.rows.filter((q) => q.status !== 'FAILED'); + } + + closeQueueDate(): void { + delete this.pg['queue']; + this.queueDetailDate = null; + } + + loadQueue(): void { + this.queueLoading = true; + this.clientsService.getKycNoticeQueue().subscribe({ + next: (rows) => { + this.queueRows = rows || []; + this.queueLoading = false; + this.queueError = ''; + }, + error: () => { + this.queueLoading = false; + this.queueError = 'Could not load the notice queue.'; + } + }); + } + + actOnQueued(id: number, command: 'cancel' | 'retry'): void { + this.clientsService.actOnKycNotice(id, command).subscribe({ + next: () => this.loadQueue(), + error: (err) => + this.toast( + err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'That could not be done.' + ) + }); + } + + /** The queue moves on its own while the scheduler tab is open, so keep it fresh. */ + private startQueuePolling(): void { + this.stopQueuePolling(); + this.loadQueue(); + this.queueTimer = setInterval(() => this.loadQueue(), 10000); + } + + private stopQueuePolling(): void { + if (this.queueTimer) { + clearInterval(this.queueTimer); + this.queueTimer = null; + } + } + + ngOnDestroy(): void { + this.stopQueuePolling(); + } + + loadStatusData(): void { + this.statusLoading = true; + this.statusError = ''; + const today = businessTodayIso(); + + forkJoin({ + lapsed: this.clientsService.getLapsedKyc().pipe( + catchError(() => { + this.statusError = 'Could not load the lapsed KYC list.'; + return of([] as any[]); + }) + ), + expiring: this.clientsService.getExpiringKyc(30).pipe( + catchError(() => { + this.statusError = this.statusError || 'Could not load the list of KYCs expiring soon.'; + return of([] as any[]); + }) + ), + dormant: this.clientsService.getDormantAccounts().pipe( + catchError(() => { + this.statusError = this.statusError || 'Could not load the dormancy list.'; + return of([] as any[]); + }) + ) + }).subscribe(({ lapsed, dormant, expiring }) => { + try { + this.dormancyRows = sortByIdle( + (dormant || []).map((a) => this.mapDormant(a)).filter((r): r is DormancyRow => r !== null) + ); + } catch { + this.dormancyRows = []; + this.statusError = this.statusError || 'Could not read the dormancy list.'; + } + this.dormancyTotals = dormancySummary(this.dormancyRows, today); + + const mapKyc = (list: any[]) => + (list || []) + .map((r: any) => ({ + clientId: r?.clientId, + clientName: r?.clientDisplayName || '—', + accountNo: r?.clientAccountNo || '', + branchCode: r?.clientOfficeName || '', + riskCategory: r?.riskCategory ?? 1, + expiryDate: toIsoDate(r?.expiryDate), + mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''), + email: r?.clientEmailMasked || (r?.hasEmail ? 'On file' : '') + })) + .filter((c) => !!c.expiryDate); + const clients = mapKyc(lapsed); + const soon = mapKyc(expiring); + const everyone = [ + ...clients, + ...soon + ]; + + const finish = (histories: KycContactRow[][]) => { + this.expiryRows = sortWorklist(clients.map((c, i) => buildExpiryRow(c, histories[i] || [], today))); + this.expiringRows = soon + .map((c, i) => buildExpiryRow(c, histories[clients.length + i] || [], today)) + .sort((a, b) => a.expiryDate.localeCompare(b.expiryDate)); + this.expiryTotals = expirySummary(this.expiryRows); + this.statusLoading = false; + // The list that is open needs each customer's contact details. + this.loadReachForList(); + }; + + if (!everyone.length) { + finish([]); + return; + } + forkJoin( + everyone.map((c) => + this.clientsService.getKycContactLog(c.clientId).pipe( + map((logs: any[]) => (logs || []).map((l) => this.mapAuditContact(l))), + catchError(() => of([] as KycContactRow[])) + ) + ) + ).subscribe({ + next: finish, + error: () => finish([]) + }); + }); + } + + private mapAuditContact(log: any): KycContactRow { + const status = log?.deliveryStatus || 'NOT_APPLICABLE'; + return { + date: toIsoDate(log?.contactedOn || log?.createdOn), + channel: log?.channel || '', + channelLabel: channelLabel(log?.channel || ''), + notes: log?.notes || '', + officer: log?.officerName || log?.loggedByUsername || '—', + deliveryStatus: status, + delivered: status === 'SENT', + destinationMasked: log?.destinationMasked || '' + }; + } + + private mapDormant(a: any): DormancyRow | null { + const stage = stageFromSubStatus(Number(a?.subStatus)); + if (!stage) { + return null; + } + const lastTxn = toIsoDate(a?.lastCustomerTransactionDate); + const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' }; + return { + accountType: a?.accountType || 'SAVINGS', + 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, + remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined, + escheatedOn: toIsoDate(a?.closedOnDate) || undefined, + remittedOn: toIsoDate(a?.deafRemittedOn) || undefined, + deafReference: a?.deafReference || undefined, + refundedOn: toIsoDate(a?.deafRefundedOn) || undefined, + mobile: a?.mobileNo || '' + }; + } ngOnInit(): void { - this.alertRows = Object.values(this.clients); this.loadExceptions(); + this.loadStatusData(); + this.loadAudit(); } loadExceptions(): void { @@ -728,17 +2359,31 @@ export class KycManagementComponent implements OnInit { return this.exceptionRows.filter((c) => c.exceptionReason?.includes('Aadhaar')).length; } - showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void { + showSection(section: 'alerts' | 'status' | 'notif' | 'audit' | 'exceptions' | 'expiry' | 'dormancy'): void { this.activeSection = section; + if (section === 'alerts') { + // Notices go out in the background while another tab is open; ask again so "Notified" is not out of date. + this.fetchNoticeInfo([...new Set(this.alertListBase.map((r) => r.clientId))]); + } + if (section === 'audit' && !this.auditDays.length) { + this.loadAudit(); + } + if (section === 'notif') { + this.startQueuePolling(); + } else { + this.stopQueuePolling(); + } } openContactDialog(client: KmClient): void { this.current = client; + this.contactNoticeType = ''; + this.contactPreview = null; this.contactChannel = 'PHONE_CALL'; this.contactNotes = ''; this.contactError = ''; this.contactSaving = false; - this.contactOfficer = this.bankOfficers[1]; // Neha Verma + this.contactOfficer = ''; this.contactDialogOpen = true; } @@ -787,7 +2432,8 @@ export class KycManagementComponent implements OnInit { .saveKycContactLog(client.id, { channel: this.contactChannel, notes: this.contactNotes.trim(), - officerName: this.contactOfficer + officerName: this.contactOfficer.trim() || undefined, + noticeType: this.contactNoticeType || undefined }) .subscribe({ next: (log: any) => { @@ -805,6 +2451,8 @@ export class KycManagementComponent implements OnInit { this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`); } this.contactDialogOpen = false; + this.loadStatusData(); + this.loadAlertNotices(); }, error: (err) => { this.contactSaving = false; @@ -974,23 +2622,6 @@ export class KycManagementComponent implements OnInit { }); } - openModal(id: string): void { - const c = this.clients[id]; - if (!c) return; - this.current = c; - this.modalTab = 'overview'; - this.modalOpen = true; - } - - closeModal(): void { - this.modalOpen = false; - this.current = null; - } - - showMTab(tab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action'): void { - this.modalTab = tab; - } - toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; @@ -998,151 +2629,6 @@ export class KycManagementComponent implements OnInit { this.toastTimer = setTimeout(() => (this.toastVisible = false), 2800); } - // ── Re-KYC dispatch ladder ───────────────────────────────────────────────── - - /** - * Re-KYC outreach is one channel at a time, escalating only when the previous channel has been - * dispatched — not a bundle of channels fired at once. The regulatory 3-notice structure is kept - * as the grouping (notice), while each row is a single dispatch, which is how it actually happens - * at the branch. - * - * status: DELIVERED / FAILED are done, PENDING is the one dispatch that may go out now, LOCKED is - * waiting on an earlier channel. - */ - reKycSteps: { - step: number; - notice: number; - channel: string; - detail: string; - date: string; - offset: string; - status: 'DELIVERED' | 'FAILED' | 'PENDING' | 'LOCKED'; - chip: string; - }[] = [ - { - step: 1, - notice: 1, - channel: 'SMS', - detail: 'Registered mobile', - date: '31 March 2027', - offset: 'Window opens', - status: 'DELIVERED', - chip: 'km-cp-sms' - }, - { - step: 2, - notice: 1, - channel: 'Email', - detail: 'Registered email', - date: '3 April 2027', - offset: '+3 days', - status: 'DELIVERED', - chip: 'km-cp-mail' - }, - { - step: 3, - notice: 2, - channel: 'IVR Call', - detail: 'Outbound IVR', - date: '10 April 2027', - offset: '+10 days', - status: 'PENDING', - chip: 'km-cp-ivr' - }, - { - step: 4, - notice: 2, - channel: 'Reg. Post', - detail: 'India Post, tracked', - date: '14 April 2027', - offset: '+14 days', - status: 'LOCKED', - chip: 'km-cp-post' - }, - { - step: 5, - notice: 3, - channel: 'Home Visit', - detail: 'Field officer, manual', - date: '20 April 2027', - offset: '+20 days', - status: 'LOCKED', - chip: 'km-cp-vis' - }, - { - step: 6, - notice: 3, - channel: 'Video KYC', - detail: 'V-CIP link', - date: '25 April 2027', - offset: '+25 days', - status: 'LOCKED', - chip: 'km-cp-vid' - } - ]; - - /** The single dispatch that may go out now — nothing later is actionable until it is done. */ - get currentReKycStep(): number | null { - return this.reKycSteps.find((s) => s.status === 'PENDING')?.step ?? null; - } - - get reKycCompletedCount(): number { - return this.reKycSteps.filter((s) => s.status === 'DELIVERED' || s.status === 'FAILED').length; - } - - /** First row of a notice carries the notice heading; the rest indent under it. */ - isFirstOfNotice(index: number): boolean { - return index === 0 || this.reKycSteps[index - 1].notice !== this.reKycSteps[index].notice; - } - - reKycStatusLabel(status: string): string { - switch (status) { - case 'DELIVERED': - return '✓ Dispatched'; - case 'FAILED': - return '✗ Failed — escalated'; - case 'PENDING': - return '⏳ Due now'; - default: - return '🔒 Waiting on previous channel'; - } - } - - reKycStatusClass(status: string): string { - switch (status) { - case 'DELIVERED': - return 'km-ok'; - case 'FAILED': - return 'km-fail'; - case 'PENDING': - return 'km-pending'; - default: - return 'km-locked'; - } - } - - /** - * Marks the due channel dispatched and releases only the next one, so the ladder can never skip a - * rung. A failure still advances — the point of escalation is that the next channel is tried. - */ - dispatchReKycStep(step: number, failed = false): void { - const index = this.reKycSteps.findIndex((s) => s.step === step); - if (index < 0 || this.reKycSteps[index].status !== 'PENDING') { - return; - } - const current = this.reKycSteps[index]; - current.status = failed ? 'FAILED' : 'DELIVERED'; - const next = this.reKycSteps[index + 1]; - if (next && next.status === 'LOCKED') { - next.status = 'PENDING'; - } - this.toast( - failed - ? `${current.channel} failed — escalated to ${next ? next.channel : 'freeze workflow'}` - : `${current.channel} dispatched${next ? ` · next: ${next.channel} on ${next.date}` : ''}` - ); - } - // ── Exceptions export ────────────────────────────────────────────────────── /** True while the workbook is being built, so the button cannot be double-fired. */ @@ -1282,209 +2768,7 @@ export class KycManagementComponent implements OnInit { .join(' ; '); } - /** - * Routes a "Take Action" click. Opens the Home Visit dialog if the action is - * a home-visit scheduling action; otherwise falls back to a toast confirmation. - */ - takeAction(action: string): void { - if (!this.current) return; - const lower = action.toLowerCase(); - if (lower.includes('home visit')) { - this.openHomeVisitDialog(); - return; - } - if (lower.includes('video kyc')) { - this.openVkycDialog(); - return; - } - this.toast(action + ' initiated for ' + this.current.name); - } - - openHomeVisitDialog(): void { - this.homeVisitOfficer = ''; - this.homeVisitNote = ''; - // Default to tomorrow at 10:00 AM (datetime-local format: YYYY-MM-DDTHH:mm) - const t = new Date(); - t.setDate(t.getDate() + 1); - t.setHours(10, 0, 0, 0); - const pad = (n: number) => n.toString().padStart(2, '0'); - this.homeVisitDateTime = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}T${pad(t.getHours())}:${pad(t.getMinutes())}`; - this.homeVisitOpen = true; - } - - closeHomeVisitDialog(): void { - this.homeVisitOpen = false; - } - - saveHomeVisit(): void { - if (!this.current || !this.homeVisitOfficer || !this.homeVisitDateTime) return; - this.scheduledHomeVisits.push({ - client: this.current.name, - address: this.current.contact.address, - officer: this.homeVisitOfficer, - dateTime: this.homeVisitDateTime, - note: this.homeVisitNote - }); - const when = new Date(this.homeVisitDateTime).toLocaleString('en-IN', { - day: '2-digit', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - this.toast(`Home Visit scheduled for ${this.current.name} on ${when}`); - this.homeVisitOpen = false; - } - - openVkycDialog(): void { - this.vkycOfficer = ''; - this.vkycMessage = ''; - this.vkycChannels = { sms: true, email: true, whatsapp: false }; - // Default: link expires 48 hours from now (RBI Video-based KYC norms) - const t = new Date(); - t.setHours(t.getHours() + 48); - const pad = (n: number) => n.toString().padStart(2, '0'); - this.vkycExpiry = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}T${pad(t.getHours())}:${pad(t.getMinutes())}`; - // Generate a session id (token preview only — server would issue the real one) - this.vkycSessionId = 'VKYC-' + Math.random().toString(36).slice(2, 10).toUpperCase(); - this.vkycOpen = true; - } - - closeVkycDialog(): void { - this.vkycOpen = false; - } - - get vkycSelectedChannels(): string[] { - const out: string[] = []; - if (this.vkycChannels.sms) out.push('SMS'); - if (this.vkycChannels.email) out.push('Email'); - if (this.vkycChannels.whatsapp) out.push('WhatsApp'); - return out; - } - - get vkycLink(): string { - return `https://vkyc.bank.example/session/${this.vkycSessionId}`; - } - - saveVkyc(): void { - if (!this.current || !this.vkycOfficer || !this.vkycExpiry) return; - const channels = this.vkycSelectedChannels; - if (channels.length === 0) return; - this.sentVkycLinks.push({ - client: this.current.name, - mobile: this.current.contact.mobile, - email: this.current.contact.email, - officer: this.vkycOfficer, - expiry: this.vkycExpiry, - channels, - sessionId: this.vkycSessionId, - link: this.vkycLink - }); - this.toast(`Video KYC link sent to ${this.current.name} via ${channels.join(' + ')}`); - this.vkycOpen = false; - } - - /** Compliance journey stepper state for a given client */ - getStepperState(c: KmClient): { label: string; state: 'done' | 'pending' | 'current' | 'danger' }[] { - return [ - { label: 'KYC Active', state: 'done' }, - { label: 'Alert Sent', state: c.noticesSent >= 1 ? 'done' : 'pending' }, - { label: 'Notice 2', state: c.noticesSent >= 2 ? 'done' : c.noticesSent >= 1 ? 'current' : 'pending' }, - { - label: 'Notice 3', - state: - c.noticesSent >= 3 ? 'done' : c.noticesSent >= 2 ? (c.state === 'urgent' ? 'danger' : 'current') : 'pending' - }, - { - label: 'Debit Freeze', - state: [ - 'freeze', - 'inop', - 'dormant' - ].includes(c.state) ? 'done' : c.noticesSent >= 3 ? 'danger' : 'pending' - }, - { label: 'Inoperative', state: [ - 'inop', - 'dormant' - ].includes(c.state) ? 'done' : 'pending' }, - { label: 'Dormant', state: c.state === 'dormant' ? 'danger' : 'pending' } - ]; - } - - stepIcon(state: string): string { - if (state === 'done') return '✓'; - if (state === 'danger') return '!'; - return '·'; - } - - stateLabel(state: string): string { - return ( - ( - { - alert: 'Alert Window', - freeze: 'Debit Freeze', - inop: 'Inoperative', - dormant: 'Dormant', - urgent: 'Urgent Alert' - } as Record - )[state] || state - ); - } - - stateClass(state: string): string { - return ( - ( - { - alert: 'sp-al', - freeze: 'sp-fr', - inop: 'sp-in', - dormant: 'sp-do', - urgent: 'sp-fr' - } as Record - )[state] || 'sp-al' - ); - } - riskClass(risk: string): string { return ({ low: 'b-gr', mid: 'b-am', high: 'b-re' } as Record)[risk] || 'b-gy'; } - - /** Notices pending count (cap at 0) */ - noticesPending(c: KmClient): number { - return Math.max(0, 3 - c.noticesSent); - } - - deliveryClass(d: string): string { - const m: Record = { - Delivered: 'd-ok', - 'In Transit': 'd-tr', - 'No Response': 'd-fa', - Pending: 'd-pe', - 'Visit Done': 'd-ok', - 'Not Found': 'd-fa' - }; - return m[d] || 'd-pe'; - } - - barWidth(c: KmClient): number { - if (c.daysLeft <= 0) return 100; - return Math.max(2, Math.min(100, c.daysLeft * 3)); - } - - barColor(c: KmClient): string { - if (c.daysLeft <= 0) return 'var(--km-g400)'; - return c.daysLeft > 10 ? 'var(--km-amber)' : 'var(--km-red)'; - } - - responseClass(r: string): string { - const m: Record = { - 'No Action': 'd-pe', - 'No Response': 'd-fa', - Pending: 'd-pe', - Refused: 'd-fa', - 'Not Found at Address': 'd-fa', - Awaited: 'd-pe' - }; - return m[r] || 'd-pe'; - } } diff --git a/src/app/organization/institution-profile/institution-profile.component.html b/src/app/organization/institution-profile/institution-profile.component.html index a4d4f79a2..7d867579d 100644 --- a/src/app/organization/institution-profile/institution-profile.component.html +++ b/src/app/organization/institution-profile/institution-profile.component.html @@ -144,15 +144,15 @@ PAN - + TAN - + GSTIN - +
diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts index db05e8a75..651e72d10 100644 --- a/src/app/organization/institution-profile/institution-profile.component.ts +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { catchError, of } from 'rxjs'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RestrictInputDirective } from '../../directives/restrict-input.directive'; import { OrganizationService } from '../organization.service'; import { SettingsService } from 'app/settings/settings.service'; import { DISABLED_ACCOUNTING_FEATURES } from '../../accounting/disabled-features'; @@ -57,7 +58,10 @@ const STATUSES = [ selector: 'mifosx-institution-profile', templateUrl: './institution-profile.component.html', styleUrls: ['./institution-profile.component.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RestrictInputDirective + ] }) export class InstitutionProfileComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 22b411aff..40dadf490 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -308,6 +308,13 @@ const routes: Routes = [ loadComponent: () => import('./tds-config/tds-config.component').then((m) => m.TdsConfigComponent), data: { title: 'TDS Configuration', breadcrumb: 'TDS Configuration' } }, + { + // Reserve funds and the percentage of year-end net profit appropriated to each, with their GL accounts. + path: 'reserve-fund-config', + loadComponent: () => + import('./reserve-fund-config/reserve-fund-config.component').then((m) => m.ReserveFundConfigComponent), + data: { title: 'Reserve Fund Configuration', breadcrumb: 'Reserve Fund Configuration' } + }, { // OSS 1 keeps its own screen: sixteen roles of its own, none shared with CRR or SLR. path: 'oss1-gl-config', diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index c19767a54..9715445f4 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -104,6 +104,26 @@
+ + +
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index c89ee7739..2d63e6728 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -1339,4 +1339,38 @@ export class OrganizationService { deleteRbiReserveRate(rateId: number): Observable { return this.http.delete(`/rbireserveconfig/rate/${rateId}`); } + + /** + * @returns {Observable} `{ reserves[], totalPercentage, remainingPercentage, updatedOn, updatedBy }` — each + * reserve fund, the GL account it is credited to (may be unset) and its percentage of the year's net profit. + */ + getProfitAppropriationConfig(): Observable { + return this.http.get('/profitappropriationconfig'); + } + + /** @returns {Observable} `{ glAccountOptions[] }` — postable equity and liability accounts. */ + getProfitAppropriationConfigTemplate(): Observable { + return this.http.get('/profitappropriationconfig/template'); + } + + /** Replaces the whole list of reserve funds; the list as sent is exactly what is stored. */ + updateProfitAppropriationConfig( + reserves: { name: string; glAccountId: number | null; percentage: number }[] + ): Observable { + return this.http.put('/profitappropriationconfig', { reserves }); + } + + /** + * What appropriating a year's profit into the reserves would post (or, once posted, what was posted). + * Without a year, the backend picks the newest closed year not yet appropriated. + */ + getProfitAppropriationPosting(financialYear?: string): Observable { + const query = financialYear ? `?financialYear=${encodeURIComponent(financialYear)}` : ''; + return this.http.get(`/profitappropriationconfig/posting${query}`); + } + + /** Posts the year's appropriation: Dr profit GL / Cr each reserve GL, as one journal entry. */ + postProfitAppropriation(financialYear: string, note: string | null): Observable { + return this.http.post('/profitappropriationconfig/posting', { financialYear, note }); + } } diff --git a/src/app/organization/reserve-fund-config/reserve-fund-config.component.html b/src/app/organization/reserve-fund-config/reserve-fund-config.component.html new file mode 100644 index 000000000..8ef9b086d --- /dev/null +++ b/src/app/organization/reserve-fund-config/reserve-fund-config.component.html @@ -0,0 +1,359 @@ + + +
+
+ +

Reserve Fund Configuration

+
+

+ The reserve funds the bank keeps, the percentage of each year's net profit appropriated to each, and the GL account + each one is credited to. Whatever is not appropriated stays in profit. +

+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { + +
+ +
+

Reserve funds

+

+ Only postable equity and liability accounts are listed, and each account can back one reserve only. A reserve + can be saved before its GL account exists; it cannot receive an appropriation until one is selected. +

+ + @if (rows.length) { +
+ + + + + + + + + + + + @for (row of rows; track row.rowId; let i = $index) { + + + + + + + + } + + + + + + + + + + + + + + + +
#Reserve / fundCredited to (GL) + % of profit + @if (profitSource.state === 'mapped') { + from {{ profitSource.glCode }} + } +
{{ i + 1 }} + + + @if (isNameDuplicate(row)) { + Listed twice + } + + + + + + + + — Not selected — + @for (o of availableAccounts(row); track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + @if (row.glAccountId == null) { + GL not selected + } + + + + + % + + + +
Total appropriated{{ totalPercentage }}%
Remaining in profit{{ remainingPercentage }}%
+
+ } @else { +
No reserve fund is configured yet. Add one to begin.
+ } + + @if (overLimit) { +
+ The reserves add up to {{ totalPercentage }}% of net profit. The total cannot exceed 100%. +
+ } + @if (unmappedCount > 0) { +
+ {{ unmappedCount }} reserve{{ unmappedCount === 1 ? ' has' : 's have' }} no GL account yet. Create the account + in Chart of Accounts if needed, then select it here. +
+ } + +
+ + + @if (dirty && !saving) { + + } + @if (updatedOn) { + + Last saved {{ updatedOn | slice: 0 : 16 }} + @if (updatedBy) { + by {{ updatedBy }} + } + + } +
+
+ + +
+

Post to reserves

+

+ Moves a closed year's profit out of the profit GL into the reserve GLs above, at their percentages — one journal + entry, dated the last day of the year. Each year can be posted once. +

+ + @if (postingError) { +
{{ postingError }}
+ } + @if (postingSuccess) { +
{{ postingSuccess }}
+ } + +
+ + Financial year + + @for (fy of posting?.financialYearOptions || []; track fy) { + FY {{ fy }} + } + + + + @if (posting) { +
+ Profit in {{ posting.profitGlCode || 'profit GL' }} + ₹{{ posting.profitAmount | number: '1.2-2' }} +
+
+ @if (posting.posted) { + Posted + } @else if (posting.yearClosed) { + Year closed + } @else { + Year not closed + } +
+ } +
+ + @if (postingLoading) { +
Loading…
+ } @else if (posting) { +
+ + + + + + + + + + + @for (l of posting.lines || []; track $index) { + + + + + + + } + + + + + + + + + + + +
Reserve / fundCredited to (GL)%Amount
{{ l.reserveName }} + @if (l.glCode) { + {{ l.glCode }} · {{ l.glAccountName }} + } @else { + GL not selected + } + {{ l.percentage }}%₹{{ l.amount | number: '1.2-2' }}
{{ posting.posted ? 'Posted to reserves' : 'To be posted to reserves' }}₹{{ posting.totalAmount | number: '1.2-2' }}
Remaining in {{ posting.profitGlCode || 'profit GL' }}₹{{ posting.remainingAmount | number: '1.2-2' }}
+
+ + @if (posting.posted) { +

+ Posted on {{ posting.postedOnDate }} by {{ posting.postedBy || '—' }} · Transaction + {{ posting.glTransactionId }} + @if (posting.note) { + · {{ posting.note }} + } +

+ } @else { + @for (b of posting.blockers || []; track $index) { +
{{ b }}
+ } + @if (dirty) { +
+ Save the reserve changes above first — posting uses the saved configuration. +
+ } + +
+ + Note (e.g. AGM / board resolution no.) + + + @if (!postingConfirm) { + + } @else { + + Post ₹{{ posting.totalAmount | number: '1.2-2' }} from {{ posting.profitGlCode }} for FY + {{ postingYear }}? This cannot be undone here. + + + + } +
+ } + } +
+ } +
diff --git a/src/app/organization/reserve-fund-config/reserve-fund-config.component.scss b/src/app/organization/reserve-fund-config/reserve-fund-config.component.scss new file mode 100644 index 000000000..3b2c68752 --- /dev/null +++ b/src/app/organization/reserve-fund-config/reserve-fund-config.component.scss @@ -0,0 +1,143 @@ +/** + * 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/. + */ + +/* Layout on top of the RBI reserve configuration styles this screen shares. */ + +.rfc-source { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + + &__icon { + flex: 0 0 auto; + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + background: #fef3c7; + color: #b45309; + } + + &__body { + flex: 1 1 260px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__label { + font-size: 0.72rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgb(0 0 0 / 55%); + font-weight: 600; + } + + &__gl { + font-size: 15px; + font-weight: 600; + } + + &__link { + flex: 0 0 auto; + } +} + +/* Four columns do not fit a phone; the table scrolls inside its card rather than widening the page. */ +.rfc-table-wrap { + overflow-x: auto; +} + +.rfc-table { + min-width: 600px; +} + +.rfc-post-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px 24px; + margin-bottom: 8px; + + &__fy { + width: 180px; + } + + &__profit { + display: flex; + flex-direction: column; + gap: 2px; + } +} + +.rfc-post-amt { + width: 10rem; + text-align: right; + white-space: nowrap; +} + +.rfc-post-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 12px; + + &__note { + flex: 1 1 280px; + max-width: 480px; + } + + &__confirm { + font-weight: 600; + color: #b45309; + } +} + +.rfc-th-sub { + display: block; + text-transform: none; + letter-spacing: 0; + font-weight: 500; +} + +.rfc-sr { + width: 2rem; + color: rgb(0 0 0 / 55%); +} + +.rfc-warn { + color: #b45309; +} + +.rfc-error { + color: #b91c1c; +} + +.rfc-total td, +.rfc-remaining td { + padding-top: 0.6rem; + font-weight: 600; +} + +.rfc-total td { + border-top: 1px solid rgb(0 0 0 / 12%); +} + +.rfc-total--over td { + color: #b91c1c; +} + +.rfc-remaining td { + font-weight: 500; + color: rgb(0 0 0 / 60%); +} diff --git a/src/app/organization/reserve-fund-config/reserve-fund-config.component.ts b/src/app/organization/reserve-fund-config/reserve-fund-config.component.ts new file mode 100644 index 000000000..2d010516c --- /dev/null +++ b/src/app/organization/reserve-fund-config/reserve-fund-config.component.ts @@ -0,0 +1,308 @@ +/** + * 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 { FormsModule } from '@angular/forms'; +import { forkJoin } from 'rxjs'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; +import { AccountingService } from 'app/accounting/accounting.service'; + +/** Financial activity whose GL holds the year's closed-out profit (FinancialActivity.DISTRIBUTABLE_PROFIT). */ +const DISTRIBUTABLE_PROFIT_ACTIVITY = 302; + +/** Where the appropriated profit is taken from, as shown at the top of the screen. */ +type ProfitSource = + | { state: 'loading' } + | { state: 'mapped'; mappingId: number; glCode: string; glName: string } + | { state: 'unmapped' } + | { state: 'unknown' }; + +/** One reserve fund on screen, as a working copy - committed on Save. */ +interface ReserveRow { + /** Screen-only identity, so deleting a row never shifts the inputs bound to the rows after it. */ + rowId: number; + name: string; + glAccountId: number | null; + percentage: number | null; +} + +interface GlOption { + id: number; + name: string; + glCode: string; +} + +/** + * Reserve funds (Statutory Reserve, Building Fund, Investment Fluctuation Fund...) and the percentage of the + * year's net profit appropriated to each, with the GL account each one is credited to. + * + * The list is saved as a whole: what is on screen when Save is pressed is exactly what is stored. + */ +@Component({ + selector: 'mifosx-reserve-fund-config', + templateUrl: './reserve-fund-config.component.html', + styleUrls: [ + '../rbi-reserve-config/rbi-reserve-config.component.scss', + './reserve-fund-config.component.scss' + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + NgxMatSelectSearchModule + ] +}) +export class ReserveFundConfigComponent implements OnInit { + private organizationService = inject(OrganizationService); + private accountingService = inject(AccountingService); + + loading = true; + saving = false; + errorMsg = ''; + successMsg = ''; + + rows: ReserveRow[] = []; + glAccountOptions: GlOption[] = []; + accountSearch = ''; + updatedOn: string | null = null; + updatedBy: string | null = null; + profitSource: ProfitSource = { state: 'loading' }; + + /** "Post to reserves": the chosen year's preview, or what was posted for it. */ + posting: any = null; + postingYear = ''; + postingLoading = false; + postingBusy = false; + postingConfirm = false; + postingNote = ''; + postingError = ''; + postingSuccess = ''; + + private nextRowId = 1; + private savedSnapshot = '[]'; + + ngOnInit(): void { + forkJoin({ + config: this.organizationService.getProfitAppropriationConfig(), + template: this.organizationService.getProfitAppropriationConfigTemplate() + }).subscribe({ + next: ({ config, template }) => { + this.glAccountOptions = template?.glAccountOptions || []; + this.applyConfig(config); + this.loading = false; + }, + error: (err) => { + this.errorMsg = this.messageFrom(err, 'Could not load the reserve fund configuration.'); + this.loading = false; + } + }); + this.loadProfitSource(); + this.loadPosting(); + } + + /** Loads the posting preview for a year; with none, the backend picks the year to open on. */ + loadPosting(financialYear?: string): void { + this.postingLoading = true; + this.postingConfirm = false; + this.postingError = ''; + this.organizationService.getProfitAppropriationPosting(financialYear).subscribe({ + next: (preview) => { + this.posting = preview; + this.postingYear = preview?.financialYear ?? ''; + this.postingLoading = false; + }, + error: (err) => { + this.postingError = this.messageFrom(err, 'Could not load the appropriation for this year.'); + this.postingLoading = false; + } + }); + } + + /** Unsaved reserve edits are not what would be posted - the backend posts the saved configuration. */ + get canPost(): boolean { + return !!this.posting?.canPost && !this.dirty && !this.postingBusy; + } + + post(): void { + if (!this.canPost) { + return; + } + this.postingBusy = true; + this.postingError = ''; + this.postingSuccess = ''; + this.organizationService.postProfitAppropriation(this.postingYear, this.postingNote.trim() || null).subscribe({ + next: (result) => { + this.posting = result; + this.postingBusy = false; + this.postingConfirm = false; + this.postingNote = ''; + this.postingSuccess = `Profit for FY ${this.postingYear} posted to the reserves (${result?.glTransactionId}).`; + }, + error: (err) => { + this.postingBusy = false; + this.postingConfirm = false; + this.postingError = this.messageFrom(err, 'Could not post the appropriation.'); + } + }); + } + + /** + * The profit GL is not chosen here: it is the account mapped to "Distributable Profit" under Accounting → + * Accounts Linked to Financial Activities, which the year-end close posts net profit into. Loaded on its own so + * a user without access to the mappings still gets the rest of the screen. + */ + private loadProfitSource(): void { + this.accountingService.getFinancialActivityAccounts().subscribe({ + next: (mappings: any[]) => { + const profit = (mappings || []).find((m) => m?.financialActivityData?.id === DISTRIBUTABLE_PROFIT_ACTIVITY); + this.profitSource = profit?.glAccountData + ? { + state: 'mapped', + mappingId: profit.id, + glCode: profit.glAccountData.glCode, + glName: profit.glAccountData.name + } + : { state: 'unmapped' }; + }, + error: () => (this.profitSource = { state: 'unknown' }) + }); + } + + addRow(): void { + this.rows = [ + ...this.rows, + { rowId: this.nextRowId++, name: '', glAccountId: null, percentage: null }]; + this.successMsg = ''; + } + + removeRow(row: ReserveRow): void { + this.rows = this.rows.filter((r) => r.rowId !== row.rowId); + this.successMsg = ''; + } + + /** Accounts a row may pick: matching the search, and not already credited by another reserve. */ + availableAccounts(row: ReserveRow): GlOption[] { + const taken = new Set( + this.rows.filter((r) => r.rowId !== row.rowId && r.glAccountId != null).map((r) => r.glAccountId) + ); + const term = this.accountSearch.trim().toLowerCase(); + return this.glAccountOptions.filter( + (o) => + (!taken.has(o.id) || o.id === row.glAccountId) && + (!term || o.glCode?.toLowerCase().includes(term) || o.name?.toLowerCase().includes(term)) + ); + } + + get totalPercentage(): number { + return this.round(this.rows.reduce((sum, r) => sum + (Number(r.percentage) || 0), 0)); + } + + get remainingPercentage(): number { + return this.round(100 - this.totalPercentage); + } + + get overLimit(): boolean { + return this.totalPercentage > 100; + } + + get unmappedCount(): number { + return this.rows.filter((r) => r.glAccountId == null).length; + } + + isNameDuplicate(row: ReserveRow): boolean { + const name = row.name.trim().toLowerCase(); + return !!name && this.rows.some((r) => r.rowId !== row.rowId && r.name.trim().toLowerCase() === name); + } + + isRowInvalid(row: ReserveRow): boolean { + const pct = Number(row.percentage); + return ( + !row.name.trim() || + row.name.trim().length > 100 || + this.isNameDuplicate(row) || + row.percentage == null || + !(pct > 0) || + pct > 100 + ); + } + + get dirty(): boolean { + return this.snapshot(this.rows) !== this.savedSnapshot; + } + + get canSave(): boolean { + return !this.saving && this.dirty && !this.overLimit && !this.rows.some((r) => this.isRowInvalid(r)); + } + + save(): void { + if (!this.canSave) { + return; + } + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + const payload = this.rows.map((r) => ({ + name: r.name.trim(), + glAccountId: r.glAccountId, + percentage: Number(r.percentage) + })); + this.organizationService.updateProfitAppropriationConfig(payload).subscribe({ + next: (config) => { + this.applyConfig(config); + this.saving = false; + this.successMsg = 'Reserve fund configuration saved.'; + this.loadPosting(this.postingYear || undefined); + }, + error: (err) => { + this.saving = false; + this.errorMsg = this.messageFrom(err, 'Could not save the reserve fund configuration.'); + } + }); + } + + /** Throws away unsaved edits. */ + reset(): void { + this.rows = JSON.parse(this.savedSnapshot).map((r: Omit) => ({ + ...r, + rowId: this.nextRowId++ + })); + this.errorMsg = ''; + this.successMsg = ''; + } + + private applyConfig(config: any): void { + this.rows = (config?.reserves || []).map((r: any) => ({ + rowId: this.nextRowId++, + name: r.name ?? '', + glAccountId: r.glAccountId ?? null, + percentage: r.percentage != null ? Number(r.percentage) : null + })); + this.savedSnapshot = this.snapshot(this.rows); + this.updatedOn = config?.updatedOn ?? null; + this.updatedBy = config?.updatedBy ?? null; + } + + private snapshot(rows: ReserveRow[]): string { + return JSON.stringify( + rows.map((r) => ({ + name: r.name.trim(), + glAccountId: r.glAccountId, + percentage: r.percentage == null ? null : Number(r.percentage) + })) + ); + } + + private round(value: number): number { + return Math.round(value * 10000) / 10000; + } + + private messageFrom(err: any, fallback: string): string { + return err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || fallback; + } +} diff --git a/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html index f01407d85..eb0b1276b 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html @@ -452,15 +452,22 @@ @if (isCashOrAccrualAccounting()) { +

GL Mapping

+
+ Accounting Rule + {{ accountingRule?.value }} +
+
Assets
- Overdraft portfolio control + Overdraft Portfolio Control {{ formatGlAccountById( CurrentProduct.overdraftPortfolioControlId, @@ -468,39 +475,41 @@ ) }}
-
- Interest receivable account - {{ - formatGlAccountById( - CurrentProduct.interestReceivableAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
-
- Fees receivable account - {{ - formatGlAccountById( - CurrentProduct.feesReceivableAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
-
- Penalties receivable account - {{ - formatGlAccountById( - CurrentProduct.penaltiesReceivableAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
+ @if (isAccrualAccounting()) { +
+ Interest Receivable Account + {{ + formatGlAccountById( + CurrentProduct.interestReceivableAccountId, + CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+
+ Fees Receivable Account + {{ + formatGlAccountById( + CurrentProduct.feesReceivableAccountId, + CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+
+ Penalties Receivable Account + {{ + formatGlAccountById( + CurrentProduct.penaltiesReceivableAccountId, + CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+ }
Liabilities
- Current control account + Current Control Account {{ formatGlAccountById( CurrentProduct.savingsControlAccountId, @@ -508,18 +517,20 @@ ) }}
-
- Interest payable account - {{ - formatGlAccountById( - CurrentProduct.interestPayableAccountId, - CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
+ @if (isAccrualAccounting()) { +
+ Interest Payable Account + {{ + formatGlAccountById( + CurrentProduct.interestPayableAccountId, + CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions + ) + }} +
+ } @if (CurrentProduct.isDormancyTrackingActive) {
- Escheat liability account + Escheat Liability Account {{ formatGlAccountById( CurrentProduct.escheatLiabilityId, @@ -533,7 +544,7 @@
Income
- Income from fees + Income From Fees {{ formatGlAccountById( CurrentProduct.incomeFromFeeAccountId, @@ -542,7 +553,7 @@ }}
- Income from penalties + Income From Penalties {{ formatGlAccountById( CurrentProduct.incomeFromPenaltyAccountId, @@ -551,7 +562,7 @@ }}
- Income from interest + Income From Interest {{ formatGlAccountById( CurrentProduct.incomeFromInterestId, diff --git a/src/app/products/fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component.html b/src/app/products/fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component.html index 358361197..2f48d42ef 100644 --- a/src/app/products/fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component.html +++ b/src/app/products/fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component.html @@ -11,16 +11,18 @@

CBS Base Interest Rate

- Manage master CBS interest rate slabs by deposit type. Saving a new version automatically - deactivates the current active version and activates the new one. This chart - is used as the reference for both Fixed Deposit and Recurring Deposit products. + Manage master CBS interest rate slabs by deposit type. SINGLE, DOUBLE and SPECIAL are the fixed deposit charts; + the + RD tab is the recurring deposit chart, with its own senior citizen rates, used when an RD + product is created and when an RD account is opened. Saving a new version automatically + deactivates the current active version and activates the new one.

@for (fdType of fdTypes; track fdType) { - +
@if (!showPreview[fdType]) { @@ -31,6 +33,14 @@
+ @if (fdType === 'RD' && rdChartInDays) { +

+ The recurring deposit chart in use is still in days. Recurring deposits are priced by months or years, + so enter the slabs below in Months or Years (for example 1 to 3 Months, 4 to 5 Months, 6 to 11 Months, + 12 to 35 Months) and save. +

+ } + @if (slabs(fdType).length === 0) {

No slabs defined. Click "Add Slab" to begin.

} @else { @@ -55,7 +65,7 @@ - @for (pt of periodTypes; track pt.id) { + @for (pt of periodTypesFor(fdType); track pt.id) { {{ pt.label }} } @@ -198,7 +208,7 @@ Period - {{ getPeriodLabel(s.periodTypeEnum) }} + {{ getPeriodLabel(s.periodTypeEnum, fdType) }} From @@ -245,7 +255,7 @@ Period - {{ getPeriodLabel(s.periodTypeEnum) }} + {{ getPeriodLabel(s.periodTypeEnum, fdType) }} From @@ -335,7 +345,7 @@ Period - {{ getPeriodLabel(s.periodTypeEnum) }} + {{ getPeriodLabel(s.periodTypeEnum, fdType) }} From diff --git a/src/app/products/fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component.ts b/src/app/products/fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component.ts index 1f3fc561e..7c5c9fe40 100644 --- a/src/app/products/fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component.ts +++ b/src/app/products/fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component.ts @@ -58,10 +58,12 @@ export class BaseFdInterestRateChartComponent implements OnInit { private productsService = inject(ProductsService); private alertService = inject(AlertService); + /** SINGLE, DOUBLE and SPECIAL are the fixed deposit charts; RD is the recurring deposit's own chart. */ readonly fdTypes = [ 'SINGLE', 'DOUBLE', - 'SPECIAL' + 'SPECIAL', + 'RD' ]; readonly periodTypes = [ { id: 0, label: 'Days' }, @@ -69,6 +71,14 @@ export class BaseFdInterestRateChartComponent implements OnInit { { id: 2, label: 'Months' }, { id: 3, label: 'Years' } ]; + /** + * Recurring deposits keep tenure in months or years, and the server numbers the units its own way (1 days, 2 months, + * 3 years), so the RD tab offers only those two. + */ + readonly rdPeriodTypes = [ + { id: 2, label: 'Months' }, + { id: 3, label: 'Years' } + ]; readonly slabColumns = [ 'srNo', 'description', @@ -89,6 +99,9 @@ export class BaseFdInterestRateChartComponent implements OnInit { 'seniorRate' ]; + /** Set when the recurring deposit's active chart is still the old one, kept in days, which this screen no longer edits. */ + rdChartInDays = false; + forms: Record = {}; slabsDataSources: Record> = {}; activeRates: Record = {}; @@ -110,6 +123,16 @@ export class BaseFdInterestRateChartComponent implements OnInit { this.loadAll(); } + /** The period units a slab of this chart can use. */ + periodTypesFor(fdType: string): { id: number; label: string }[] { + return fdType === 'RD' ? this.rdPeriodTypes : this.periodTypes; + } + + /** The tab's heading: RD spelled out, the fixed deposit types as they are. */ + tabLabel(fdType: string): string { + return fdType === 'RD' ? 'RD (Recurring Deposit)' : fdType; + } + slabs(fdType: string): FormArray { return this.forms[fdType].get('slabs') as FormArray; } @@ -158,6 +181,8 @@ export class BaseFdInterestRateChartComponent implements OnInit { return 3; // Years case 'SPECIAL': return 2; // Months + case 'RD': + return 2; // Months default: return 0; } @@ -264,7 +289,11 @@ export class BaseFdInterestRateChartComponent implements OnInit { } this.isSaving[fdType] = true; const { versionLabel, remarks, slabs } = this.forms[fdType].value; - this.productsService.saveFdBaseInterestRates({ fdType, versionLabel, remarks, slabs }).subscribe({ + const save$ = + fdType === 'RD' + ? this.productsService.saveRdBaseInterestRates({ versionLabel, remarks, slabs }) + : this.productsService.saveFdBaseInterestRates({ fdType, versionLabel, remarks, slabs }); + save$.subscribe({ next: (response: any) => { this.isSaving[fdType] = false; this.showPreview[fdType] = false; @@ -286,8 +315,13 @@ export class BaseFdInterestRateChartComponent implements OnInit { }); } - getPeriodLabel(enumVal: number): string { - return this.periodTypes.find((p) => p.id === enumVal)?.label ?? String(enumVal); + getPeriodLabel(enumVal: number, fdType?: string): string { + // The RD chart numbers its units differently from the fixed deposit ones, so its labels are read from its own list. + const units = fdType === 'RD' ? [ + { id: 1, label: 'Days' }, + ...this.rdPeriodTypes + ] : this.periodTypes; + return units.find((p) => p.id === enumVal)?.label ?? String(enumVal); } historyForType(fdType: string): any[] { @@ -305,13 +339,25 @@ export class BaseFdInterestRateChartComponent implements OnInit { private loadAll(): void { this.isLoading = true; this.fdTypes.forEach((type) => { - this.productsService.getFdBaseInterestRates(type).subscribe({ - next: (rates: any[]) => { - const active = (rates || []).find((r: any) => r.isActive); + const rates$ = + type === 'RD' + ? this.productsService.getRdBaseInterestRates() + : this.productsService.getFdBaseInterestRates(type); + rates$.subscribe({ + next: (rates: any) => { + const versions: any[] = Array.isArray(rates) ? rates : rates ? [rates] : []; + const active = versions.find((r: any) => r.isActive); this.activeRates[type] = active ?? null; const arr = this.slabs(type); arr.clear(); - (active?.slabs ?? []).forEach((s: any) => arr.push(this.buildSlabRow(s))); + // A recurring deposit is priced by months or years. A chart still held in days cannot be shown in those units, so + // it is not loaded: the rows would open with no period type, and the officer enters the chart afresh. + const rows: any[] = active?.slabs ?? []; + const inDays = type === 'RD' && rows.some((s: any) => s.periodTypeEnum === 1); + if (type === 'RD') { + this.rdChartInDays = inDays; + } + (inDays ? [] : rows).forEach((s: any) => arr.push(this.buildSlabRow(s))); this.slabsDataSources[type].data = [...arr.controls]; this.forms[type].patchValue({ versionLabel: '', remarks: '' }); }, @@ -323,10 +369,27 @@ export class BaseFdInterestRateChartComponent implements OnInit { this.productsService.getFdBaseInterestRateHistory().subscribe({ next: (h: any[]) => { this.historyRates = h || []; - this.isLoading = false; + this.loadRdHistory(); }, error: () => { this.historyRates = []; + this.loadRdHistory(); + } + }); + } + + /** The RD chart's past versions, tagged RD so they sit under their own tab. */ + private loadRdHistory(): void { + this.productsService.getRdBaseInterestRateHistory().subscribe({ + next: (h: any) => { + const versions: any[] = Array.isArray(h) ? h : h ? [h] : []; + this.historyRates = [ + ...this.historyRates, + ...versions.map((v) => ({ ...v, fdType: 'RD' })) + ]; + this.isLoading = false; + }, + error: () => { this.isLoading = false; } }); diff --git a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html index bc9302911..a907e97c6 100644 --- a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html +++ b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html @@ -90,13 +90,13 @@ }

- RD tenure is counted in months, from {{ rdChart.minTenureMonths ?? '-' }} months (the Minimum Tenure on the - Terms tab). A rate below the first slab comes from the RD CBS base chart; the rest from this chart. + RD tenure is counted in months, from {{ rdChart.minTenureMonths ?? '-' }} months (the Minimum Tenure on + the Terms tab). Every rate comes from the RD CBS base chart.

@if (rdChart.shortTermRateMissing) {

- The RD CBS base chart has no rate for {{ configuredMinTenureMonths }} months, so this product can only be - opened from {{ rdChart.minTenureMonths }} months. + The RD CBS base chart has no rate for {{ configuredMinTenureMonths }} months, so this product can only + be opened from {{ rdChart.minTenureMonths }} months.

} @@ -127,6 +127,10 @@ {{ slab.annualInterestRate | number: '1.2-2' }}
+ + Senior Citizen (%) + {{ slab.seniorCitizenRate | number: '1.2-2' }} + diff --git a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts index c27a58bf8..28a378b0a 100644 --- a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts +++ b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts @@ -96,15 +96,20 @@ export class CreateRecurringDepositProductComponent { selectedTabIndex = 0; rdInterestRates: any[] = []; rdBaseSlabs: any[] = []; - private rdChartCache: { baseSlabs: any[]; rdBaseSlabs: any[]; minTenureMonths: number | null; chart: RdChart } | null = - null; + private rdChartCache: { + baseSlabs: any[]; + rdBaseSlabs: any[]; + minTenureMonths: number | null; + chart: RdChart; + } | null = null; readonly rdSlabColumns = [ 'srNo', 'description', 'fromPeriod', 'toPeriod', 'periodTypeLabel', - 'annualInterestRate' + 'annualInterestRate', + 'seniorCitizenRate' ]; /** @@ -119,26 +124,15 @@ export class CreateRecurringDepositProductComponent { this.recurringDepositProductsTemplate = data.recurringDepositProductsTemplate; }); this.accountingRuleData = this.accounting.getAccountingRulesForSavings(); - // Load the CBS base chart as the reference for the editable table - this.productsService.getFdBaseInterestRates().subscribe({ - next: (rates: any[]) => { - this.rdInterestRates = rates || []; + // The product's rates come from the recurring deposit's own CBS base chart (in months, with senior citizen rates) + this.productsService.getRdBaseInterestRates().subscribe({ + next: (rates: any) => { + this.rdInterestRates = Array.isArray(rates) ? rates : rates ? [rates] : []; }, error: () => { this.rdInterestRates = []; } }); - // The RD base chart supplies the rate for 6 months to below 1 year, which the FD chart does not cover - this.productsService.getRdBaseInterestRates().subscribe({ - next: (rates: any) => { - const versions: any[] = Array.isArray(rates) ? rates : rates ? [rates] : []; - const active = versions.find((version: any) => version.isActive) ?? versions[0]; - this.rdBaseSlabs = active?.slabs || []; - }, - error: () => { - this.rdBaseSlabs = []; - } - }); } get recurringDepositProductDetailsForm() { @@ -170,18 +164,8 @@ export class CreateRecurringDepositProductComponent { } get activeBaseVersion() { - const chart = - this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ?? - this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ?? - this.rdInterestRates[0]; - if (chart) { - return { - ...chart, - versionLabel: chart.versionLabel?.replace('FD', 'RD'), - remarks: chart.remarks?.replace('FD', 'RD') - }; - } - return null; + const chart = this.rdInterestRates.find((version: any) => version.isActive) ?? this.rdInterestRates[0]; + return chart ? { ...chart } : null; } /** The product's chart in months, from 6 months up - see rd-product-chart.ts. */ diff --git a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.html b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.html index 1a456d361..7a1d9acd8 100644 --- a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.html +++ b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.html @@ -116,6 +116,10 @@ {{ slab.annualInterestRate | number: '1.2-2' }} + + Senior Citizen (%) + {{ slab.seniorCitizenRate | number: '1.2-2' }} + diff --git a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts index f6affcdb4..3066d4a82 100644 --- a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts +++ b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts @@ -95,7 +95,8 @@ export class EditRecurringDepositProductComponent { 'fromPeriod', 'toPeriod', 'periodTypeLabel', - 'annualInterestRate' + 'annualInterestRate', + 'seniorCitizenRate' ]; selectedTabIndex = 0; @@ -109,9 +110,10 @@ export class EditRecurringDepositProductComponent { constructor() { this.route.data.subscribe((data: { recurringDepositProductAndTemplate: any }) => { this.recurringDepositProductsTemplate = data.recurringDepositProductAndTemplate; - this.productsService.getFdBaseInterestRates().subscribe({ - next: (rates: any[]) => { - this.rdInterestRates = rates || []; + // The recurring deposit's own CBS base chart, which carries the senior citizen rates + this.productsService.getRdBaseInterestRates().subscribe({ + next: (rates: any) => { + this.rdInterestRates = Array.isArray(rates) ? rates : rates ? [rates] : []; }, error: () => { this.rdInterestRates = []; @@ -150,18 +152,8 @@ export class EditRecurringDepositProductComponent { } get activeBaseVersion() { - const chart = - this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ?? - this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ?? - this.rdInterestRates[0]; - if (chart) { - return { - ...chart, - versionLabel: chart.versionLabel?.replace('FD', 'RD'), - remarks: chart.remarks?.replace('FD', 'RD') - }; - } - return null; + const chart = this.rdInterestRates.find((version: any) => version.isActive) ?? this.rdInterestRates[0]; + return chart ? { ...chart } : null; } get activeBaseSlabs() { diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html index 62b3fc3d7..3f784a9c1 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html @@ -21,15 +21,6 @@ {{ totalCount }} {{ totalCount === 1 ? 'product' : 'products' }}
- +
+ +
+
+
₹{{ totalIn | number: '1.2-2' }}
+
Came in
+
+
+
₹{{ totalOut | number: '1.2-2' }}
+
Went out
+
+
+
{{ shown.length }}
+
Accounts shown ({{ ccCount }} CC)
+
+
+ +@if (loading) { +
Loading the CC account figures…
+} @else if (error) { +
{{ error }}
+} @else if (!shown.length) { +
+ @if (ccOnly && !ccCount) { + No CC account yet. A CC account is a current account opened with the overdraft facility switched on; untick the + box to see every current account. + } @else { + No active current account in your branch. + } +
+} @else { +
+ + + + + + + + + + + + + + + + + + + @for (a of shown; track a.accountId; let i = $index) { + + + + + + + + + + + + + + + } + +
#Customer / AccountBranchLimitDrawnDeclared / monthCame inWent outNetIn ÷ declaredCash inLast txn
{{ i + 1 }} +
+ {{ a.clientName }} + {{ a.isCc ? 'CC' : 'Current' }} +
+
{{ a.accountNo }}
+
{{ a.branchName || '—' }}{{ a.isCc ? '₹' + (a.sanctionedLimit | number: '1.0-0') : '—' }}{{ a.limitDrawnPercent != null ? a.limitDrawnPercent + '%' : '—' }} + {{ + a.declaredMonthlyTurnover != null ? '₹' + (a.declaredMonthlyTurnover | number: '1.0-0') : 'not declared' + }} + + ₹{{ a.credits | number: '1.2-2' }} +
{{ a.creditCount }} txn
+
+ ₹{{ a.debits | number: '1.2-2' }} +
{{ a.debitCount }} txn
+
₹{{ a.net | number: '1.2-2' }} + @if (a.creditsVsDeclared != null) { + + {{ a.creditsVsDeclared }}× + + } @else { + — + } + {{ a.cashCreditPercent != null ? a.cashCreditPercent + '%' : '—' }}{{ a.lastTxnDate ? (a.lastTxnDate | date: 'dd MMM yyyy') : '—' }}
+
+

+ Busiest first. “In ÷ declared” compares what came in with the turnover the customer declared, scaled to the window; + {{ spikeMultiple }}× or more is highlighted for a look. It is an indicative starting value, not a finding. +

+} diff --git a/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.scss b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.scss new file mode 100644 index 000000000..403df0bfc --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.scss @@ -0,0 +1,234 @@ +/** + * 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 { + display: block; + max-width: 100%; + overflow-x: hidden; +} + +.cc-intro { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px 16px; + margin-bottom: 14px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: #eff6ff; + color: #1e3a8a; + font-size: 13px; + line-height: 1.5; + + mat-icon { + flex-shrink: 0; + color: #1d4ed8; + } +} + +.cc-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 14px; +} + +.cc-field { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + + select { + padding: 6px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font: inherit; + font-size: 13px; + text-transform: none; + letter-spacing: 0; + color: #0f172a; + } +} + +.cc-check { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #334155; +} + +.cc-asof { + font-size: 12px; + color: #64748b; +} + +.cc-refresh { + margin-left: auto; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + cursor: pointer; + padding: 2px 6px; + color: #334155; +} + +.cc-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.cc-card { + padding: 12px 16px; + border: 1px solid #e2e8f0; + border-left: 4px solid #1a237e; + border-radius: 8px; + background: #fff; + + &--in { + border-left-color: #15803d; + } + + &--out { + border-left-color: #b91c1c; + } + + &__n { + font-size: 20px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; + } + + &__l { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; + } +} + +.cc-note { + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + color: #334155; + font-size: 13px; + + &--warn { + border-color: #fcd34d; + background: #fffbeb; + color: #78350f; + } +} + +.cc-table-wrap { + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; +} + +.cc-table { + width: 100%; + border-collapse: collapse; + + th { + padding: 12px 10px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + text-align: left; + } + + td { + padding: 10px; + border-top: 1px solid #f1f5f9; + font-size: 13px; + color: #1e293b; + vertical-align: top; + } + + .r { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .in { + color: #15803d; + font-weight: 600; + } + + .out { + color: #b91c1c; + font-weight: 600; + } +} + +.cc-name { + font-weight: 600; +} + +.cc-sub { + margin-top: 1px; + font-size: 11px; + font-weight: 400; + color: #64748b; +} + +.mono { + font-family: ui-monospace, monospace; +} + +.cc-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 999px; + background: #e2e8f0; + color: #475569; + font-size: 10px; + font-weight: 700; + + &--cc { + background: #dbeafe; + color: #1d4ed8; + } +} + +.cc-ratio { + display: inline-block; + padding: 1px 7px; + border-radius: 999px; + background: #f1f5f9; + font-weight: 700; + + &--hi { + background: #fee2e2; + color: #b91c1c; + } +} + +.cc-foot { + margin: 10px 2px 0; + font-size: 12px; + color: #64748b; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.ts b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.ts new file mode 100644 index 000000000..0ddfe1b48 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.ts @@ -0,0 +1,95 @@ +/** + * 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 { MatTooltipModule } from '@angular/material/tooltip'; +import { AmlService } from '../../../aml/aml.service'; + +/** + * How much came into and went out of each Cash Credit account, against what the customer declared and what the bank + * sanctioned. Read from the posted current-account transactions, so it works without the AML engine being fed them. + */ +@Component({ + selector: 'mifosx-aml-cc-tracking', + templateUrl: './aml-cc-tracking.component.html', + styleUrls: ['./aml-cc-tracking.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatTooltipModule + ] +}) +export class AmlCcTrackingComponent implements OnInit { + private amlService = inject(AmlService); + + readonly windows = [ + { days: 7, label: 'Last 7 days' }, + { days: 30, label: 'Last 30 days' }, + { days: 90, label: 'Last 90 days' } + ]; + days = 30; + /** CC accounts only, or every current account (a plain current account is not hidden by default). */ + ccOnly = false; + + loading = false; + error = ''; + asOf = ''; + from = ''; + accounts: any[] = []; + + /** A credit or debit this many times the declared turnover for the window is flagged for a look. Indicative only. */ + readonly spikeMultiple = 3; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.error = ''; + this.amlService.getCcTracking(this.days).subscribe({ + next: (res) => { + this.loading = false; + this.accounts = res?.accounts || []; + this.asOf = res?.asOf || ''; + this.from = res?.from || ''; + }, + error: (err: any) => { + this.loading = false; + this.accounts = []; + this.error = err?.error?.defaultUserMessage || 'Could not load the CC account figures.'; + } + }); + } + + get shown(): any[] { + return this.ccOnly ? this.accounts.filter((a) => a.isCc) : this.accounts; + } + + get ccCount(): number { + return this.accounts.filter((a) => a.isCc).length; + } + + get totalIn(): number { + return this.shown.reduce((sum, a) => sum + (Number(a.credits) || 0), 0); + } + + get totalOut(): number { + return this.shown.reduce((sum, a) => sum + (Number(a.debits) || 0), 0); + } + + /** True when a side of the account's activity is well beyond what the customer declared for the window. */ + isSpike(ratio: number | null): boolean { + return ratio != null && ratio >= this.spikeMultiple; + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html index ab2aaf069..6768b5be4 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html @@ -303,153 +303,6 @@ - - - - - -
- @if (isExpanded(alert)) { -
-
- Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — - {{ amount(alert) }} - @if (selectableRulesOf(alert).length > 1) { - - } -
- - @for (rule of matchedRules(alert); track rule.ruleCode) { -
- - @if (isRuleSelectable(rule)) { - - } @else if (!isGradedRule(rule)) { - - - gavel - - } @else { - - {{ - !rule.directlyDecided - ? 'remove_circle_outline' - : rule.disposition === 'ESCALATE' - ? 'report' - : 'verified' - }} - - } - {{ - severityLabel(rule.riskCategory) - }} -
- - {{ rule.ruleCode }} - @if (rule.governing) { - governing - } - - @if (!isGradedRule(rule)) { - - → CTR filing - - } - - @if (rule.disposition) { - - {{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }} - - } - - {{ rule.ruleDescription }} -
-
- } - - - @if (selectedRuleCount > 0) { -
- {{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected - - - - -
- } -
- @if (isHighSeverity(alert)) { - A high-severity rule fired, so this transaction is decided on its own rather than in a batch. - } @else if (!alert.highestSeverity) { - A statutory return rather than a graded suspicion — decided on its own. - } @else { - Every rule here is low or medium, so this transaction can be decided together with others. - } -
-
- } -
- -
- Amount {{ amount(alert) }} @@ -541,9 +394,147 @@ [class.row-expanded]="isExpanded(row)" (click)="toggleExpanded(row)" > - + + +
+ + @if (expandedAlert; as alert) { +
+
+ Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — + {{ amount(alert) }} + @if (selectableRulesOf(alert).length > 1) { + + } +
+ + @for (rule of matchedRules(alert); track rule.ruleCode) { +
+ + @if (isRuleSelectable(rule)) { + + } @else if (!isGradedRule(rule)) { + + + gavel + + } @else { + + {{ + !rule.directlyDecided + ? 'remove_circle_outline' + : rule.disposition === 'ESCALATE' + ? 'report' + : 'verified' + }} + + } + {{ + severityLabel(rule.riskCategory) + }} +
+ + {{ rule.ruleCode }} + @if (rule.governing) { + governing + } + + @if (!isGradedRule(rule)) { + + → CTR filing + + } + + @if (rule.disposition) { + + {{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }} + + } + + {{ rule.ruleDescription }} +
+
+ } + + + @if (selectedRuleCount > 0) { +
+ {{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected + + + + +
+ } +
+ @if (isHighSeverity(alert)) { + A high-severity rule fired, so this transaction is decided on its own rather than in a batch. + } @else if (!alert.highestSeverity) { + A statutory return rather than a graded suspicion — decided on its own. + } @else { + Every rule here is low or medium, so this transaction can be decided together with others. + } +
+
+ } +
+
+ diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss index a11f5bc7c..3e2efe30d 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +:host { + display: block; + max-width: 100%; + overflow-x: hidden; +} + .flagged-toolbar { display: flex; align-items: center; @@ -744,11 +750,13 @@ } .actions-cell { - white-space: nowrap; + white-space: normal; } .action-group { display: inline-flex; + flex-wrap: wrap; + justify-content: center; align-items: center; gap: 2px; } @@ -1011,3 +1019,21 @@ color: #1e293b; font-variant-numeric: tabular-nums; } + +/* The rules popup. Its content is the same markup the inline pane used, so it only needs room and a close button. */ +.rules-dialog { + position: relative; + padding: 8px 0 0; + + .detail-inner { + padding: 18px 24px 22px; + border-bottom: none; + } +} + +.rules-dialog-close { + position: absolute; + top: 4px; + right: 8px; + z-index: 1; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts index 85b49edf4..6efa46158 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core'; +import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild, TemplateRef } from '@angular/core'; import { MatTableModule, MatTableDataSource } from '@angular/material/table'; import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; import { MatIconModule } from '@angular/material/icon'; @@ -167,9 +167,24 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * and the one state the flag existed to make visible — this one is actually being worked — became meaningless. */ toggleExpanded(alert: any): void { - this.expandedId = this.expandedId === alert.id ? null : alert.id; - // Ticks belong to the alert they were made on, not to whichever pane is open next. + this.expandedId = alert.id; + // Ticks belong to the alert they were made on, not to whichever popup is open next. this.selectedRuleCodes.clear(); + this.dialog + .open(this.rulesDialogTpl, { width: '780px', maxWidth: '95vw', maxHeight: '90vh', autoFocus: false }) + .afterClosed() + .subscribe(() => { + this.expandedId = null; + this.selectedRuleCodes.clear(); + }); + } + + /** The popup that lists every rule the open alert tripped. */ + @ViewChild('rulesDialog') rulesDialogTpl!: TemplateRef; + + /** The alert the popup is showing, looked up fresh so a feed refresh behind it does not leave it stale. */ + get expandedAlert(): any { + return this.alerts.data.find((a: any) => a.id === this.expandedId) ?? null; } isExpanded(alert: any): boolean { diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss index 9d62ff9ed..1010c69df 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +:host { + display: block; + max-width: 100%; + overflow-x: hidden; +} + .ra-intro { display: flex; align-items: flex-start; diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.html b/src/app/rbi-circulars/aml-rules/aml-rules.component.html index 7747c541c..8c7b26502 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.html @@ -58,6 +58,22 @@
+ +
+ Account + +
+
Applies over @@ -203,6 +219,15 @@
+ + + + +
+ +
+
+
diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.scss b/src/app/rbi-circulars/aml-rules/aml-rules.component.scss index 2c230ac84..43aed012d 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.scss @@ -86,10 +86,30 @@ ::ng-deep .mat-mdc-tab-body-wrapper { padding-top: 4px; } + + /* Material makes each tab body a horizontal scroller, so the few pixels the tables overshoot by showed up as a + scrollbar under every tab. The tables are width: 100% and their cells wrap, so nothing real sits out there. */ + ::ng-deep .mat-mdc-tab-body-content { + overflow-x: hidden; + } + + ::ng-deep .mat-mdc-tab-body-wrapper, + ::ng-deep .mat-mdc-tab-body { + max-width: 100%; + } } .aml-tab-body { padding-top: 16px; + + /* The tab's own box, in case something inside it still overshoots by a few pixels. */ + overflow-x: hidden; + max-width: 100%; +} + +/* stylelint-disable-next-line no-duplicate-selectors */ +.aml-card-content { + overflow-x: hidden; } .aml-table { diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.ts b/src/app/rbi-circulars/aml-rules/aml-rules.component.ts index f161186c5..2ddee959f 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.ts @@ -28,6 +28,7 @@ import { AlertService } from '../../core/alert/alert.service'; import { AddAmlRuleDialogComponent } from './add-aml-rule-dialog/add-aml-rule-dialog.component'; import { AmlFlaggedTransactionsComponent } from './aml-flagged-transactions/aml-flagged-transactions.component'; import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurring-alerts.component'; +import { AmlCcTrackingComponent } from './aml-cc-tracking/aml-cc-tracking.component'; @Component({ selector: 'mifosx-aml-rules', @@ -49,7 +50,8 @@ import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurrin MatTabsModule, FormsModule, AmlFlaggedTransactionsComponent, - AmlRecurringAlertsComponent + AmlRecurringAlertsComponent, + AmlCcTrackingComponent ] }) export class AmlRulesComponent implements OnInit { @@ -85,6 +87,18 @@ export class AmlRulesComponent implements OnInit { ]; activePeriod = ''; + /** + * Which kind of account the rules are for. CC is a Cash Credit account: a current account allowed to draw an + * overdraft against a sanctioned limit. The chip lists every scenario that watches such an account, whatever its + * coverage status, so the officer sees what is and is not yet being checked. + */ + readonly accountFilters: { key: string; label: string }[] = [ + { key: '', label: 'All accounts' }, + { key: 'CC', label: 'CC' } + ]; + activeAccount = ''; + private accountCounts: Record = {}; + /** Free-text search, applied on top of the period filter. */ searchTerm = ''; @@ -145,6 +159,21 @@ export class AmlRulesComponent implements OnInit { this.applyPeriodFilter(); } + setAccount(key: string): void { + this.activeAccount = key; + this.applyPeriodFilter(); + } + + /** How many rules belong to each account chip — shown on the chips. */ + accountCount(key: string): number { + return this.accountCounts[key] ?? 0; + } + + /** Whether a rule is for the account kind the officer has picked. */ + private inAccountScope(rule: any): boolean { + return !this.activeAccount || (this.activeAccount === 'CC' && !!rule.ccRelevant); + } + applySearch(value: string): void { this.searchTerm = value; this.rules.filter = value.trim().toLowerCase(); @@ -171,9 +200,9 @@ export class AmlRulesComponent implements OnInit { } private applyPeriodFilter(): void { - this.rules.data = this.activePeriod - ? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod) - : this.allRules; + this.rules.data = this.allRules.filter( + (r) => this.inAccountScope(r) && (!this.activePeriod || this.periodKey(r) === this.activePeriod) + ); this.recomputePeriodCounts(); if (this.rules.paginator) { this.rules.paginator.firstPage(); @@ -191,7 +220,11 @@ export class AmlRulesComponent implements OnInit { */ private recomputePeriodCounts(): void { const filter = this.rules.filter; - const pool = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules; + const searched = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules; + // The account chips count within the search and the chosen period; the period chips within the chosen account. + const inPeriod = searched.filter((r) => !this.activePeriod || this.periodKey(r) === this.activePeriod); + this.accountCounts = { '': inPeriod.length, CC: inPeriod.filter((r) => !!r.ccRelevant).length }; + const pool = searched.filter((r) => this.inAccountScope(r)); this.periodCounts = { '': pool.length }; for (const p of this.periodFilters) { if (p.key) { diff --git a/src/app/rbi-circulars/form9-return/form9-bank-format.ts b/src/app/rbi-circulars/form9-return/form9-bank-format.ts new file mode 100644 index 000000000..1a0d24b78 --- /dev/null +++ b/src/app/rbi-circulars/form9-return/form9-bank-format.ts @@ -0,0 +1,480 @@ +/** + * 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 type { Form9Line, Form9Return } from './form9-return.component'; + +/** + * Form IX as the bank's own system prints it (code 982-P): every head of the form in its printed + * order and wording, three dated columns, and figures with no separators. + * + * This is a presentation of the same return the working view shows. Every figure is a head's + * amount from the return, in rupees thousands as that head was rounded, and every total is the + * sum of the rounded lines above it — which is how the printed form adds, and why its totals + * differ by a thousand from a total taken on the exact rupees. + */ +export interface BankRow { + kind: 'heading' | 'line' | 'total' | 'spacer' | 'text'; + label: string; + indent: number; + /** In rupees thousands; null prints blank, as the printed form leaves an empty head. */ + value?: number | null; + /** The same figure repeated on a line of its own beneath, as the form prints a loss. */ + echo?: number | null; + /** Spaced capitals, as the printed form sets its two grand totals. */ + spaced?: boolean; +} + +export interface BankFormat { + rows: BankRow[]; + totalLiabilities: number; + assetsBeforeLoss: number; + balanceOfLoss: number; + totalAssets: number; + /** Assets less liabilities when assets are the larger side; the form prints it as DIFF ADJUSTED. */ + diffAdjusted: number; + finalTotal: number; + /** Liabilities less assets when the assets fall short — nothing on the printed form carries it. */ + shortfall: number; +} + +/** The sub-lines of 2(g) as the form prints them, with the words an account's name is matched on. */ +const FUND_SUBLINES: { label: string; pattern: RegExp | null }[] = [ + { label: '(i) Building Fund.......................', pattern: /BUILDING FUND/i }, + { label: '(II) Gratuity Fund........................', pattern: /GRATUITY/i }, + { label: '(III) Staff Welfare Fund................', pattern: /WELFARE|STAFF/i }, + { label: '(IV) Silver jubiliee Fund..............', pattern: /SILVER/i }, + { label: '(v) Development Fund...............', pattern: /DEVELOPMENT FUND/i }, + { label: '(VI) Provision on overdue interest', pattern: /OVERDUE/i }, + { label: '(vii) Charitable Fund....................', pattern: /CHARITABLE/i }, + { label: '(viii) Any other Fund....................', pattern: null } +]; + +export function buildBankFormat(data: Form9Return, manualLoss: number | null): BankFormat { + const byKey = new Map(); + for (const line of [ + ...data.liabilityLines, + ...data.assetLines + ]) { + byKey.set(line.configKey, line); + } + + /** A head's rounded amount, or 0 where it is unmapped or empty. */ + const n = (key: string): number => byKey.get(key)?.amount ?? 0; + /** What the form prints against a head: blank where there is nothing to print. */ + const v = (key: string): number | null => { + const amount = n(key); + return amount === 0 ? null : amount; + }; + const sum = (...keys: string[]): number => keys.reduce((total, key) => total + n(key), 0); + const shown = (value: number): number | null => (value === 0 ? null : value); + + const rows: BankRow[] = []; + const heading = (label: string, indent = 0) => rows.push({ kind: 'heading', label, indent }); + const line = (label: string, key: string | null, indent = 1, value?: number | null) => + rows.push({ kind: 'line', label, indent, value: value !== undefined ? value : key ? v(key) : null }); + const text = (label: string, indent = 2) => rows.push({ kind: 'text', label, indent }); + const total = (label: string, value: number, spaced = false) => + rows.push({ kind: 'total', label, indent: 0, value: spaced ? value : shown(value), spaced }); + const spacer = () => rows.push({ kind: 'spacer', label: '', indent: 0 }); + const inter = (key: string, ...labels: string[]) => { + // A head the form splits across several printed lines (i-iii, v) carries its figure on the + // first of them, so an amount mapped there still prints. + labels.forEach((label, i) => line(label, key, 2, i === 0 ? v(key) : null)); + }; + + // ── Liabilities ── + heading('1. PAID UP CAPITAL'); + line('(a) individuals', 'FORM9_L1_A_CAPITAL_INDIVIDUALS'); + line('(b) Co-operatives societies', 'FORM9_L1_B_CAPITAL_COOP_SOCIETIES'); + line('(c) Government Co.op. Bank on behalf of the Sta', 'FORM9_L1_C_CAPITAL_STATE_GOVT'); + text('Government', 1); + line('(d) Others', 'FORM9_L1_D_CAPITAL_OTHERS'); + const total1 = sum( + 'FORM9_L1_A_CAPITAL_INDIVIDUALS', + 'FORM9_L1_B_CAPITAL_COOP_SOCIETIES', + 'FORM9_L1_C_CAPITAL_STATE_GOVT', + 'FORM9_L1_D_CAPITAL_OTHERS' + ); + total('Total of 1.......................................', total1); + spacer(); + + heading('2. Reserve fund etc.'); + line('(a) statutory Reserve', 'FORM9_L2_A_STATUTORY_RESERVE'); + line('(b) Agricultural Credit (Stabilisation) Fund', 'FORM9_L2_B_AGRI_CREDIT_FUND'); + line('(c) Dividend Equalisation Fund', 'FORM9_L2_C_DIVIDEND_EQUALISATION'); + line('(d) Special bad debts Reserve', 'FORM9_L2_D_SPECIAL_BAD_DEBT'); + line('(e) Bad and doubtful debts Reserve', 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE'); + line('(f) Investment depreciation Reserve', 'FORM9_L2_F_INVESTMENT_DEPRECIATION'); + heading('(g) Other Funds and Reserve', 1); + + // 2(g) is one head in the return and eight lines on the form. The accounts under it are placed + // on the printed line their name belongs to; whatever matches none lands on "Any other Fund", + // and that line is the head's total less the others — so the eight can never disagree with it. + const funds = byKey.get('FORM9_L2_G_OTHER_FUNDS'); + const fundTotal = funds?.amount ?? 0; + const placed = FUND_SUBLINES.map(() => 0); + const other = FUND_SUBLINES.length - 1; + for (const account of funds?.accounts ?? []) { + if (account.excluded || account.balanceInRupees === null || account.balanceInRupees === undefined) { + continue; + } + const at = FUND_SUBLINES.findIndex((s) => s.pattern && s.pattern.test(account.name)); + placed[at === -1 ? other : at] += account.balanceInRupees; + } + let assigned = 0; + FUND_SUBLINES.forEach((sub, i) => { + let figure: number; + if (i === other) { + figure = fundTotal - assigned; + } else { + figure = Math.round(placed[i] / 1000); + assigned += figure; + } + line(sub.label, null, 2, figure === 0 ? null : figure); + }); + const total2 = sum( + 'FORM9_L2_A_STATUTORY_RESERVE', + 'FORM9_L2_B_AGRI_CREDIT_FUND', + 'FORM9_L2_C_DIVIDEND_EQUALISATION', + 'FORM9_L2_D_SPECIAL_BAD_DEBT', + 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE', + 'FORM9_L2_F_INVESTMENT_DEPRECIATION', + 'FORM9_L2_G_OTHER_FUNDS' + ); + total('Total of 2.......................................', total2); + spacer(); + + heading('3. Principal/Subsidiary state Partnership Fund for'); + text('Share Capital of', 1); + line('(a) Central Co-op. Bank', 'FORM9_L3_A_PARTNERSHIP_CCB'); + line('(b) Primary Agricultural Credit Societies', 'FORM9_L3_B_PARTNERSHIP_PACS'); + line('(c) Other Societies', 'FORM9_L3_C_PARTNERSHIP_OTHER'); + total( + 'Total of 3.......................................', + sum('FORM9_L3_A_PARTNERSHIP_CCB', 'FORM9_L3_B_PARTNERSHIP_PACS', 'FORM9_L3_C_PARTNERSHIP_OTHER') + ); + spacer(); + + heading('4. Demand Liabilities'); + heading('(a) CURRENT DEPOSITS', 1); + line('(i) From Central Co-op. Banks', 'FORM9_L4_A1_CURRENT_CCB', 2); + line('(ii) From Primary Co-op. banks', 'FORM9_L4_A2_CURRENT_PCB', 2); + line('(iii) From Societies', 'FORM9_L4_A3_CURRENT_SOCIETIES', 2); + line('(iv) From Individuals,Firms,Association etc.', 'FORM9_L4_A4_CURRENT_INDIVIDUALS', 2); + line('(v) Reserve fund deposits maintained by', 'FORM9_L4_A5_CURRENT_RESERVE_FUND', 2); + text("societies within the bank's area", 2); + heading('(b) SAVINGS DEPOSITS', 1); + text('(DEMAND LIABILITY PORTION ONLY)', 1); + inter( + 'FORM9_L4_B_SAVINGS_DEMAND_OTHER', + '(i) From Central Co-op. Banks', + '(ii) From Primary Co-op. Banks', + '(iii) From Other Societies' + ); + line('(IV) FROM INDIVIDUALS, FIRMS, ASSOCIATON', 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', 2); + line('(v) Reserve fund deposits maintained by', null, 2); + text("Societies within the bank's area", 3); + heading('(c) BORROWING FROM BANK', 1); + for (const label of [ + '(i) From Reserve Bank of India', + '(ii) From State Bank of India', + '(iii) From Development Bank', + '(iv) From Nationalised Bank', + '(v) From Exim Bank', + '(vi) From National Co-op. Development', + ' Corporation', + '(vii) From State Co-op. Bank', + '(viii) From Central Co-op. Bank' + ]) { + line(label, null, 2); + } + line('(ix) From othes(which may be specified)', 'FORM9_L4_C_BORROWINGS_DEMAND', 2); + line('(D) OTHER DEMAND LIABILITIES', 'FORM9_L4_D_OTHER_DEMAND', 1); + const total4 = sum( + 'FORM9_L4_A1_CURRENT_CCB', + 'FORM9_L4_A2_CURRENT_PCB', + 'FORM9_L4_A3_CURRENT_SOCIETIES', + 'FORM9_L4_A4_CURRENT_INDIVIDUALS', + 'FORM9_L4_A5_CURRENT_RESERVE_FUND', + 'FORM9_L4_B_SAVINGS_DEMAND_OTHER', + 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', + 'FORM9_L4_C_BORROWINGS_DEMAND', + 'FORM9_L4_D_OTHER_DEMAND' + ); + total('Total of 4.......................................', total4); + spacer(); + + heading('5. Time Liabilities'); + heading('(a) FIXED DEPOSITS', 1); + line('(i) From Central Co-op. Banks', 'FORM9_L5_A1_FD_CCB', 2); + line('(ii) From Primary Co-op. Banks', 'FORM9_L5_A2_FD_PCB', 2); + line('(iii) From Other Societies', 'FORM9_L5_A3_FD_SOCIETIES', 2); + line('(iv) From Individuals, Firms, Association etc.', 'FORM9_L5_A4_FD_INDIVIDUALS', 2); + line('(v) Reserve fund deposits maintained by', 'FORM9_L5_A5_FD_RESERVE_FUND', 2); + text("societies within the bank's area", 2); + heading('(b) SAVINGS DEPOSITS', 1); + text('(DEMAND LIABILITY PORTION ONLY)', 1); + inter( + 'FORM9_L5_B_SAVINGS_TIME_OTHER', + '(i) From Central Co-op. Banks', + '(ii) From Primary Co-op. Banks', + '(iii) From other Societies' + ); + line('(IV) FROM INDIVIDUALS,FIRMS,ASSOCIATION ETC.', 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', 2); + line('(v) Reserve fund deposits maintained by', null, 2); + text("societies within the bank's area", 3); + heading('(c) BORROWINGS FROM BANK', 1); + for (const label of [ + '(i) From Reserve Bank of India', + '(ii) From State Bank of India', + '(iii) From Development Bank', + '(iv) From Nationalised Bank', + '(v) From Exim Bank', + '(vi) From National Co-operative Development', + ' Corporation', + '(vii) From State Co-op. Bank', + '(viii) From Central Co-op. Bank' + ]) { + line(label, null, 2); + } + line('(IX) FROM OTHERS (TO BE SPECIFIED)', 'FORM9_L5_C_BORROWINGS_TIME', 2); + line('(d) CASH CERTIFICATES, RECURRING DEPOSITS ETC.', 'FORM9_L5_D_CASH_CERT_RD', 1); + line('(e) OTHER TIME LIABILITIES IF ANY', 'FORM9_L5_E_OTHER_TIME', 1); + const total5 = sum( + 'FORM9_L5_A1_FD_CCB', + 'FORM9_L5_A2_FD_PCB', + 'FORM9_L5_A3_FD_SOCIETIES', + 'FORM9_L5_A4_FD_INDIVIDUALS', + 'FORM9_L5_A5_FD_RESERVE_FUND', + 'FORM9_L5_B_SAVINGS_TIME_OTHER', + 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', + 'FORM9_L5_C_BORROWINGS_TIME', + 'FORM9_L5_D_CASH_CERT_RD', + 'FORM9_L5_E_OTHER_TIME' + ); + total('Total of 5.......................................', total5); + spacer(); + + heading('6. Branch Adjustment'); + const total6 = n('FORM9_L6_BRANCH_ADJUSTMENTS') + n('FORM9_L7_OVERDUE_INTEREST_RESERVE'); + total('Total of 6.......................................', total6); + spacer(); + + const demandAndTime = total4 + total5 + total6; + total('7. Total demand & Time Liabilities(A4+A5+A6)', demandAndTime); + spacer(); + line('8. BALANCE OF PROFIT', 'FORM9_L9_BALANCE_OF_PROFIT', 0); + const totalLiabilities = + total1 + + total2 + + sum('FORM9_L3_A_PARTNERSHIP_CCB', 'FORM9_L3_B_PARTNERSHIP_PACS', 'FORM9_L3_C_PARTNERSHIP_OTHER') + + demandAndTime + + n('FORM9_L9_BALANCE_OF_PROFIT'); + total('TOTAL LIABILITIES', totalLiabilities, true); + + // ── Assets ── + line('1. Cash on hand', 'FORM9_A1_CASH_IN_HAND', 0); + total('TOTAL OF 1', n('FORM9_A1_CASH_IN_HAND')); + heading('2. Balance with Bank (CURRENT)'); + line('(a) With the Reserve Bank of India', 'FORM9_A2_A_CUR_RBI'); + line('(b) With the State Bank of India and its', 'FORM9_A2_B_CUR_SBI'); + text('subsidiaries.', 2); + line('(C) WITH BANKING SYSTEM', 'FORM9_A2_C_CUR_BANKING_SYSTEM'); + line('(d) With the State Co-op. Bank of the State', 'FORM9_A2_D_CUR_STATE_COOP'); + text('Concerned.', 2); + line('(e) With the Central Co-op. Bank of the Dist.', 'FORM9_A2_E_CUR_DCCB'); + text('Concerned.', 2); + line('(f) With Other Co-op. Banks', 'FORM9_A2_F_CUR_OTHER_COOP'); + const currentTotal = sum( + 'FORM9_A2_A_CUR_RBI', + 'FORM9_A2_B_CUR_SBI', + 'FORM9_A2_C_CUR_BANKING_SYSTEM', + 'FORM9_A2_D_CUR_STATE_COOP', + 'FORM9_A2_E_CUR_DCCB', + 'FORM9_A2_F_CUR_OTHER_COOP' + ); + total('Total of 2.(CURRENT)', currentTotal); + heading('2. Balance with Bank (SAVINGS)'); + line('(a) With the Reserve Bank of India', null); + line('(b) With the State Bank of India and its', null); + text('subsidiaries.', 2); + line('(c) With Banking System', 'FORM9_A2_SAV_BANKING_SYSTEM'); + line('(d) With the State Co-op. Bank of the Dist.', null); + text('Concerned.', 2); + line('(e) With the Central Co-op. Bank of the Dist.', null); + text('Concerned.', 2); + line('(f) With Other Co-op. Banks', 'FORM9_A2_SAV_COOP'); + const savingsTotal = sum('FORM9_A2_SAV_BANKING_SYSTEM', 'FORM9_A2_SAV_COOP'); + total('Total of 2.(SAVINGS)', savingsTotal); + // The printed form leaves this combined line empty even where the current total above is filled, + // so it is printed as the form prints it. + total('Total of (CURRENT+SAVINGS)........................', 0); + spacer(); + + heading('3. Money at call & Short notoce.'); + line('(A) WITH THE STATE BANK OF INDIA AND ITS', null); + text('subsidiaries.', 2); + line('(b) With the Banking System', 'FORM9_A3_MONEY_AT_CALL'); + line('(c) With the State Co-op. Bank of', null); + text('the State Concerned.', 2); + line('(d) With the Central Co-op. Bank Of', null); + text('the State Concerned.', 2); + line('(e) With Other Co-op. Banks', null); + total('Total of 3.......................................', n('FORM9_A3_MONEY_AT_CALL')); + spacer(); + + heading('4. Investment'); + heading('(a) In Central and State Government', 1); + line('(i) Central Government', 'FORM9_A4_A1_CENTRAL_GOVT_SEC', 1); + line('(II)STATE GOVERNMENT', 'FORM9_A4_A2_STATE_GOVT_SEC', 1); + line('(b) Debentures of Land Mortgage Banks', 'FORM9_A4_B_LAND_MORTGAGE_DEBENTURES'); + line('(c) Other Trustee securities', 'FORM9_A4_C_OTHER_TRUSTEE_SEC'); + line('(d) Shares of the State Co-op. Banks', 'FORM9_A4_D_SHARES_STATE_COOP'); + line('(e) Shares of the Central Co-op. Banks', 'FORM9_A4_E_SHARES_DCCB'); + line('(f) Shares of other Co-op. Institutions', 'FORM9_A4_F_SHARES_OTHER_COOP'); + heading('(g) Fixed deposits with', 1); + line('(i) The State Bank of India and its subsidiaries', 'FORM9_A4_G1_FD_SBI', 0); + line('(II) THE BANKING SYSTEM(COMPANIES)', 'FORM9_A4_G2_FD_BANKING_SYSTEM', 0); + line('(iii) The State Co-op. Bank of the State', 'FORM9_A4_G3_FD_STATE_COOP', 0); + text('Concerned.', 2); + line('(iv) The Central Co-op. Bank of the district', 'FORM9_A4_G4_FD_DCCB', 0); + text('Concerned.', 2); + line('(v) With other Co-op. Banks', 'FORM9_A4_G5_FD_OTHER_COOP', 0); + line('(H) OTHER INVESTMENTS', 'FORM9_A4_H_OTHER_INVESTMENTS', 1); + const total4a = sum( + 'FORM9_A4_A1_CENTRAL_GOVT_SEC', + 'FORM9_A4_A2_STATE_GOVT_SEC', + 'FORM9_A4_B_LAND_MORTGAGE_DEBENTURES', + 'FORM9_A4_C_OTHER_TRUSTEE_SEC', + 'FORM9_A4_D_SHARES_STATE_COOP', + 'FORM9_A4_E_SHARES_DCCB', + 'FORM9_A4_F_SHARES_OTHER_COOP', + 'FORM9_A4_G1_FD_SBI', + 'FORM9_A4_G2_FD_BANKING_SYSTEM', + 'FORM9_A4_G3_FD_STATE_COOP', + 'FORM9_A4_G4_FD_DCCB', + 'FORM9_A4_G5_FD_OTHER_COOP', + 'FORM9_A4_H_OTHER_INVESTMENTS' + ); + total('Total of 4.......................................', total4a); + spacer(); + + heading('5. Investment out of the principal/subsidiary'); + text('State partnership fund in the Shares of', 1); + line('(a) Central Co-op. Bank', 'FORM9_A5_PARTNERSHIP_INVESTMENTS'); + line('(b) Primary Agricultural Credit Societies', null); + line('(c) Other Societies', null); + total('Total of 5.......................................', n('FORM9_A5_PARTNERSHIP_INVESTMENTS')); + spacer(); + + heading('6. Loan and Advances'); + line('(a) For Seasonal Agricultural Operation', 'FORM9_A6_A_SEASONAL_AGRI'); + line('(b) For Marketing of crops', 'FORM9_A6_B_MARKETING_CROPS'); + line('(i) Secured', null, 2); + line('(ii) Unsecured', null, 2); + line('(c) For Medium-term agricultural purposes', 'FORM9_A6_C_MEDIUM_TERM_AGRI'); + line("(d) For Weaver's Societies", 'FORM9_A6_D_WEAVERS_SOCIETIES'); + heading('(e) For other industrial purposes', 1); + heading('(i) Short term', 2); + line('(ia) Secured', 'FORM9_A6_E_INDUSTRIAL_SHORT', 3); + line('(ib) Unsecured', null, 3); + total('Total of ia+ib ...................................', n('FORM9_A6_E_INDUSTRIAL_SHORT')); + heading('(ii) Medium term', 2); + line('(iia) Secured', 'FORM9_A6_E_INDUSTRIAL_MEDIUM', 3); + line('(iib) Unsecured', null, 3); + total('Total of iia+iib .................................', n('FORM9_A6_E_INDUSTRIAL_MEDIUM')); + heading('(e) For other purposes', 1); + heading('(i) Short term', 2); + line('(ia) Secured', 'FORM9_A6_F_OTHER_SHORT', 3); + line('(ib) Unsecured', null, 3); + total('Total of ia+ib...................................', n('FORM9_A6_F_OTHER_SHORT')); + heading('(ii) Medium term', 2); + line('(iia) Secured', null, 3); + line('(iib) Unsecured', 'FORM9_A6_F_OTHER_MEDIUM', 3); + total('Total of iia+iib..................................', n('FORM9_A6_F_OTHER_MEDIUM')); + heading('(iii) Long term', 2); + line('(iiia) Secured', 'FORM9_A6_F_OTHER_LONG', 3); + line('(iiib) Unsecured', null, 3); + total('Total of iiia+iiib..................................', n('FORM9_A6_F_OTHER_LONG')); + spacer(); + text('Explanation', 0); + text('(i) of the above advances those which are', 1); + text('overdue percentage of overdue to', 2); + text('(a) Demand %', 1); + text('(b) Outstanding %', 1); + text('(ii) (a) Advances outstanding in respect of', 0); + text('priority sector', 3); + text('(b) Their percentage to total loans outstanding %', 0); + const total6a = sum( + 'FORM9_A6_A_SEASONAL_AGRI', + 'FORM9_A6_B_MARKETING_CROPS', + 'FORM9_A6_C_MEDIUM_TERM_AGRI', + 'FORM9_A6_D_WEAVERS_SOCIETIES', + 'FORM9_A6_E_INDUSTRIAL_SHORT', + 'FORM9_A6_E_INDUSTRIAL_MEDIUM', + 'FORM9_A6_F_OTHER_SHORT', + 'FORM9_A6_F_OTHER_MEDIUM', + 'FORM9_A6_F_OTHER_LONG' + ); + total('TOTAL OF 6.......................................', total6a); + spacer(); + + line('8. INTEREST RECEIVABLE (OF WHICH OVERDUE)', 'FORM9_A8_INTEREST_RECEIVABLE', 0); + total('TOTAL OF 8.......................................', n('FORM9_A8_INTEREST_RECEIVABLE')); + spacer(); + line('9. PREMISES, FURNITURE, FIXTURES & OTHER FIXED', 'FORM9_A9_FIXED_ASSETS', 0); + text('assets', 1); + total('TOTAL OF 9 ....................................', n('FORM9_A9_FIXED_ASSETS')); + heading('10. Branch Adjustments'); + heading('11. Other assets'); + line('(I) TANGIBLE ASSETS', 'FORM9_A11_I_TANGIBLE_ASSETS', 1); + line('(ii) Intangible assets', 'FORM9_A11_II_INTANGIBLE_ASSETS', 1); + total( + 'Total of 11......................................', + n('FORM9_A11_I_TANGIBLE_ASSETS') + n('FORM9_A11_II_INTANGIBLE_ASSETS') + ); + spacer(); + + // Item 12. The ledger's own figure where GLs are mapped to it; otherwise the figure the filer + // entered. Nothing in the ledger produces the printed form's loss, so it is never invented here. + const ledgerLoss = n('FORM9_A12_BALANCE_OF_LOSS'); + const balanceOfLoss = ledgerLoss !== 0 ? ledgerLoss : (manualLoss ?? 0); + rows.push({ + kind: 'line', + label: '12. Balance of Loss', + indent: 0, + value: shown(balanceOfLoss), + echo: shown(balanceOfLoss) + }); + + const assetKeys = data.assetLines.map((l) => l.configKey).filter((k) => k !== 'FORM9_A12_BALANCE_OF_LOSS'); + const assetsBeforeLoss = sum(...assetKeys); + const totalAssets = assetsBeforeLoss + balanceOfLoss; + total('T O T A L A S S E T S', totalAssets, true); + + const gap = totalAssets - totalLiabilities; + const diffAdjusted = gap > 0 ? gap : 0; + const shortfall = gap < 0 ? -gap : 0; + if (diffAdjusted !== 0) { + rows.push({ kind: 'line', label: 'DIFF ADJUSTED -------------->', indent: 0, value: diffAdjusted }); + } + // The form prints Bills Purchased after the adjustment, not under item 6. + line('7. Bills Purchased & Discounted %', 'FORM9_A7_BILLS_PURCHASED', 0); + const finalTotal = totalAssets + diffAdjusted; + rows.push({ kind: 'total', label: 'FINAL TOTAL OF ASSEST', indent: 0, value: finalTotal, echo: finalTotal }); + + return { + rows, + totalLiabilities, + assetsBeforeLoss, + balanceOfLoss, + totalAssets, + diffAdjusted, + finalTotal, + shortfall + }; +} diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.html b/src/app/rbi-circulars/form9-return/form9-return.component.html index 98878e3e0..6b1b71352 100644 --- a/src/app/rbi-circulars/form9-return/form9-return.component.html +++ b/src/app/rbi-circulars/form9-return/form9-return.component.html @@ -62,200 +62,276 @@
-
-
-
{{ data.formName }}
-
{{ data.statutoryReference }} · Return code {{ data.returnCode }}
-
As at {{ data.reportingDate }} · {{ data.unit }}
-
+
+ + +
+ @if (view === 'bank' && bank) { + +
+
+ {{ details.address }} + Date : {{ bankDate(data.reportingDate) }} +
+
{{ details.formCode }}   THE BANKING REGULATION FORM IX
+
NAME & DESIG. OF THE OFFICER SUBMITTING THE RETURN
+
+ @if (details.officer || details.designation) { + + {{ details.officer }} + @if (details.officer && details.designation) { + ,   + } + {{ details.designation }} + + } + RS.  IN THOUSAND +
+ + + + + + + + + + + + @for (row of bank.rows; track $index) { + + + + + + + @if (row.echo !== undefined && row.echo !== null) { + + + + + + + } + } + +
{{ bankDate(data.reportingDate) }}{{ bankDate(data.reportingDate) }}{{ bankDate(data.reportingDate) }}
{{ row.label }}{{ bankNum(row.value) }}
{{ bankNum(row.echo) }}
+ +
+ @if (details.bankName) { +
FOR {{ details.bankName }}
+ } +
AUTHORISED  SIGNATORY
+
+
+ User : User Code:{{ userCode }} + Print Date :  {{ printedAt }} +
+
+ } @else { +
+
+
{{ data.formName }}
+
{{ data.statutoryReference }} · Return code {{ data.returnCode }}
+
As at {{ data.reportingDate }} · {{ data.unit }}
+
+ + -
-
- Total Liabilities - {{ amount(data.reconciliation.totalLiabilities) }} +
+
+ Total Liabilities + {{ amount(data.reconciliation.totalLiabilities) }} +
+
+ Total Assets + {{ amount(data.reconciliation.totalAssets) }} +
+
+ Difference + + {{ amount(data.reconciliation.difference) }} + + + {{ + data.reconciliation.balanced + ? 'The two sides agree' + : rupees(data.reconciliation.differenceInRupees) + ' in rupees' + }} + +
-
- Total Assets - {{ amount(data.reconciliation.totalAssets) }} -
-
- Difference - - {{ amount(data.reconciliation.difference) }} - - - {{ - data.reconciliation.balanced - ? 'The two sides agree' - : rupees(data.reconciliation.differenceInRupees) + ' in rupees' - }} - -
-
- @if (data.caveats.length) { -
- Check before filing. -
    - @for (caveat of data.caveats; track caveat) { -
  • {{ caveat }}
  • - } -
-
- } + @if (data.caveats.length) { +
+ Check before filing. +
    + @for (caveat of data.caveats; track caveat) { +
  • {{ caveat }}
  • + } +
+
+ } - - - - - - - - - - @for (line of data.liabilityLines; track line.configKey) { - - - - - - @for (account of line.accounts; track $index) { - - - - - } - } - - - - - - - - - -
A. Liabilities in IndiaRs. in Thousands
- {{ line.label }} - @if (!line.configured) { - Not mapped - } - - @if (line.note) { - {{ line.note }} - } - {{ amount(line.amount) }}
- {{ account.glCode }} - {{ account.name }} - @if (account.excluded) { - excluded — {{ account.excluded }} - } - @if (isPartial(account)) { - {{ account.weightPercentage }}% share - } - {{ amount(account.balance) }}
Total Demand and Time Liabilities (4 + 5 + 6){{ amount(total('totalDemandAndTime')) }}
TOTAL LIABILITIES{{ amount(total('totalLiabilities')) }}
- - - - - - - - - - - @for (line of data.assetLines; track line.configKey) { - - - - - - @if (line.accountsMirrorHead && line.accounts.length) { - - - - } - @for (account of line.accounts; track $index) { - - - - - } - } - - - - - -
B. Assets in IndiaRs. in Thousands
- {{ line.label }} - @if (!line.configured) { - Not mapped - } - - @if (line.note) { - {{ line.note }} - } - {{ amount(line.amount) }}
- As the ledger holds them — these balances carry the opposite sign and add to the figure above -
- {{ account.glCode }} - {{ account.name }} - @if (account.excluded) { - excluded — {{ account.excluded }} - } - @if (isPartial(account)) { - {{ account.weightPercentage }}% share - } - {{ amount(account.balance) }}
TOTAL ASSETS{{ amount(total('totalAssets')) }}
- - - @if (data.memoLines.length) { + - + - @for (line of data.memoLines; track line.configKey) { + @for (line of data.liabilityLines; track line.configKey) { + + @for (account of line.accounts; track $index) { + + + + + } } - - - + + + - - - + + +
Stated splits and explanations — entering no totalA. Liabilities in India Rs. in Thousands
{{ line.label }} @if (!line.configured) { Not mapped } + + @if (line.note) { + {{ line.note }} + } {{ amount(line.amount) }}
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + @if (isPartial(account)) { + {{ account.weightPercentage }}% share + } + {{ amount(account.balance) }}
Percentage of overdues to total loans and advances{{ percent(total('overduePercentage')) }}
Total Demand and Time Liabilities (4 + 5 + 6){{ amount(total('totalDemandAndTime')) }}
Priority sector advances as a percentage of total loans{{ percent(total('prioritySectorPercentage')) }}
TOTAL LIABILITIES{{ amount(total('totalLiabilities')) }}
- } -
-
AUTHORISED SIGNATORY
+ + + + + + + + + + @for (line of data.assetLines; track line.configKey) { + + + + + + @if (line.accountsMirrorHead && line.accounts.length) { + + + + } + @for (account of line.accounts; track $index) { + + + + + } + } + + + + + +
B. Assets in IndiaRs. in Thousands
+ {{ line.label }} + @if (!line.configured) { + Not mapped + } + + @if (line.note) { + {{ line.note }} + } + {{ amount(line.amount) }}
+ As the ledger holds them — these balances carry the opposite sign and add to the figure above +
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + @if (isPartial(account)) { + {{ account.weightPercentage }}% share + } + {{ amount(account.balance) }}
TOTAL ASSETS{{ amount(total('totalAssets')) }}
+ + + @if (data.memoLines.length) { + + + + + + + + + @for (line of data.memoLines; track line.configKey) { + + + + + } + + + + + + + + + +
Stated splits and explanations — entering no totalRs. in Thousands
+ {{ line.label }} + @if (!line.configured) { + Not mapped + } + {{ amount(line.amount) }}
Percentage of overdues to total loans and advances{{ percent(total('overduePercentage')) }}
Priority sector advances as a percentage of total loans{{ percent(total('prioritySectorPercentage')) }}
+ } + +
+
AUTHORISED SIGNATORY
+
-
+ } }
diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.scss b/src/app/rbi-circulars/form9-return/form9-return.component.scss index 3a3fd42e1..854364d92 100644 --- a/src/app/rbi-circulars/form9-return/form9-return.component.scss +++ b/src/app/rbi-circulars/form9-return/form9-return.component.scss @@ -393,3 +393,129 @@ $red: #c5221f; print-color-adjust: exact; } } + +// ── The bank's printed Form IX ── +// Plain on purpose: the point of this view is that it reads like the form the bank files, so it +// carries none of the screen's tinting, badges or rounded cards. +.f9-viewtabs { + display: flex; + gap: 8px; + margin: 0 0 12px; + + &__on { + background: rgb(19 83 160 / 10%); + border-color: $primary; + color: $primary; + } +} + +.bf { + max-width: 900px; + margin: 0 auto; + padding: 18px 22px; + background: #fff; + border: 1px solid $border; + color: #000; + font-family: Arial, Helvetica, sans-serif; + font-size: 13px; + line-height: 1.35; + + &__top { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + } + + // The heading block sits in the middle of the page, as the bank's form sets it. + &__title { + display: block; + margin-bottom: 8px; + font-weight: 700; + text-align: center; + } + + &__officer { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 22px; + } + + &__table { + width: 100%; + border-collapse: collapse; + + th { + padding: 2px 6px; + font-weight: 400; + text-align: right; + } + } + + &__label { + padding-top: 5px; + padding-bottom: 5px; + white-space: pre-wrap; + width: 62%; + } + + &__num { + padding-right: 28px !important; + text-align: right; + white-space: nowrap; + width: 12%; + } + + &__sign { + margin-top: 28px; + } + + &__sign-gap { + margin-top: 60px; + } + + &__foot { + display: flex; + gap: 120px; + margin-top: 48px; + font-size: 12px; + } +} + +.bf-row { + &--total td { + font-weight: 700; + } + + &--spaced .bf__label { + letter-spacing: 3px; + } + + &--spacer td { + height: 14px; + padding: 0; + } + + td { + vertical-align: top; + } +} + +@media print { + .bf { + max-width: none; + padding: 0; + border: 0; + font-size: 12px; + } + + .bf-row { + break-inside: avoid; + } +} + +// Keeps the date at the right edge when the address line is left empty. +.bf__date { + margin-left: auto; +} diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.ts b/src/app/rbi-circulars/form9-return/form9-return.component.ts index 01608f2f9..bf518caac 100644 --- a/src/app/rbi-circulars/form9-return/form9-return.component.ts +++ b/src/app/rbi-circulars/form9-return/form9-return.component.ts @@ -12,6 +12,8 @@ import { catchError, of } from 'rxjs'; import * as ExcelJS from 'exceljs'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { RbiCircularsService } from '../rbi-circulars.service'; +import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { BankFormat, buildBankFormat } from './form9-bank-format'; import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook'; /** One account behind a head — a row of the form's "specify" sheets. */ @@ -75,8 +77,33 @@ export interface Form9Return { reconciliation: Form9Reconciliation; caveats: string[]; computable: boolean; + /** The Balance of Loss the bank filed for this date, in rupees thousands, where one is on record. */ + filedBalanceOfLoss?: number; } +/** + * The identity block the bank's printed Form IX carries: the address line, the return's code, the + * officer submitting it and the bank signing. Left empty until the bank's own details are supplied; + * nothing here belongs to any particular bank. + */ +export interface BankReturnDetails { + address: string; + formCode: string; + officer: string; + designation: string; + bankName: string; +} + +// Empty on purpose: the bank's name, address and officer are its own, entered once under Return +// details. Only the form's code, which belongs to the form, starts filled. +const DEFAULT_DETAILS: BankReturnDetails = { + address: '', + formCode: 'CODE NO. 982-P', + officer: '', + designation: '', + bankName: '' +}; + /** * Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408. * @@ -100,6 +127,15 @@ export interface Form9Return { }) export class Form9ReturnComponent { private rbiService = inject(RbiCircularsService); + private authentication = inject(AuthenticationService); + + /** The bank's own printed layout, or the working view with the accounts behind every head. */ + view: 'bank' | 'working' = 'bank'; + details: BankReturnDetails = { ...DEFAULT_DETAILS }; + /** The Balance of Loss on record in Global Configurations for the date, if any. */ + filedLoss: number | null = null; + bank: BankFormat | null = null; + printedAt = ''; data: Form9Return | null = null; loading = false; @@ -148,9 +184,47 @@ export class Form9ReturnComponent { .subscribe((res: Form9Return | null) => { this.loading = false; this.data = res; + // The bank's filed Balance of Loss, sent with the return where one is on record for the date. + this.filedLoss = res?.filedBalanceOfLoss ?? null; + this.printedAt = this.stamp(new Date()); + this.rebuildBank(); }); } + /** Rebuilds the printed layout after the data, or the loss, changes. */ + rebuildBank(): void { + this.bank = this.data ? buildBankFormat(this.data, this.filedLoss) : null; + } + + /** dd/MM/yyyy, as the printed form dates its columns. */ + bankDate(iso: string | null | undefined): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.slice(0, 10).split('-'); + return `${d}/${m}/${y}`; + } + + /** Figures print bare, with no separators, as the bank's form does. */ + bankNum(value: number | null | undefined): string { + return value === null || value === undefined ? '' : String(value); + } + + private stamp(at: Date): string { + const p = (n: number) => String(n).padStart(2, '0'); + const hours = at.getHours(); + const hour12 = hours % 12 === 0 ? 12 : hours % 12; + return `${p(at.getDate())}/${p(at.getMonth() + 1)}/${at.getFullYear()} ${p(hour12)}:${p(at.getMinutes())}:${p(at.getSeconds())}${hours < 12 ? 'AM' : 'PM'}`; + } + + get userCode(): string { + return this.authentication.getCredentials()?.username ?? ''; + } + /** Back to the date prompt, so a filer can run a different month. */ changeDate(): void { this.awaitingDate = true; diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 7bffbc36f..0066b46c6 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -209,6 +209,45 @@ export class RbiCircularsService { return this.http.get('/rbireservereturn/form1-statutory', { params }); } + /** + * Today's reserve position — what must be held, what is held, and in which accounts. + * + * A different question from the returns above, and the one a treasury desk asks each morning: a + * return made up to a fortnight close cannot say whether the bank is short RIGHT NOW, because the + * balances it reports have since moved. The requirement is still charged on the base date, since + * RBI's lag is a rule rather than an approximation, so the response states both dates. + */ + getReservePosition(onDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (onDate) { + params = params.set('onDate', onDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/position', { params }); + } + + /** + * Moves money between the accounts the cash reserve is held in. + * + * Its own endpoint rather than the manual journal entry screen: that screen is turned off at the + * HTTP edge on this installation, deliberately, so that nobody posts a free-hand entry against + * the ledger. This is not one — both sides must be reserve accounts an administrator has mapped, + * and the amount is checked against what the source actually holds — and the entry it raises goes + * through maker-checker like any other. + */ + transferReserve(transfer: { + fromGlAccountId: number; + toGlAccountId: number; + amount: number; + officeId?: number; + reference?: string; + comments?: string; + }): Observable { + return this.http.post('/rbireservereturn/transfer', transfer); + } + // ── Saved returns ── /** diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss index 938ff68c2..5a390d9df 100644 --- a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss +++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss @@ -507,3 +507,310 @@ $red: #c5221f; print-color-adjust: exact; } } + +// ─── Part C.XII.c — the securities behind item XII(c) ─── +// Four columns per reported date, so the sheet is wider than the form above it and scrolls on its +// own rather than forcing the whole return into a horizontal scroll. +.f1s-sec { + margin-top: 28px; +} + +.f1s-sec__title { + margin: 0 0 10px; + font-size: 14px; + font-weight: 700; + color: $primary; +} + +.f1s-sec__table { + min-width: 900px; + + th, + td { + padding: 7px 10px; + } +} + +// The date spanning its four value columns. +.f1s-sec__date { + text-align: center; + background: #fff8e1; + border-bottom: 1px solid $gray-200; + font-size: 13px; + letter-spacing: 0.02em; +} + +// A rule down the left of each date's block, so four columns read as one date's working rather +// than as eight columns running together. +.f1s-sec__open { + border-left: 2px solid $gray-500 !important; +} + +// The template numbers its columns 1, 2, 3 and (2-3); carrying the numbers in the heading saves a +// reader working out which column the net value is struck from. +.f1s-sec__no { + display: inline-block; + margin-left: 4px; + padding: 0 4px; + border-radius: 3px; + background: $gray-200; + color: $gray-500; + font-size: 10px; + font-weight: 600; +} + +// "Part - I" and "Part - II" head their own blocks, so they carry more weight than a sub-caption +// inside a part of the form above. +.f1s-sec__part td { + background: $gray-200; + color: $gray-900; + font-size: 12px; + letter-spacing: 0.03em; + text-transform: uppercase; + border-top: 2px solid $gray-500; +} + +// Opening, addition, deduction, closing — named at the left where the eye starts. +.f1s-sec__row { + padding-left: 22px !important; +} + +// The column the requirement is actually settled on, shaded as the RBI template shades it. +.f1s-sec__net { + background: #e8f0fe; + font-weight: 600; +} + +// Depreciation is structurally nil here, not merely zero this period: a dash says the column does +// not apply, where a 0 would read as a figure the bank computed and found to be nothing. +.f1s-sec__nil { + color: $gray-500; +} + +// The grand total of both parts — the figure item XII(c) reports, so it is set apart from the two +// closing balances that make it up. +.f1s-sec__grand td { + background: #d7e3fc; + border-top: 2px solid $primary; +} + +// ─── Daily maintenance, laid out as the CIMS template lays it out ─── +.f1s-daily__sr { + width: 48px; + text-align: center; + color: $gray-500; + font-variant-numeric: tabular-nums; +} + +.f1s-daily__date { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +// The ratio each block of four columns belongs to. +.f1s-daily__group { + text-align: center; + background: #eaf1fb; + border-bottom: 1px solid $gray-200; + letter-spacing: 0.02em; +} + +// The parenthetical under a heading — how the column is struck, not part of its name. +.f1s-daily__note { + display: block; + margin-top: 2px; + font-size: 10px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: $gray-500; +} + +// Deficit and surplus carry the template's own shading: a day that fell short is the thing the +// sheet exists to surface, so it is red wherever it appears rather than only on the row. +.f1s-daily__deficit { + background: #fdecea; + color: $red; + font-weight: 600; +} + +.f1s-daily__surplus { + background: #e6f4ea; + color: $green; +} + +// A dash where there is no deficit or no surplus: the column does not apply that day, which a 0 +// would state as a figure computed and found to be nothing. +.f1s-daily__deficit, +.f1s-daily__surplus { + font-variant-numeric: tabular-nums; +} + +// ─── A wide sheet scrolls within itself ─── +// The securities working and the daily sheet both carry more columns than a narrow window holds. +// Without this they were cut off at the window's edge with no way to reach the right-hand columns: +// the page itself does not scroll sideways, so the figures were simply unreachable. +.f1s-scroll { + overflow-x: auto; + // Room for the scrollbar, so it does not sit on top of the last row's figures. + padding-bottom: 4px; + // A scrollbar that is visible without hovering — on a sheet that is cut off, the thing a reader + // needs first is to know there is more to the right. + scrollbar-width: thin; + + &::-webkit-scrollbar { + height: 10px; + } + + &::-webkit-scrollbar-track { + background: $gray-50; + border-radius: 5px; + } + + &::-webkit-scrollbar-thumb { + background: $gray-500; + border-radius: 5px; + + &:hover { + background: $gray-900; + } + } + + // The particulars column stays put while the figures scroll past it, so a row can still be read + // for what it is once the sheet has been scrolled away from its left edge. + .f1s-table__particulars, + .f1s-daily__sr { + position: sticky; + left: 0; + z-index: 1; + background: #fff; + } + + .f1s-daily__sr { + left: 0; + } + + .f1s-daily__date { + position: sticky; + left: 48px; + z-index: 1; + background: #fff; + } + + // A sticky cell on a shaded row keeps that row's shading rather than showing white through it. + .f1s-heading .f1s-table__particulars, + .f1s-sec__part .f1s-table__particulars { + background: $gray-200; + } + + .f1s-total .f1s-table__particulars, + .f1s-total .f1s-daily__sr, + .f1s-total .f1s-daily__date { + background: #e8f0fe; + } + + .f1s-sec__grand .f1s-table__particulars { + background: #d7e3fc; + } + + thead .f1s-table__particulars, + thead .f1s-daily__sr { + background: $gray-50; + z-index: 2; + } +} + +/* stylelint-disable-next-line no-duplicate-selectors */ +.f1s-daily__table { + // Two more columns now that each ratio states its own rate. + min-width: 1100px; +} + +// The rate each day's requirement was struck at. +.f1s-daily__rate { + white-space: nowrap; + color: $gray-500; + font-variant-numeric: tabular-nums; +} + +// The day a rate came into force. Marked because on thirty rows of the same percentage this is the +// one row that matters: the requirement steps here, and the days either side are charged +// differently. +.f1s-daily__rate--new { + background: #fff4e5; + color: $amber; + font-weight: 700; + + &::after { + content: ' ▲'; + font-size: 9px; + } +} + +// The popup shown while the pointer rests on a figure on the return. +.f1s-has-tip { + cursor: help; + + &:hover { + background: rgb(25 118 210 / 8%); + } +} + +.f1s-tip { + position: fixed; + z-index: 2000; + width: 460px; + max-width: calc(100vw - 16px); + padding: 10px 12px; + background: #fff; + border: 1px solid #c5ccd6; + border-radius: 6px; + box-shadow: 0 6px 24px rgb(0 0 0 / 18%); + font-size: 12px; + color: #1f2937; + pointer-events: none; + + &__title { + font-weight: 600; + margin-bottom: 2px; + } + + &__sub { + color: #6b7280; + margin-bottom: 6px; + } + + &__table { + width: 100%; + border-collapse: collapse; + + th, + td { + padding: 3px 4px; + border-top: 1px solid #e5e7eb; + text-align: left; + vertical-align: top; + } + + th { + color: #6b7280; + font-weight: 500; + } + } + + &__num { + text-align: right !important; + white-space: nowrap; + } + + &__out { + color: #9ca3af; + } + + &__formula { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid #e5e7eb; + color: #374151; + } +} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts index cf3c439e1..7ec31c641 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -85,6 +85,31 @@ export interface Form1Row { values?: number[]; /** On the base-date row, the date each column's liabilities were read at. */ baseDates?: string[]; + /** Per column, the GL accounts the figure was made of. Absent on a return the backend has not stated them for. */ + sources?: Form1Source[][]; + /** How a figure with no account of its own is worked out, in words. */ + formula?: string; +} + +/** One GL account behind a printed figure: its own balance, the share taken, and the result. */ +export interface Form1Source { + glCode: string; + name: string; + accountBalance?: number | null; + weightPercentage?: number | null; + amount?: number | null; + excluded?: 'disabled' | 'header' | null; +} + +/** The popup shown while the pointer rests on a figure. */ +export interface Form1Tip { + x: number; + y: number; + label: string; + column: string; + value: number; + sources: Form1Source[]; + formula?: string; } export interface Form1Part { @@ -116,6 +141,20 @@ export interface Form1DailyRow { * no figure, and a zero would read as a day with no liabilities rather than as one not recorded. */ ownLiabilities?: number; + /** + * The rates each requirement was struck at that day, and the dates they came into force. + * + * Per day rather than once for the sheet because rates are effective-dated: a rate changed + * mid-period leaves two requirements on one return, and a reader checking a day's figure against + * a single rate quoted at the top would find arithmetic that fails on one side of the change. + * + * Optional throughout — a return saved before the backend stated them carries no rate, and the + * screen shows nothing rather than claiming a rate the snapshot never recorded. + */ + crrPercentage?: number | null; + crrEffectiveFrom?: string | null; + slrPercentage?: number | null; + slrEffectiveFrom?: string | null; crrRequired: number; crrMaintained: number; /** Negative where the day fell short. */ @@ -172,9 +211,33 @@ export interface Form1Statutory { */ partB?: Form1Part; partC: Form1Part; + /** + * The working behind item XII(c) — what the SLR securities opened at, what moved, what they + * closed at. Absent on a return saved before the sheet existed, so the screen reads it + * defensively rather than assuming every snapshot carries it. + */ + securities?: Form1Securities; unmetRequirements: string[]; } +/** Part C.XII.c — the SLR securities, and how they moved across each fortnight reported. */ +export interface Form1Securities { + title: string; + sections: Form1SecuritySection[]; + /** Both parts together, which is the figure item XII(c) reports. */ + closing: number[]; +} + +/** One part of the securities sheet, with one figure per column reported. */ +export interface Form1SecuritySection { + title: string; + configKey: string; + opening: number[]; + additions: number[]; + deductions: number[]; + closing: number[]; +} + /** One row of the Memorandum or an Annexure — a stated figure, entering no total. */ export interface SectionRow { label: string; @@ -963,6 +1026,93 @@ export abstract class ReserveReturnBase { return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } + /** + * A Form 1 figure, in thousands of rupees - the unit the filed return is printed in. + * + * RBI's own form carries its columns in Rs .000, so a balance of 70,10,45,061.62 is filed + * as 7,01,045. Reporting the rupees here instead would have the screen and the filed + * return disagree on every line, so the conversion belongs with the form, not the reader. + */ + /** + * Whether this is the day a ratio's rate came into force. + * + * The sheet marks that day rather than leaving a reader to compare thirty rows of the same + * percentage: it is where the requirement steps, and every day after it is charged differently + * from every day before. A rate in force since before the period began is not marked — it did not + * change here, and marking it would point at a day where nothing happened. + */ + rateChangedOn(day: Form1DailyRow, ratio: 'crr' | 'slr'): boolean { + const effective = ratio === 'crr' ? day.crrEffectiveFrom : day.slrEffectiveFrom; + return !!effective && effective === day.date; + } + + /** + * A securities section as the sheet prints it — opening, what moved, and the closing balance. + * + * The movement rows are built here rather than in the template because the sheet names them in + * the order a holding actually changes, and a template walking the object's own keys would print + * whatever order the server happened to serialise. + */ + securityRows(section: Form1SecuritySection): { label: string; values: number[]; closing: boolean }[] { + return [ + { label: 'Opening Balance', values: section.opening ?? [], closing: false }, + { label: 'Addition during the fortnight', values: section.additions ?? [], closing: false }, + { label: 'Deduction during the fortnight', values: section.deductions ?? [], closing: false }, + { label: 'Closing Balance', values: section.closing ?? [], closing: true } + ]; + } + + /** The popup, or null while the pointer is off a figure. */ + tip: Form1Tip | null = null; + + /** + * Shows where a figure on the return came from. Placed beside the pointer and kept inside the + * window, so a figure near the right edge opens its popup to the left instead of off screen. + */ + showTip(event: MouseEvent, line: Form1Row, col: number, column: string, value: number): void { + const sources = line.sources?.[col] ?? []; + if (!sources.length && !line.formula) { + this.tip = null; + return; + } + this.tip = { x: 0, y: 0, label: line.label, column, value, sources, formula: line.formula }; + this.moveTip(event); + } + + moveTip(event: MouseEvent): void { + if (!this.tip) { + return; + } + const width = 460; + const height = 60 + this.tip.sources.length * 24 + (this.tip.formula ? 40 : 0); + const gap = 16; + const left = event.clientX + gap + width > window.innerWidth ? event.clientX - gap - width : event.clientX + gap; + const top = event.clientY + gap + height > window.innerHeight ? event.clientY - gap - height : event.clientY + gap; + this.tip = { ...this.tip, x: Math.max(8, left), y: Math.max(8, top) }; + } + + hideTip(): void { + this.tip = null; + } + + /** Rupees with paise, for the popup: the form prints thousands, a reviewer checks against the ledger. */ + rupees(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + thousands(value: number | null | undefined): string { + if (value === null || value === undefined) { + return ''; + } + // A head that rounds to nothing is left blank, as the filed form leaves it. Printing a + // zero would state a reported nil where the bank reports no figure at all. + const inThousands = Math.round(value / 1000); + return inThousands === 0 ? '' : inThousands.toLocaleString('en-IN'); + } + percent(value: number | null | undefined): string { if (value === null || value === undefined) { return '—'; @@ -1405,6 +1555,22 @@ export abstract class ReserveReturnBase { } /** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */ + /** + * A Form 1 line's figures as the filed return carries them - in thousands of rupees. + * + * A blank stays blank: a line the form leaves empty must not become a zero on the sheet, + * which would read as a reported nil rather than a head that does not apply. + */ + private cimsFigures(values: (number | null)[] | undefined): (number | null)[] { + return (values ?? []).map((v) => { + if (v === null || v === undefined) { + return null; + } + const inThousands = Math.round(v / 1000); + return inThousands === 0 ? null : inThousands; + }); + } + private addCimsPartA(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part A'); sheet.columns = [ @@ -1421,7 +1587,7 @@ export abstract class ReserveReturnBase { // would lose which line sits under which head. const row = sheet.addRow([ `${' '.repeat(line.indent ?? 0)}${line.label}`, - ...(line.values ?? []) + ...this.cimsFigures(line.values) ]); row.getCell(1).alignment = { wrapText: true }; if (line.heading || line.total) { @@ -1655,14 +1821,20 @@ export abstract class ReserveReturnBase { numbers.font = { bold: true }; numbers.alignment = { horizontal: 'center' }; - // Part C's own securities line, so the valuation sheet totals to the figure the compliance - // sheet claimed. Depreciation is nil because the return counts securities at the value the - // ledger holds, which is already net of it. + // The movement the ledger actually shows: what each holding opened at, what was bought and + // sold inside the fortnight, and what it closed at. Read off the journal by the server rather + // than stated here, so the closing balance is arithmetic a reader can follow. + // + // Depreciation is nil and face value equals book value because a securities GL holds one + // figure, already net of any provision made against it — a depreciation column drawn off the + // investment reserve would deduct it twice, since that reserve is not held security by + // security. + const sections = this.statutory?.securities?.sections ?? []; const held = this.cimsPartCLine('(c) Unencumbered') ?? dates.map(() => 0); - const movement = (label: string, carries: boolean, closing = false) => { + const movement = (label: string, amounts: number[] | null, closing = false) => { const cells: (string | number)[] = [label]; for (let i = 0; i < dates.length; i++) { - const value = carries ? (held[i] ?? 0) : 0; + const value = amounts?.[i] ?? 0; cells.push(value, value, 0, value); } const row = sheet.addRow(cells); @@ -1671,7 +1843,9 @@ export abstract class ReserveReturnBase { row.font = { bold: true }; } for (let c = 2; c <= dates.length * perDate + 1; c++) { - row.getCell(c).numFmt = '#,##0.00'; + // Thousands, as the filed form prints them: a paise column on a return made up in + // Rs '000 would be reporting a precision the form does not carry. + row.getCell(c).numFmt = '#,##0'; row.getCell(c).alignment = { horizontal: 'right' }; // The template shades the Net Value column on every row — it is the figure that counts for // SLR — and the whole of a closing balance row, which is the line the return reads off. @@ -1682,17 +1856,24 @@ export abstract class ReserveReturnBase { } }; + // Part I is the government securities, Part II everything else counted toward the requirement. + // Where the server has not sent the working — an older saved return — the closing balance still + // prints from Part C's own line, so the sheet totals to the figure the compliance sheet claimed + // rather than coming out empty. + const partOne = sections[0] ?? null; + const partTwo = sections[1] ?? null; + this.addCimsSection(sheet, 'Part - I (Government Securities)'); - movement('Opening Balance', false); - movement('Addition during the fortnights', false); - movement('Deduction during the fortnights', false); - movement('Closing Balance (a)', true, true); + movement('Opening Balance', partOne?.opening ?? null); + movement('Addition during the fortnights', partOne?.additions ?? null); + movement('Deduction during the fortnights', partOne?.deductions ?? null); + movement('Closing Balance (a)', partOne?.closing ?? held, true); this.addCimsSection(sheet, 'Part - II (Other Approved Securities)'); - movement('Opening Balance', false); - movement('Addition during the fortnights', false); - movement('Deduction during the fortnights', false); - movement('Closing Balance (b)', false, true); - movement('Closing Balance (a+b)', true, true); + movement('Opening Balance', partTwo?.opening ?? null); + movement('Addition during the fortnights', partTwo?.additions ?? null); + movement('Deduction during the fortnights', partTwo?.deductions ?? null); + movement('Closing Balance (b)', partTwo?.closing ?? null, true); + movement('Closing Balance (a+b)', this.statutory?.securities?.closing ?? held, true); boxAllCells(sheet, start, sheet.rowCount, dates.length * perDate + 1); } @@ -1773,16 +1954,19 @@ export abstract class ReserveReturnBase { const required = isCrr ? day.crrRequired : day.slrRequired; const held = isCrr ? day.crrMaintained : day.slrMaintained; const gap = isCrr ? day.crrSurplus : day.slrSurplus; + // Thousands, as on the screen's day-by-day table and the Part A to C sheets of this same + // workbook. This sheet alone was left in whole rupees. + const thousands = (v: number): number => Math.round(v / 1000); const row = sheet.addRow([ i + 1, day.date, // Blank, not zero, where an older snapshot never carried the base: a nil in this column // would read as a day with no liabilities, which is a different claim from not knowing. - day.baseLiabilities ?? '', - required, - held, - gap < 0 ? -gap : 0, - gap > 0 ? gap : 0, + day.baseLiabilities === undefined ? '' : thousands(day.baseLiabilities), + thousands(required), + thousands(held), + gap < 0 ? thousands(-gap) : 0, + gap > 0 ? thousands(gap) : 0, '', // The template gives NDTL one column, and it is the base's — so the day's own liabilities // go in Remarks rather than in a column the template does not have. A reader can still see @@ -1790,7 +1974,7 @@ export abstract class ReserveReturnBase { this.cimsDailyRemark(day, gap) ]); for (let c = 3; c <= 8; c++) { - row.getCell(c).numFmt = '#,##0.00'; + row.getCell(c).numFmt = '#,##0'; row.getCell(c).alignment = { horizontal: 'right' }; } // A day short is the thing this sheet exists to show, and in a column of near-identical @@ -1810,7 +1994,8 @@ export abstract class ReserveReturnBase { private cimsDailyRemark(day: Form1DailyRow, gap: number): string { const parts = [`Charged on ${day.baseDate}`]; if (day.ownLiabilities !== undefined) { - parts.push(`NDTL on the day ${day.ownLiabilities.toFixed(2)}`); + // In thousands, the unit of every other figure on the sheet. + parts.push(`NDTL on the day ${Math.round(day.ownLiabilities / 1000).toLocaleString('en-IN')} (Rs '000)`); } if (gap < 0) { parts.push('SHORT'); @@ -1918,9 +2103,16 @@ export abstract class ReserveReturnBase { * that part at all. */ private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void { + // In thousands, like Part A above it. Only Part A's lines were converted when the form went to + // thousands, so on this sheet Part A read 7,01,045 while IX to XII beneath it read the same + // amounts in whole rupees — two units on one return, and nothing on the sheet to say so. + // A zero stays a zero here, unlike Part A: a requirement or a held amount of nil is a real + // answer on these lines, not a head the bank does not use. const row = sheet.addRow([ label, - ...(values ?? this.cimsDates.map((): null => null)) + ...(values === undefined + ? this.cimsDates.map((): null => null) + : values.map((v) => (v === null || v === undefined ? null : Math.round(v / 1000)))) ]); row.getCell(1).alignment = { wrapText: true }; if (bold) { @@ -1932,7 +2124,9 @@ export abstract class ReserveReturnBase { private formatCimsFigures(row: ExcelJS.Row, from = 2): void { for (let c = from; c < from + this.cimsDates.length; c++) { - row.getCell(c).numFmt = '#,##0.00'; + // Thousands, as the filed form prints them: a paise column on a return made up in Rs '000 + // would report a precision the form does not carry. + row.getCell(c).numFmt = '#,##0'; row.getCell(c).alignment = { horizontal: 'right' }; } } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html index 10555ff73..6df4d239a 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -712,7 +712,17 @@ } @for (v of line.values; track $index) { - {{ amount(v) }} + + + {{ thousands(v) }} + } + @if (statutory.securities?.sections?.length) { +
+

{{ statutory.securities!.title }}

+
+ + + + + @for (c of statutory.columns; track $index) { + + } + + + @for (c of statutory.columns; track $index) { + + + + + } + + + + @for (section of statutory.securities!.sections; track section.configKey) { + + + + @for (row of securityRows(section); track row.label) { + + + @for (v of row.values; track $index) { + + + + + } + + } + } + + + @for (v of statutory.securities!.closing; track $index) { + + + + + } + + +
Particulars{{ c }}
Face Value 1Book Value 2Depreciation 3Net Value for SLR 2-3
{{ section.title }}
{{ row.label }}{{ thousands(v) || '–' }}{{ thousands(v) || '–' }}–{{ thousands(v) || '–' }}
Closing Balance (a+b){{ thousands(v) || '–' }}{{ thousands(v) || '–' }}–{{ thousands(v) || '–' }}
+
+
+ } + - Net demand & time liabilities - Section 18 — CRR - Section 24 — SLR - - - On the day - Charged on - Base amount - Required - Maintained - Surplus / (short) - Required - Maintained - Surplus / (short) - - - - @for (day of statutory.daily!.days; track day.date) { - - {{ day.date }} - - - {{ day.ownLiabilities === undefined ? '—' : amount(day.ownLiabilities) }} - - {{ day.baseDate }} - - {{ day.baseLiabilities === undefined ? '—' : amount(day.baseLiabilities) }} - - {{ amount(day.crrRequired) }} - {{ amount(day.crrMaintained) }} - {{ amount(day.crrSurplus) }} - {{ amount(day.slrRequired) }} - {{ amount(day.slrMaintained) }} - {{ amount(day.slrSurplus) }} + +
+ + + + + + + + - } - -
Sr. No.Date + Net Demand and Time Liabilities
+ (as on second preceding fortnight) +
Cash Reserve Ratio AmountStatutory Liquidity Ratio Amount
+ + Rate + Required to be maintained + Actually maintained + + Deficit
(Required−Actual) + + + Surplus
(Actual−Required) + + Rate + Required to be maintained + Actually maintained + + Deficit
(Required−Actual) + + + Surplus
(Actual−Required) + + + + + @for (day of statutory.daily!.days; track day.date) { + + {{ $index + 1 }} + {{ day.date }} + + + {{ day.baseLiabilities === undefined ? '—' : thousands(day.baseLiabilities) }} + + + + {{ day.crrPercentage == null ? '—' : percent(day.crrPercentage) }} + + {{ thousands(day.crrRequired) }} + {{ thousands(day.crrMaintained) }} + + {{ day.crrSurplus < 0 ? thousands(-day.crrSurplus) : '–' }} + + + {{ day.crrSurplus > 0 ? thousands(day.crrSurplus) : '–' }} + + + {{ day.slrPercentage == null ? '—' : percent(day.slrPercentage) }} + + {{ thousands(day.slrRequired) }} + {{ thousands(day.slrMaintained) }} + + {{ day.slrSurplus < 0 ? thousands(-day.slrSurplus) : '–' }} + + + {{ day.slrSurplus > 0 ? thousands(day.slrSurplus) : '–' }} + + + } + + +
} } @@ -994,7 +1146,7 @@ {{ line.label }} @for (value of line.values || []; track $index) { - {{ amount(value) }} + {{ thousands(value) }} } } diff --git a/src/app/savings/create-savings-account/create-savings-account.component.ts b/src/app/savings/create-savings-account/create-savings-account.component.ts index 525c98f2b..561766304 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.ts +++ b/src/app/savings/create-savings-account/create-savings-account.component.ts @@ -172,6 +172,11 @@ export class CreateSavingsAccountComponent { if (this.savingsAccountTemplate.clientId) { savingsAccount.clientId = this.savingsAccountTemplate.clientId; + // Set by "Open account in this branch" for a customer of another branch: the branch this account is held at. + const accountOfficeId = this.route.snapshot.queryParamMap.get('accountOfficeId'); + if (accountOfficeId) { + savingsAccount.accountOfficeId = Number(accountOfficeId); + } } else { savingsAccount.groupId = this.savingsAccountTemplate.groupId; } diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html index 4fecc1971..64590ad4b 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html @@ -26,6 +26,62 @@
+ + @if (interBranch) { +
+
+
+
+ Inter-branch transfer + {{ interBranch.voucherNo }} +
+
+ {{ branchesOf(interBranch.from) }} + → + {{ branchesOf(interBranch.to) }} + {{ interBranch.amount | formatNumber }} +
+
+ This is the {{ thisBranchPart?.branchName }} branch's part of the transfer. Each branch + posts its own part, and the two are joined through Liability Transfer Suspense (200901), so they cancel out + across the bank. +
+
+ @for (leg of interBranch.from; track leg.accountNo) { + From {{ leg.accountNo }} · {{ leg.accountName }} ({{ leg.branchName }}) + } + @for (leg of interBranch.to; track leg.accountNo) { + To {{ leg.accountNo }} · {{ leg.accountName }} ({{ leg.branchName }}) + } +
+ @if (interBranch.raisedBy) { +
+ Raised by {{ interBranch.raisedBy }} on {{ interBranch.raisedAt | datetimeFormat }}, + approved by {{ interBranch.approvedBy }} on + {{ interBranch.approvedAt | datetimeFormat }}. +
+ } +
+ @for (part of otherBranchParts; track part.transactionId) { + The {{ part.branchName }} part: + + } +
+
+ + Do not revert one branch's part on its own: the other branch's part would stay and the books would not + balance. +
+
+
+ } + @if (isViewTransaction()) { @if (dataSource) { diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss index 1f216f178..33038d49f 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss @@ -515,3 +515,95 @@ $shadow: 0 2px 12px rgb(0 0 0 / 8%); text-overflow: ellipsis; vertical-align: middle; } + +/* One branch's part of a transfer between branches. */ +.txn-interbranch { + display: flex; + align-items: flex-start; + gap: 14px; + margin: 0 0 16px; + padding: 14px 18px; + border: 1px solid #fcd34d; + border-left: 4px solid #f59e0b; + border-radius: 10px; + background: #fffbeb; + color: #78350f; + + &__icon { + flex-shrink: 0; + margin-top: 2px; + font-size: 18px; + color: #d97706; + } + + &__body { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + } + + &__title { + font-size: 13px; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + &__voucher { + margin-left: 8px; + padding: 1px 8px; + border-radius: 10px; + background: #fde68a; + font-size: 11px; + letter-spacing: 0.02em; + } + + &__route { + font-size: 17px; + } + + &__arrow { + margin: 0 8px; + color: #d97706; + } + + &__amount { + margin-left: 12px; + padding: 1px 10px; + border-radius: 10px; + background: #fde68a; + font-size: 14px; + font-weight: 700; + } + + &__text { + font-size: 13px; + line-height: 1.5; + } + + &__legs { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; + } + + &__link { + padding: 0; + border: 0; + background: none; + color: #b45309; + font-family: monospace; + font-size: 13px; + font-weight: 700; + text-decoration: underline; + cursor: pointer; + } + + &__warn { + font-size: 12.5px; + font-weight: 600; + color: #b91c1c; + } +} diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts index 2b3f5c753..03bf4448d 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts @@ -9,7 +9,8 @@ import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component'; import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AccountingService } from 'app/accounting/accounting.service'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatPaginator } from '@angular/material/paginator'; import { @@ -26,6 +27,8 @@ import { MatRow } from '@angular/material/table'; import { Location, NgClass } from '@angular/common'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { DatetimeFormatPipe } from '../../../pipes/datetime-format.pipe'; @@ -63,7 +66,9 @@ import { MatTooltipModule } from '@angular/material/tooltip'; ] }) export class ViewJournalEntryTransactionComponent implements OnInit { + private accountingService = inject(AccountingService); private route = inject(ActivatedRoute); + private router = inject(Router); dialog = inject(MatDialog); private location = inject(Location); @@ -90,6 +95,8 @@ export class ViewJournalEntryTransactionComponent implements OnInit { isJournalEntryLoaded = false; isManualJournalEntry = false; + /** Set when this ledger transaction is one branch's part of a transfer between branches (the whole transfer, both branches). */ + interBranch: any = null; ngOnInit() { this.route.data.subscribe((data: { title: string; transaction: any; transferJournalEntryData: any }) => { @@ -101,6 +108,7 @@ export class ViewJournalEntryTransactionComponent implements OnInit { this.isJournalEntryLoaded = true; this.transactionId = data.transaction.pageItems[0].transactionId; this.isManualJournalEntry = data.transaction.pageItems[0].manualEntry; + this.loadInterBranchTransfer(); } } else if (this.isViewTransfer()) { this.journalEntriesData = data.transferJournalEntryData.journalEntryData.content; @@ -110,6 +118,36 @@ export class ViewJournalEntryTransactionComponent implements OnInit { }); } + private loadInterBranchTransfer(): void { + this.interBranch = null; + this.accountingService + .getInterBranchTransfer(this.transactionId) + .pipe(catchError(() => of(null))) + .subscribe((transfer: any) => (this.interBranch = transfer?.interBranch ? transfer : null)); + } + + /** This transaction's own branch part of the inter-branch transfer. */ + get thisBranchPart(): any { + return this.interBranch?.parts?.find((part: any) => part.transactionId === this.transactionId) ?? null; + } + + /** The other branches' parts of the same transfer, each its own ledger transaction. */ + get otherBranchParts(): any[] { + return (this.interBranch?.parts ?? []).filter((part: any) => part.transactionId !== this.transactionId); + } + + /** Distinct branch names among the accounts on one side of the transfer. */ + branchesOf(legs: any[]): string { + return [...new Set((legs ?? []).map((leg: any) => leg.branchName).filter((name: string) => !!name))].join(', '); + } + + openTransaction(transactionId: string): void { + this.router.navigate([ + '/accounting/journal-entries/transactions/view', + transactionId + ]); + } + isViewTransaction(): boolean { return this.title === 'View Transaction'; } diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts index debedb2fe..644c6e4bf 100644 --- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts +++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts @@ -67,9 +67,11 @@ import { SponsorDDService } from '../sponsor-dd.service'; DD Charges / Commission Income GL * - {{ acc.glCode }} · {{ acc.name }} + + {{ acc.glCode }} · {{ acc.name }} + - Credited for DD Issuance Charges/Commission + Income accounts only — credited for DD Issuance Charges/Commission Required @@ -77,9 +79,12 @@ import { SponsorDDService } from '../sponsor-dd.service'; GST Payable on Charges GL * - {{ acc.glCode }} · {{ acc.name }} + + {{ acc.glCode }} · {{ acc.name }} + - Credited for GST collected on charges + + Liability accounts only — GST is owed to the government, not the bank's income Required @@ -87,12 +92,29 @@ import { SponsorDDService } from '../sponsor-dd.service'; Cash at Tellers / Vault GL Account * - {{ acc.glCode }} · {{ acc.name }} + + {{ acc.glCode }} · {{ acc.name }} + Debited when cash is received from walk-in customer Required + + + DD Cash Suspense GL Account * + + + {{ acc.glCode }} · {{ acc.name }} + + + Holds a walk-in's cash until the DD is approved — must differ from the Cash GL + Required + + + Cash Suspense GL must be a different account from the Cash GL. + + Cheque Clearing Suspense GL Account @@ -176,6 +198,10 @@ import { SponsorDDService } from '../sponsor-dd.service'; .half-width { flex: 1; } + .form-error { + font-size: 12px; + margin-top: -8px; + } .hint-text { color: #64748b; font-size: 13px; @@ -203,38 +229,71 @@ export class GlSettingsDialogComponent implements OnInit { mapping: any = null; glAccounts: any[] = []; + // Each account has one job, and the job fixes its type (GLAccountType ids). The backend rejects a + // mismatch too; filtering here keeps a wrong account from being offered at all. + get assetAccounts(): any[] { + return this.glAccounts.filter((acc) => acc.type?.id === 1); + } + + get liabilityAccounts(): any[] { + return this.glAccounts.filter((acc) => acc.type?.id === 2); + } + + get incomeAccounts(): any[] { + return this.glAccounts.filter((acc) => acc.type?.id === 4); + } + + /** Cash awaiting approval is either still the bank's (asset) or already owed (liability), never P&L. */ + get suspenseAccounts(): any[] { + const cashGlAccountId = this.form?.get('cashGlAccountId')?.value; + return this.glAccounts.filter((acc) => (acc.type?.id === 1 || acc.type?.id === 2) && acc.id !== cashGlAccountId); + } + ngOnInit(): void { - this.form = this.fb.group({ - // No longer edited here — it lives on the sponsor bank. Kept in the form, unvalidated, so - // saving this screen round-trips the stored value instead of clearing it: existing banks were - // seeded from it on upgrade, and nothing should quietly erase what they were seeded from. - ddPayableGlAccountId: [null], - chargesIncomeGlAccountId: [ - null, - [Validators.required] - ], - gstPayableGlAccountId: [ - null, - [Validators.required] - ], - cashGlAccountId: [ - null, - [Validators.required] - ], - chequeClearingGlAccountId: [null], - gstPercentage: [ - 18, - [ - Validators.required, - Validators.min(0), - Validators.max(100) + this.form = this.fb.group( + { + // No longer edited here — it lives on the sponsor bank. Kept in the form, unvalidated, so + // saving this screen round-trips the stored value instead of clearing it: existing banks were + // seeded from it on upgrade, and nothing should quietly erase what they were seeded from. + ddPayableGlAccountId: [null], + chargesIncomeGlAccountId: [ + null, + [Validators.required] + ], + gstPayableGlAccountId: [ + null, + [Validators.required] + ], + cashGlAccountId: [ + null, + [Validators.required] + ], + cashSuspenseGlAccountId: [ + null, + [Validators.required] + ], + chequeClearingGlAccountId: [null], + gstPercentage: [ + 18, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + ddLimitAmount: [ + null, + [Validators.min(0)] ] - ], - ddLimitAmount: [ - null, - [Validators.min(0)] - ] - }); + }, + { + validators: (group: FormGroup) => { + const cash = group.get('cashGlAccountId')?.value; + const suspense = group.get('cashSuspenseGlAccountId')?.value; + return cash != null && cash === suspense ? { suspenseSameAsCash: true } : null; + } + } + ); this.loadGlAccountsAndMapping(); } @@ -250,15 +309,23 @@ export class GlSettingsDialogComponent implements OnInit { next: (mappings) => { this.mapping = mappings && mappings.length ? mappings[0] : null; if (this.mapping) { + // A stored account of the wrong type is not carried into the form: the field shows empty and + // required, so the next save has to pick a correct one instead of re-saving the mistake. + const ofType = (id: any, allowed: any[]) => (allowed.some((acc) => acc.id === id) ? id : null); this.form.patchValue({ ddPayableGlAccountId: this.mapping.ddPayableGlAccountId ?? null, - chargesIncomeGlAccountId: this.mapping.chargesIncomeGlAccountId ?? null, - gstPayableGlAccountId: this.mapping.gstPayableGlAccountId ?? null, - cashGlAccountId: this.mapping.cashGlAccountId ?? null, + chargesIncomeGlAccountId: ofType(this.mapping.chargesIncomeGlAccountId, this.incomeAccounts), + gstPayableGlAccountId: ofType(this.mapping.gstPayableGlAccountId, this.liabilityAccounts), + cashGlAccountId: ofType(this.mapping.cashGlAccountId, this.assetAccounts), chequeClearingGlAccountId: this.mapping.chequeClearingGlAccountId ?? null, gstPercentage: this.mapping.gstPercentage ?? 18, ddLimitAmount: this.mapping.ddLimitAmount ?? null }); + // Patched after the cash account so the suspense list already excludes it; a suspense that + // equals cash (what this dialog used to save by default) is dropped the same way. + this.form.patchValue({ + cashSuspenseGlAccountId: ofType(this.mapping.cashSuspenseGlAccountId, this.suspenseAccounts) + }); } this.loading = false; }, @@ -295,7 +362,7 @@ export class GlSettingsDialogComponent implements OnInit { chequeClearingGlAccountId: val.chequeClearingGlAccountId, cashGlAccountId: val.cashGlAccountId, chargesIncomeGlAccountId: val.chargesIncomeGlAccountId, - cashSuspenseGlAccountId: this.mapping?.cashSuspenseGlAccountId || val.cashGlAccountId, + cashSuspenseGlAccountId: val.cashSuspenseGlAccountId, gstPercentage: val.gstPercentage, gstPayableGlAccountId: val.gstPayableGlAccountId, ddLimitAmount: val.ddLimitAmount diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html index a42722cf0..234429d41 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html @@ -121,7 +121,17 @@ @for (r of requests; track r.id) { {{ r.voucher_no || '—' }} - {{ r.account_no }} + + {{ r.account_no }} + @if (r.inter_branch) { + + Inter-branch · {{ r.counter_office_name }} → {{ r.account_office_name }} + + } + {{ r.client_name }} ₹{{ r.amount | number: '1.2-2' }} {{ r.payment_type_name || 'Cash' }} @@ -173,6 +183,20 @@
+ @if (reviewRequest.inter_branch) { +
+ alt_route +
+
Inter-branch deposit
+
+ Cash was received at {{ reviewRequest.counter_office_name }} but the account is held at + {{ reviewRequest.account_office_name }}. The customer's account is credited in {{ reviewRequest.account_office_name }}'s books. Check the + account before approving. +
+
+
+ }
Account No diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss index 06fc3df0a..816bdd58f 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss @@ -463,6 +463,51 @@ } } +/* Shown on a deposit whose cash was taken at one branch for an account held at another. */ +.dri-interbranch { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 0 12px; + padding: 12px 16px; + border: 1px solid #fcd34d; + border-left: 4px solid #f59e0b; + border-radius: 8px; + background: #fffbeb; + color: #78350f; + + mat-icon { + flex-shrink: 0; + color: #d97706; + } + + &__title { + font-size: 13px; + font-weight: 800; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + &__text { + margin-top: 2px; + font-size: 13px; + line-height: 1.5; + } +} + +.dri-branch-badge { + display: block; + margin-top: 4px; + width: fit-content; + padding: 1px 8px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-family: inherit; + font-size: 11px; + font-weight: 700; +} + .dri-detail-grid { display: grid; grid-template-columns: 1fr 1fr; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 5cf0d7b56..3f149c5fc 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '261012', - 'hash': '6b0f218b' + 'version': '261020', + 'hash': 'd88f2a6eb' }, 'allow_switching_backend_instance': true };