From 2124bb4b76aef9c9b2cf1a6ad7aa840d3a31110b Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 14 Sep 2026 11:43:26 +0530 Subject: [PATCH] feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports --- .../search-journal-entry.component.html | 11 +- src/app/aml/aml.service.ts | 9 + .../cashier-issue-dd-dialog.component.ts | 7 +- .../transaction-entry.component.html | 16 +- .../transaction-entry.component.ts | 151 +++++- .../kyc-tab/kyc-tab.component.html | 95 +++- .../kyc-tab/kyc-tab.component.scss | 126 +++++ .../clients-view/kyc-tab/kyc-tab.component.ts | 345 ++++++++++++- .../kyc-document-update-dialog.component.html | 137 ++++++ .../kyc-document-update-dialog.component.scss | 172 +++++++ .../kyc-document-update-dialog.component.ts | 211 ++++++++ src/app/core/utils/api-date.ts | 29 ++ src/app/fiu/fiu-data.ts | 16 + src/app/fiu/fiu.component.html | 334 ++----------- src/app/fiu/fiu.component.scss | 11 + src/app/fiu/fiu.component.ts | 285 +++++++++-- .../aml-recurring-alerts.component.html | 9 - .../aml-recurring-alerts.component.scss | 21 - .../aml-recurring-alerts.component.ts | 45 +- .../aml-recurring-alerts.demo-data.ts | 248 ---------- src/app/risk-review/risk-review-data.ts | 17 +- .../risk-review-report.component.html | 74 ++- .../risk-review-report.component.scss | 88 +++- .../risk-review-report.component.ts | 139 +++++- src/app/risk-review/risk-review.compute.ts | 9 +- .../dd-issue-approvals.component.ts | 221 +++++++-- .../sponsor-dd/dd-list/dd-list.component.html | 25 + .../sponsor-dd/dd-list/dd-list.component.ts | 136 +++++- .../dd-revoke-approvals.component.html | 130 +++++ .../dd-revoke-approvals.component.ts | 460 ++++++++++++++++++ .../sponsor-dd/sponsor-dd-routing.module.ts | 6 + src/app/sponsor-dd/sponsor-dd.service.ts | 21 + .../checker-inbox-and-tasks.component.html | 6 + src/app/tasks/tasks-routing.module.ts | 8 + .../tds-interest-report.component.html | 2 +- .../tds-interest-report.component.ts | 46 +- src/app/tds-reports/tds-reports-data.ts | 187 +------ src/app/tds-reports/tds-reports.compute.ts | 9 +- src/app/tds-reports/tds-reports.service.ts | 91 ++++ src/environments/.env.ts | 4 +- 40 files changed, 3028 insertions(+), 929 deletions(-) create mode 100644 src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html create mode 100644 src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss create mode 100644 src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts delete mode 100644 src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts create mode 100644 src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html create mode 100644 src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts create mode 100644 src/app/tds-reports/tds-reports.service.ts diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index f8dacebff..ade207e63 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -715,7 +715,10 @@ > - @if (totalEntries === 0) { + + @if (((dataSource?.entries$ | async) || []).length === 0) {
No entries found
@@ -724,7 +727,11 @@ }
- +
diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 59385a350..76bbc1905 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -122,6 +122,15 @@ export class AmlService { return this.http.get('/aml/ctr-reports'); } + /** + * The reportable lines behind the generated CTRs, each resolved to the account, customer, branch + * and date a FIU-IND filing names. Omit `reportId` for every generated month. + */ + getCtrReportLines(reportId?: number): Observable { + const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined; + return this.http.get('/aml/ctr-reports/lines', { params }); + } + /** month format: yyyy-MM */ generateCtrReport(month: string): Observable { return this.http.post('/aml/ctr-reports/generate', { month }); diff --git a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts index 50e51c402..fff71aee1 100644 --- a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts +++ b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts @@ -71,8 +71,11 @@ export interface CashierIssueDdDialogData {
+
- Auto-Selected Leaflet + DD Number Being Issued {{ data.leaflet.ddNumber }} {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}
@@ -234,7 +237,7 @@ export interface CashierIssueDdDialogData {
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 98ae9647e..de55bc0f0 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -178,7 +178,7 @@ (row.dbCr = 'Cr') " > - @for (a of acTypOptions; track a) { + @for (a of acTypOptionsFor(row); track a) { } @@ -432,8 +432,14 @@ : 'Auto-selects next available leaflet and opens walk-in purchaser form' " > - post_add - Fill DD Details & Send for Approval + {{ + row.ddDetails ? 'edit_note' : 'post_add' + }} + + {{ + row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details' + }} @@ -1529,7 +1535,7 @@ (row.dbCr = 'Cr') " > - @for (a of acTypOptions; track a) { + @for (a of acTypOptionsFor(row); track a) { } @@ -1756,7 +1762,7 @@ " > post_add - Fill DD Details & Send for Approval + Fill DD Details diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 5a49e956d..f787adb1c 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -57,6 +57,27 @@ interface TxnRow { * account — governs the share redemption (see submitShareRedemptionTransfer()). */ resolutionNumber?: string; meetingDate?: string; + /** + * Beneficiary and leaflet captured in the DD dialog, held on the row until Save. + * + * The dialog used to submit the request itself, which left the worksheet's own Save button doing + * nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing + * Save afterwards had no effect. Filling in details and committing them are two different + * actions, so the dialog now only records what was entered and Save is what sends it. + */ + ddDetails?: { + leafletId: number; + ddNumber: string; + beneficiaryName: string; + payableAtBranch: string; + payableAtCity: string; + /** Present only for a walk-in cash draft. */ + purchaser?: { name: string; address: string; aadhaar: string }; + /** Note-and-coin breakdown, cash drafts only. */ + denominations?: { [key: string]: number }; + fundingAccountId?: number; + fundingClientId?: number; + }; } interface Denomination { @@ -1671,6 +1692,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { 'GL', 'LA' ]; + + /** + * Account types a clerk may fund a demand draft from. + * + * A clerk raises a draft against a member's own balance, so the only sensible sources are the + * accounts a member actually holds money in. GL was offered here too, and picking it quietly + * switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a + * note-and-coin count, neither of which a clerk debiting an account has. The option is removed + * rather than validated afterwards, so the wrong path cannot be reached at all. + */ + private static readonly DD_CLERK_ACTYPS = [ + 'SD', + 'CA' + ]; + + /** + * The account types this particular row may use. + * + * Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the + * only row where the account type silently decides which funding flow runs. + */ + acTypOptionsFor(row: TxnRow): string[] { + if (this.isClerk && row.typ === 'DD') { + return TransactionEntryComponent.DD_CLERK_ACTYPS; + } + return this.acTypOptions; + } dbCrOptions = [ 'Dr', 'Cr' @@ -2230,12 +2278,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // panel never appears and the row would post as an ordinary entry with the charges silently // dropped. // - // A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no - // longer offers cash, so defaulting to it would open the field on a value it cannot show. - // isDdCashGlRow() still holds for them through AC.TP = GL. + // A clerk funds a draft by debiting a member's own account, so their row opens on savings and + // is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on + // GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin + // count that a clerk debiting an account does not have. case 'DD': - row.dbCr = 'Cr'; - row.acTyp = 'GL'; + row.dbCr = this.isClerk ? 'Dr' : 'Cr'; + row.acTyp = this.isClerk ? 'SD' : 'GL'; row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId(); row.chequeNo = ''; row.cheqDate = ''; @@ -2416,13 +2465,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } }); + // Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that + // submitted itself here left that button with nothing to do — so the clerk had no way to + // review the entered details, and no way to abandon them either. dialogRef.afterClosed().subscribe((result) => { if (!result) return; - if (accountDebit) { - this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!); - } else { - this.submitWalkinCashDd(row, leaflet, result); - } + row.ddDetails = { + leafletId: leaflet.id, + ddNumber: leaflet.ddNumber, + beneficiaryName: result.beneficiaryName, + payableAtBranch: result.payableAtBranch, + payableAtCity: result.payableAtCity, + purchaser: accountDebit + ? undefined + : { + name: result.walkinPurchaserName, + address: result.walkinPurchaserAddress, + aadhaar: result.walkinPurchaserAadhaar + }, + fundingAccountId: accountDebit ? fundingAccountId! : undefined, + fundingClientId: accountDebit ? fundingClientId! : undefined + }; + this.showAlert('success', 'DD Details Captured', [ + `DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`, + accountDebit + ? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.` + : `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`, + 'Press Save to send this draft for approval.' + ]); }); }, error: (err) => { @@ -2480,6 +2550,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.amount = null; row.ddCharges = null; row.description = ''; + // Cleared with the row: leaving the captured details behind would let a second Save raise + // the same draft again against a leaflet that has already been used. + row.ddDetails = undefined; }, error: (err) => { this.saving = false; @@ -2488,6 +2561,53 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } + /** + * Sends the drafts on the worksheet for approval. + * + * Each row carries the beneficiary details the dialog captured; a row that was never filled in is + * refused by name rather than skipped, because a clerk who pressed Save expecting one draft and + * got none has no way of telling which row was missing. + */ + private saveDdRows(ddRows: TxnRow[]): void { + const unfilled = ddRows.filter((r) => !r.ddDetails); + if (unfilled.length > 0) { + this.showAlert('error', 'DD Details Not Entered', [ + unfilled.length === 1 + ? 'One DD row has no beneficiary details yet.' + : `${unfilled.length} DD rows have no beneficiary details yet.`, + 'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.' + ]); + return; + } + + for (const row of ddRows) { + const details = row.ddDetails!; + const leaflet = { id: details.leafletId, ddNumber: details.ddNumber }; + if (this.isDdAccountDebitRow(row)) { + this.submitAccountDebitDd( + row, + leaflet, + { + beneficiaryName: details.beneficiaryName, + payableAtBranch: details.payableAtBranch, + payableAtCity: details.payableAtCity + }, + details.fundingAccountId!, + details.fundingClientId! + ); + } else { + this.submitWalkinCashDd(row, leaflet, { + beneficiaryName: details.beneficiaryName, + payableAtBranch: details.payableAtBranch, + payableAtCity: details.payableAtCity, + walkinPurchaserName: details.purchaser?.name, + walkinPurchaserAddress: details.purchaser?.address, + walkinPurchaserAadhaar: details.purchaser?.aadhaar + }); + } + } + } + private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void { // Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked // right but matched nothing, so every cash DD went in with no breakdown at all and its money @@ -2557,6 +2677,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.amount = null; row.ddCharges = null; row.description = ''; + // See the account-debit path: a second Save must not re-raise a draft already issued. + row.ddDetails = undefined; this.clearDenominations(); }, error: (err) => { @@ -4361,6 +4483,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } onSave(): void { + // 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. + const ddRows = this.rows.filter((r) => this.isDdRow(r)); + if (ddRows.length > 0) { + this.saveDdRows(ddRows); + return; + } + // Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer' // is the same flow under a second TYPE label — onTypChange() already defaults it identically to // 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index 57311257a..dd1e065c3 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -82,12 +82,13 @@ {{ kycDocumentAlertTitle }}

{{ kycDocumentAlertMessage }}

+ @@ -470,7 +471,11 @@ } - + + } @@ -1188,15 +1193,83 @@ - - - photo_library - Replace the scanned front / back of this document - chevron_right - + +
+
+ photo_library + Scanned document + @if (docScansLoading) { + + } +
+ + @if (!docScansLoading && !docScans.front && !docScans.back) { +
+ No scan is on file for this document. Upload one below. +
+ } + +
+ @for (slot of docScanSlots; track slot) { +
+
{{ slot === 'front' ? 'Front' : 'Back' }}
+ +
+ @if (docScanOf(slot)?.previewUrl) { + + } @else if (docScanOf(slot)) { + +
+ description + {{ docScanOf(slot)!.fileName }} +
+ } @else { +
Not on file
+ } +
+ + + +
+ } +
+ + @if (docScanError) { +
+ error_outline{{ docScanError }} +
+ } + @if (docScanSuccess) { +
+ check_circle{{ docScanSuccess }} +
+ } +
} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss index 101a3e073..0f4520920 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss @@ -1681,3 +1681,129 @@ .kyc-doc-scans-link__go { margin-left: auto; } + +/* The scan itself, beside the number and dates it belongs to. */ +.kyc-doc-scans { + margin-top: 10px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.kyc-doc-scans__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #475569; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #64748b; + } +} + +.kyc-doc-scans__empty { + margin-bottom: 10px; + font-size: 12.5px; + color: #64748b; +} + +.kyc-doc-scans__sides { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.kyc-doc-side { + display: flex; + flex-direction: column; + gap: 6px; + width: 190px; +} + +.kyc-doc-side__label { + font-size: 11.5px; + font-weight: 700; + color: #334155; +} + +/* Fixed height so front and back line up whatever the scan's aspect ratio is. */ +.kyc-doc-side__frame { + display: flex; + align-items: center; + justify-content: center; + height: 118px; + padding: 6px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; + overflow: hidden; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 3px; + } +} + +.kyc-doc-side__none { + font-size: 12px; + color: #94a3b8; +} + +.kyc-doc-side__file { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 0 6px; + font-size: 11px; + color: #475569; + text-align: center; + word-break: break-word; + + mat-icon { + color: #64748b; + } +} + +.kyc-doc-side__btn { + font-size: 12px; + + mat-progress-spinner { + display: inline-block; + margin-right: 6px; + vertical-align: middle; + } +} + +.kyc-doc-scans__msg { + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; + font-size: 12.5px; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } + + &--err { + color: #b91c1c; + } + + &--ok { + color: #15803d; + } +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index 365e9c0a7..ecfdd0210 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -11,7 +11,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; -import { catchError, of, Subject, takeUntil } from 'rxjs'; +import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; @@ -30,6 +30,11 @@ import { AadhaarReceiptDialogComponent, AadhaarReceiptResult } from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component'; +import { + KycDocumentUpdateDialogComponent, + KycDocumentUpdateData, + KycDocumentUpdateResult +} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component'; import { Dates } from 'app/core/utils/dates'; import { jsPDF } from 'jspdf'; import { MatIcon } from '@angular/material/icon'; @@ -38,6 +43,15 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +/** One side of the scanned KYC document, with the preview once it has been fetched. */ +interface DocScanSide { + documentId: string; + /** The identifier row this scan hangs off; the two sides can sit on different rows. */ + identifierId: string; + fileName: string; + previewUrl: string | null; +} + /** * KYC Tab Component. * Displays and manages KYC (Know Your Customer) details for an existing client. @@ -149,9 +163,39 @@ export class KycTabComponent implements OnInit, OnDestroy { exceptionReason: [[]] }); + // ── Officially valid document scans ──────────────────────────────────────── + + /** + * The scans of the document named above, by side. + * + * The screen used to show the document's number and dates but not the image behind them, and + * sent the officer to the client edit screen to change it. Re-collecting an expiring document is + * the one thing this panel exists for, so the scan is shown and replaced here. + */ + docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null }; + + /** The two sides, in display order, so the template loops rather than repeating itself. */ + readonly docScanSlots: ('front' | 'back')[] = ['front', 'back']; + + docScanOf(slot: 'front' | 'back') { + return this.docScans[slot]; + } + + /** Identifier row replacements are filed against. Null when the document has no identifier. */ + private docIdentifierId: string | null = null; + /** Every identifier row carrying this document, since the sides can be split across them. */ + private docIdentifierIds: string[] = []; + docScansLoading = false; + /** Which side is mid-upload, so only that button shows a spinner. */ + docScanUploading: 'front' | 'back' | null = null; + docScanError: string | null = null; + docScanSuccess: string | null = null; + ngOnInit(): void { this.route.parent.params.subscribe((params: any) => { this.clientId = params['clientId']; + // Scans are matched against the document number on the KYC record, so they are loaded once + // that record has arrived rather than alongside it. this.loadKycData(); this.loadClientDateOfBirth(); }); @@ -190,6 +234,8 @@ export class KycTabComponent implements OnInit, OnDestroy { isException: data.isException || false, exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); + // Now that the document number is known, the scans filed under it can be found. + this.loadDocumentScans(); } }); } @@ -1307,6 +1353,297 @@ I further confirm that: doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`); } + // ── Document scans ───────────────────────────────────────────────────────── + + /** + * Finds the identifier the KYC document was filed under and loads its scans. + * + * The KYC record keeps the document's name and number but not the identifier id, so the two are + * matched on the number — that is what makes them the same document, and it survives the client + * holding several identifiers of the same type. + */ + private loadDocumentScans(): void { + if (!this.clientId) { + return; + } + this.docScansLoading = true; + this.clientsService + .getClientIdentifiers(this.clientId) + .pipe(catchError(() => of([] as any[]))) + .subscribe((identifiers: any[]) => { + const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim(); + const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase(); + const norm = (s: any) => (s || '').toString().trim().toLowerCase(); + + // Every identifier carrying this document number, not just the first. The onboarding step + // files each side against its own identifier row, so this client's Aadhaar front sits on + // one row and the back on another under the same number - taking only the first match + // showed the front and reported the back as missing. + let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey)); + if (!matches.length) { + matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName); + } + + if (!matches.length) { + this.docScansLoading = false; + this.docIdentifierId = null; + this.docIdentifierIds = []; + this.docScans = { front: null, back: null }; + return; + } + // Replacements are filed against the first row, so the sides stay together from here on. + this.docIdentifierId = String(matches[0].id); + this.docIdentifierIds = matches.map((m: any) => String(m.id)); + this.fetchScansFor(this.docIdentifierIds); + }); + } + + /** + * Sides are read off the document name, which is how they were written at upload + * (" — Front" / "— Back"). Where the name says nothing, upload order stands in, front + * first, rather than leaving a scan the officer cannot see. + */ + private fetchScansFor(identifierIds: string[]): void { + forkJoin( + identifierIds.map((id) => + this.clientsService.getClientIdentificationDocuments(id).pipe( + // Tagged with the row it came from, because the download call needs the parent id and + // the sides can be spread across more than one. + map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))), + catchError(() => of([] as any[])) + ) + ) + ).subscribe((perIdentifier: any[][]) => { + this.docScansLoading = false; + // This runs again after every replace, so the previews being thrown away are revoked + // rather than left holding their blobs. + for (const old of [this.docScans.front, this.docScans.back]) { + if (old?.previewUrl) { + URL.revokeObjectURL(old.previewUrl); + } + } + + // Oldest first by document id, so the newest upload for a side is the last one written and + // therefore the one that stands. Identifier order cannot do this job: a replacement is filed + // against the first row while the original sits on the second, so reading the rows in order + // let a document uploaded months ago overwrite the scan collected a moment ago - the officer + // pressed Replace, saw "replaced", and the old image came straight back. + const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id)); + const sides: typeof this.docScans = { front: null, back: null }; + const unlabelled: any[] = []; + + const entryOf = (d: any): DocScanSide => ({ + documentId: String(d.id), + identifierId: String(d.identifierId), + fileName: d.fileName || d.name, + previewUrl: null + }); + + for (const d of docs) { + const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase(); + // Later uploads win, so a replaced side shows the new scan rather than the original. + if (text.includes('back')) { + sides.back = entryOf(d); + } else if (text.includes('front')) { + sides.front = entryOf(d); + } else { + unlabelled.push(d); + } + } + for (const d of unlabelled) { + if (!sides.front) { + sides.front = entryOf(d); + } else if (!sides.back) { + sides.back = entryOf(d); + } + } + + this.docScans = sides; + for (const key of ['front', 'back'] as const) { + if (sides[key]) { + this.loadScanPreview(sides[key]!); + } + } + }); + } + + private loadScanPreview(side: DocScanSide): void { + this.clientsService + .downloadClientIdentificationDocument(side.identifierId, side.documentId) + .pipe(catchError(() => of(null))) + .subscribe((blob: Blob | null) => { + if (!blob || !blob.type?.startsWith('image/')) { + return; + } + // Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of + // the tab. + side.previewUrl = URL.createObjectURL(blob); + }); + } + + /** + * Replaces one side with a freshly collected scan. + * + * The new file is uploaded under the same side name, which is what the readers here and on the + * client screen key on, so the latest upload becomes the scan on show. The old file is left in + * place deliberately: a KYC document that has been re-collected is a record of what was held + * before and when it changed, and destroying it would leave the audit trail with a gap. + */ + onDocScanSelected(event: Event, slot: 'front' | 'back'): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + // Cleared so choosing the same filename twice still fires a change event. + input.value = ''; + if (!file || !this.docIdentifierId) { + return; + } + + this.docScanError = null; + this.docScanSuccess = null; + this.docScanUploading = slot; + + const label = slot === 'front' ? 'Front' : 'Back'; + const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document'; + + const formData = new FormData(); + formData.append('file', file); + formData.append('name', `${docName} — ${label}`); + formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`); + + this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({ + next: () => { + this.docScanUploading = null; + this.docScanSuccess = `${label} side replaced.`; + // Re-read every row, not just the one written to: the other side still lives on its own. + this.fetchScansFor(this.docIdentifierIds); + }, + error: (err: any) => { + this.docScanUploading = null; + this.docScanError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`; + } + }); + } + + /** + * A verified record only accepts an amendment with a reason on it, so the dialog has to ask for + * one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload. + */ + private get isKycVerified(): boolean { + return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified'; + } + + /** + * Re-collects the officially valid document from its own row. + * + * PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the + * whole KYC form to do the same job. The dialog asks for the number, the dates and each side of + * the scan together, which is what re-collecting a document actually is. + */ + updateKycDocument(): void { + if (!this.kycId) { + this.docScanError = 'KYC record not found. Save KYC details first.'; + return; + } + + this.dialog + .open(KycDocumentUpdateDialogComponent, { + width: '640px', + maxWidth: '95vw', + disableClose: true, + data: { + documentName: this.kycData?.kycDocumentName || 'KYC document', + documentNumber: this.kycData?.kycDocumentNumber || '', + issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate), + expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate), + reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '', + currentFront: this.docScans.front?.previewUrl || null, + currentBack: this.docScans.back?.previewUrl || null, + requiresRemarks: this.isKycVerified + } as KycDocumentUpdateData + }) + .afterClosed() + .subscribe((result: KycDocumentUpdateResult | undefined) => { + if (result) { + this.persistKycDocument(result); + } + }); + } + + /** + * Saves the corrected details, then uploads whichever sides were re-scanned. + * + * The details go first: they are the record, and a scan attached to a document whose number was + * never corrected would file the new image under the old identity. + */ + private persistKycDocument(result: KycDocumentUpdateResult): void { + this.saving = true; + this.docScanError = null; + this.docScanSuccess = null; + + const dateFormat = 'dd MMMM yyyy'; + const payload: any = { + locale: 'en', + dateFormat, + kycDocumentName: this.kycData?.kycDocumentName || null, + kycDocumentNumber: result.documentNumber || null, + kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null, + kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null + }; + // The server refuses an amendment to a verified record without one, and records it as the + // reason the document changed. + if (result.remarks) { + payload.remarks = result.remarks; + } + + this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({ + next: () => { + this.saving = false; + this.docScanSuccess = 'Document updated.'; + this.loadKycData(); + this.uploadReplacedSides(result); + }, + error: (err: any) => { + this.saving = false; + this.docScanError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.'; + } + }); + } + + /** Sends each re-scanned side through the same upload path the panel below uses. */ + private uploadReplacedSides(result: KycDocumentUpdateResult): void { + if (!this.docIdentifierId) { + return; + } + const sides: { slot: 'front' | 'back'; file: File }[] = []; + if (result.front) { + sides.push({ slot: 'front', file: result.front }); + } + if (result.back) { + sides.push({ slot: 'back', file: result.back }); + } + if (!sides.length) { + return; + } + + const docName = this.kycData?.kycDocumentName || 'KYC document'; + forkJoin( + sides.map(({ slot, file }) => { + const formData = new FormData(); + formData.append('file', file); + formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`); + formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`); + return this.clientsService + .uploadClientIdentifierDocument(this.docIdentifierId!, formData) + .pipe(catchError(() => of(null))); + }) + ).subscribe(() => { + this.docScanSuccess = 'Document and scan updated.'; + this.fetchScansFor(this.docIdentifierIds); + }); + } + /** Handle file selection for signed declaration upload */ onDeclarationFileSelected(event: Event): void { const input = event.target as HTMLInputElement; @@ -1365,5 +1702,11 @@ I further confirm that: ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); + // Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab. + for (const side of [this.docScans.front, this.docScans.back]) { + if (side?.previewUrl) { + URL.revokeObjectURL(side.previewUrl); + } + } } } diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html new file mode 100644 index 000000000..6e646f72a --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html @@ -0,0 +1,137 @@ + + +

+ description + Update {{ data.documentName }} +

+ + + + @if (data.reason) { +
+ schedule + {{ data.reason }} +
+ } + +
+ + Document Number + + @if (form.get('documentNumber')?.hasError('required') && form.get('documentNumber')?.touched) { + Document number is required + } + + + + Issued On + + + + + + + Valid Till + + + + Blank if it does not expire + + + + @if (data.requiresRemarks) { + + Reason for update + + Recorded against the verified KYC record + @if (form.get('remarks')?.hasError('required') && form.get('remarks')?.touched) { + A reason is required to change a verified KYC record + } + + } +
+ +
+
Scanned copy
+

+ Leave a side untouched to keep the scan already on file. The previous copy is kept either way. +

+ +
+ @for (slot of slots; track slot) { +
+
+ {{ slot === 'front' ? 'Front' : 'Back' }} + @if (isReplaced(slot)) { + New + } +
+ +
+ @if (previewOf(slot)) { + + } @else if (isReplaced(slot)) { + +
+ insert_drive_file + {{ pickedName(slot) }} +
+ } @else { +
Not on file
+ } +
+ +
+ + @if (isReplaced(slot)) { + + } +
+ +
+ } +
+
+
+ + + + + diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss new file mode 100644 index 000000000..1837b342c --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss @@ -0,0 +1,172 @@ +/** + * 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/. + */ + +.kdu-title { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + color: #64748b; + } +} + +.kdu-body { + padding-top: 4px; +} + +.kdu-reason { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 16px; + padding: 10px 12px; + border: 1px solid #fde68a; + border-radius: 6px; + background: #fffbeb; + color: #92400e; + font-size: 13px; + line-height: 1.5; + + mat-icon { + flex: 0 0 auto; + font-size: 18px; + width: 18px; + height: 18px; + color: #d97706; + } +} + +.kdu-form { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.kdu-field { + flex: 1 1 180px; +} + +.kdu-field--wide { + flex: 1 1 100%; +} + +.kdu-scans { + margin-top: 8px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.kdu-scans__head { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #475569; +} + +.kdu-scans__hint { + margin: 4px 0 12px; + font-size: 12px; + color: #64748b; +} + +.kdu-sides { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.kdu-side { + display: flex; + flex-direction: column; + gap: 6px; + width: 210px; +} + +.kdu-side__label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: #334155; +} + +/* Marks the side the officer has just re-scanned, so it is obvious what will change on save. */ +.kdu-side__new { + padding: 1px 7px; + border-radius: 999px; + background: #dcfce7; + color: #15803d; + font-size: 10.5px; + font-weight: 700; +} + +.kdu-side__frame { + display: flex; + align-items: center; + justify-content: center; + height: 132px; + padding: 6px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fff; + overflow: hidden; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 3px; + } + + &--new { + border-color: #86efac; + box-shadow: 0 0 0 1px #86efac; + } +} + +.kdu-side__none { + font-size: 12px; + color: #94a3b8; +} + +.kdu-side__file { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 0 6px; + font-size: 11px; + color: #475569; + text-align: center; + word-break: break-word; + + mat-icon { + color: #64748b; + } +} + +.kdu-side__actions { + display: flex; + align-items: center; + gap: 4px; +} + +.kdu-side__btn { + flex: 1; + font-size: 12px; +} + +.kdu-actions { + padding: 8px 24px 20px; + gap: 8px; +} diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts new file mode 100644 index 000000000..870bb38e1 --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts @@ -0,0 +1,211 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, Inject, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** What the caller hands in: the document as it stands, and the scans already on file. */ +export interface KycDocumentUpdateData { + documentName: string; + documentNumber: string; + issueDate: Date | string | null; + expiryDate: Date | string | null; + /** Why the document is being asked for, shown at the top so the officer sees the reason. */ + reason: string; + /** Previews of what is currently held, so the officer can see what they are replacing. */ + currentFront: string | null; + currentBack: string | null; + /** + * True when the KYC record is already verified. Changing data on one of those needs a reason on + * the record, which the server enforces - so the dialog asks for it rather than letting the + * officer fill the whole form in and be refused on submit. + */ + requiresRemarks: boolean; +} + +/** What the caller gets back: the corrected details, plus whichever sides were re-scanned. */ +export interface KycDocumentUpdateResult { + documentNumber: string; + issueDate: Date | null; + expiryDate: Date | null; + front: File | null; + back: File | null; + /** Why the document was re-collected. Blank unless the record was already verified. */ + remarks: string; +} + +/** + * Re-collects an officially valid document without leaving the KYC record. + * + * PAN and Aadhaar are each dealt with on their own row of the verification table. The OVD was the + * exception: its Update button put the whole KYC form into edit mode, so re-collecting one expiring + * document meant scrolling a form of unrelated fields and finding the same document again. The work + * is the same either way - a new number, new dates, a fresh scan of each side - so it is asked for + * in one place. + */ +@Component({ + selector: 'mifosx-kyc-document-update-dialog', + templateUrl: './kyc-document-update-dialog.component.html', + styleUrls: ['./kyc-document-update-dialog.component.scss'], + standalone: true, + // The shared set carries the date adapter the two datepickers below need; the sibling dialogs + // pull it in the same way. + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatDatepickerModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + MatTooltipModule + ] +}) +export class KycDocumentUpdateDialogComponent implements OnDestroy { + private formBuilder = inject(UntypedFormBuilder); + private dialogRef = inject>(MatDialogRef); + + form: UntypedFormGroup; + + /** Newly chosen files, with a local preview so the officer sees what they picked. */ + picked: { front: File | null; back: File | null } = { front: null, back: null }; + pickedPreview: { front: string | null; back: string | null } = { front: null, back: null }; + + readonly slots: ('front' | 'back')[] = ['front', 'back']; + + constructor(@Inject(MAT_DIALOG_DATA) public data: KycDocumentUpdateData) { + this.form = this.formBuilder.group({ + documentNumber: [ + data.documentNumber || '', + [Validators.required] + ], + issueDate: [this.toDate(data.issueDate)], + // Left free rather than required: a passport expires, a PAN card does not. + expiryDate: [this.toDate(data.expiryDate)], + // Required only where the server requires it, so completing a record that was never verified + // does not demand a justification for filling in blanks. + remarks: [ + '', + data.requiresRemarks ? [Validators.required] : [] + ] + }); + } + + private toDate(value: Date | string | null): Date | null { + if (!value) { + return null; + } + const d = value instanceof Date ? value : new Date(value); + return isNaN(d.getTime()) ? null : d; + } + + previewOf(slot: 'front' | 'back'): string | null { + return this.pickedPreview[slot] || (slot === 'front' ? this.data.currentFront : this.data.currentBack); + } + + /** True once this side has been re-scanned in this dialog, so the label can say so. */ + isReplaced(slot: 'front' | 'back'): boolean { + return !!this.picked[slot]; + } + + pickedName(slot: 'front' | 'back'): string { + return this.picked[slot]?.name || ''; + } + + onFilePicked(event: Event, slot: 'front' | 'back'): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] || null; + // Cleared so re-picking the same filename still fires a change event. + input.value = ''; + if (!file) { + return; + } + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + this.picked[slot] = file; + this.pickedPreview[slot] = file.type.startsWith('image/') ? URL.createObjectURL(file) : null; + } + + clearPick(slot: 'front' | 'back'): void { + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + this.picked[slot] = null; + this.pickedPreview[slot] = null; + } + + /** + * Something has to change for the update to mean anything - either a corrected detail or a fresh + * scan. Submitting an untouched form would reopen a verified KYC record for no reason. + */ + get hasChanges(): boolean { + if (this.picked.front || this.picked.back) { + return true; + } + const v = this.form.value; + const sameNumber = (v.documentNumber || '').trim() === (this.data.documentNumber || '').trim(); + const sameIssue = this.sameDay(v.issueDate, this.toDate(this.data.issueDate)); + const sameExpiry = this.sameDay(v.expiryDate, this.toDate(this.data.expiryDate)); + return !(sameNumber && sameIssue && sameExpiry); + } + + private sameDay(a: Date | null, b: Date | null): boolean { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + } + + submit(): void { + if (this.form.invalid || !this.hasChanges) { + this.form.markAllAsTouched(); + return; + } + const v = this.form.value; + const result: KycDocumentUpdateResult = { + documentNumber: (v.documentNumber || '').trim(), + issueDate: v.issueDate || null, + expiryDate: v.expiryDate || null, + front: this.picked.front, + back: this.picked.back, + remarks: (v.remarks || '').trim() + }; + this.dialogRef.close(result); + } + + cancel(): void { + this.dialogRef.close(); + } + + ngOnDestroy(): void { + // Only the previews this dialog created; the caller owns the ones it passed in. + for (const slot of this.slots) { + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + } + } +} diff --git a/src/app/core/utils/api-date.ts b/src/app/core/utils/api-date.ts index 25c64dc54..77a34947c 100644 --- a/src/app/core/utils/api-date.ts +++ b/src/app/core/utils/api-date.ts @@ -35,3 +35,32 @@ export function toIsoDate(value: any): string { } return String(value).slice(0, 10); } + +/** + * A Fineract timestamp as a real Date, so Angular's `date` pipe can format it. + * + * LocalDateTime arrives as [y, M, d, H, m, s] — an array the pipe renders as nothing at all, which + * is how a "Raised On" column came to sit empty next to an alert that plainly had been raised. + * Month is 1-based on the wire and 0-based in Date, hence the shift. + */ +export function toApiDate(value: any): Date | null { + if (!value) { + return null; + } + if (Array.isArray(value)) { + const [ + year, + month, + day, + hour = 0, + minute = 0, + second = 0 + ] = value; + if (!year || !month || !day) { + return null; + } + return new Date(year, month - 1, day, hour, minute, second); + } + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; +} diff --git a/src/app/fiu/fiu-data.ts b/src/app/fiu/fiu-data.ts index b423d948e..f87077470 100644 --- a/src/app/fiu/fiu-data.ts +++ b/src/app/fiu/fiu-data.ts @@ -65,6 +65,22 @@ export interface CtrTransaction { evidenceDocType?: string; evidenceReference?: string; evidencedOn?: string; + + /** + * Account and customer facts carried alongside the transaction, read off the books when the CTR + * line is loaded. + * + * Account_Detail and Account_Person_Relation are derived from these rows, and they used to fill + * these columns with constants — every account opened on 20/05/2018, every account ACTIVE, every + * customer reference '0'. Those are statements about a customer's account in a filing, so they + * come from the record rather than from a placeholder. + */ + accountOpenedOn?: string; + accountClosedOn?: string; + accountStatus?: string; + ckycNumber?: string; + clientExternalId?: string; + individual?: boolean; } /** Outcome of the Principal Officer's investigation into an STR case */ diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index 499752e34..d2f31d2ec 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -14,8 +14,8 @@

Financial Intelligence Unit – India (FIU-IND)

- {{ bank.name }} · {{ scopeName }} · Reporting Month {{ bank.reportingMonth }} - · PMLA 2002 / FINnet 2.0 + {{ bank.name }} · {{ scopeName }} · Reporting Month + {{ reportingMonthLabel }} · PMLA 2002 / FINnet 2.0
@@ -33,12 +33,13 @@ {{ scopeName }} } -
- Principal Officer{{ bank.principalOfficer }} -
-
- REBANK BUID{{ bank.reBuid }} -
+ + @if (principalOfficerName) { +
+ Principal Officer{{ principalOfficerName }} +
+ } @@ -164,7 +165,9 @@
Threshold: ₹{{ bank.cashThreshold | number }} per single txn / aggregate per month
-
Due: 15 Feb 2026
+ +
Due: {{ ctrDueDateLabel }}
@@ -206,11 +209,29 @@ with the same bundle and are worked on the Customer Cards tab.

- + + @if (ctrLinesLoading) { +
Loading the generated CTR lines…
+ } @else if (ctrLinesError) { +
+ {{ ctrLinesError }} +
+ } @else if (!ctrTxns.length) { +
+ + No CTR has been generated yet. Pick a month under “Generate CTR” below and run it — every cash transaction + of ₹10,00,000 or more, and every customer whose smaller cash transactions add up to that within the month, + will be picked up from the books. +
+ } +
@@ -450,7 +471,7 @@ {{ alert.transactionType }} {{ alert.ruleCode }} — {{ alert.ruleDescription }} ₹{{ alert.amount | number: '1.0-0' }} - {{ alert.raisedOn | date: 'medium' }} + {{ asDate(alert.raisedOn) | date: 'medium' }} {{ alert.status }} @@ -523,7 +544,7 @@ {{ alert.transactionType }} {{ alert.ruleCode }} — {{ alert.ruleDescription }} ₹{{ alert.amount | number: '1.0-0' }} - {{ alert.raisedOn | date: 'medium' }} + {{ asDate(alert.raisedOn) | date: 'medium' }} {{ alert.reviewedByUsername || '—' }} {{ alert.reviewNotes || '—' }} @@ -744,295 +765,6 @@ Download STR XML
- -
-
- - -
-
- - -
- -
- -
- @for (s of filteredStr; track s.reportRefNo) { -
-
-
- STR-{{ s.reportRefNo }} - {{ statusLabel(s.status) }} - {{ riskLabel(s.riskCategory) }} -
-
- {{ s.depositOrWithdrawal }} - ₹{{ s.txnAmount | number: '1.0-0' }} -
-
-
-
-
- {{ s.customerName }} -
-
- {{ s.accountNumber }} -
-
- {{ s.txnDate }} {{ s.txnTime }} -
-
- {{ s.detectedOn }} -
-
- {{ s.flaggedBy }} -
-
- {{ s.instrumentType }} {{ s.instrumentId }} -
-
- {{ s.branchCode }} {{ branchNameFor(s.branchCode) }} -
-
-
- -

{{ s.narrative }}

-
- @if (s.poRemarks) { -
- -

{{ s.poRemarks }}

-
- } - @if (s.poFinding) { -
- -

{{ poFindingLabel(s.poFinding) }}

-
- } - - - @if (s.status === 'awaiting-filing') { -
- -

- Generate the FINnet STR XML, upload it on the FIU-IND portal under - {{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here. -

-
- - - -
-
- } - - @if (s.status === 'filed' && s.fiuReferenceNo) { -
- - - Filed with FIU-IND on {{ s.filedOn }} · acknowledgement - {{ s.fiuReferenceNo }} - -
- } - - @if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) { -
- - -
- } - - - @if (investigatingStr?.reportRefNo === s.reportRefNo) { -
-
- Principal Officer investigation — STR-{{ s.reportRefNo }} - -
- - -
-
1 Documentary evidence called for
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
2 Customer’s explanation of the source
- -
- - -
-
- 3 Verification checklist - - {{ answeredCount }} / {{ strChecklist.length }} answered - -
- - - - - - - - - - @for (item of strChecklist; track item.key) { - - - - - - } - -
Verification pointGenuineNot satisfied
-
{{ item.label }}
-
{{ item.hint }}
-
- - - -
-
- - -
- @if (!canConcludeLocal) { - - - Complete all three steps to determine the outcome. - @if (!evidenceRecorded) { - Evidence not yet recorded. - } - @if (!allAnswered) { - {{ strChecklist.length - answeredCount }} verification point(s) pending. - } - - } @else if (derivedConclusion === 'NOT_SUSPICIOUS') { - - - Source of funds established. Case closes as clean — the transaction stays in - the CTR report and no STR is filed with FIU-IND. - - } @else { - - - Source of funds not established. {{ failedFindings.length }} point(s) - unsatisfied — an STR will be filed with FIU-IND in addition to the CTR. - - } -
- - -
- } -
-
- } @empty { -
No STR records match the filters.
- } -
} diff --git a/src/app/fiu/fiu.component.scss b/src/app/fiu/fiu.component.scss index dbac07b0c..f8f0390c8 100644 --- a/src/app/fiu/fiu.component.scss +++ b/src/app/fiu/fiu.component.scss @@ -1086,6 +1086,17 @@ flex-shrink: 0; margin-top: 2px; } + + /* Nothing to file yet, or the lines could not be read — not the neutral blue of an explanation. */ + &--warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; + + fa-icon { + color: #d97706; + } + } } /* TOAST */ diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index c2dc8081b..304f3375a 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -13,17 +13,14 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { AmlService } from 'app/aml/aml.service'; import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { toApiDate, toIsoDate } from 'app/core/utils/api-date'; import { BRANCHES, BranchMaster, CardTransaction, CtrTransaction, - CTR_TXNS, - TC1_TXNS, FIU_BANK, FIU_BRANCH_CODE_TO_CANONICAL, - KC1_INDIVIDUALS, - KC2_ENTITIES, KycEntity, KycIndividual, PoFinding, @@ -127,7 +124,7 @@ export const FIU_OBLIGATIONS: FiuObligation[] = [ ] }) export class FiuComponent implements OnInit { - /** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */ + /** Bump when the seeded STR set changes so cached rows are re-seeded. */ private static readonly SEED_VERSION = '2026-01-cash-threshold'; private amlService = inject(AmlService); @@ -140,11 +137,33 @@ export class FiuComponent implements OnInit { readonly obligations = FIU_OBLIGATIONS; bank = FIU_BANK; - ctrTxns: CtrTransaction[] = CTR_TXNS; - tc1Txns: CardTransaction[] = TC1_TXNS; + /** + * The CTR lines the bank has actually generated. Empty until the engine has produced a report — + * a filing screen that shows invented accounts is worse than one that shows none, because every + * count, total and CSV on it is then something nobody can submit. + */ + ctrTxns: CtrTransaction[] = []; + ctrLinesLoading = false; + ctrLinesError = ''; + /** + * ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2. + * + * Empty: the sample rows that used to sit here belonged to the sample accounts in TC2, and with + * those gone they referenced accounts this bank does not hold — twelve rows claiming to reconcile + * against a file that had none. Card transactions are not yet sourced from the books, so the file + * is honestly empty rather than populated with rows that cannot be filed. + */ + tc1Txns: CardTransaction[] = []; strTxns: StrTransaction[] = STR_TXNS; - kc1: KycIndividual[] = KC1_INDIVIDUALS; - kc2: KycEntity[] = KC2_ENTITIES; + /** + * KC1 / KC2 — the customer cards that travel with the CTR bundle. + * + * Empty for the same reason as TC1: these described sample customers of sample accounts. A card + * naming a person this bank has never onboarded cannot be filed, and leaving them in would put + * invented customers into a bundle an officer might submit. + */ + kc1: KycIndividual[] = []; + kc2: KycEntity[] = []; // -------------------- Head Office vs Branch scope -------------------- // CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole @@ -155,6 +174,12 @@ export class FiuComponent implements OnInit { readonly branches = BRANCHES; isHeadOffice = false; currentOfficeName = 'Branch Office'; + /** The month being filed, from the clock rather than a constant in the data file. */ + reportingMonthLabel = ''; + /** Blank unless the signed-in user actually holds the Principal Officer role. */ + principalOfficerName = ''; + /** CTR is due by the 15th of the month after the one being reported. */ + ctrDueDateLabel = ''; private ownBranchCode: string | null = null; /** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */ branchFilter = ''; @@ -372,6 +397,21 @@ export class FiuComponent implements OnInit { const creds = this.authenticationService.getCredentials(); this.isHeadOffice = this.authenticationService.isHeadOfficeUser(); this.currentOfficeName = creds?.officeName || 'Branch Office'; + + // The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and + // "January 2026" — on a screen whose whole purpose is to say which month is being filed and + // by whom. Both now come from the session and the clock. + const today = new Date(); + this.reportingMonthLabel = today.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); + // The 15th of the following month, which is the PMLA deadline for the month being reported. + this.ctrDueDateLabel = new Date(today.getFullYear(), today.getMonth() + 1, 15).toLocaleDateString('en-IN', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); + this.principalOfficerName = this.authenticationService.isPrincipalOfficerUser() + ? creds?.staffDisplayName || creds?.username || 'Principal Officer' + : ''; if (this.isHeadOffice) { this.branchFilter = ''; } else { @@ -384,22 +424,17 @@ export class FiuComponent implements OnInit { this.loadAmlAlerts(); this.loadAmlCtrReports(); this.loadAmlStrCases(); + // The CTR grids are built from the reports the bank generated, not from a sample set. Loaded + // after the cached rows below so a fresh read always wins over whatever was stored last. + this.loadCtrLines(); // Bumping SEED_VERSION discards previously cached rows so a new data set takes effect. if (localStorage.getItem('fiu_seed_version') !== FiuComponent.SEED_VERSION) { localStorage.setItem('fiu_seed_version', FiuComponent.SEED_VERSION); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns)); return; } - const storedCtr = localStorage.getItem('fiu_ctr_txns'); - if (storedCtr) { - this.ctrTxns = JSON.parse(storedCtr); - } else { - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); - } - const storedStr = localStorage.getItem('fiu_str_cases'); if (storedStr) { this.strTxns = JSON.parse(storedStr); @@ -442,7 +477,7 @@ export class FiuComponent implements OnInit { txn.evidenceDocType = this.ctrEvidenceDocType; txn.evidenceReference = this.ctrEvidenceRef.trim(); txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-'); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); this.evidencingTxn = null; this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`); } @@ -453,7 +488,7 @@ export class FiuComponent implements OnInit { */ referCtrTxnToStr(txn: CtrTransaction): void { txn.complianceStatus = 'REFERRED'; - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1; const newStrCase: StrTransaction = { @@ -521,7 +556,7 @@ export class FiuComponent implements OnInit { origin.evidenceDocType = this.evidenceDocType; origin.evidenceReference = this.evidenceReference.trim(); origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-'); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); } this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`); } else { @@ -598,6 +633,129 @@ export class FiuComponent implements OnInit { this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || [])); } + /** + * Fineract sends timestamps as [y, M, d, H, m, s]. Angular's date pipe renders an array as + * nothing, which left "Raised On" blank beside alerts that had obviously been raised. + */ + asDate(value: any): Date | null { + return toApiDate(value); + } + + /** + * Loads the generated CTR lines and shapes them into the TC2 rows every grid on this tab derives + * from — Account_Detail, Account_Person_Relation and TC1 are all computed off `ctrTxns`, so the + * one conversion here puts real data behind all four files. + */ + loadCtrLines(): void { + this.ctrLinesLoading = true; + this.ctrLinesError = ''; + this.amlService.getCtrReportLines().subscribe({ + next: (lines) => { + this.ctrLinesLoading = false; + this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1)); + // 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(); + }, + error: (err: any) => { + this.ctrLinesLoading = false; + this.ctrTxns = []; + this.ctrLinesError = + err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.'; + } + }); + } + + /** + * One reportable line as a TC2 row. + * + * A connected-group line is a month of smaller cash movements by one customer that together cross + * the threshold, so it has no single transaction, account or date behind it. It is still + * reportable, and is shown as the aggregate it is rather than dropped. + */ + private toCtrTransaction(line: any, refNo: number): CtrTransaction { + const branchCode = this.branchCodeOfName(line.branchName) || ''; + return { + reportType: 'CTR', + reportRefNo: refNo, + branchCode, + accountNumber: line.accountNo || '', + txnBranchCode: branchCode, + depositOrWithdrawal: line.direction === 'Withdrawal' ? 'Withdrawal' : 'Deposit', + txnId: line.transactionId || line.id, + txnDate: toIsoDate(line.transactionDate), + txnTime: '', + txnAmount: Number(line.amount) || 0, + instrumentType: 'Cash', + instrumentId: 'CASH', + nonCustomerRef: '', + relationshipFlag: 'Customer', + thirdPartyPan: '', + declaration: line.connectedGroup ? 'Connected transactions aggregated for the month' : '', + customerName: line.clientName || '', + complianceStatus: 'PENDING', + accountOpenedOn: toIsoDate(line.accountOpenedOn), + accountClosedOn: toIsoDate(line.accountClosedOn), + accountStatus: line.accountStatus || '', + ckycNumber: line.ckycNumber || '', + clientExternalId: line.clientExternalId || '', + individual: line.individual !== false + }; + } + + /** Branch master code for an office name, so the branch filter on this tab still works. */ + private branchCodeOfName(name: string | null): string | null { + if (!name) { + return null; + } + const wanted = name.trim().toLowerCase(); + return this.branches.find((b: BranchMaster) => wanted.includes(b.name.toLowerCase()))?.code || null; + } + + /** + * Re-applies the evidence an officer recorded against these transactions. + * + * The rows themselves come from the server every load; only the counter-side compliance fields + * are held locally, so they are matched back on transaction id rather than by replacing the row. + */ + private applyStoredCtrCompliance(): void { + const stored = localStorage.getItem('fiu_ctr_compliance'); + if (!stored) { + return; + } + try { + const byTxnId: Record = JSON.parse(stored); + for (const t of this.ctrTxns) { + const saved = byTxnId[String(t.txnId)]; + if (saved) { + t.complianceStatus = saved.complianceStatus; + t.evidenceDocType = saved.evidenceDocType; + t.evidenceReference = saved.evidenceReference; + t.evidencedOn = saved.evidencedOn; + } + } + } catch { + // A corrupted cache must not take the filing screen down with it. + localStorage.removeItem('fiu_ctr_compliance'); + } + } + + /** Persists only the compliance fields, keyed on transaction id. */ + private storeCtrCompliance(): void { + const byTxnId: Record = {}; + for (const t of this.ctrTxns) { + if (t.complianceStatus && t.complianceStatus !== 'PENDING') { + byTxnId[String(t.txnId)] = { + complianceStatus: t.complianceStatus, + evidenceDocType: t.evidenceDocType, + evidenceReference: t.evidenceReference, + evidencedOn: t.evidencedOn + }; + } + } + localStorage.setItem('fiu_ctr_compliance', JSON.stringify(byTxnId)); + } + /** Surfaces a failed backend call — without this a rejected request looks like a dead button. */ private apiError(action: string) { return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`); @@ -654,6 +812,9 @@ export class FiuComponent implements OnInit { next: () => { this.toast(`CTR report generated for ${this.ctrGenerateMonth}`); this.loadAmlCtrReports(); + // The grids above are built from these lines, so they are re-read rather than left showing + // the state before the run. + this.loadCtrLines(); }, error: this.apiError('CTR report generation') }); @@ -1016,7 +1177,9 @@ export class FiuComponent implements OnInit { t.txnBranchCode, t.depositOrWithdrawal, t.txnId.toString(), - t.txnDate.replace(/-/g, '/'), + // dd/MM/yyyy, as FINnet prints it. A straight slash-swap on an ISO date gave 2026/09/11, + // which reads as a different date to anyone expecting the prescribed format. + this.toFinnetDate(t.txnDate), t.txnTime, t.txnAmount.toString(), t.instrumentType, @@ -1254,6 +1417,19 @@ export class FiuComponent implements OnInit { return { headers, rows }; } + /** FINnet prints dates as dd/MM/yyyy; the API sends them as yyyy-MM-dd. Blank stays blank. */ + private toFinnetDate(iso: string | undefined): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return y && m && d ? `${d}/${m}/${y}` : ''; + } + /** Account_Detail — per-report account aggregates (12-month) */ private buildAccountDetail(): FinnetTable { const headers = [ @@ -1290,12 +1466,15 @@ export class FiuComponent implements OnInit { rows.push([ t.reportType, t.reportRefNo.toString(), + // 212 is FINnet's savings-account code; every CTR line this module produces is a savings + // transaction, and a loan line would need its own code before it could be filed. '212', t.accountNumber, t.branchCode, - '20/05/2018', - '', - 'ACTIVE', + // Read off the account, not assumed. Every row used to carry 20/05/2018 and ACTIVE. + this.toFinnetDate(t.accountOpenedOn), + this.toFinnetDate(t.accountClosedOn), + t.accountStatus || '', '', debits.length.toString(), debits.reduce((s, x) => s + x.txnAmount, 0).toString(), @@ -1333,16 +1512,16 @@ export class FiuComponent implements OnInit { const key = `${t.reportRefNo}|${t.accountNumber}`; if (seen.has(key)) continue; seen.add(key); - // Find matching KC1 by reportRefNo (best-effort dummy mapping) - const k = this.kc1.find((x) => x.reportRefNo === t.reportRefNo); rows.push([ t.reportType, t.reportRefNo.toString(), t.branchCode, t.accountNumber, '1', - k ? k.ucic : '0', - 'Individual', + // The customer's own reference: their CKYC number where one is on file, else the external + // id the bank knows them by. This used to read '0' for every row, which identified nobody. + t.ckycNumber || t.clientExternalId || '', + t.individual === false ? 'Non-individual' : 'Individual', t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : '' ]); } @@ -1488,14 +1667,56 @@ export class FiuComponent implements OnInit { } /** STR XML stub (FINnet 2.0 STR XML) */ + /** + * Writes the STR XML from the cases the Principal Officer has actually concluded as suspicious. + * + * It used to be built from the sample set, so the file named accounts and customers this bank + * does not hold — a return that could not be filed and should not have been offered. + */ exportStrXml(): void { - const suspects = this.strSuspects; + const suspects = (this.amlStrCases || []).filter( + (c: any) => c.conclusion === 'SUSPICIOUS' || c.status === 'CONCLUDED_SUSPICIOUS' || c.status === 'FILED' + ); if (!suspects.length) { - this.toast('No suspect cases to export — nothing matches the current filters'); + this.toast('No case has been concluded suspicious yet — nothing to file'); return; } - this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`); - this.toast(`STR XML exported (${suspects.length} suspects)`); + this.writeLiveStrXml(suspects, `STR-${this.reportingMonthLabel.replace(/\s+/g, '-')}.xml`); + this.toast(`STR XML exported (${suspects.length} case${suspects.length === 1 ? '' : 's'})`); + } + + /** Serialises the concluded cases as FINnet 2.0 STR XML and downloads it. */ + private writeLiveStrXml(cases: any[], filename: string): void { + const xml: string[] = []; + xml.push(''); + xml.push(``); + xml.push(` `); + if (this.principalOfficerName) { + xml.push(` `); + } + for (const c of cases) { + xml.push(` `); + xml.push(` ${this.escapeXml(c.clientDisplayName || '')}`); + xml.push(` ${this.escapeXml(c.conclusion || '')}`); + if (c.investigationNotes) { + xml.push(` ${this.escapeXml(c.investigationNotes)}`); + } + if (c.sourceOfFundsNotes) { + xml.push(` ${this.escapeXml(c.sourceOfFundsNotes)}`); + } + if (c.fiuReferenceNo) { + xml.push(` `); + } + xml.push(' '); + } + xml.push(''); + const blob = new Blob([xml.join('\n')], { type: 'application/xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); } /** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */ diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html index f614c533c..8d8edac1e 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html @@ -51,15 +51,6 @@ Could not load the review queue. - -
- science - - Showing sample data — the AML engine returned no recurring alerts for this threshold and window. - These rows are for demonstration only and are not reportable. - -
- 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 0f64353be..9d62ff9ed 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 @@ -79,27 +79,6 @@ font-size: 13px; } -.ra-demo { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 10px 12px; - margin-bottom: 12px; - border: 1px solid #fde68a; - border-radius: 6px; - background: #fffbeb; - color: #92400e; - font-size: 12.5px; - line-height: 1.5; - - mat-icon { - flex: 0 0 auto; - font-size: 18px; - width: 18px; - height: 18px; - } -} - .ra-table { width: 100%; border-collapse: separate; diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts index e69fcd8d5..bf2198e8c 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts @@ -25,7 +25,6 @@ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.service'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component'; -import { amlRecurringAlertsDemoData } from './aml-recurring-alerts.demo-data'; /** * Customers whose transactions keep tripping the AML engine, queued for an employee to review why. @@ -95,8 +94,6 @@ export class AmlRecurringAlertsComponent implements OnInit { loading = false; loadFailed = false; - /** Set when the rows on screen are the sample set rather than engine output. */ - usingDemoData = false; /** Which customer's alert detail is expanded inline. */ expandedClientId: number | null = null; @@ -111,20 +108,20 @@ export class AmlRecurringAlertsComponent implements OnInit { this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({ next: (data) => { this.loading = false; - const rows = data || []; - this.usingDemoData = rows.length === 0; - this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows); + this.rows.data = this.mapRows(data || []); }, error: () => { this.loading = false; this.loadFailed = true; - this.usingDemoData = true; - this.rows.data = this.mapRows(this.demoRows()); + // An AML review queue shows what the engine found or nothing at all. Standing in sample + // customers when the call fails invents suspicious activity against names that are not + // the bank's, which is worse than an empty table saying the load failed. + this.rows.data = []; } }); } - /** Parses the date fields the template renders. Shared by engine rows and the sample set. */ + /** Parses the date fields the template renders. */ private mapRows(rows: any[]): any[] { return rows.map((r: any) => ({ ...r, @@ -135,36 +132,6 @@ export class AmlRecurringAlertsComponent implements OnInit { })); } - /** - * The sample set narrowed by the on-screen controls, so the threshold and window selectors behave - * the way they would against the engine instead of returning the same three rows regardless. - */ - private demoRows(): any[] { - const cutoff = Date.now() - this.windowDays * 24 * 60 * 60 * 1000; - return amlRecurringAlertsDemoData() - .map((row: any) => { - const alerts = (row.alerts || []).filter((a: any) => new Date(a.raisedOn).getTime() >= cutoff); - const ruleCodes = Array.from(new Set(alerts.map((a: any) => a.ruleCode))); - return { - ...row, - alerts, - alertCount: alerts.length, - openCount: alerts.filter((a: any) => a.status === 'OPEN').length, - escalatedCount: alerts.filter((a: any) => a.status === 'ESCALATED').length, - // Severity sits on the alert server-side; the sample rows carry a row total, so it is - // capped at what survived the window rather than recomputed. - highRiskCount: Math.min(row.highRiskCount, alerts.length), - distinctRules: ruleCodes.length, - ruleCodes, - totalAmount: alerts.reduce((sum: number, a: any) => sum + (Number(a.amount) || 0), 0), - firstAlertOn: alerts.length ? alerts[0].raisedOn : null, - lastAlertOn: alerts.length ? alerts[alerts.length - 1].raisedOn : null - }; - }) - .filter((row: any) => row.alertCount >= this.minAlerts) - .filter((row: any) => !this.onlyNeedingReview || row.needsReview); - } - toggleExpanded(row: any): void { this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId; } diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts deleted file mode 100644 index ffab67a8e..000000000 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/** - * Sample recurring-alert rows for the AML review queue, used only when the engine returns nothing - * for the selected window so the screen can be demonstrated and reviewed. - * - * Shaped exactly like the `/aml/recurring-alerts` response — dates as ISO strings, alerts nested — - * so the rows travel through the component's normal mapping rather than a second code path. - * - * Dates are generated relative to today rather than hard-coded, otherwise every row would fall - * outside the "last 90 days" window as soon as the file aged. - */ - -/** ISO timestamp `days` before now, at the given hour and minute. */ -function daysAgo(days: number, hour = 11, minute = 20): string { - const d = new Date(); - d.setDate(d.getDate() - days); - d.setHours(hour, minute, 0, 0); - return d.toISOString(); -} - -/** - * Three customers covering the three review states the table renders: never reviewed, reviewed but - * flagged again since, and reviewed with an outcome recorded. - */ -export function amlRecurringAlertsDemoData(): any[] { - return [ - // Never reviewed — the row the queue exists to surface. - { - clientId: 10078, - clientDisplayName: 'Ravi Kumar Sharma', - accountNo: '000000010078', - clientRiskCategory: 3, - alertCount: 5, - highRiskCount: 3, - openCount: 4, - escalatedCount: 1, - distinctRules: 3, - ruleCodes: [ - 'CASH_TXN_10L', - 'STRUCTURING', - 'RAPID_MOVEMENT' - ], - totalAmount: 4785000, - firstAlertOn: daysAgo(62, 10, 5), - lastAlertOn: daysAgo(3, 15, 42), - needsReview: true, - lastReviewedOn: null, - alertCountAtLastReview: null, - lastReviewOutcome: null, - lastReviewedByUsername: null, - lastReviewNotes: null, - alerts: [ - { - alertId: 90121, - raisedOn: daysAgo(62, 10, 5), - transactionType: 'Savings Deposit', - transactionId: 552301, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1200000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90144, - raisedOn: daysAgo(48, 12, 18), - transactionType: 'Savings Deposit', - transactionId: 553980, - ruleCode: 'STRUCTURING', - ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours', - amount: 890000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90190, - raisedOn: daysAgo(29, 9, 55), - transactionType: 'Savings Withdrawal', - transactionId: 556114, - ruleCode: 'RAPID_MOVEMENT', - ruleDescription: 'Funds withdrawn within 24 hours of a large credit', - amount: 1150000, - status: 'ESCALATED', - reviewNotes: 'Escalated to Principal Officer — source of funds not established.' - }, - { - alertId: 90233, - raisedOn: daysAgo(14, 16, 30), - transactionType: 'Savings Deposit', - transactionId: 558702, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1045000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90275, - raisedOn: daysAgo(3, 15, 42), - transactionType: 'Savings Withdrawal', - transactionId: 560488, - ruleCode: 'STRUCTURING', - ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours', - amount: 500000, - status: 'OPEN', - reviewNotes: null - } - ] - }, - - // Reviewed at 2 alerts, flagged twice since — returns to the queue as "New activity". - { - clientId: 10079, - clientDisplayName: 'Priya Patel', - accountNo: '000000010079', - clientRiskCategory: 2, - alertCount: 4, - highRiskCount: 1, - openCount: 2, - escalatedCount: 0, - distinctRules: 2, - ruleCodes: [ - 'HIGH_VALUE_TRANSFER', - 'ROUND_TRIPPING' - ], - totalAmount: 2130000, - firstAlertOn: daysAgo(71, 14, 10), - lastAlertOn: daysAgo(6, 11, 5), - needsReview: true, - lastReviewedOn: daysAgo(34, 17, 0), - alertCountAtLastReview: 2, - lastReviewOutcome: 'MONITOR', - lastReviewedByUsername: 'anjali.mehta', - lastReviewNotes: 'Textile trading receipts, invoices seen. Keep under watch for one more quarter.', - alerts: [ - { - alertId: 90102, - raisedOn: daysAgo(71, 14, 10), - transactionType: 'Account Transfer', - transactionId: 551002, - ruleCode: 'HIGH_VALUE_TRANSFER', - ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account', - amount: 620000, - status: 'WHITELISTED', - reviewNotes: 'Supplier payment, invoice verified.' - }, - { - alertId: 90158, - raisedOn: daysAgo(41, 10, 45), - transactionType: 'Account Transfer', - transactionId: 554330, - ruleCode: 'ROUND_TRIPPING', - ruleDescription: 'Funds returned to the originating account within seven days', - amount: 450000, - status: 'WHITELISTED', - reviewNotes: 'Advance refunded after order cancellation.' - }, - { - alertId: 90241, - raisedOn: daysAgo(19, 13, 25), - transactionType: 'Account Transfer', - transactionId: 559011, - ruleCode: 'HIGH_VALUE_TRANSFER', - ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account', - amount: 540000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90268, - raisedOn: daysAgo(6, 11, 5), - transactionType: 'Account Transfer', - transactionId: 560190, - ruleCode: 'ROUND_TRIPPING', - ruleDescription: 'Funds returned to the originating account within seven days', - amount: 520000, - status: 'OPEN', - reviewNotes: null - } - ] - }, - - // Reviewed with an outcome and no activity since — shows the settled state. - { - clientId: 10080, - clientDisplayName: 'Arjun Deshmukh', - accountNo: '000000010080', - clientRiskCategory: 1, - alertCount: 3, - highRiskCount: 0, - openCount: 0, - escalatedCount: 0, - distinctRules: 1, - ruleCodes: ['CASH_TXN_10L'], - totalAmount: 3420000, - firstAlertOn: daysAgo(80, 9, 30), - lastAlertOn: daysAgo(23, 12, 15), - needsReview: false, - lastReviewedOn: daysAgo(9, 15, 20), - alertCountAtLastReview: 3, - lastReviewOutcome: 'CLEARED', - lastReviewedByUsername: 'vikram.singh', - lastReviewNotes: 'Sugarcane crushing-season proceeds. Mill settlement statements filed with the branch.', - alerts: [ - { - alertId: 90088, - raisedOn: daysAgo(80, 9, 30), - transactionType: 'Savings Deposit', - transactionId: 550210, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1180000, - status: 'WHITELISTED', - reviewNotes: 'Mill settlement for the 2025-26 crushing season.' - }, - { - alertId: 90165, - raisedOn: daysAgo(52, 11, 0), - transactionType: 'Savings Deposit', - transactionId: 554901, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1090000, - status: 'WHITELISTED', - reviewNotes: 'Second instalment, statement on record.' - }, - { - alertId: 90212, - raisedOn: daysAgo(23, 12, 15), - transactionType: 'Savings Deposit', - transactionId: 557640, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1150000, - status: 'WHITELISTED', - reviewNotes: 'Final instalment for the season.' - } - ] - } - ]; -} diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts index 3e054ceda..633de824d 100644 --- a/src/app/risk-review/risk-review-data.ts +++ b/src/app/risk-review/risk-review-data.ts @@ -7,6 +7,7 @@ */ import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data'; +import { toIsoDate } from '../core/utils/api-date'; /** * Data layer for the Risk Review Report. @@ -390,17 +391,23 @@ export interface LiveAmlAlert { ruleDescription: string; amount: number; currencyCode: string; - raisedOn: string; + /** Fineract sends LocalDateTime as [y,m,d,h,mi,s], not a string. */ + raisedOn: number[] | string; status: string; reviewedByUsername: string | null; - reviewedOn: string | null; + reviewedOn: number[] | string | null; reviewNotes: string | null; disposition: string | null; } -/** Trims a LocalDateTime such as 2026-07-14T11:04:22 down to its date part. */ -function isoDayOf(value: string | null | undefined): string { - return (value || '').slice(0, 10); +/** + * Fineract serialises LocalDateTime as an array — raisedOn arrives as + * [2026,9,11,0,0,0], not a string. Slicing that gave back an array rather than + * a date, so every live row carried an unusable alertDate and the period filter + * discarded it: the report read empty while the engine held alerts. + */ +function isoDayOf(value: unknown): string { + return toIsoDate(value); } /** diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html index 40d308e22..503cb0dad 100644 --- a/src/app/risk-review/risk-review-report.component.html +++ b/src/app/risk-review/risk-review-report.component.html @@ -150,33 +150,62 @@ } @else {
-
+ +
-
+ +
-
+
-
+ +
+
@@ -220,7 +249,7 @@ row does not have to repeat it in a Move To and a Position column. --> @for (g of customerGroups; track g.key) {
-
+ @@ -268,6 +298,20 @@ + + - - @if (expanded === c.clientId) { - diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index a8863798f..d9df2dc0d 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -142,6 +142,12 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request. background-color: #fef3c7; color: #b45309; } + /* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only + with the paper it was printed on, and the draft itself is alive on another leaflet. */ + .status-badge.revoked { + background-color: #e2e8f0; + color: #475569; + } .action-btn-group { display: flex; gap: 8px; @@ -212,7 +218,10 @@ export class DdListComponent implements OnInit, AfterViewInit { 'ISSUED', 'CANCELLED', 'STOPPED', - 'CLEARED' + 'CLEARED', + // Spoiled at the printer. Filterable on its own because it is the count that explains a pad + // whose issued drafts do not add up to the numbers consumed. + 'REVOKED' ]; selectedStatus = 'ALL'; searchText = ''; @@ -444,6 +453,23 @@ export class DdListComponent implements OnInit, AfterViewInit { } }); } + + /** The leaflet was ruined at the printer and its draft has to move onto a fresh blank. */ + openRevokeDialog(leaflet: any): void { + this.dialog + .open(RevokeDdDialogComponent, { + width: '480px', + maxWidth: '95vw', + data: { leaflet }, + disableClose: true + }) + .afterClosed() + .subscribe((result) => { + if (result) { + this.loadLeaflets(); + } + }); + } } // ── ISSUE DRAFT DIALOG COMPONENT ── @@ -1655,6 +1681,114 @@ export class RequestReprintDialogComponent implements OnInit { } } +/** + * Replacing a leaflet ruined at the printer. + * + * Not a reprint: a reprint puts the same draft back through the printer on the same paper, which is + * right when the print jammed but the leaflet survived. Here the leaflet itself is spoiled — its + * printed number can never be handed to anyone — so the draft moves onto the next blank and both + * leaflets stay on the register, or the pad comes up short against the sponsor bank's range. + */ +@Component({ + selector: 'mifosx-revoke-dd-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Revoke Spoiled DD (DD No: {{ data.leaflet.ddNumber }})

+ +

+ Use this when the leaflet itself is ruined — misprinted, torn or jammed — and cannot be given to the customer. + On approval this leaflet is marked REVOKED and the draft is re-issued on the next available + blank. +

+

+ No money moves. {{ data.leaflet.clientName || data.leaflet.walkinPurchaserName || 'The purchaser' }} has already + paid for this draft and is still owed the same one. +

+
+ + What went wrong with this leaflet? + + Reason is required + + +
+ + + + + `, + styles: [ + ` + .dialog-form { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 8px; + } + .full-width { + width: 100%; + } + .hint-text { + color: #64748b; + font-size: 13px; + margin: 0 0 10px 0; + line-height: 1.5; + } + ` + ] +}) +export class RevokeDdDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private dialogRef = inject(MatDialogRef); + private snackBar = inject(MatSnackBar); + protected data = inject(MAT_DIALOG_DATA); + + revokeForm!: FormGroup; + loading = false; + + ngOnInit(): void { + this.revokeForm = this.fb.group({ + reason: [ + '', + Validators.required + ] + }); + } + + onCancel(): void { + this.dialogRef.close(false); + } + + onSubmit(): void { + if (this.revokeForm.invalid) { + return; + } + this.loading = true; + this.sponsorDDService.requestRevoke(this.data.leaflet.id, this.revokeForm.value.reason).subscribe({ + next: () => { + this.snackBar.open('Revoke request submitted for approval', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + } +} + // ── PRINT DRAFT DIALOG COMPONENT ── @Component({ selector: 'mifosx-print-draft-dialog', diff --git a/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html new file mode 100644 index 000000000..7d6481477 --- /dev/null +++ b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html @@ -0,0 +1,130 @@ + +
+
+
+

DD Revoke Approvals

+

+ A leaflet ruined at the printer cannot be handed over, and its number can never be given to anyone else. + Approving moves the same draft onto the next available blank — this series first, then this sponsor bank, then + any other. No money moves: the customer was already debited. +

+
+
+ + + Search by DD No. or customer + + + +
+ +
+ + +
Customer Account Number Branch Rating on fileReview says Alerts AML Rules Triggered Amount under review {{ riskText(c.currentRisk) }} + + + {{ directionText(c) }} + + @if (c.directionReason) { +
{{ c.directionReason }}
+ } +
{{ c.alertCount }} @for (label of c.ruleLabels; track label) { @@ -276,10 +320,11 @@ {{ c.totalAmount | number: '1.0-0' }} {{ c.lastAlertDate }} + + - @if (c.directionReason) { -
{{ c.directionReason }}
- }
+ @for (a of c.alerts; track a.alertId) {
diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index 117eae800..7f6dfa5d5 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -286,12 +286,61 @@ margin-bottom: 14px; } +/* The verdict, on the row. Colour carries the same meaning as the section it sits under, so a + reviewer scanning the middle of a long table does not have to look back up to the heading. */ +.rr-move { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 700; + white-space: nowrap; + + &--up { + background: #fef3c7; + color: #92400e; + } + + &--down { + background: #dbeafe; + color: #1e40af; + } + + &--same { + background: #f1f5f9; + color: var(--r-g500); + } +} + +/* A button, so the tile is reachable by keyboard and reads as the control it is. The type + defaults that come with that — centred text, the button font, the UA border — are undone + here so it still looks like the panel it replaced. */ .rr-kpi { + display: block; + width: 100%; background: #fff; border: 1px solid var(--r-g200); border-left: 4px solid var(--r-navy); border-radius: 11px; padding: 12px 16px; + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; + transition: + box-shadow 0.15s ease, + border-color 0.15s ease; + + &:hover { + box-shadow: 0 1px 6px rgb(15 23 42 / 12%); + } + + &:focus-visible { + outline: 2px solid var(--r-blue); + outline-offset: 2px; + } &--blue { border-left-color: var(--r-blue); @@ -307,6 +356,18 @@ &--dim { border-left-color: var(--r-g300); + /* Nothing to narrow to, so the tile says so rather than accepting a click that does nothing. */ + cursor: default; + + &:hover { + box-shadow: none; + } + } + + /* The filter this tile applies is currently on. */ + &--on { + border-color: var(--r-navy); + box-shadow: inset 0 0 0 1px var(--r-navy); } } @@ -502,9 +563,11 @@ } td { - padding: 8px 10px; + padding: 11px 10px; border-bottom: 1px solid var(--r-g100); - vertical-align: top; + /* Middle rather than top: one cell in the row carries a two-line reason, and top alignment + left every other value floating against the row above it. */ + vertical-align: middle; } .num { @@ -538,6 +601,8 @@ td { padding: 12px 16px 16px; + /* The expanded block is a panel, not a value, so it starts at the top of its cell. */ + vertical-align: top; } } @@ -789,6 +854,19 @@ } /* What the report says should happen to the rating */ +/* The verdict and the sentence behind it. Wide enough that the reason wraps to two readable + lines instead of a narrow ragged column. */ +.rr-move-cell { + min-width: 250px; + max-width: 330px; +} + +/* Just the button, so the column is only as wide as the control it holds. */ +.rr-action-cell { + width: 1%; + white-space: nowrap; +} + .rr-verdict-cell { min-width: 210px; max-width: 300px; @@ -817,9 +895,11 @@ } .rr-verdict-why { - font-size: 10.5px; + font-size: 11px; + line-height: 1.45; color: var(--r-g500); - margin-top: 2px; + margin-top: 4px; + white-space: normal; white-space: normal; } diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index bd59f7bd6..8f0775fc3 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -224,21 +224,35 @@ export class RiskReviewReportComponent implements OnInit { return; } + // The alert carries the client but not their account number or branch, so both columns read + // as an em dash on every live row. Both sit on the client record the KYC call already needs, + // so they are fetched alongside it rather than left blank on a compliance report. forkJoin( clientIds.map((id) => - this.clients.getClientKyc(String(id), true).pipe( - map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })), - catchError(() => of({ id, risk: null as RiskLevel | null })) + forkJoin({ + kyc: this.clients.getClientKyc(String(id), true).pipe(catchError(() => of(null))), + client: this.clients.getClientData(String(id), true).pipe(catchError(() => of(null))) + }).pipe( + map((r: any) => ({ + id, + risk: riskFromCategory(r.kyc?.riskCategory), + accountNo: r.client?.accountNo || '', + branch: r.client?.officeName || '' + })), + catchError(() => of({ id, risk: null as RiskLevel | null, accountNo: '', branch: '' })) ) ) ).subscribe((results) => { - const byClient = new Map( - results.map((r) => [ - r.id, - r.risk - ]) - ); - this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null })); + const byClient = new Map(results.map((r) => [r.id, r])); + this.liveAlerts = rows.map((r) => { + const info = byClient.get(r.clientId); + return { + ...r, + currentRisk: info?.risk ?? null, + accountNo: r.accountNo || info?.accountNo || '', + branchCode: r.branchCode || info?.branch || '' + }; + }); this.compose(); this.loading = false; }); @@ -389,8 +403,62 @@ export class RiskReviewReportComponent implements OnInit { return groups.filter((g) => g.rows.length > 0); } - /** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */ - collapsedGroups: Record = { same: true }; + /** + * Collapsed sections, by group key. Every group starts open. + * + * 'No change needed' used to start closed on the reasoning that there was nothing to act on. On a + * report that often holds one group, that meant opening the page to a heading and a count and no + * rows — the customer was there, but reading the report took a click to discover it. The toggle + * stays for folding a long section away. + */ + collapsedGroups: Record = {}; + + /** + * The summary tiles read as a question each — "which customers?", "which alerts still need a + * decision?" — so each one narrows the table to the rows it counted. They were plain text + * before, which invited a click that did nothing and left the reviewer to rebuild the same + * filter by hand from the dropdowns. + * + * Clicking an active tile clears it again, so a tile is never a one-way trip into a filter the + * reviewer then has to hunt for. + */ + focusAllCustomers(): void { + this.currentRisk = ''; + this.direction = ''; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } + + focusOpenAlerts(): void { + this.openOnly = !this.openOnly; + this.direction = ''; + this.expanded = null; + this.collapsedGroups = {}; + } + + /** Only the customers whose rating the review says should move, either way. */ + focusRatingsToMove(): void { + // Nothing to narrow to when no rating needs moving; leaving the filter alone keeps the + // table showing what is actually there rather than emptying it. + if (this.summary.upgradeCandidates + this.summary.downgradeCandidates === 0) { + return; + } + this.direction = this.direction === 'up' ? 'down' : this.direction === 'down' ? '' : 'up'; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } + + focusEscalated(): void { + if (this.summary.escalatedCustomers === 0) { + return; + } + this.status = this.status === 'ESCALATED_STR' ? '' : 'ESCALATED_STR'; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } toggleGroup(key: string): void { this.collapsedGroups[key] = !this.collapsedGroups[key]; @@ -513,6 +581,47 @@ export class RiskReviewReportComponent implements OnInit { * An escalated case is worded as a review rather than a move: the rating is held * pending the Principal Officer's conclusion, so there is nothing to apply yet. */ + /** + * The verdict in the words a reviewer needs: which way the rating moves, and to what. + * + * An escalated case is deliberately not given a direction — the rating is held where it is + * until the Principal Officer reports back, so showing "raise to High" there would invite a + * change nobody is authorised to make yet. + */ + directionText(c: RiskCustomerRow): string { + if (c.escalated) { + return 'Held — with Principal Officer'; + } + if (c.direction === 'up') { + return `Raise to ${RISK_LABEL[c.recommendedRisk]}`; + } + if (c.direction === 'down') { + return `Lower to ${RISK_LABEL[c.recommendedRisk]}`; + } + return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record'; + } + + directionIcon(c: RiskCustomerRow): string { + if (c.escalated) { + return 'lock'; + } + // 'check' rather than an equals sign: faEquals is not in the shared library, and an + // unregistered icon throws at render and takes the whole row down with it. + return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check'; + } + + /** + * A live row carries the office name straight off the client record, not a GL branch code, so + * it is already the label to print. Only a coded row needs the lookup. + */ + branchLabelOf(branch: string): string { + if (!branch) { + return '—'; + } + const named = glBranchName(branch); + return named && named !== branch ? named : branch; + } + kycActionText(c: RiskCustomerRow): string { if (c.escalated) { return 'Open KYC'; @@ -689,7 +798,7 @@ export class RiskReviewReportComponent implements OnInit { String(c.clientId), c.customerName, c.accountNo || '—', - c.branchCode ? glBranchName(c.branchCode) : '—', + this.branchLabelOf(c.branchCode), this.riskText(c.currentRisk), c.alertCount, c.ruleLabels.join('; '), @@ -749,7 +858,7 @@ export class RiskReviewReportComponent implements OnInit { String(a.clientId), a.customerName, a.accountNo || '—', - a.branchCode ? glBranchName(a.branchCode) : '—', + this.branchLabelOf(a.branchCode), this.riskText(a.currentRisk), this.txnDetail(a), a.rule.label, @@ -815,7 +924,7 @@ export class RiskReviewReportComponent implements OnInit { String(t.clientId), t.customerName, t.accountNo || '—', - t.branchCode ? glBranchName(t.branchCode) : '—', + this.branchLabelOf(t.branchCode), this.riskText(t.currentRisk), channelLabel(t.channel), t.drCr, diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index ce0dd9000..67499a2bc 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -218,7 +218,14 @@ function recommendRating( }; } - return { risk, reason: '' }; + // The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank + // read as "not assessed yet" on screen, which is the opposite of what the review concluded. + return { + risk, + reason: currentRisk + ? `${sizes}. Consistent with ${RISK_LABEL[risk]} — the rating on file stands.` + : `${sizes}. Activity points at ${RISK_LABEL[risk]}; no rating on record to compare against.` + }; } /** Applies every filter and joins each alert to its rule. */ diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts index ddfc1d72a..f0f82b719 100644 --- a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts +++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts @@ -240,45 +240,83 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { this.approveBatch(request); return; } - if ( - !confirm( - `Approve DD issuance for DD No. ${request.ddNumber} (₹${request.faceValue})? This will debit the account (or clear the cheque) and issue the draft.` - ) - ) { - return; + this.dialog + .open(ApproveIssueRequestDialogComponent, { + width: '440px', + maxWidth: '95vw', + data: { + isBatch: false, + ddNumber: request.ddNumber, + beneficiaryName: request.beneficiaryName, + clientName: request.clientName, + totalFaceValue: Number(request.faceValue) || 0, + // Named for how this particular draft is funded, rather than offering the approver every + // possibility and leaving them to work out which one applies. + effect: this.effectTextFor(request) + } + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveIssueRequest(request.id).subscribe({ + next: () => { + this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + }); + } + + /** What approving this request actually does, in the words of its own payment method. */ + private effectTextFor(request: any, count = 1): string { + const drafts = count > 1 ? `${count} drafts` : 'the draft'; + const once = count > 1 ? ' once for the combined total' : ''; + switch ((request.paymentMethod || '').toUpperCase()) { + case 'ACCOUNT_DEBIT': + return `The customer's account is debited${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; + case 'CHEQUE': + return `The cheque is marked cleared${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; + default: + return `The cash collected is posted${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; } - this.sponsorDDService.approveIssueRequest(request.id).subscribe({ - next: () => { - this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 }); - this.loadRequests(); - }, - error: (err) => { - this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); - } - }); } private approveBatch(request: any): void { const siblings = this.batchSiblings(request); - const lines = siblings.map((r) => ` • ${r.beneficiaryName} — ₹${r.faceValue}`).join('\n'); const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0); - if ( - !confirm( - `Approve this DD slip - ${siblings.length} drafts, combined face value ₹${total}?\n${lines}\n\n` + - 'This debits the account (or clears the cheque) ONCE for the combined total and issues every draft in the slip.' - ) - ) { - return; - } - this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ - next: () => { - this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); - this.loadRequests(); - }, - error: (err) => { - this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); - } - }); + + this.dialog + .open(ApproveIssueRequestDialogComponent, { + width: '460px', + maxWidth: '95vw', + data: { + isBatch: true, + clientName: request.clientName, + totalFaceValue: total, + lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })), + effect: this.effectTextFor(request, siblings.length) + } + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ + next: () => { + this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + }); } openRejectDialog(request: any): void { @@ -302,6 +340,123 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { } } +/** + * What the approver is about to commit, laid out rather than run together in a sentence. + * + * This was a browser `confirm()`, which the browser renders with its own "localhost:4200 says" + * chrome and no formatting — so the one figure that matters, the amount leaving the customer's + * account, sat mid-paragraph beside a parenthetical about cheques that does not apply to most + * drafts. An approval is the point of no return on someone else's money; it should read like one. + */ +@Component({ + selector: 'mifosx-approve-issue-request-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

+ {{ data.isBatch ? 'Approve DD Slip' : 'Approve DD Issuance' }} +

+ +
+
+ {{ data.isBatch ? 'Drafts' : 'DD Number' }} + {{ data.isBatch ? data.lines.length + ' drafts' : data.ddNumber }} +
+
+ In favour of + {{ data.beneficiaryName || '—' }} +
+
+ Purchaser + {{ data.clientName }} +
+
+ {{ data.isBatch ? 'Combined face value' : 'Face value' }} + ₹{{ data.totalFaceValue | number: '1.2-2' }} +
+
+ + +
    +
  • {{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}
  • +
+ +

+ {{ data.effect }} +

+
+ + + + + `, + styles: [ + ` + .approve-title { + margin-bottom: 4px; + } + .approve-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 4px 14px; + margin-bottom: 12px; + background: #f8fafc; + } + .approve-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 8px 0; + border-bottom: 1px solid #eef2f7; + font-size: 13px; + } + .approve-row:last-child { + border-bottom: none; + } + .approve-label { + color: #64748b; + } + .approve-value { + text-align: right; + } + .approve-amount { + font-size: 17px; + font-weight: 700; + } + .approve-lines { + margin: 0 0 12px; + padding-left: 20px; + font-size: 12.5px; + color: #334155; + max-height: 160px; + overflow-y: auto; + } + .approve-effect { + margin: 0; + padding: 10px 12px; + border-radius: 6px; + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; + font-size: 12.5px; + line-height: 1.5; + } + ` + ] +}) +export class ApproveIssueRequestDialogComponent { + dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); +} + // ── REJECT DIALOG COMPONENT ── @Component({ selector: 'mifosx-reject-issue-request-dialog', diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index a7b1fa4ff..1a0b5be09 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -209,6 +209,31 @@ Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }}) + + + + + Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }}) + + + + + Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }} + + + Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req. ID{{ element.id }}Spoiled DD + {{ element.ddNumber }} + Purchaser{{ element.clientName || '—' }}Beneficiary{{ element.beneficiaryName || '—' }}Face Value{{ element.faceValue | number: '1.2-2' }}Reason{{ element.reason }}Replaced By + {{ element.replacementDdNumber }} + — + Status + + {{ element.status }} + + Requested By{{ element.makerUsername }}Requested On{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}Actions +
+ + +
+ {{ + element.rejectionReason + }} +
+
by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}
+
+
+ +
+ No DD revoke requests match the selected filter. +
+ + + + diff --git a/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts new file mode 100644 index 000000000..8d20db0e8 --- /dev/null +++ b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts @@ -0,0 +1,460 @@ +/** + * 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, AfterViewInit, ViewChild, inject, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatTableModule, MatTableDataSource } from '@angular/material/table'; +import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { SponsorDDService } from '../sponsor-dd.service'; + +/** + * The queue for drafts spoiled at the printer. + * + * Kept apart from reprint approvals because the two decisions are not alike. A reprint puts the + * same draft back through the printer on the same leaflet and consumes nothing. Approving a revoke + * spends a blank from a limited pad the sponsor bank sent — so the checker is told which customer's + * draft it is, for how much, and that a fresh leaflet will be taken. + */ +@Component({ + selector: 'mifosx-dd-revoke-approvals', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatCardModule, + MatTableModule, + MatPaginatorModule, + MatButtonModule, + MatDialogModule, + MatSnackBarModule, + MatFormFieldModule, + MatInputModule + ], + templateUrl: './dd-revoke-approvals.component.html', + styles: [ + ` + .approvals-container { + padding: 24px; + max-width: 1240px; + margin: 0 auto; + } + .header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + } + .title-area h2 { + margin: 0 0 4px 0; + font-size: 24px; + font-weight: 600; + color: #1e293b; + } + .title-area p { + margin: 0; + color: #64748b; + font-size: 14px; + } + .search-field { + width: 320px; + display: block; + margin-bottom: 8px; + } + .mat-mdc-card { + border-radius: 12px; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + overflow: hidden; + } + table { + width: 100%; + } + th { + font-weight: 600; + color: #475569; + background-color: #f8fafc; + } + td { + color: #334155; + } + .status-badge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + } + .status-badge.pending { + background-color: #fef3c7; + color: #b45309; + } + .status-badge.approved { + background-color: #dcfce7; + color: #15803d; + } + .status-badge.rejected { + background-color: #fee2e2; + color: #b91c1c; + } + .action-btn-group { + display: flex; + gap: 8px; + } + .status-filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + } + .status-filter-row button.filter-active { + background-color: #ede9fe; + } + .filter-count { + color: #64748b; + margin-left: 4px; + } + /* The replacement is the whole point of the decision, so it reads as a number rather than + sitting in the row as ordinary text. */ + .replacement-no { + font-weight: 600; + color: #15803d; + } + .muted { + color: #94a3b8; + } + ` + ] +}) +export class DdRevokeApprovalsComponent implements OnInit, AfterViewInit { + private sponsorDDService = inject(SponsorDDService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + @ViewChild(MatPaginator) paginator!: MatPaginator; + + displayedColumns: string[] = [ + 'id', + 'ddNumber', + 'clientName', + 'beneficiaryName', + 'faceValue', + 'reason', + 'replacementDdNumber', + 'status', + 'makerUsername', + 'createdDate', + 'actions' + ]; + dataSource = new MatTableDataSource([]); + + readonly statusOptions: string[] = [ + 'PENDING', + 'APPROVED', + 'REJECTED', + 'ALL' + ]; + selectedStatus = 'PENDING'; + searchText = ''; + + ngOnInit(): void { + this.dataSource.filterPredicate = (data) => { + const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus; + const needle = this.searchText.trim().toLowerCase(); + const searchMatches = + !needle || + (data.ddNumber || '').toLowerCase().includes(needle) || + (data.clientName || '').toLowerCase().includes(needle); + return statusMatches && searchMatches; + }; + this.loadRequests(); + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + } + + loadRequests(): void { + this.sponsorDDService.getAllRevokeRequests().subscribe({ + next: (data) => { + this.dataSource.data = data || []; + this.applyFilter(); + }, + error: (err) => { + this.snackBar.open('Error loading revoke requests: ' + (err.error?.message || err.message), 'Close', { + duration: 5000 + }); + } + }); + } + + statusCount(status: string): number { + const all = this.dataSource.data; + return status === 'ALL' ? all.length : all.filter((r) => r.status === status).length; + } + + filterByStatus(status: string): void { + this.selectedStatus = status; + this.applyFilter(); + } + + onSearchChange(value: string): void { + this.searchText = value; + this.applyFilter(); + } + + private applyFilter(): void { + this.dataSource.filter = `${this.selectedStatus}|${this.searchText}`; + if (this.dataSource.paginator) { + this.dataSource.paginator.firstPage(); + } + } + + approve(request: any): void { + this.dialog + .open(ApproveRevokeRequestDialogComponent, { + width: '460px', + maxWidth: '95vw', + data: { request }, + disableClose: true + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveRevokeRequest(request.id).subscribe({ + next: (res: any) => { + // The replacement number is what the clerk has to write on and hand over, so it is said + // plainly rather than left for them to find by reopening the register. + const replacement = res?.subResourceId ? ` Replacement DD issued — leaflet #${res.subResourceId}.` : ''; + this.snackBar.open(`DD ${request.ddNumber} revoked.${replacement}`, 'Close', { duration: 6000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 7000 }); + } + }); + }); + } + + openRejectDialog(request: any): void { + this.dialog + .open(RejectRevokeRequestDialogComponent, { + width: '450px', + data: { request }, + disableClose: true + }) + .afterClosed() + .subscribe((result) => { + if (result) { + this.loadRequests(); + } + }); + } +} + +/** + * What approving actually costs, laid out before it is done. + * + * A browser `confirm()` would run the customer, the amount and the fact that a blank is consumed + * together in one sentence under the browser's own "localhost says" chrome. This is a decision + * about someone else's draft and about stock that cannot be replaced, so it reads like one. + */ +@Component({ + selector: 'mifosx-approve-revoke-request-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

Approve DD Revoke

+ +
+
+ Spoiled DD + {{ data.request.ddNumber }} +
+
+ Purchaser + {{ data.request.clientName }} +
+
+ In favour of + {{ data.request.beneficiaryName }} +
+
+ Face value + ₹{{ data.request.faceValue | number: '1.2-2' }} +
+
+ +

Reason given
{{ data.request.reason }}

+ +

+ This marks {{ data.request.ddNumber }} as REVOKED and re-issues the same draft on the next available blank — + preferring this series, then this sponsor bank, then any other. No money moves: the customer was already + debited and is still owed this one draft. +

+
+ + + + + `, + styles: [ + ` + .rv-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + background: #f8fafc; + } + .rv-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 4px 0; + } + .rv-row--total { + border-top: 1px solid #e2e8f0; + margin-top: 6px; + padding-top: 8px; + } + .rv-label { + color: #64748b; + font-size: 12.5px; + } + .rv-value { + color: #1e293b; + font-size: 13.5px; + text-align: right; + } + .rv-amount { + color: #1e293b; + font-size: 17px; + font-weight: 700; + } + .rv-reason { + font-size: 13px; + color: #334155; + margin: 0 0 12px; + } + .rv-effect { + font-size: 12.5px; + line-height: 1.55; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 10px 12px; + margin: 0; + } + ` + ] +}) +export class ApproveRevokeRequestDialogComponent { + dialogRef = inject(MatDialogRef); + + constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {} +} + +// ── REJECT DIALOG ── +@Component({ + selector: 'mifosx-reject-revoke-request-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Reject DD Revoke (DD No: {{ data.request.ddNumber }})

+ +

+ The leaflet stays issued and no replacement is taken. The clerk keeps a draft they say they cannot hand over, + so the reason should tell them what to do instead. +

+
+ + Rejection Reason + + A reason is required + +
+
+ + + + + `, + styles: [ + ` + .full-width { + width: 100%; + } + .hint-text { + font-size: 13px; + color: #64748b; + margin: 0 0 12px; + line-height: 1.5; + } + ` + ] +}) +export class RejectRevokeRequestDialogComponent { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + + rejectForm: FormGroup; + loading = false; + + constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) { + this.rejectForm = this.fb.group({ + rejectionReason: ['', Validators.required] + }); + } + + onSubmit(): void { + if (this.rejectForm.invalid) { + return; + } + this.loading = true; + this.sponsorDDService.rejectRevokeRequest(this.data.request.id, this.rejectForm.value.rejectionReason).subscribe({ + next: () => { + this.loading = false; + this.snackBar.open('Revoke request rejected', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + } +} diff --git a/src/app/sponsor-dd/sponsor-dd-routing.module.ts b/src/app/sponsor-dd/sponsor-dd-routing.module.ts index d9d6c3500..4f5609233 100644 --- a/src/app/sponsor-dd/sponsor-dd-routing.module.ts +++ b/src/app/sponsor-dd/sponsor-dd-routing.module.ts @@ -13,6 +13,7 @@ import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list. import { DdListComponent } from './dd-list/dd-list.component'; import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component'; import { DdReprintApprovalsComponent } from './dd-reprint-approvals/dd-reprint-approvals.component'; +import { DdRevokeApprovalsComponent } from './dd-revoke-approvals/dd-revoke-approvals.component'; const routes: Routes = [ Route.withShell([ @@ -44,6 +45,11 @@ const routes: Routes = [ path: 'reprint-approvals', component: DdReprintApprovalsComponent, data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' } + }, + { + path: 'revoke-approvals', + component: DdRevokeApprovalsComponent, + data: { title: 'DD Revoke Approvals', breadcrumb: 'Revoke Approvals' } } ] } diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 7f6e34ff4..707a5a9da 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -218,6 +218,27 @@ export class SponsorDDService { return this.http.post(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason }); } + // ── DD revoke requests (maker-checker) ── + // A leaflet ruined at the printer. Its number can never be handed to anyone, so the draft moves + // onto the next available blank and both leaflets stay on the register — one spoiled, one + // carrying the draft. Distinct from a reprint, which re-prints the same leaflet. + requestRevoke(leafletId: number, reason: string): Observable { + return this.http.post(`/sponsordd/leaflets/${leafletId}/revoke-request`, { reason }); + } + + getAllRevokeRequests(status?: string): Observable { + const params = status ? { status } : {}; + return this.http.get('/sponsordd/revoke-requests', { params }); + } + + approveRevokeRequest(id: number): Observable { + return this.http.post(`/sponsordd/revoke-requests/${id}/approve`, {}); + } + + rejectRevokeRequest(id: number, rejectionReason: string): Observable { + return this.http.post(`/sponsordd/revoke-requests/${id}/reject`, { rejectionReason }); + } + // ── DD amendment requests (maker-checker) ── // Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else // is amendable: the money behind the face value has already moved, so a draft wrong in that way 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 5fab7cf1b..9a5bb2c85 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 @@ -226,6 +226,12 @@ DD Reprint Requests + + + + DD Revoke Requests +
diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index f379b5c81..03713aea3 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -235,6 +235,14 @@ const routes: Routes = [ (m) => m.DdReprintApprovalsComponent ), data: { title: 'DD Reprint Approvals' } + }, + { + path: 'dd-revoke-approvals', + loadComponent: () => + import('../sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component').then( + (m) => m.DdRevokeApprovalsComponent + ), + data: { title: 'DD Revoke Approvals' } } ] }, diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index 45d79127d..27cd9f12e 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -80,7 +80,7 @@