Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
# Conflicts: # src/environments/.env.ts
This commit is contained in:
@@ -1,70 +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/.
|
||||
-->
|
||||
|
||||
<h2 class="mk-title" [class.mk-title--str]="isEscalation">
|
||||
<fa-icon [icon]="isEscalation ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
|
||||
{{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="mk-entry">
|
||||
<div>
|
||||
<label>Journal entry</label><span class="mono">#{{ data.journalEntryId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Transaction</label><span class="mono">{{ data.transactionId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Date</label><span>{{ data.transactionDate }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Amount</label><span class="mono">₹{{ data.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<label>GL account</label><span>{{ data.glAccountName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isEscalation) {
|
||||
<p class="mk-warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
This raises an AML alert against the customer behind this entry and escalates it. An STR case opens for the
|
||||
Principal Officer with the seven working day FIU-IND clock running. It cannot be undone from this screen.
|
||||
</p>
|
||||
} @else {
|
||||
<p class="mk-note">
|
||||
Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched.
|
||||
</p>
|
||||
}
|
||||
|
||||
<label class="mk-field">
|
||||
<span>Reviewer notes {{ isEscalation ? '(required)' : '(optional)' }}</span>
|
||||
<textarea
|
||||
rows="4"
|
||||
[(ngModel)]="notes"
|
||||
[placeholder]="
|
||||
isEscalation
|
||||
? 'Why is this entry suspicious? What did you check?'
|
||||
: 'What satisfied you that this entry is in order?'
|
||||
"
|
||||
></textarea>
|
||||
</label>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="mk-actions">
|
||||
<button class="mk-btn" (click)="dialogRef.close()">Cancel</button>
|
||||
<button
|
||||
class="mk-btn"
|
||||
[class.mk-btn--str]="isEscalation"
|
||||
[class.mk-btn--ok]="!isEscalation"
|
||||
[disabled]="!canSubmit"
|
||||
(click)="confirm()"
|
||||
>
|
||||
{{ isEscalation ? 'Escalate to STR' : 'Whitelist entry' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,165 +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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--mk-g100: #f1f5f9;
|
||||
--mk-g200: #e2e8f0;
|
||||
--mk-g300: #cbd5e1;
|
||||
--mk-g500: #64748b;
|
||||
--mk-g700: #334155;
|
||||
--mk-g900: #0f172a;
|
||||
--mk-green: #15803d;
|
||||
--mk-red: #c0392b;
|
||||
--mk-amber-l: #fef3c7;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--mk-g900);
|
||||
min-width: 460px;
|
||||
}
|
||||
|
||||
.mk-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 18px 22px 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--mk-green);
|
||||
|
||||
&--str {
|
||||
color: var(--mk-red);
|
||||
}
|
||||
}
|
||||
|
||||
.mk-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 18px;
|
||||
padding: 11px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mk-g200);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mk-g500);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.mk-warn {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #78350f;
|
||||
background: var(--mk-amber-l);
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.mk-note {
|
||||
font-size: 12px;
|
||||
color: var(--mk-g500);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.mk-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mk-g500);
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--mk-g300);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--mk-g700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mk-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
padding: 8px 22px 18px;
|
||||
}
|
||||
|
||||
.mk-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--mk-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--mk-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--mk-g100);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: var(--mk-green);
|
||||
border-color: var(--mk-green);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #12692f;
|
||||
}
|
||||
}
|
||||
|
||||
&--str {
|
||||
background: var(--mk-red);
|
||||
border-color: var(--mk-red);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #a5301f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export interface MarkEntryDialogData {
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
journalEntryId: number;
|
||||
transactionId: string;
|
||||
amount: number;
|
||||
glAccountName: string;
|
||||
transactionDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the reviewer's reason before a ledger entry is marked.
|
||||
*
|
||||
* Notes are not optional on an escalation: the Principal Officer picks the case up
|
||||
* with nothing else to go on, and an STR filed on an unexplained referral is not
|
||||
* defensible. A whitelist may be recorded without them.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-mark-entry-dialog',
|
||||
templateUrl: './mark-entry-dialog.component.html',
|
||||
styleUrls: ['./mark-entry-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatDialogContent,
|
||||
MatDialogActions
|
||||
]
|
||||
})
|
||||
export class MarkEntryDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<MarkEntryDialogComponent>>(MatDialogRef);
|
||||
data = inject<MarkEntryDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
notes = '';
|
||||
|
||||
get isEscalation(): boolean {
|
||||
return this.data.disposition === 'ESCALATE';
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return !this.isEscalation || this.notes.trim().length > 0;
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
if (this.canSubmit) {
|
||||
this.dialogRef.close(this.notes.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,14 +535,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (markError) {
|
||||
<div class="gl-mark-error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>{{ markError }}</span>
|
||||
<button class="gl-mark-error__close" (click)="markError = ''">×</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="gl-table-shell">
|
||||
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
|
||||
<!-- #ID -->
|
||||
@@ -700,50 +692,6 @@
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- AML review — whitelist or refer the entry to STR -->
|
||||
<ng-container matColumnDef="amlMark">
|
||||
<mat-header-cell *matHeaderCellDef class="gl-th">AML Review</mat-header-cell>
|
||||
<mat-cell *matCellDef="let e" class="gl-td">
|
||||
@if (markOf(e); as mark) {
|
||||
<span
|
||||
class="gl-mark"
|
||||
[class.gl-mark--str]="mark.disposition === 'ESCALATE'"
|
||||
[matTooltip]="
|
||||
(mark.disposition === 'ESCALATE'
|
||||
? 'Escalated to STR' + (mark.strCaseId ? ' — case #' + mark.strCaseId : '')
|
||||
: 'Whitelisted') +
|
||||
(mark.reviewedBy ? ' by ' + mark.reviewedBy : '') +
|
||||
(mark.notes ? ' — ' + mark.notes : '')
|
||||
"
|
||||
>
|
||||
<fa-icon [icon]="mark.disposition === 'ESCALATE' ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
|
||||
{{ mark.disposition === 'ESCALATE' ? 'Suspicious' : 'Whitelisted' }}
|
||||
</span>
|
||||
} @else if (isMarking(e)) {
|
||||
<span class="gl-mark gl-mark--busy">Saving…</span>
|
||||
} @else if (canMark(e)) {
|
||||
<span class="gl-mark-actions">
|
||||
<button
|
||||
class="gl-mark-btn gl-mark-btn--ok"
|
||||
matTooltip="Reviewed and in order — clear this entry"
|
||||
(click)="openMarkDialog(e, 'WHITELIST')"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon> Whitelist
|
||||
</button>
|
||||
<button
|
||||
class="gl-mark-btn gl-mark-btn--str"
|
||||
matTooltip="Refer to the Principal Officer as an STR case"
|
||||
(click)="openMarkDialog(e, 'ESCALATE')"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Suspicious
|
||||
</button>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="gl-mark gl-mark--na" [matTooltip]="markBlockedReason(e)">—</span>
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
|
||||
|
||||
@@ -1328,17 +1328,6 @@
|
||||
flex: 0 0 130px;
|
||||
}
|
||||
|
||||
// Wide enough to hold both action buttons on one line. Every other column
|
||||
// declares a width; without one this collapsed and clipped its contents.
|
||||
::ng-deep .mat-column-amlMark {
|
||||
width: 184px;
|
||||
min-width: 184px;
|
||||
flex: 0 0 184px;
|
||||
// Keeps the "AML REVIEW" heading on one line instead of clipping it.
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
::ng-deep .mat-column-actions {
|
||||
width: 50px;
|
||||
min-width: 50px;
|
||||
@@ -1567,112 +1556,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── AML review column ─────────────────────────────────────────────────────────
|
||||
// Never wrap — a wrapped second button used to be clipped by the row height.
|
||||
.gl-mark-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gl-mark-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
border-radius: 6px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--c-text-2);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
transition: all var(--transition);
|
||||
|
||||
// Tinted at rest, not only on hover — these have to be findable in a dense
|
||||
// table without the reviewer hunting for them.
|
||||
&--ok {
|
||||
border-color: rgb(16 185 129 / 45%);
|
||||
background: var(--c-cr-bg);
|
||||
color: #047857;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--c-cr);
|
||||
background: #d1fae5;
|
||||
}
|
||||
}
|
||||
|
||||
&--str {
|
||||
border-color: rgb(239 68 68 / 45%);
|
||||
background: var(--c-dr-bg);
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--c-dr);
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gl-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: var(--c-cr-bg);
|
||||
color: #047857;
|
||||
|
||||
&--str {
|
||||
background: var(--c-dr-bg);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&--busy {
|
||||
background: #f1f5f9;
|
||||
color: var(--c-text-3);
|
||||
}
|
||||
|
||||
&--na {
|
||||
background: transparent;
|
||||
color: var(--c-text-3);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.gl-mark-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0 0 10px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #fecaca;
|
||||
background: var(--c-dr-bg);
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: #b91c1c;
|
||||
|
||||
&__close {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
.gl-empty {
|
||||
display: flex;
|
||||
|
||||
@@ -41,20 +41,8 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
import { MarkEntryDialogComponent, MarkEntryDialogData } from './mark-entry-dialog.component';
|
||||
|
||||
const STATE_KEY = 'gl_ledger_filter_state';
|
||||
|
||||
/** How a ledger entry has been marked by a reviewer, keyed on journal entry id. */
|
||||
interface EntryMark {
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
strCaseId: number | null;
|
||||
reviewedBy: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-search-journal-entry',
|
||||
standalone: true,
|
||||
@@ -98,8 +86,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
private route = inject(ActivatedRoute);
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
// ── Misc ──────────────────────────────────────────────────────────────────
|
||||
minDate = new Date(2000, 0, 1);
|
||||
@@ -142,7 +128,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
'manualEntry',
|
||||
'createdByUserName',
|
||||
'submittedOnDate',
|
||||
'amlMark',
|
||||
'actions'
|
||||
];
|
||||
dayWiseColumns = [
|
||||
@@ -171,14 +156,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
dayWiseData: any[] = [];
|
||||
glAccountInfo: any = null;
|
||||
|
||||
// ── AML marking ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Marks already recorded, by journal entry id. */
|
||||
private marks = new Map<number, EntryMark>();
|
||||
/** Entry ids with a request in flight, so a row cannot be double-submitted. */
|
||||
private marking = new Set<number>();
|
||||
markError = '';
|
||||
|
||||
// ── UI state ──────────────────────────────────────────────────────────────
|
||||
activeQuickFilter = '';
|
||||
activeFiltersCount = 0;
|
||||
@@ -234,103 +211,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
this.setFilteredOffices();
|
||||
this.getJournalEntries();
|
||||
this.loadLedgerSummary();
|
||||
this.loadMarks();
|
||||
}
|
||||
|
||||
// ── AML marking ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Marks are loaded once for the whole ledger rather than per page, because the
|
||||
* user pages and re-filters constantly and the set is small — only entries a
|
||||
* reviewer has actually touched.
|
||||
*/
|
||||
private loadMarks(): void {
|
||||
this.amlService.getManualReferrals().subscribe({
|
||||
next: (rows) => {
|
||||
this.marks = new Map(
|
||||
(rows || []).map((r: any) => [
|
||||
r.journalEntryId,
|
||||
{
|
||||
disposition: r.disposition === 'ESCALATE' ? 'ESCALATE' : 'WHITELIST',
|
||||
strCaseId: r.strCaseId ?? null,
|
||||
reviewedBy: r.reviewedByUsername || '',
|
||||
notes: r.reviewNotes || ''
|
||||
} as EntryMark
|
||||
])
|
||||
);
|
||||
},
|
||||
// A ledger that cannot reach the AML service still has to render.
|
||||
error: () => (this.marks = new Map())
|
||||
});
|
||||
}
|
||||
|
||||
markOf(entry: any): EntryMark | null {
|
||||
return this.marks.get(entry?.id) ?? null;
|
||||
}
|
||||
|
||||
isMarking(entry: any): boolean {
|
||||
return this.marking.has(entry?.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* An alert needs a customer, and a journal entry only reaches one through the
|
||||
* savings, loan or client transaction that produced it. A purely manual GL
|
||||
* posting reaches none, so it cannot be referred — the server enforces this too.
|
||||
*/
|
||||
canMark(entry: any): boolean {
|
||||
return !!entry && !this.markOf(entry) && !!(entry.savingsAccountNo || entry.entityId);
|
||||
}
|
||||
|
||||
markBlockedReason(entry: any): string {
|
||||
if (this.markOf(entry)) {
|
||||
return 'Already reviewed — a ledger entry is marked once';
|
||||
}
|
||||
return 'No customer behind this entry, so it cannot be referred';
|
||||
}
|
||||
|
||||
openMarkDialog(entry: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
if (!this.canMark(entry) || this.isMarking(entry)) {
|
||||
return;
|
||||
}
|
||||
const data: MarkEntryDialogData = {
|
||||
disposition,
|
||||
journalEntryId: entry.id,
|
||||
transactionId: entry.transactionId,
|
||||
amount: entry.amount,
|
||||
glAccountName: `${entry.glAccountCode} — ${entry.glAccountName}`,
|
||||
transactionDate: this.dateUtils.formatDate(entry.transactionDate, this.settingsService.dateFormat)
|
||||
};
|
||||
|
||||
this.dialog
|
||||
.open(MarkEntryDialogComponent, { data, width: '520px', autoFocus: false })
|
||||
.afterClosed()
|
||||
.subscribe((notes: string | undefined) => {
|
||||
if (notes !== undefined) {
|
||||
this.submitMark(entry, disposition, notes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private submitMark(entry: any, disposition: 'WHITELIST' | 'ESCALATE', notes: string): void {
|
||||
this.markError = '';
|
||||
this.marking.add(entry.id);
|
||||
|
||||
this.amlService.referJournalEntry(entry.id, disposition, notes).subscribe({
|
||||
next: (res: any) => {
|
||||
this.marking.delete(entry.id);
|
||||
this.marks.set(entry.id, {
|
||||
disposition,
|
||||
strCaseId: res?.strCaseId ?? null,
|
||||
reviewedBy: res?.reviewedByUsername || '',
|
||||
notes
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.marking.delete(entry.id);
|
||||
this.markError =
|
||||
err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -34,6 +34,30 @@ export class AmlService {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ Recurring-alert review queue ═══
|
||||
|
||||
/**
|
||||
* Customers whose transactions repeatedly trip the engine. Each row carries the triggering
|
||||
* alerts, so the reviewer can see why the customer keeps surfacing.
|
||||
*/
|
||||
getRecurringAlerts(minAlerts: number, windowDays: number, onlyNeedingReview: boolean): Observable<any[]> {
|
||||
const params = new HttpParams()
|
||||
.set('minAlerts', String(minAlerts))
|
||||
.set('windowDays', String(windowDays))
|
||||
.set('onlyNeedingReview', String(onlyNeedingReview));
|
||||
return this.http.get<any[]>('/aml/recurring-alerts', { params });
|
||||
}
|
||||
|
||||
/** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */
|
||||
reviewRecurringAlert(
|
||||
clientId: number,
|
||||
outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED',
|
||||
notes: string,
|
||||
windowDays: number
|
||||
): Observable<any> {
|
||||
return this.http.post(`/aml/recurring-alerts/${clientId}/review`, { outcome, notes, windowDays });
|
||||
}
|
||||
|
||||
// ═══ Manual referrals from the general ledger ═══
|
||||
|
||||
/**
|
||||
|
||||
@@ -1750,6 +1750,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
return salutation ? `${salutation} ${full}` : full;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name PAN and Aadhaar are actually matched against.
|
||||
*
|
||||
* Deliberately not {@link fullNamePreview}: that prefixes the salutation, while
|
||||
* ClientKycWritePlatformServiceJpaRepositoryImpl builds the verification name from firstname +
|
||||
* middlename + lastname only. Values are passed through untouched rather than capitalised, so what
|
||||
* the KYC step displays is exactly what goes to the provider.
|
||||
*/
|
||||
get verificationName(): string {
|
||||
return [
|
||||
this.createClientForm?.get('firstname')?.value,
|
||||
this.createClientForm?.get('middlename')?.value,
|
||||
this.createClientForm?.get('lastname')?.value
|
||||
]
|
||||
.map((part: any) => (part || '').toString().trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** Date of birth as captured, sent to the provider formatted dd/MM/yyyy. */
|
||||
get verificationDateOfBirth(): Date | null {
|
||||
const value = this.createClientForm?.get('dateOfBirth')?.value;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
getSelectedGender(): any {
|
||||
const val = this.createClientForm.get('genderId')?.value;
|
||||
return this.genderOptions?.find((g: any) => g.id === val) || null;
|
||||
|
||||
+52
-14
@@ -38,10 +38,17 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
}
|
||||
</div>
|
||||
<p class="sanctions-msg">
|
||||
Screening <strong>{{ clientName }}</strong> against<br />
|
||||
<strong>UN Security Council</strong> &
|
||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||
Screening <strong>{{ clientName }}</strong> against the<br />
|
||||
<strong>UN Security Council Consolidated List</strong><br />
|
||||
<span class="sanctions-lists"
|
||||
>ISIL (Da’esh) & Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists</span
|
||||
>
|
||||
</p>
|
||||
@if (listDataAsOf) {
|
||||
<p class="list-as-of">
|
||||
Sanctions list data as of <strong>{{ listDataAsOf }}</strong>
|
||||
</p>
|
||||
}
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -54,8 +61,8 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<h3 class="approve-title">No Sanctions Match Found</h3>
|
||||
<p class="approve-detail">
|
||||
<strong>{{ clientName }}</strong> has been screened against the
|
||||
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
|
||||
match was found.
|
||||
<strong>UN Security Council Consolidated List</strong> (ISIL & Al-Qaida and Taliban sanctions lists). No
|
||||
confirmed match was found.
|
||||
</p>
|
||||
<div class="approve-meta">
|
||||
<div class="meta-row">
|
||||
@@ -222,6 +229,20 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-as-of {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
text-align: center;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.sanctions-lists {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.regulatory-note {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
@@ -550,16 +571,24 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
screeningLogId: number | null = null;
|
||||
listDataAsOf: string | null = null;
|
||||
|
||||
/**
|
||||
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
|
||||
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
|
||||
* the older Master Direction this screen used to name.
|
||||
*/
|
||||
regulatoryBasis =
|
||||
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
|
||||
'Screened under Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and Chapter IX of the ' +
|
||||
'RBI Know Your Customer Directions, 2025 (amended as on December 29, 2025).';
|
||||
|
||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
progressValue = 0;
|
||||
currentStep = 0;
|
||||
// Steps describe the screening that actually runs against the UNSC consolidated list; there is no
|
||||
// FIU-IND lookup behind this call, so it is no longer claimed here.
|
||||
progressSteps = [
|
||||
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
|
||||
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
|
||||
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
|
||||
{ label: 'Cross-checking listed aliases and entities...', icon: 'database' as any },
|
||||
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
|
||||
];
|
||||
|
||||
@@ -577,6 +606,9 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
next: (result: any) => {
|
||||
this.apiResult = result;
|
||||
this.apiDone = true;
|
||||
// Surfaced while the screening is still running, so the operator can see which snapshot of
|
||||
// the UNSC list the check is being made against rather than only learning it afterwards.
|
||||
this.listDataAsOf = this.formatListDate(result?.listDataAsOf);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.apiError = err;
|
||||
@@ -652,13 +684,7 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
'Unable to connect to sanctions screening service.';
|
||||
} else {
|
||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||
this.listDataAsOf = this.apiResult?.listDataAsOf
|
||||
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
: null;
|
||||
this.listDataAsOf = this.formatListDate(this.apiResult?.listDataAsOf);
|
||||
if (this.apiResult?.matchFound) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = this.apiResult.matchCount || 0;
|
||||
@@ -668,6 +694,18 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** Formats the date the UNSC snapshot was loaded; null when the backend did not report one. */
|
||||
private formatListDate(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
clearInterval(this.progressTimer);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
<!-- What PAN / Aadhaar are matched against. Shown before the officer clicks Verify, because
|
||||
the provider matches on exactly these two values and returns nothing identifiable when
|
||||
they disagree — a blind check that fails is much harder to act on than a visible one. -->
|
||||
@if (!isNonIndividual) {
|
||||
<div
|
||||
class="identity-basis full-width"
|
||||
[class.identity-basis--incomplete]="!verificationName || !verificationDateOfBirth"
|
||||
>
|
||||
<mat-icon class="identity-basis__icon">badge</mat-icon>
|
||||
<div class="identity-basis__body">
|
||||
<span class="identity-basis__legend">Documents are verified against the General tab</span>
|
||||
<div class="identity-basis__pairs">
|
||||
<span class="identity-basis__pair">
|
||||
<span class="identity-basis__k">Name</span>
|
||||
<span class="identity-basis__v">{{ verificationName || 'Not entered' }}</span>
|
||||
</span>
|
||||
<span class="identity-basis__pair">
|
||||
<span class="identity-basis__k">Date of Birth</span>
|
||||
<span class="identity-basis__v">
|
||||
{{ verificationDateOfBirth ? (verificationDateOfBirth | date: 'dd MMM yyyy') : 'Not entered' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (!verificationName || !verificationDateOfBirth) {
|
||||
<span class="identity-basis__warn">
|
||||
Both are required for PAN and Aadhaar verification — complete them on the General tab first.
|
||||
</span>
|
||||
} @else {
|
||||
<span class="identity-basis__hint">
|
||||
Must match the card exactly. Salutation is excluded; a mismatch in either fails verification.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Individual PAN with inline verify -->
|
||||
@if (!isMinor && kycForm.get('hasPan')?.value) {
|
||||
<div class="form-field verify-field">
|
||||
@@ -105,8 +141,109 @@
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
|
||||
<span class="verify-chip declared"><fa-icon icon="file-signature"></fa-icon> Declaration</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<div
|
||||
class="pal-card full-width"
|
||||
[class.pal-card--ok]="panAadhaarSeedingStatus === 'Y'"
|
||||
[class.pal-card--fail]="panAadhaarSeedingStatus === 'N'"
|
||||
[class.pal-card--info]="panAadhaarSeedingStatus === 'E'"
|
||||
[class.pal-card--pending]="
|
||||
panAadhaarSeedingStatus !== 'Y' && panAadhaarSeedingStatus !== 'N' && panAadhaarSeedingStatus !== 'E'
|
||||
"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<div class="pal-card__left">
|
||||
<div class="pal-card__icon">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
</div>
|
||||
<div class="pal-card__txt">
|
||||
<div class="pal-card__small">Verification Status</div>
|
||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||
<div class="pal-card__status">
|
||||
@if (panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||
} @else if (panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>cancel</mat-icon> Not Linked
|
||||
} @else if (panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon> Exempt
|
||||
} @else {
|
||||
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pal-card__badge">
|
||||
@if (panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else if (panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="pal-card__hint">As per Income Tax Portal — Section 139AA</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
PAN could not be verified but a declaration is on file. Shown in full because it is the
|
||||
only thing standing between this client and a PAN KYC exception — an officer reviewing
|
||||
the record has to be able to see what was declared, not just that something was.
|
||||
-->
|
||||
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
|
||||
<div class="pan-decl-card">
|
||||
<div class="pan-decl-card__head">
|
||||
<mat-icon class="pan-decl-card__icon">assignment_turned_in</mat-icon>
|
||||
<span class="pan-decl-card__title">PAN Declaration Taken</span>
|
||||
@if (panDeclarationTakenOn) {
|
||||
<span class="pan-decl-card__date">{{ panDeclarationTakenOn }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="pan-decl-card__rows">
|
||||
<div class="pan-decl-card__row">
|
||||
<span class="k">Does not match</span>
|
||||
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||
</div>
|
||||
@if (panDeclaration?.nameOnPan) {
|
||||
<div class="pan-decl-card__row">
|
||||
<span class="k">Name on PAN</span>
|
||||
<span class="v">{{ panDeclaration?.nameOnPan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (panDeclaration?.dobOnPan) {
|
||||
<div class="pan-decl-card__row">
|
||||
<span class="k">DOB on PAN</span>
|
||||
<span class="v">{{ panDeclaration?.dobOnPan | date: 'dd MMMM yyyy' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (panDeclaration?.fileName) {
|
||||
<div class="pan-decl-card__row">
|
||||
<span class="k">Declaration file</span>
|
||||
<span class="v">{{ panDeclaration?.fileName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="pan-decl-card__note">
|
||||
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||
</div>
|
||||
@if (!readOnly) {
|
||||
<div class="pan-decl-card__actions">
|
||||
<button mat-stroked-button type="button" (click)="openPanDeclaration()">Edit Declaration</button>
|
||||
<button mat-stroked-button type="button" class="danger" (click)="removePanDeclaration()">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Aadhaar with independent verify (Individual only) -->
|
||||
@@ -175,9 +312,68 @@
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
|
||||
<span class="verify-chip declared"><fa-icon icon="receipt"></fa-icon> Receipt</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
A customer who has applied for Aadhaar but not received it has no number to verify, so no
|
||||
amount of retrying the OTP will clear the check. Offer the enrolment slip instead rather
|
||||
than leaving the officer unable to finish the client.
|
||||
-->
|
||||
@if (aadhaarVerifyStatus !== 'VERIFIED' && !aadhaarReceiptTaken && !aadhaarOtpSent && !readOnly) {
|
||||
<div class="aad-rcpt-offer full-width">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Record Enrolment Receipt</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||
only thing standing between this client and an Aadhaar KYC exception.
|
||||
-->
|
||||
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
|
||||
<div class="aad-rcpt-card full-width">
|
||||
<div class="aad-rcpt-card__head">
|
||||
<mat-icon class="aad-rcpt-card__icon">receipt_long</mat-icon>
|
||||
<span class="aad-rcpt-card__title">Aadhaar Enrolment Receipt Recorded</span>
|
||||
@if (aadhaarReceiptTakenOn) {
|
||||
<span class="aad-rcpt-card__date">{{ aadhaarReceiptTakenOn }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="aad-rcpt-card__rows">
|
||||
<div class="aad-rcpt-card__row">
|
||||
<span class="k">Enrolment ID</span>
|
||||
<span class="v mono">{{ aadhaarReceipt?.enrolmentId }}</span>
|
||||
</div>
|
||||
@if (aadhaarReceipt?.enrolmentDate) {
|
||||
<div class="aad-rcpt-card__row">
|
||||
<span class="k">Enrolment date</span>
|
||||
<span class="v">{{ aadhaarReceipt?.enrolmentDate | date: 'dd MMMM yyyy' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (aadhaarReceipt?.remarks) {
|
||||
<div class="aad-rcpt-card__row">
|
||||
<span class="k">Remarks</span>
|
||||
<span class="v">{{ aadhaarReceipt?.remarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="aad-rcpt-card__note">
|
||||
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
||||
</div>
|
||||
@if (!readOnly) {
|
||||
<div class="aad-rcpt-card__actions">
|
||||
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Edit Receipt</button>
|
||||
<button mat-stroked-button type="button" class="danger" (click)="removeAadhaarReceipt()">Remove</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Aadhaar OTP Section -->
|
||||
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
|
||||
<div class="otp-card full-width">
|
||||
@@ -725,51 +921,53 @@
|
||||
}
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
<div
|
||||
class="exception-flag-box"
|
||||
style="
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
border-radius: 8px;
|
||||
clear: both;
|
||||
"
|
||||
>
|
||||
<h5
|
||||
@if (!kycAllDone) {
|
||||
<div
|
||||
class="exception-flag-box"
|
||||
style="
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
border-radius: 8px;
|
||||
clear: both;
|
||||
"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
KYC Exception Flag
|
||||
</h5>
|
||||
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
|
||||
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
|
||||
<h5
|
||||
style="
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
KYC Exception Flag
|
||||
</h5>
|
||||
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
|
||||
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||
} @else {
|
||||
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||
} @else {
|
||||
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
||||
@@ -847,19 +1045,23 @@
|
||||
<!-- Existing saved ID-proof doc — locked by default. Replace flow
|
||||
unlocks automatically when the section-level Edit button toggles
|
||||
`readOnly` off. -->
|
||||
@if (existingKycDocuments.length > 0 && !kycDocFiles.front && readOnly) {
|
||||
@if (existingKycDocSides.front && !kycDocFiles.front && readOnly) {
|
||||
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
@if (existingKycDocSides.front?.previewUrl) {
|
||||
<img
|
||||
[src]="existingKycDocSides.front?.previewUrl"
|
||||
alt="Saved front side"
|
||||
class="kyc-doc-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
{{ existingKycDocSides.front?.fileName }}
|
||||
</span>
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Click here to view
|
||||
</button>
|
||||
@@ -869,8 +1071,8 @@
|
||||
} @else {
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocuments.length > 0"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && existingKycDocuments.length === 0"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocSides.front"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && !existingKycDocSides.front"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
|
||||
@if (kycDocFiles.front) {
|
||||
@@ -886,24 +1088,28 @@
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (existingKycDocuments.length > 0) {
|
||||
} @else if (existingKycDocSides.front) {
|
||||
<!-- Edit mode WITH existing doc — let the user view it OR
|
||||
click anywhere to replace. -->
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
@if (existingKycDocSides.front?.previewUrl) {
|
||||
<img
|
||||
[src]="existingKycDocSides.front?.previewUrl"
|
||||
alt="Saved front side"
|
||||
class="kyc-doc-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
{{ existingKycDocSides.front?.fileName }}
|
||||
</span>
|
||||
<div class="kyc-doc-dropzone__existing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="kyc-doc-dropzone__view"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('front')"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
@@ -930,25 +1136,45 @@
|
||||
<div class="kyc-doc-upload-slot">
|
||||
<div class="kyc-doc-upload-head">
|
||||
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
|
||||
<!-- Hide Required pill when saved combined doc covers both sides -->
|
||||
@if (!(existingKycDocuments.length > 0 && !kycDocFiles.back)) {
|
||||
<!-- Hide Required pill when a back scan (or a stacked scan holding it) is on file -->
|
||||
@if (!((existingKycDocSides.back || kycDocIsLegacyCombined) && !kycDocFiles.back)) {
|
||||
<span class="kyc-doc-upload-req">Required</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Locked view: saved combined doc covers the back side too. -->
|
||||
@if (existingKycDocuments.length > 0 && !kycDocFiles.back && readOnly) {
|
||||
<!--
|
||||
A stacked scan that could not be cut in two has no separate back image. Say so rather
|
||||
than repeating the front slot's picture here.
|
||||
-->
|
||||
@if (kycDocIsLegacyCombined && !kycDocFiles.back) {
|
||||
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">filter_none</mat-icon>
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">Included in the combined scan</span>
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View both sides
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (existingKycDocSides.back && !kycDocFiles.back && readOnly) {
|
||||
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (existingKycDocSides.back?.previewUrl) {
|
||||
<img
|
||||
[src]="existingKycDocSides.back?.previewUrl"
|
||||
alt="Saved back side"
|
||||
class="kyc-doc-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
{{ existingKycDocSides.back?.fileName }}
|
||||
</span>
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('back')">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Click here to view
|
||||
</button>
|
||||
@@ -958,8 +1184,8 @@
|
||||
} @else {
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocuments.length > 0"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && existingKycDocuments.length === 0"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocSides.back"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && !existingKycDocSides.back"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
|
||||
@if (kycDocFiles.back) {
|
||||
@@ -975,23 +1201,27 @@
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (existingKycDocuments.length > 0) {
|
||||
<!-- Edit mode WITH existing combined doc — view + replace. -->
|
||||
} @else if (existingKycDocSides.back) {
|
||||
<!-- Edit mode WITH an existing back scan — view + replace. -->
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
@if (existingKycDocSides.back?.previewUrl) {
|
||||
<img
|
||||
[src]="existingKycDocSides.back?.previewUrl"
|
||||
alt="Saved back side"
|
||||
class="kyc-doc-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
{{ existingKycDocSides.back?.fileName }}
|
||||
</span>
|
||||
<div class="kyc-doc-dropzone__existing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="kyc-doc-dropzone__view"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('back')"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
@@ -1027,7 +1257,9 @@
|
||||
<mat-icon>filter_none</mat-icon>
|
||||
Combined preview (front + back)
|
||||
</span>
|
||||
<span class="kyc-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
|
||||
<span class="kyc-merged__hint">
|
||||
Preview only — each side is saved as its own file. Re-crop a side to update it.
|
||||
</span>
|
||||
</div>
|
||||
<div class="kyc-merged__preview">
|
||||
<img [src]="kycDocMergedPreview" alt="Merged KYC document" />
|
||||
@@ -1197,27 +1429,36 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="remarks"
|
||||
rows="2"
|
||||
[placeholder]="isKycVerified ? 'Mandatory — explain why KYC is being re-edited' : ''"
|
||||
></textarea>
|
||||
@if (isKycVerified && !kycForm.get('remarks')?.value?.trim()) {
|
||||
<mat-error>Remarks are mandatory when updating a verified KYC record</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (isKycVerified) {
|
||||
<div class="kyc-reedit-alert">
|
||||
<mat-icon class="kyc-reedit-alert__icon">info</mat-icon>
|
||||
<span
|
||||
>KYC is <strong>VERIFIED</strong>. Any update will reset the status to <strong>IN PROGRESS</strong> and
|
||||
require PAN re-verification.</span
|
||||
>
|
||||
</div>
|
||||
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>
|
||||
{{
|
||||
isKycVerified
|
||||
? 'Reason for Re-edit'
|
||||
: kycForm.get('allowRiskOverride')?.value
|
||||
? 'Remarks (Required for Risk Override)'
|
||||
: 'Remarks'
|
||||
}}
|
||||
</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="remarks"
|
||||
rows="2"
|
||||
[placeholder]="
|
||||
isKycVerified
|
||||
? 'Mandatory — explain why KYC is being re-edited'
|
||||
: kycForm.get('allowRiskOverride')?.value
|
||||
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
||||
: ''
|
||||
"
|
||||
></textarea>
|
||||
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
|
||||
<mat-error>
|
||||
Remarks are mandatory when
|
||||
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1393,6 +1634,39 @@
|
||||
<mat-option value="REGULATORY">Regulatory Authority</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="freezeDatePicker.open()">
|
||||
<mat-label>Freeze Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input matInput required [max]="today" [matDatepicker]="freezeDatePicker" formControlName="freezeDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="freezeDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #freezeDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Freeze Amount</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>currency_rupee</mat-icon></span>
|
||||
<input matInput type="number" min="0" formControlName="freezeAmount" />
|
||||
<mat-hint>Leave blank for a full freeze</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (kycForm.get('accountStatus')?.value === 'UNFREEZE') {
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="unfreezeDatePicker.open()">
|
||||
<mat-label>Unfreeze Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>lock_open</mat-icon></span>
|
||||
<input matInput required [max]="today" [matDatepicker]="unfreezeDatePicker" formControlName="unfreezeDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="unfreezeDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #unfreezeDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (kycForm.get('accountStatus')?.value === 'NO_DEBIT') {
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="noDebitDatePicker.open()">
|
||||
<mat-label>No Debit From Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>money_off</mat-icon></span>
|
||||
<input matInput required [matDatepicker]="noDebitDatePicker" formControlName="noDebitDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="noDebitDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #noDebitDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1545,4 +1819,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Validation Summary -->
|
||||
@if (kycForm && kycForm.invalid) {
|
||||
<div
|
||||
class="kyc-validation-summary"
|
||||
style="
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fee2e2;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
clear: both;
|
||||
"
|
||||
>
|
||||
<mat-icon style="color: #ef4444; flex-shrink: 0">error_outline</mat-icon>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<span style="font-size: 14px; font-weight: 600; color: #991b1b"
|
||||
>Please complete/correct the following fields to proceed:</span
|
||||
>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0; font-size: 13px; color: #b91c1c; line-height: 1.5">
|
||||
@for (label of invalidFieldLabels; track label) {
|
||||
<li>{{ label }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -304,6 +304,206 @@
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
// Not verified, but covered by a declaration — deliberately amber rather than green so it never
|
||||
// reads as a passing check.
|
||||
&.declared {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
}
|
||||
|
||||
// ── PAN declaration summary ────────────────────────────────────────────────
|
||||
.pan-decl-card {
|
||||
margin: -4px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pan-decl-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pan-decl-card__icon {
|
||||
color: #b45309;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.pan-decl-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pan-decl-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pan-decl-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pan-decl-card__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #451a03;
|
||||
word-break: break-word;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl-card__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #fde68a;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pan-decl-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aadhaar enrolment receipt ──────────────────────────────────────────────
|
||||
// Blue rather than the declaration's amber: nothing has failed here, the customer simply does not
|
||||
// hold the document yet.
|
||||
.aad-rcpt-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: -4px 0 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
font-size: 12.5px;
|
||||
color: #1e40af;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
color: #1d4ed8 !important;
|
||||
border-color: #bfdbfe !important;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt-card {
|
||||
margin: -4px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt-card__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.verify-icon-ok {
|
||||
@@ -2431,3 +2631,358 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The name and DOB that PAN / Aadhaar are matched against, surfaced from the General tab so a
|
||||
mismatch is visible before the check runs rather than only after it fails. */
|
||||
.identity-basis {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.identity-basis--incomplete {
|
||||
border-color: #fdba74;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.identity-basis__icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #1d4ed8;
|
||||
|
||||
.identity-basis--incomplete & {
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.identity-basis__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-basis__legend {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.identity-basis__pairs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 24px;
|
||||
}
|
||||
|
||||
.identity-basis__pair {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.identity-basis__k {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.identity-basis__v {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.identity-basis__hint {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.identity-basis__warn {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #c2410c;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
PAN ↔ Aadhaar premium status card
|
||||
─── Replaces the legacy table-row treatment. Dark gradient with
|
||||
offset glow circles, large gradient icon, and a state-driven
|
||||
accent (green / red / blue / amber).
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.pal-card {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: 14px 18px 30px;
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #111827, #0f172a);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
box-shadow:
|
||||
0 12px 32px rgb(0 0 0 / 35%),
|
||||
inset 0 1px 1px rgb(255 255 255 / 5%);
|
||||
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Decorative glows — colors swap with the state variant */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
top: -110px;
|
||||
right: -70px;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
bottom: -70px;
|
||||
left: -50px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #2563eb, #06b6d4);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&__txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__small {
|
||||
font-size: 10.5px;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 50px;
|
||||
background: rgb(34 197 94 / 12%);
|
||||
border: 1px solid rgb(74 222 128 / 25%);
|
||||
color: #4ade80;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(5px);
|
||||
width: fit-content;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
z-index: 2;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 16px;
|
||||
font-size: 10px;
|
||||
color: rgb(255 255 255 / 35%);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ─── State variants ─── */
|
||||
&--ok {
|
||||
&::before {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-color: rgb(239 68 68 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #ef4444;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #f87171;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(239 68 68 / 14%);
|
||||
border-color: rgb(248 113 113 / 32%);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #ef4444, #f97316);
|
||||
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-color: rgb(59 130 246 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #3b82f6;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #60a5fa;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(59 130 246 / 14%);
|
||||
border-color: rgb(96 165 250 / 32%);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #f59e0b;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #fbbf24;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(245 158 11 / 14%);
|
||||
border-color: rgb(251 191 36 / 32%);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #f59e0b, #d97706);
|
||||
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 540px) {
|
||||
.pal-card {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
|
||||
&__hint {
|
||||
position: static;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,32 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
The OVD captured at onboarding has gone stale, or is about to. Sits beside the exception
|
||||
banner because it is the same kind of thing: something staff must act on before the account
|
||||
drifts out of compliance.
|
||||
-->
|
||||
@if (showKycDocumentAlert) {
|
||||
<div class="kyc-doc-alert" [class.kyc-doc-alert--critical]="kycDocumentAlertSeverity === 'critical'">
|
||||
<mat-icon class="kyc-doc-alert__icon">{{
|
||||
kycDocumentAlertSeverity === 'critical' ? 'error' : 'schedule'
|
||||
}}</mat-icon>
|
||||
<div class="kyc-doc-alert__body">
|
||||
<strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong>
|
||||
<p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p>
|
||||
</div>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="kyc-doc-alert__action"
|
||||
(click)="toggleEditMode()"
|
||||
>
|
||||
Update Document
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Verification Table -->
|
||||
<div class="kv-table">
|
||||
<div class="kv-table__header">
|
||||
@@ -87,12 +113,21 @@
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (panDeclarationTaken) {
|
||||
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
||||
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
|
||||
kycData.panVerified
|
||||
? kycData.panVerifiedOn
|
||||
? formatDate(kycData.panVerifiedOn)
|
||||
: '—'
|
||||
: panDeclarationTaken && kycData.panDeclarationTakenOn
|
||||
? formatDate(kycData.panDeclarationTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button
|
||||
@@ -108,9 +143,80 @@
|
||||
Re-Verify
|
||||
}
|
||||
</button>
|
||||
<!--
|
||||
A PAN that has never passed is not always fixable by another attempt. Offer the
|
||||
declaration here too, so an existing client sitting in the exception list can be
|
||||
cleared without the officer having to run a verification they know will fail.
|
||||
-->
|
||||
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
Declaration
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
PAN is unverified but a declaration is on file. Shown in full because it is the only
|
||||
reason PAN carries no KYC exception — a reviewer has to see what was declared.
|
||||
-->
|
||||
@if (!kycData.panVerified && panDeclarationTaken) {
|
||||
<div class="kv-decl">
|
||||
<div class="kv-decl__head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span class="kv-decl__title">PAN Declaration</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-decl__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removePanDeclaration()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-decl__rows">
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Does not match</span>
|
||||
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||
</div>
|
||||
@if (kycData.panDeclarationNameOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Name on PAN</span>
|
||||
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationDobOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">DOB on PAN</span>
|
||||
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationRemarks) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Declaration file</span>
|
||||
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-decl__note">
|
||||
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Aadhaar Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
@@ -120,12 +226,21 @@
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.aadhaarVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (aadhaarReceiptTaken) {
|
||||
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
|
||||
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—'
|
||||
kycData.aadhaarVerified
|
||||
? kycData.aadhaarVerifiedOn
|
||||
? formatDate(kycData.aadhaarVerifiedOn)
|
||||
: '—'
|
||||
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
||||
? formatDate(kycData.aadhaarReceiptTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
@@ -139,9 +254,142 @@
|
||||
Re-Verify
|
||||
</button>
|
||||
}
|
||||
<!--
|
||||
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
|
||||
slip here so an existing client sitting in the exception list can be cleared.
|
||||
-->
|
||||
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
Receipt
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||
only reason Aadhaar carries no KYC exception.
|
||||
-->
|
||||
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
||||
<div class="kv-rcpt">
|
||||
<div class="kv-rcpt__head">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-rcpt__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removeAadhaarReceipt()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-rcpt__rows">
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment ID</span>
|
||||
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
|
||||
</div>
|
||||
@if (kycData.aadhaarEnrolmentDate) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment date</span>
|
||||
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.aadhaarReceiptRemarks) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Remarks</span>
|
||||
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-rcpt__note">
|
||||
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
The OVD collected at onboarding. It has never appeared here, so the dates driving the
|
||||
renewal alert were invisible — an officer told to update the document could not see which
|
||||
one, nor when it was issued.
|
||||
-->
|
||||
@if (kycData.kycDocumentName) {
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">description</mat-icon> {{ kycData.kycDocumentName }}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.kycDocumentNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@switch (kycDocumentRenewal.state) {
|
||||
@case ('EXPIRED') {
|
||||
<span class="kv-status kv-status--fail"><mat-icon>error</mat-icon> Expired</span>
|
||||
}
|
||||
@case ('EXPIRING_SOON') {
|
||||
<span class="kv-status kv-status--declared"><mat-icon>schedule</mat-icon> Expires Soon</span>
|
||||
}
|
||||
@case ('DUE') {
|
||||
<span class="kv-status kv-status--declared"><mat-icon>update</mat-icon> Update Due</span>
|
||||
}
|
||||
@case ('OK') {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Current</span>
|
||||
}
|
||||
@default {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>help_outline</mat-icon> No dates</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<!--
|
||||
Issued-on, not verified-on: this document is sighted, never API-verified.
|
||||
|
||||
Every document shows an expiry, printed or not: one with no expiry on the card is
|
||||
still only good for five years from issue, so the derived date is shown rather than a
|
||||
blank. It is tagged so a calculated date is never mistaken for one on the document.
|
||||
-->
|
||||
<span class="kv-table__col kv-table__col--date">
|
||||
@if (kycData.kycDocIssueDate || kycData.kycDocExpiryDate) {
|
||||
<span class="kv-doc-dates">
|
||||
@if (kycData.kycDocIssueDate) {
|
||||
<span>Issued {{ formatDate(kycData.kycDocIssueDate) }}</span>
|
||||
}
|
||||
@if (kycDocumentExpiresOn) {
|
||||
<span
|
||||
class="kv-doc-dates__expiry"
|
||||
[class.kv-doc-dates__expiry--derived]="kycDocumentExpiryIsDerived"
|
||||
>
|
||||
Expires {{ kycDocumentExpiresOn }}
|
||||
@if (kycDocumentExpiryIsDerived) {
|
||||
<span
|
||||
class="kv-doc-dates__tag"
|
||||
matTooltip="No expiry printed on this document — calculated as 5 years from the issue date"
|
||||
>5-yr</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button mat-stroked-button class="kv-action-btn" type="button" (click)="toggleEditMode()">Update</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- GSTIN Row (Non-Individual only) -->
|
||||
@if (isNonIndividual) {
|
||||
<div class="kv-table__row">
|
||||
@@ -360,39 +608,72 @@
|
||||
<!-- KYC Meta Grid -->
|
||||
<div class="kv-meta-grid">
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">KYC Type</span>
|
||||
<span class="kv-meta-value">{{
|
||||
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
|
||||
}}</span>
|
||||
<mat-icon class="kv-meta-icon icon-blue">assignment_ind</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">KYC Type</span>
|
||||
<span class="kv-meta-value">
|
||||
{{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Risk Category</span>
|
||||
<span class="kv-meta-value">{{
|
||||
kycData.riskCategory === 1
|
||||
? 'Low'
|
||||
: kycData.riskCategory === 2
|
||||
? 'Medium'
|
||||
: kycData.riskCategory === 3
|
||||
? 'High'
|
||||
: '—'
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="kv-meta-icon"
|
||||
[ngClass]="{
|
||||
'icon-green': kycData.riskCategory === 1,
|
||||
'icon-orange': kycData.riskCategory === 2,
|
||||
'icon-red': kycData.riskCategory === 3
|
||||
}"
|
||||
>shield</mat-icon
|
||||
>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Risk Category</span>
|
||||
<span class="kv-meta-value">
|
||||
{{
|
||||
kycData.riskCategory === 1
|
||||
? 'Low'
|
||||
: kycData.riskCategory === 2
|
||||
? 'Medium'
|
||||
: kycData.riskCategory === 3
|
||||
? 'High'
|
||||
: '—'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Initiated On</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-grey">calendar_today</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Initiated On</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Last Verified</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-green">check_circle</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Last Verified</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Expiry Date</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-red">history_toggle_off</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Expiry Date</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (kycData.remarks) {
|
||||
<div class="kv-meta-item kv-meta-item--full">
|
||||
<span class="kv-meta-label">Remarks</span>
|
||||
<span class="kv-meta-value">{{ kycData.remarks }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-grey">notes</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Remarks</span>
|
||||
<span class="kv-meta-value">{{ kycData.remarks }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -567,6 +848,22 @@
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.pan }}</span>
|
||||
</div>
|
||||
<!--
|
||||
The check can fail for reasons no retry will fix — a PAN card that spells the
|
||||
name differently, or a vendor that is simply down. The declaration is the way
|
||||
out, so it is offered here rather than leaving the officer at a dead end.
|
||||
-->
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
||||
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
|
||||
@@ -678,6 +975,22 @@
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.aadhaar }}</span>
|
||||
</div>
|
||||
<!--
|
||||
A failed OTP is not always something a retry can fix — a customer who has only
|
||||
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
|
||||
out, so it is offered here rather than leaving the officer at a dead end.
|
||||
-->
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -559,6 +559,19 @@
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
// Unverified, covered by a declaration — amber, never green, so it cannot read as a pass.
|
||||
&--declared {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
// Covered by an enrolment slip. Blue rather than amber: nothing failed, the customer simply
|
||||
// does not hold the document yet.
|
||||
&--receipt {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-action-btn {
|
||||
@@ -570,6 +583,265 @@
|
||||
font-weight: 600 !important;
|
||||
color: #1074b9 !important;
|
||||
border-color: #1074b9 !important;
|
||||
|
||||
&.danger {
|
||||
color: #b91c1c !important;
|
||||
border-color: #fca5a5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PAN declaration detail, shown under the PAN row when the check did not pass */
|
||||
.kv-decl {
|
||||
margin: 4px 0 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.kv-decl__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
mat-icon {
|
||||
color: #b45309;
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-decl__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.kv-decl__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-decl__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kv-decl__row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #451a03;
|
||||
word-break: break-word;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-decl__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #fde68a;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* KYC document renewal alert — amber for a stale document, red once it has actually expired */
|
||||
.kyc-doc-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
background: #fef3c7;
|
||||
|
||||
&--critical {
|
||||
border-color: #ef4444;
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: #d97706;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kyc-doc-alert__title {
|
||||
font-size: 14px;
|
||||
color: #92400e;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__text {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__action {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Issue / expiry pair in the document row */
|
||||
.kv-doc-dates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kv-doc-dates__expiry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
|
||||
/* Calculated from the issue date rather than printed on the document. */
|
||||
&--derived {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-doc-dates__tag {
|
||||
padding: 0 5px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Aadhaar enrolment receipt detail, shown under the Aadhaar row when the check did not pass */
|
||||
.kv-rcpt {
|
||||
margin: 4px 0 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.kv-rcpt__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-rcpt__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.kv-rcpt__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-rcpt__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kv-rcpt__row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-rcpt__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* Offer shown beneath a failed PAN verification in edit mode */
|
||||
.kyc-decl-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
background: #fffbeb;
|
||||
font-size: 12.5px;
|
||||
color: #92400e;
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
color: #b45309 !important;
|
||||
border-color: #fcd34d !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* OTP Bar (below table) */
|
||||
@@ -622,27 +894,76 @@
|
||||
.kv-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 2%),
|
||||
0 4px 6px -1px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.kv-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 12px 14px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:nth-child(5n) {
|
||||
border-right: none;
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
border-right: none;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
padding-top: 16px;
|
||||
margin-top: 4px;
|
||||
border-radius: 0;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-meta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kv-meta-icon {
|
||||
font-size: 24px !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.icon-blue {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.icon-green {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
&.icon-orange {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&.icon-red {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.icon-grey {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +977,7 @@
|
||||
|
||||
.kv-meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
@@ -677,14 +998,7 @@
|
||||
|
||||
.kv-meta-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.kv-meta-item:nth-child(5n) {
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.kv-meta-item:nth-child(3n) {
|
||||
border-right: none;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,26 @@
|
||||
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';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||
import {
|
||||
deriveKycDocumentRenewal,
|
||||
kycDocumentNeedsAttention,
|
||||
KycDocumentRenewalStatus,
|
||||
KYC_DOCUMENT_RENEWAL_YEARS
|
||||
} from '../../kyc-document-renewal.util';
|
||||
import {
|
||||
PanDeclarationDialogComponent,
|
||||
PanDeclarationResult
|
||||
} from '../../shared/pan-declaration-dialog/pan-declaration-dialog.component';
|
||||
import {
|
||||
AadhaarReceiptDialogComponent,
|
||||
AadhaarReceiptResult
|
||||
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -47,6 +62,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
private clientsService = inject(ClientsService);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private dateUtils = inject(Dates);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -200,6 +216,127 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the stored record carries a signed declaration standing in for the PAN check. */
|
||||
get panDeclarationTaken(): boolean {
|
||||
return this.kycData?.panDeclarationTaken === true;
|
||||
}
|
||||
|
||||
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
|
||||
get panDeclarationMismatchLabel(): string {
|
||||
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
||||
if (!fields || fields === 'NONE') {
|
||||
return 'PAN could not be verified';
|
||||
}
|
||||
return fields
|
||||
.split(',')
|
||||
.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth'))
|
||||
.join(' and ');
|
||||
}
|
||||
|
||||
/** True when the stored record carries a UIDAI enrolment slip standing in for the OTP check. */
|
||||
get aadhaarReceiptTaken(): boolean {
|
||||
return this.kycData?.aadhaarReceiptTaken === true;
|
||||
}
|
||||
|
||||
// ── KYC document renewal ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the OVD captured at onboarding is still current — see `deriveKycDocumentRenewal()`.
|
||||
*
|
||||
* The issue and expiry dates have always been captured on the KYC step but never read back, so
|
||||
* a client onboarded on a ten-year-old licence looked identical to one onboarded yesterday.
|
||||
*/
|
||||
get kycDocumentRenewal(): KycDocumentRenewalStatus {
|
||||
return deriveKycDocumentRenewal({
|
||||
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
|
||||
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
|
||||
today: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/** Only raise the banner for a document that actually exists and actually needs action. */
|
||||
get showKycDocumentAlert(): boolean {
|
||||
return !!this.kycData?.kycDocumentName && kycDocumentNeedsAttention(this.kycDocumentRenewal.state);
|
||||
}
|
||||
|
||||
/** Heading for the alert — the three states call for visibly different things. */
|
||||
get kycDocumentAlertTitle(): string {
|
||||
switch (this.kycDocumentRenewal.state) {
|
||||
case 'EXPIRED':
|
||||
return 'KYC Document Expired';
|
||||
case 'EXPIRING_SOON':
|
||||
return 'KYC Document Expires Soon';
|
||||
default:
|
||||
return 'KYC Document Due for Update';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence staff act on. Each case names the date that triggered it, because "update the
|
||||
* document" without saying why leaves the officer checking the form to work out which clock ran
|
||||
* out.
|
||||
*/
|
||||
get kycDocumentAlertMessage(): string {
|
||||
const status = this.kycDocumentRenewal;
|
||||
const docName = this.kycData?.kycDocumentName || 'The KYC document';
|
||||
// `formatDate()` only understands Fineract's [y, m, d] arrays; these are real Date objects.
|
||||
const dueOn = status.dueOn ? this.dateUtils.formatDate(status.dueOn, 'dd MMMM yyyy') : '';
|
||||
const days = status.daysRemaining ?? 0;
|
||||
|
||||
switch (status.state) {
|
||||
case 'EXPIRED':
|
||||
return `${docName} expired on ${dueOn} and is no longer a valid officially valid document. Collect a renewed document from the customer and update the KYC record.`;
|
||||
case 'EXPIRING_SOON':
|
||||
return days === 0
|
||||
? `${docName} expires today (${dueOn}). Ask the customer for the renewed document before it lapses.`
|
||||
: `${docName} expires on ${dueOn} — ${days} day${days === 1 ? '' : 's'} from now. Ask the customer for the renewed document before it lapses.`;
|
||||
default:
|
||||
return `${docName} was issued more than ${KYC_DOCUMENT_RENEWAL_YEARS} years ago and fell due for update on ${dueOn}. Re-collect the document from the customer and update the KYC record.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drives the banner's colour — an expired document is a harder failure than a stale one. */
|
||||
get kycDocumentAlertSeverity(): 'critical' | 'warning' {
|
||||
return this.kycDocumentRenewal.state === 'EXPIRED' ? 'critical' : 'warning';
|
||||
}
|
||||
|
||||
/**
|
||||
* The date the document stops counting as current, whether or not one is printed on it.
|
||||
*
|
||||
* A Voter ID or NREGA card carries no expiry, but it is not valid forever for KYC purposes —
|
||||
* the five-year rule is its expiry. Showing the derived date rather than a blank means every
|
||||
* document answers "valid until when?", and the officer sees the same date the alert will fire on
|
||||
* instead of having to add five years in their head.
|
||||
*/
|
||||
get kycDocumentExpiresOn(): string {
|
||||
const dueOn = this.kycDocumentRenewal.dueOn;
|
||||
return dueOn ? this.dateUtils.formatDate(dueOn, 'dd MMMM yyyy') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the date above was calculated from the issue date rather than read off the document.
|
||||
* Flagged in the UI so a derived date is never mistaken for one printed on the card.
|
||||
*/
|
||||
get kycDocumentExpiryIsDerived(): boolean {
|
||||
return this.kycDocumentRenewal.reason === 'AGE';
|
||||
}
|
||||
|
||||
/** Compact chip label for the document row in the verification table. */
|
||||
get kycDocumentStatusLabel(): string {
|
||||
switch (this.kycDocumentRenewal.state) {
|
||||
case 'EXPIRED':
|
||||
return 'Expired';
|
||||
case 'EXPIRING_SOON':
|
||||
return 'Expires Soon';
|
||||
case 'DUE':
|
||||
return 'Update Due';
|
||||
case 'OK':
|
||||
return 'Current';
|
||||
default:
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
|
||||
*
|
||||
@@ -213,7 +350,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
return deriveKycExceptionFields({
|
||||
panVerified,
|
||||
// A signed declaration closes the PAN item by paperwork when the API never can.
|
||||
panDeclarationTaken: this.panDeclarationTaken,
|
||||
aadhaarVerified,
|
||||
// An enrolment slip closes the Aadhaar item when there is no number to verify yet.
|
||||
aadhaarReceiptTaken: this.aadhaarReceiptTaken,
|
||||
aadhaarApplicable: !this.isNonIndividual,
|
||||
hadException: this.kycData?.isException === true,
|
||||
previousReason: this.kycData?.exceptionReason || null,
|
||||
@@ -326,7 +467,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
if (response?.changes?.panVerified) {
|
||||
this.verificationStatus.pan = 'VERIFIED';
|
||||
this.verificationDetails.pan = response.changes;
|
||||
this.loadKycData();
|
||||
// A PAN that now verifies makes any earlier declaration moot.
|
||||
if (this.panDeclarationTaken) {
|
||||
this.writePanDeclaration(null);
|
||||
} else {
|
||||
this.loadKycData();
|
||||
}
|
||||
} else {
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan = 'PAN verification failed.';
|
||||
@@ -345,6 +491,283 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
}, 'pan');
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the customer declaration that stands in for a PAN check the client cannot pass.
|
||||
*
|
||||
* Offered for any PAN failure, not just a name/DOB mismatch — an officer facing an unreachable
|
||||
* vendor is just as stuck as one facing a misspelt PAN card, and the same signed paperwork
|
||||
* resolves both. When the failure was not a comparable mismatch there is no PAN card to compare
|
||||
* against, so the dialog only asks why the declaration is being taken.
|
||||
*/
|
||||
takePanDeclaration(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
||||
return;
|
||||
}
|
||||
const failureMessage = this.verificationError.pan || 'PAN has not been verified.';
|
||||
const mismatch = failureMessage.toLowerCase().includes('does not match');
|
||||
|
||||
this.dialog
|
||||
.open(PanDeclarationDialogComponent, {
|
||||
width: '720px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
submittedName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
|
||||
submittedDob: this.clientDateOfBirth || '',
|
||||
panNumber: this.kycForm.value.panNumber || this.kycData?.panNumber || '',
|
||||
failureMessage,
|
||||
// Only a reported name/DOB mismatch gives the officer something to compare against.
|
||||
unverifiable: !mismatch,
|
||||
existing: this.panDeclarationTaken
|
||||
? {
|
||||
mismatchFields: this.kycData?.panDeclarationMismatchFields || 'NONE',
|
||||
nameOnPan: this.kycData?.panDeclarationNameOnPan || null,
|
||||
dobOnPan: this.parseApiDate(this.kycData?.panDeclarationDobOnPan),
|
||||
// The scan itself lives with the client's documents; the record keeps its name.
|
||||
file: null,
|
||||
fileName: this.kycData?.panDeclarationRemarks || ''
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: PanDeclarationResult | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
// The scan has to be filed against the client before the record says a declaration exists —
|
||||
// otherwise the PAN exception is waived by a document nobody can produce.
|
||||
if (result.file) {
|
||||
this.uploadPanDeclarationFile(result.file, () => this.writePanDeclaration(result));
|
||||
} else {
|
||||
this.writePanDeclaration(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Files the signed declaration under the client's documents, then continues with `onDone`. */
|
||||
private uploadPanDeclarationFile(file: File, onDone: () => void): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', 'PAN Declaration');
|
||||
formData.append(
|
||||
'description',
|
||||
`Signed PAN declaration for ${this.kycForm.value.panNumber || this.kycData?.panNumber || 'PAN'}`
|
||||
);
|
||||
|
||||
this.clientsService
|
||||
.uploadClientDocument(this.clientId, formData)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
onDone();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
err?.message ||
|
||||
'Could not upload the signed declaration. Please try again.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Officer-initiated removal — the PAN exception is raised again on this same save. */
|
||||
removePanDeclaration(): void {
|
||||
if (this.panDeclarationTaken) {
|
||||
this.writePanDeclaration(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists (or clears) the declaration on the KYC record.
|
||||
*
|
||||
* `buildExceptionFields()` reads the declaration off `kycData`, so that has to be updated before
|
||||
* the payload is built — otherwise the save would carry the declaration and the PAN exception at
|
||||
* the same time, which is exactly the contradiction the declaration exists to prevent.
|
||||
*/
|
||||
private writePanDeclaration(declaration: PanDeclarationResult | null): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const previous = {
|
||||
panDeclarationTaken: this.kycData?.panDeclarationTaken,
|
||||
panDeclarationMismatchFields: this.kycData?.panDeclarationMismatchFields,
|
||||
panDeclarationNameOnPan: this.kycData?.panDeclarationNameOnPan,
|
||||
panDeclarationDobOnPan: this.kycData?.panDeclarationDobOnPan,
|
||||
panDeclarationRemarks: this.kycData?.panDeclarationRemarks,
|
||||
panDeclarationTakenOn: this.kycData?.panDeclarationTakenOn
|
||||
};
|
||||
|
||||
const dateFormat = 'dd MMMM yyyy';
|
||||
const declarationFields = {
|
||||
panDeclarationTaken: !!declaration,
|
||||
panDeclarationMismatchFields: declaration?.mismatchFields || null,
|
||||
panDeclarationNameOnPan: declaration?.nameOnPan || null,
|
||||
panDeclarationDobOnPan: declaration?.dobOnPan
|
||||
? this.dateUtils.formatDate(declaration.dobOnPan, dateFormat)
|
||||
: null,
|
||||
// Names the uploaded declaration filed under the client's documents.
|
||||
panDeclarationRemarks: declaration?.fileName || null,
|
||||
panDeclarationTakenOn: declaration ? this.dateUtils.formatDate(new Date(), dateFormat) : null
|
||||
};
|
||||
|
||||
this.kycData = { ...(this.kycData || {}), ...declarationFields };
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...declarationFields,
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
.updateClientKyc(this.clientId, this.kycId, payload)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.loadKycData();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
// Put the record back so the tab never shows a declaration the server did not accept.
|
||||
this.kycData = { ...(this.kycData || {}), ...previous };
|
||||
this.errorMessage =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to save the PAN declaration.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one has no number to put through
|
||||
* the OTP flow, so the check can never pass. The slip closes the item instead.
|
||||
*/
|
||||
takeAadhaarReceipt(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AadhaarReceiptDialogComponent, {
|
||||
width: '560px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
clientName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
|
||||
reason:
|
||||
this.verificationError.aadhaar ||
|
||||
'Aadhaar has not been verified. Record the enrolment slip if the customer has applied but not yet received their Aadhaar.',
|
||||
existing: this.aadhaarReceiptTaken
|
||||
? {
|
||||
enrolmentId: this.kycData?.aadhaarEnrolmentId || '',
|
||||
enrolmentDate: this.parseApiDate(this.kycData?.aadhaarEnrolmentDate),
|
||||
remarks: this.kycData?.aadhaarReceiptRemarks || ''
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: AadhaarReceiptResult | undefined) => {
|
||||
if (result) {
|
||||
this.writeAadhaarReceipt(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Officer-initiated removal — the Aadhaar exception is raised again on this same save. */
|
||||
removeAadhaarReceipt(): void {
|
||||
if (this.aadhaarReceiptTaken) {
|
||||
this.writeAadhaarReceipt(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists (or clears) the enrolment receipt on the KYC record.
|
||||
*
|
||||
* `buildExceptionFields()` reads the receipt off `kycData`, so that has to be updated before the
|
||||
* payload is built — otherwise the save would carry the receipt and the Aadhaar exception at the
|
||||
* same time, the contradiction the receipt exists to prevent.
|
||||
*/
|
||||
private writeAadhaarReceipt(receipt: AadhaarReceiptResult | null): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const previous = {
|
||||
aadhaarReceiptTaken: this.kycData?.aadhaarReceiptTaken,
|
||||
aadhaarEnrolmentId: this.kycData?.aadhaarEnrolmentId,
|
||||
aadhaarEnrolmentDate: this.kycData?.aadhaarEnrolmentDate,
|
||||
aadhaarReceiptRemarks: this.kycData?.aadhaarReceiptRemarks,
|
||||
aadhaarReceiptTakenOn: this.kycData?.aadhaarReceiptTakenOn
|
||||
};
|
||||
|
||||
const dateFormat = 'dd MMMM yyyy';
|
||||
const receiptFields = {
|
||||
aadhaarReceiptTaken: !!receipt,
|
||||
aadhaarEnrolmentId: receipt?.enrolmentId || null,
|
||||
aadhaarEnrolmentDate: receipt?.enrolmentDate
|
||||
? this.dateUtils.formatDate(receipt.enrolmentDate, dateFormat)
|
||||
: null,
|
||||
aadhaarReceiptRemarks: receipt?.remarks || null,
|
||||
aadhaarReceiptTakenOn: receipt ? this.dateUtils.formatDate(new Date(), dateFormat) : null
|
||||
};
|
||||
|
||||
this.kycData = { ...(this.kycData || {}), ...receiptFields };
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...receiptFields,
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
.updateClientKyc(this.clientId, this.kycId, payload)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.verificationError.aadhaar = null;
|
||||
this.loadKycData();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
// Put the record back so the tab never shows a receipt the server did not accept.
|
||||
this.kycData = { ...(this.kycData || {}), ...previous };
|
||||
this.errorMessage =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to save the Aadhaar enrolment receipt.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Fineract serialises dates as `[yyyy, M, d]`; the same field can also arrive as a string. */
|
||||
private parseApiDate(raw: any): Date | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length >= 3) {
|
||||
return new Date(raw[0], raw[1] - 1, raw[2]);
|
||||
}
|
||||
const parsed = new Date(raw);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
/** Show Aadhaar input in summary view for re-verification */
|
||||
startAadhaarReVerify(): void {
|
||||
this.showAadhaarReVerify = true;
|
||||
@@ -416,7 +839,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationStatus.aadhaar = 'VERIFIED';
|
||||
this.verificationDetails.aadhaar = response.changes;
|
||||
this.resetOtpState();
|
||||
this.loadKycData();
|
||||
// Aadhaar has been issued and verified, so the enrolment slip has served its purpose —
|
||||
// drop it rather than leave the record claiming the customer is still waiting.
|
||||
if (this.aadhaarReceiptTaken) {
|
||||
this.writeAadhaarReceipt(null);
|
||||
} else {
|
||||
this.loadKycData();
|
||||
}
|
||||
} else {
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar = 'OTP verification failed.';
|
||||
@@ -619,13 +1048,43 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
// PAN
|
||||
y = addLine('PAN Number:', kyc.panNumber || '—', y);
|
||||
y = addLine('PAN Status:', kyc.panVerified ? 'Verified' : 'Pending', y);
|
||||
// "Pending" would misdescribe a PAN that was checked, failed, and was settled by declaration —
|
||||
// nothing is pending on that record, and the certificate has to say what actually happened.
|
||||
y = addLine(
|
||||
'PAN Status:',
|
||||
kyc.panVerified ? 'Verified' : this.panDeclarationTaken ? 'Not Verified — Declaration Taken' : 'Pending',
|
||||
y
|
||||
);
|
||||
if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y);
|
||||
if (!kyc.panVerified && this.panDeclarationTaken) {
|
||||
y = addLine('Declared Mismatch:', this.panDeclarationMismatchLabel, y);
|
||||
if (kyc.panDeclarationNameOnPan) y = addLine('Name on PAN:', kyc.panDeclarationNameOnPan, y);
|
||||
if (kyc.panDeclarationDobOnPan) {
|
||||
y = addLine('DOB on PAN:', this.formatDate(kyc.panDeclarationDobOnPan), y);
|
||||
}
|
||||
if (kyc.panDeclarationTakenOn) {
|
||||
y = addLine('Declaration Taken On:', this.formatDate(kyc.panDeclarationTakenOn), y);
|
||||
}
|
||||
}
|
||||
|
||||
// Aadhaar
|
||||
y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y);
|
||||
y = addLine('Aadhaar Status:', kyc.aadhaarVerified ? 'Verified' : 'Pending', y);
|
||||
// As with PAN: "Pending" would misdescribe a client who holds only an enrolment slip.
|
||||
y = addLine(
|
||||
'Aadhaar Status:',
|
||||
kyc.aadhaarVerified ? 'Verified' : this.aadhaarReceiptTaken ? 'Not Verified — Enrolment Receipt' : 'Pending',
|
||||
y
|
||||
);
|
||||
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
|
||||
if (!kyc.aadhaarVerified && this.aadhaarReceiptTaken) {
|
||||
if (kyc.aadhaarEnrolmentId) y = addLine('Enrolment ID:', kyc.aadhaarEnrolmentId, y);
|
||||
if (kyc.aadhaarEnrolmentDate) {
|
||||
y = addLine('Enrolment Date:', this.formatDate(kyc.aadhaarEnrolmentDate), y);
|
||||
}
|
||||
if (kyc.aadhaarReceiptTakenOn) {
|
||||
y = addLine('Receipt Recorded On:', this.formatDate(kyc.aadhaarReceiptTakenOn), y);
|
||||
}
|
||||
}
|
||||
|
||||
// PAN-Aadhaar Link
|
||||
const seedingStatus =
|
||||
|
||||
@@ -98,12 +98,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Client ID Column -->
|
||||
<ng-container matColumnDef="accountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- CIF Number Column -->
|
||||
<ng-container matColumnDef="cifNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.CIF Number' | translate }}</th>
|
||||
|
||||
@@ -88,7 +88,6 @@ export class ClientsComponent implements OnInit {
|
||||
|
||||
displayedColumns = [
|
||||
'displayName',
|
||||
'accountNumber',
|
||||
'cifNumber',
|
||||
'legalForm',
|
||||
'status',
|
||||
|
||||
@@ -809,6 +809,15 @@ export class ClientsService {
|
||||
return this.http.get<any[]>('/kyc/risk-rules');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a rule to the AML catalog. Amount/window details in the payload also create the
|
||||
* matching live-transaction rule server side; the response is the persisted rule in the same
|
||||
* shape as {@link getClientRiskRules}.
|
||||
*/
|
||||
createClientRiskRule(rule: any): Observable<any> {
|
||||
return this.http.post('/kyc/risk-rules', rule);
|
||||
}
|
||||
|
||||
/** Fetch code values by code ID */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
[clientId]="createdClientId"
|
||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
|
||||
@@ -223,6 +223,15 @@ export class CreateClientComponent {
|
||||
// KYC tab — block when the kyc form has unfilled required fields
|
||||
// (kycDocumentName / kycDocumentNumber / pattern errors on PAN / Aadhaar etc.)
|
||||
const kf = this.clientKycStep?.kycForm;
|
||||
if (kf && !kf.valid) {
|
||||
const invalid = [];
|
||||
for (const name in kf.controls) {
|
||||
if (kf.controls[name].invalid) {
|
||||
invalid.push({ name, errors: kf.controls[name].errors, value: kf.controls[name].value });
|
||||
}
|
||||
}
|
||||
console.log('--- KYC Form is INVALID. Offending controls:', invalid);
|
||||
}
|
||||
return !kf || kf.valid;
|
||||
}
|
||||
if (this.legalFormType === 2) {
|
||||
@@ -824,6 +833,9 @@ export class CreateClientComponent {
|
||||
* Mirrors identities-tab.createIdentifierWithDocs.
|
||||
*/
|
||||
private saveAddressProof(clientId: string) {
|
||||
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
@@ -839,6 +851,9 @@ export class CreateClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientIndiaAddressStep) {
|
||||
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -855,6 +870,9 @@ export class CreateClientComponent {
|
||||
* + upload each selected file as a document attached to that identifier.
|
||||
*/
|
||||
private saveKycDocProof(clientId: string) {
|
||||
if (this.clientKycStep?.existingKycIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientKycStep?.kycDocPayload;
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
@@ -882,6 +900,9 @@ export class CreateClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -906,8 +927,45 @@ export class CreateClientComponent {
|
||||
return this.clientsService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const targetName = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const target = cleanStr(proofName || proofId);
|
||||
|
||||
// 1. Try exact code/name match
|
||||
let match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode === target || pname === target;
|
||||
});
|
||||
|
||||
// 2. Try loose contains/includes match
|
||||
if (!match) {
|
||||
match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Specific fallbacks for common keywords
|
||||
if (!match && target.includes('voter')) {
|
||||
match = list.find((p: any) => {
|
||||
const name = cleanStr(p.name);
|
||||
return name.includes('voter') || name.includes('epic');
|
||||
});
|
||||
}
|
||||
if (!match && target.includes('aadhaar')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
|
||||
}
|
||||
if (!match && target.includes('passport')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
|
||||
}
|
||||
if (!match && target.includes('driving')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
|
||||
}
|
||||
if (!match && target.includes('pan')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
|
||||
}
|
||||
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -220,6 +220,8 @@
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[clientId]="clientId"
|
||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
@@ -342,7 +344,6 @@
|
||||
[degreeQualificationOptions]="clientGeneralStep?.degreeQualificationOptions || []"
|
||||
[religionOptions]="clientGeneralStep?.religionOptions || []"
|
||||
[socialCategoryOptions]="clientGeneralStep?.socialCategoryOptions || []"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-client-preview-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
@@ -350,9 +351,11 @@
|
||||
|
||||
<!-- Bottom Action Bar -->
|
||||
<div class="action-bar">
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
}
|
||||
<div class="action-bar-right">
|
||||
@if (selectedTabIndex > 0) {
|
||||
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
|
||||
@@ -361,15 +364,7 @@
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="onNext()"
|
||||
[disabled]="!currentStepValid"
|
||||
[matTooltip]="currentStepBlockerMessage"
|
||||
[matTooltipDisabled]="currentStepValid"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||
Saving...
|
||||
@@ -383,15 +378,10 @@
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
|
||||
<button mat-raised-button color="primary" (click)="submit()" [disabled]="savingClient">
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||
Saving...
|
||||
} @else {
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
}
|
||||
@if (selectedTabIndex === totalTabs - 1) {
|
||||
<button mat-raised-button color="primary" [routerLink]="['../']">
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
Finish
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1021,6 +1021,9 @@ export class EditClientComponent {
|
||||
* Mirrors identities-tab.createIdentifierWithDocs.
|
||||
*/
|
||||
private saveAddressProof(clientId: string) {
|
||||
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
@@ -1036,6 +1039,9 @@ export class EditClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientIndiaAddressStep) {
|
||||
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -1056,6 +1062,21 @@ export class EditClientComponent {
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'KYC Document';
|
||||
|
||||
const uploadFiles = (identifierId: string) => {
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
};
|
||||
|
||||
if (this.clientKycStep?.existingKycIdentifierId) {
|
||||
uploadFiles(this.clientKycStep.existingKycIdentifierId);
|
||||
return;
|
||||
}
|
||||
|
||||
// The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR").
|
||||
// The backend ClientIdentifierRequest.documentTypeId is a Long, so a string proofId
|
||||
// trips Jackson → HttpMessageNotReadableException → 400. Resolve to numeric CodeValue
|
||||
@@ -1075,12 +1096,10 @@ export class EditClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
|
||||
}
|
||||
uploadFiles(identifierId.toString());
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
@@ -1098,8 +1117,45 @@ export class EditClientComponent {
|
||||
return this.clientsService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const targetName = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const target = cleanStr(proofName || proofId);
|
||||
|
||||
// 1. Try exact code/name match
|
||||
let match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode === target || pname === target;
|
||||
});
|
||||
|
||||
// 2. Try loose contains/includes match
|
||||
if (!match) {
|
||||
match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Specific fallbacks for common keywords
|
||||
if (!match && target.includes('voter')) {
|
||||
match = list.find((p: any) => {
|
||||
const name = cleanStr(p.name);
|
||||
return name.includes('voter') || name.includes('epic');
|
||||
});
|
||||
}
|
||||
if (!match && target.includes('aadhaar')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
|
||||
}
|
||||
if (!match && target.includes('passport')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
|
||||
}
|
||||
if (!match && target.includes('driving')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
|
||||
}
|
||||
if (!match && target.includes('pan')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
|
||||
}
|
||||
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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 {
|
||||
deriveKycDocumentRenewal,
|
||||
kycDocumentNeedsAttention,
|
||||
KYC_DOCUMENT_EXPIRY_WARNING_DAYS,
|
||||
KYC_DOCUMENT_RENEWAL_YEARS
|
||||
} from './kyc-document-renewal.util';
|
||||
|
||||
const TODAY = new Date(2026, 7, 7); // 07 Aug 2026
|
||||
|
||||
const at = (y: number, m: number, d: number) => new Date(y, m - 1, d);
|
||||
|
||||
const check = (issueDate: Date | null, expiryDate: Date | null = null, today: Date = TODAY) =>
|
||||
deriveKycDocumentRenewal({ issueDate, expiryDate, today });
|
||||
|
||||
describe('deriveKycDocumentRenewal', () => {
|
||||
describe('the five-year rule', () => {
|
||||
it('says nothing about a document with neither date captured', () => {
|
||||
const result = check(null, null);
|
||||
expect(result.state).toBe('UNKNOWN');
|
||||
expect(result.dueOn).toBeNull();
|
||||
expect(result.daysRemaining).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a recently issued document alone', () => {
|
||||
const result = check(at(2025, 1, 15));
|
||||
expect(result.state).toBe('OK');
|
||||
expect(result.reason).toBe('AGE');
|
||||
});
|
||||
|
||||
it('falls due exactly five years after issue', () => {
|
||||
const result = check(at(2021, 8, 7));
|
||||
expect(result.dueOn).toEqual(at(2026, 8, 7));
|
||||
expect(result.daysRemaining).toBe(0);
|
||||
// Due today is not yet past — the branch still has the day to act.
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
|
||||
it('becomes DUE the day after the fifth anniversary', () => {
|
||||
const result = check(at(2021, 8, 6));
|
||||
expect(result.state).toBe('DUE');
|
||||
expect(result.reason).toBe('AGE');
|
||||
});
|
||||
|
||||
it('is DUE, not EXPIRED, once past five years — the document is still valid, just stale', () => {
|
||||
const result = check(at(2020, 3, 10));
|
||||
expect(result.state).toBe('DUE');
|
||||
expect(result.reason).toBe('AGE');
|
||||
expect(result.daysRemaining).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('does not warn ahead of the five-year mark — a document one day short is entirely fine', () => {
|
||||
// Due tomorrow on age alone, and still OK: only expiry gets an advance warning.
|
||||
const result = check(at(2021, 8, 8));
|
||||
expect(result.daysRemaining).toBe(1);
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an explicit expiry date', () => {
|
||||
it('warns seven days before expiry', () => {
|
||||
const result = check(at(2025, 6, 1), at(2026, 8, 14));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.daysRemaining).toBe(7);
|
||||
expect(result.state).toBe('EXPIRING_SOON');
|
||||
});
|
||||
|
||||
it('is still OK eight days out', () => {
|
||||
expect(check(at(2025, 6, 1), at(2026, 8, 15)).state).toBe('OK');
|
||||
});
|
||||
|
||||
it('warns on the day of expiry itself', () => {
|
||||
const result = check(at(2025, 6, 1), at(2026, 8, 7));
|
||||
expect(result.daysRemaining).toBe(0);
|
||||
expect(result.state).toBe('EXPIRING_SOON');
|
||||
});
|
||||
|
||||
it('marks a passed expiry as EXPIRED — no longer a valid OVD', () => {
|
||||
const result = check(at(2024, 1, 1), at(2026, 1, 1));
|
||||
expect(result.state).toBe('EXPIRED');
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
});
|
||||
|
||||
it('governs when it falls before the five-year mark', () => {
|
||||
// Issued last year, so the age clock is years away — expiry is what matters.
|
||||
const result = check(at(2025, 6, 1), at(2026, 10, 1));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.dueOn).toEqual(at(2026, 10, 1));
|
||||
});
|
||||
|
||||
it('yields to the five-year rule when it falls later', () => {
|
||||
// A passport issued in 2019 expiring in 2029 is stale long before it expires.
|
||||
const result = check(at(2019, 5, 20), at(2029, 5, 20));
|
||||
expect(result.reason).toBe('AGE');
|
||||
expect(result.dueOn).toEqual(at(2024, 5, 20));
|
||||
expect(result.state).toBe('DUE');
|
||||
});
|
||||
|
||||
it('is judged on expiry alone when no issue date was captured', () => {
|
||||
const result = check(null, at(2027, 1, 1));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('leap-year issue dates', () => {
|
||||
it('pins a 29 February issue to 28 February, not 1 March', () => {
|
||||
// 2020-02-29 + 5 years has no real anniversary; rolling into March would push the
|
||||
// document into the wrong month.
|
||||
const result = check(at(2020, 2, 29));
|
||||
expect(result.dueOn).toEqual(at(2025, 2, 28));
|
||||
});
|
||||
});
|
||||
|
||||
describe('kycDocumentNeedsAttention', () => {
|
||||
it('flags only the states staff must act on', () => {
|
||||
expect(kycDocumentNeedsAttention('UNKNOWN')).toBe(false);
|
||||
expect(kycDocumentNeedsAttention('OK')).toBe(false);
|
||||
expect(kycDocumentNeedsAttention('EXPIRING_SOON')).toBe(true);
|
||||
expect(kycDocumentNeedsAttention('EXPIRED')).toBe(true);
|
||||
expect(kycDocumentNeedsAttention('DUE')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a five-year age window and a seven-day expiry warning', () => {
|
||||
expect(KYC_DOCUMENT_RENEWAL_YEARS).toBe(5);
|
||||
expect(KYC_DOCUMENT_EXPIRY_WARNING_DAYS).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decides when the officially valid document (OVD) captured at KYC needs updating.
|
||||
*
|
||||
* The KYC step records a document type, its number, an issue date and — for documents that carry
|
||||
* one — an expiry date. Nothing has ever read those dates back, so a client onboarded on a
|
||||
* ten-year-old driving licence looked exactly like one onboarded yesterday. This module turns the
|
||||
* two dates into a single answer to "does this document need updating?", so the KYC tab can say so
|
||||
* rather than leaving staff to work it out from a date on a form.
|
||||
*
|
||||
* Two separate clocks run, and they warn differently because they mean different things:
|
||||
*
|
||||
* - **Age.** Five years after issue the document is stale and must be re-collected. It is still a
|
||||
* valid document, so there is nothing to pre-empt — the alert appears once the date passes.
|
||||
* - **Expiry.** A printed expiry date is a hard stop: the day after, the document is not a valid
|
||||
* OVD at all and the account is out of compliance. That one is worth heading off, so it warns
|
||||
* seven days ahead while the customer can still be asked to bring the renewed document in.
|
||||
*/
|
||||
|
||||
/** A document is treated as due for refresh five years after it was issued. */
|
||||
export const KYC_DOCUMENT_RENEWAL_YEARS = 5;
|
||||
|
||||
/** How many days before a printed expiry date the "expires soon" warning starts. */
|
||||
export const KYC_DOCUMENT_EXPIRY_WARNING_DAYS = 7;
|
||||
|
||||
export type KycDocumentRenewalState =
|
||||
/** No dates captured — nothing can be said about this document. */
|
||||
| 'UNKNOWN'
|
||||
/** Within its life, and not close to either threshold. */
|
||||
| 'OK'
|
||||
/** Printed expiry date falls within the next seven days. */
|
||||
| 'EXPIRING_SOON'
|
||||
/** Past its printed expiry date: no longer a valid OVD. */
|
||||
| 'EXPIRED'
|
||||
/** Past the five-year mark: still a valid document, but must be re-collected. */
|
||||
| 'DUE';
|
||||
|
||||
/** Which of the two clocks decided the outcome. */
|
||||
export type KycDocumentRenewalReason = 'EXPIRY' | 'AGE' | null;
|
||||
|
||||
export interface KycDocumentRenewalInput {
|
||||
/** Issue date printed on the document. */
|
||||
issueDate: Date | null;
|
||||
/** Expiry date printed on the document — only some carry one (passport, DL, visa). */
|
||||
expiryDate: Date | null;
|
||||
/** Today, injected so the calculation is testable and matches the tenant's date. */
|
||||
today: Date;
|
||||
}
|
||||
|
||||
export interface KycDocumentRenewalStatus {
|
||||
state: KycDocumentRenewalState;
|
||||
/** The date the document falls due — the earlier of its expiry and its fifth anniversary. */
|
||||
dueOn: Date | null;
|
||||
/** Days until `dueOn`; negative once it has passed. Null when nothing can be judged. */
|
||||
daysRemaining: number | null;
|
||||
reason: KycDocumentRenewalReason;
|
||||
}
|
||||
|
||||
/** Midnight-aligned, so a document due today is not counted as a fraction of a day overdue. */
|
||||
function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds whole years, clamping to the end of the month.
|
||||
*
|
||||
* A 29 February issue date has no fifth anniversary. Letting the Date constructor roll it into
|
||||
* 1 March would push the document into the wrong month, so it is pinned to 28 February instead.
|
||||
*/
|
||||
function addYears(date: Date, years: number): Date {
|
||||
const target = new Date(date.getFullYear() + years, date.getMonth(), date.getDate());
|
||||
if (target.getMonth() !== date.getMonth()) {
|
||||
return new Date(target.getFullYear(), target.getMonth(), 0);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function daysBetween(from: Date, to: Date): number {
|
||||
return Math.round((to.getTime() - from.getTime()) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out whether the captured document needs updating.
|
||||
*
|
||||
* The two dates are not alternatives — whichever falls first governs. A passport issued last year
|
||||
* but expiring in six months is due in six months, not in four years: treating the five-year rule
|
||||
* as the only clock would leave an expired passport on file looking current. A document with no
|
||||
* printed expiry is judged purely on age.
|
||||
*/
|
||||
export function deriveKycDocumentRenewal(input: KycDocumentRenewalInput): KycDocumentRenewalStatus {
|
||||
const today = startOfDay(input.today);
|
||||
const issue = input.issueDate ? startOfDay(input.issueDate) : null;
|
||||
const expiry = input.expiryDate ? startOfDay(input.expiryDate) : null;
|
||||
|
||||
if (!issue && !expiry) {
|
||||
return { state: 'UNKNOWN', dueOn: null, daysRemaining: null, reason: null };
|
||||
}
|
||||
|
||||
const ageDue = issue ? addYears(issue, KYC_DOCUMENT_RENEWAL_YEARS) : null;
|
||||
|
||||
// Whichever comes first governs — see the doc comment above.
|
||||
let dueOn: Date;
|
||||
let reason: KycDocumentRenewalReason;
|
||||
if (expiry && ageDue) {
|
||||
if (expiry.getTime() <= ageDue.getTime()) {
|
||||
dueOn = expiry;
|
||||
reason = 'EXPIRY';
|
||||
} else {
|
||||
dueOn = ageDue;
|
||||
reason = 'AGE';
|
||||
}
|
||||
} else if (expiry) {
|
||||
dueOn = expiry;
|
||||
reason = 'EXPIRY';
|
||||
} else {
|
||||
dueOn = ageDue as Date;
|
||||
reason = 'AGE';
|
||||
}
|
||||
|
||||
const daysRemaining = daysBetween(today, dueOn);
|
||||
|
||||
let state: KycDocumentRenewalState;
|
||||
if (reason === 'EXPIRY') {
|
||||
if (daysRemaining < 0) {
|
||||
state = 'EXPIRED';
|
||||
} else if (daysRemaining <= KYC_DOCUMENT_EXPIRY_WARNING_DAYS) {
|
||||
state = 'EXPIRING_SOON';
|
||||
} else {
|
||||
state = 'OK';
|
||||
}
|
||||
} else {
|
||||
// Age alone: a document one day short of five years is entirely fine, so there is nothing to
|
||||
// warn about in advance. It becomes due the moment the anniversary passes.
|
||||
state = daysRemaining < 0 ? 'DUE' : 'OK';
|
||||
}
|
||||
|
||||
return { state, dueOn, daysRemaining, reason };
|
||||
}
|
||||
|
||||
/** Whether this state warrants surfacing an alert to staff at all. */
|
||||
export function kycDocumentNeedsAttention(state: KycDocumentRenewalState): boolean {
|
||||
return state === 'EXPIRING_SOON' || state === 'EXPIRED' || state === 'DUE';
|
||||
}
|
||||
@@ -93,6 +93,117 @@ describe('deriveKycExceptionFields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PAN declaration', () => {
|
||||
it('waives the PAN exception once a declaration is taken', () => {
|
||||
// The client's name/DOB will never match the PAN record, so the API can never pass. The
|
||||
// signed declaration closes the item instead — the client must not sit in the exception list.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
panDeclarationTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('still raises the PAN exception when no declaration has been taken', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
panDeclarationTaken: false
|
||||
}).exceptionReason
|
||||
).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
|
||||
it('leaves an outstanding Aadhaar untouched — a PAN declaration covers PAN only', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
panDeclarationTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(true);
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_AADHAAR);
|
||||
});
|
||||
|
||||
it('closes an existing PAN exception when a declaration is taken afterwards', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...existingException(false, true, KYC_EXCEPTION_PAN),
|
||||
panDeclarationTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: KYC_EXCEPTION_PAN,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aadhaar enrolment receipt', () => {
|
||||
it('waives the Aadhaar exception once the enrolment receipt is recorded', () => {
|
||||
// The client holds no Aadhaar yet, so there is no number to run the OTP check against. The
|
||||
// UIDAI enrolment slip closes the item instead.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(true, false),
|
||||
aadhaarReceiptTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('still raises the Aadhaar exception when no receipt has been taken', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(true, false),
|
||||
aadhaarReceiptTaken: false
|
||||
}).exceptionReason
|
||||
).toBe(KYC_EXCEPTION_AADHAAR);
|
||||
});
|
||||
|
||||
it('leaves an outstanding PAN untouched — a receipt covers Aadhaar only', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
aadhaarReceiptTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(true);
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
|
||||
it('closes an existing Aadhaar exception when a receipt is taken afterwards', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...existingException(true, false, KYC_EXCEPTION_AADHAAR),
|
||||
aadhaarReceiptTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
|
||||
it('clears both items when a PAN declaration and an Aadhaar receipt are both on file', () => {
|
||||
// Neither check can pass, yet the branch holds paperwork for both — nothing is outstanding.
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
panDeclarationTaken: true,
|
||||
aadhaarReceiptTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(false);
|
||||
expect(result.exceptionReason).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolving', () => {
|
||||
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
|
||||
@@ -29,8 +29,28 @@ export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
|
||||
export interface KycExceptionInput {
|
||||
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
|
||||
panVerified: boolean;
|
||||
/**
|
||||
* A signed customer declaration has been accepted in place of a passing PAN check.
|
||||
*
|
||||
* The Income Tax check matches on name *and* date of birth, so a client whose PAN card carries a
|
||||
* different spelling — or a different DOB — can never clear it, however many times the officer
|
||||
* retries. Once the branch holds a declaration recording exactly what differs, the outstanding
|
||||
* item is closed by paperwork rather than by the API, and it must not keep the client sitting in
|
||||
* the exception list. PAN still reads as unverified everywhere else; only the exception is waived.
|
||||
*/
|
||||
panDeclarationTaken?: boolean;
|
||||
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
|
||||
aadhaarVerified: boolean;
|
||||
/**
|
||||
* The customer's Aadhaar enrolment slip has been sighted and recorded.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one yet holds only a UIDAI
|
||||
* enrolment receipt. There is no Aadhaar number to put through the OTP flow, so the check cannot
|
||||
* pass however long the branch waits — the same dead end the PAN declaration exists to resolve.
|
||||
* Once the receipt is on file the outstanding item is closed by paperwork, and the client must
|
||||
* not keep sitting in the exception list. Aadhaar still reads as unverified everywhere else.
|
||||
*/
|
||||
aadhaarReceiptTaken?: boolean;
|
||||
/**
|
||||
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
|
||||
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
|
||||
@@ -69,10 +89,13 @@ export interface KycExceptionFields {
|
||||
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
|
||||
const outstanding: string[] = [];
|
||||
|
||||
if (!input.panVerified) {
|
||||
// A declaration settles the PAN item without the API ever passing — see `panDeclarationTaken`.
|
||||
if (!input.panVerified && !input.panDeclarationTaken) {
|
||||
outstanding.push(KYC_EXCEPTION_PAN);
|
||||
}
|
||||
if (input.aadhaarApplicable && !input.aadhaarVerified) {
|
||||
// An enrolment receipt settles the Aadhaar item when the OTP check has nothing to run against —
|
||||
// see `aadhaarReceiptTaken`.
|
||||
if (input.aadhaarApplicable && !input.aadhaarVerified && !input.aadhaarReceiptTaken) {
|
||||
outstanding.push(KYC_EXCEPTION_AADHAAR);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="aad-rcpt">
|
||||
<div class="aad-rcpt__header">
|
||||
<mat-icon class="aad-rcpt__header-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<div class="aad-rcpt__title">Aadhaar Enrolment Receipt</div>
|
||||
<div class="aad-rcpt__subtitle">{{ data.clientName || 'Client' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="aad-rcpt__body">
|
||||
<div class="aad-rcpt__reason">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ data.reason }}</span>
|
||||
</div>
|
||||
|
||||
<p class="aad-rcpt__intro">
|
||||
For a customer who has applied for Aadhaar but has not been issued one yet, there is no Aadhaar number to run the
|
||||
OTP check against. Record the UIDAI enrolment slip instead — the client can then be created without an Aadhaar KYC
|
||||
exception being raised.
|
||||
</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__field">
|
||||
<mat-label>Enrolment number (EID)</mat-label>
|
||||
<span matPrefix class="aad-rcpt__prefix"><mat-icon>confirmation_number</mat-icon></span>
|
||||
<input matInput [(ngModel)]="enrolmentId" placeholder="1234/56789/01234" maxlength="20" autocomplete="off" />
|
||||
<mat-hint [class.aad-rcpt__hint--bad]="enrolmentIdEntered && !enrolmentIdValid">
|
||||
{{ enrolmentIdHint }}
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__field">
|
||||
<mat-label>Enrolment date</mat-label>
|
||||
<input matInput [matDatepicker]="enrolPicker" [max]="today" [(ngModel)]="enrolmentDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="enrolPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #enrolPicker></mat-datepicker>
|
||||
<mat-hint>Date printed on the slip</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
@if (monthsSinceEnrolment !== null) {
|
||||
<div class="aad-rcpt__age" [class.aad-rcpt__age--stale]="receiptIsStale">
|
||||
<mat-icon>{{ receiptIsStale ? 'warning' : 'schedule' }}</mat-icon>
|
||||
<span>
|
||||
@if (receiptIsStale) {
|
||||
Enrolled {{ monthsSinceEnrolment }} months ago. An enrolment normally completes well inside six months —
|
||||
chase the customer for the Aadhaar itself. The receipt can still be recorded.
|
||||
} @else {
|
||||
Enrolled {{ monthsSinceEnrolment }} month{{ monthsSinceEnrolment === 1 ? '' : 's' }} ago.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__remarks">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
[(ngModel)]="remarks"
|
||||
maxlength="500"
|
||||
placeholder="What was sighted, and when the Aadhaar is expected"
|
||||
></textarea>
|
||||
<mat-hint>{{ remarks.length }}/500</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox class="aad-rcpt__sighted" [(ngModel)]="sighted">
|
||||
The original UIDAI enrolment slip has been sighted and a copy filed with the account opening form.
|
||||
</mat-checkbox>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="aad-rcpt__actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
Save Receipt
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.aad-rcpt {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.aad-rcpt__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.aad-rcpt__header-icon {
|
||||
color: #2563eb;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.aad-rcpt__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.aad-rcpt__subtitle {
|
||||
font-size: 12px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt__body {
|
||||
padding: 18px 24px !important;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.aad-rcpt__reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aad-rcpt__field,
|
||||
.aad-rcpt__remarks {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aad-rcpt__prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.aad-rcpt__hint--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
// Age of the slip — informational, so it reads as context rather than a blocking error.
|
||||
.aad-rcpt__age {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 2px 0 14px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--stale {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__sighted {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
::ng-deep .mdc-form-field > label {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__actions {
|
||||
padding: 10px 24px 16px !important;
|
||||
margin: 0 !important;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface AadhaarReceiptDialogData {
|
||||
/** Client name, shown so the officer can check the slip belongs to the right person. */
|
||||
clientName: string;
|
||||
/** Message the Aadhaar step came back with, or why the officer is recording a receipt. */
|
||||
reason: string;
|
||||
/** Existing receipt, when the officer re-opens one already taken. */
|
||||
existing?: AadhaarReceiptResult | null;
|
||||
}
|
||||
|
||||
export interface AadhaarReceiptResult {
|
||||
/** 14-digit enrolment number as printed on the slip, separators kept. */
|
||||
enrolmentId: string;
|
||||
enrolmentDate: Date | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one holds only an enrolment slip.
|
||||
* There is no Aadhaar number to send to the OTP flow, so no amount of retrying will clear the
|
||||
* check — the same dead end the PAN declaration resolves. Recording the slip lets the client be
|
||||
* created and waives the Aadhaar exception, while Aadhaar itself stays unverified.
|
||||
*
|
||||
* Only the 14-digit enrolment number is taken. The date and time printed beside it on the slip are
|
||||
* captured by the enrolment-date field, so asking for them again in the same box would just be a
|
||||
* second chance to mistype them.
|
||||
*
|
||||
* The number is validated on digit count rather than an exact layout: slips print it with varying
|
||||
* separators, and rejecting one because of a slash or a space would block a customer over
|
||||
* formatting rather than substance.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aadhaar-receipt-dialog',
|
||||
templateUrl: './aadhaar-receipt-dialog.component.html',
|
||||
styleUrls: ['./aadhaar-receipt-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class AadhaarReceiptDialogComponent {
|
||||
data = inject<AadhaarReceiptDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject(MatDialogRef<AadhaarReceiptDialogComponent>);
|
||||
|
||||
enrolmentId = '';
|
||||
enrolmentDate: Date | null = null;
|
||||
remarks = '';
|
||||
/** The officer confirms the original slip was sighted — nothing is saved without it. */
|
||||
sighted = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
constructor() {
|
||||
const existing = this.data.existing;
|
||||
if (existing) {
|
||||
this.enrolmentId = existing.enrolmentId || '';
|
||||
this.enrolmentDate = existing.enrolmentDate || null;
|
||||
this.remarks = existing.remarks || '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Digits only — separators on the printed slip are cosmetic and vary between forms. */
|
||||
private get enrolmentDigits(): string {
|
||||
return (this.enrolmentId || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
get enrolmentIdEntered(): boolean {
|
||||
return this.enrolmentDigits.length > 0;
|
||||
}
|
||||
|
||||
/** UIDAI's enrolment number is 14 digits — the timestamp beside it is not part of it. */
|
||||
get enrolmentIdValid(): boolean {
|
||||
return this.enrolmentDigits.length === 14;
|
||||
}
|
||||
|
||||
/** How many digits are still missing, so the officer is told what to fix, not just that it failed. */
|
||||
get enrolmentIdHint(): string {
|
||||
const count = this.enrolmentDigits.length;
|
||||
if (!count) {
|
||||
return 'The 14-digit enrolment number only, e.g. 1234/56789/01234';
|
||||
}
|
||||
return `${count} of 14 digits`;
|
||||
}
|
||||
|
||||
/** A slip dated in the future is a keying error, not a valid enrolment. */
|
||||
get enrolmentDateValid(): boolean {
|
||||
return !!this.enrolmentDate && this.enrolmentDate.getTime() <= this.today.getTime();
|
||||
}
|
||||
|
||||
/** Whole months since enrolment — shown because a receipt is temporary by nature. */
|
||||
get monthsSinceEnrolment(): number | null {
|
||||
if (!this.enrolmentDate) {
|
||||
return null;
|
||||
}
|
||||
const months =
|
||||
(this.today.getFullYear() - this.enrolmentDate.getFullYear()) * 12 +
|
||||
(this.today.getMonth() - this.enrolmentDate.getMonth()) -
|
||||
(this.today.getDate() < this.enrolmentDate.getDate() ? 1 : 0);
|
||||
return Math.max(0, months);
|
||||
}
|
||||
|
||||
/**
|
||||
* An enrolment normally completes well inside six months, so an older slip is a prompt to chase
|
||||
* the customer for the Aadhaar itself. It is a warning, not a block: the branch may have good
|
||||
* reason to accept it, and the record shows the age either way.
|
||||
*/
|
||||
get receiptIsStale(): boolean {
|
||||
const months = this.monthsSinceEnrolment;
|
||||
return months !== null && months >= 6;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.enrolmentIdValid && this.enrolmentDateValid && !!this.remarks.trim() && this.sighted;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close({
|
||||
enrolmentId: this.enrolmentId.trim(),
|
||||
enrolmentDate: this.enrolmentDate,
|
||||
remarks: this.remarks.trim()
|
||||
} as AadhaarReceiptResult);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a stacked front-above-back scan back into its two sides.
|
||||
*
|
||||
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
|
||||
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
|
||||
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
|
||||
* completely white rows across the full width — that band is the seam, and it is the only thing
|
||||
* in the file that says where one side ends and the other begins.
|
||||
*
|
||||
* Resolves null when no such seam is found, which is the honest answer for an image that was not
|
||||
* produced by that merge: better to show one scan than to cut a document in half at a guess.
|
||||
*/
|
||||
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
type Sides = { front: Blob; back: Blob } | null;
|
||||
return this.blobToImage(blob)
|
||||
.then((img): Sides | Promise<Sides> => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx || canvas.width === 0 || canvas.height === 0) {
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
|
||||
if (!seam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
this.cropToBlob(img, canvas.width, 0, seam.start),
|
||||
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
|
||||
]).then(
|
||||
([
|
||||
front,
|
||||
back
|
||||
]): Sides => (front && back ? { front, back } : null)
|
||||
);
|
||||
})
|
||||
.catch((): Sides => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tallest run of all-white rows in the middle of the image.
|
||||
*
|
||||
* Confined to the middle half because a scan often has white margins at the top and bottom, and
|
||||
* those are part of the document rather than the join between two of them.
|
||||
*/
|
||||
private findWhiteSeam(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): { start: number; end: number } | null {
|
||||
const from = Math.floor(height * 0.25);
|
||||
const to = Math.ceil(height * 0.75);
|
||||
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
|
||||
|
||||
let bestStart = -1;
|
||||
let bestLength = 0;
|
||||
let runStart = -1;
|
||||
|
||||
for (let y = from; y <= to; y++) {
|
||||
let white = y < to;
|
||||
if (white) {
|
||||
const row = ctx.getImageData(0, y, width, 1).data;
|
||||
for (let x = 0; x < width; x += step) {
|
||||
const i = x * 4;
|
||||
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
|
||||
white = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (white) {
|
||||
if (runStart < 0) {
|
||||
runStart = y;
|
||||
}
|
||||
} else if (runStart >= 0) {
|
||||
if (y - runStart > bestLength) {
|
||||
bestStart = runStart;
|
||||
bestLength = y - runStart;
|
||||
}
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
|
||||
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
|
||||
}
|
||||
|
||||
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
|
||||
if (height <= 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
|
||||
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
|
||||
}
|
||||
|
||||
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
|
||||
return this.fileToImage(blob as File);
|
||||
}
|
||||
|
||||
private fileToImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pan-decl">
|
||||
<div class="pan-decl__header">
|
||||
<mat-icon class="pan-decl__header-icon">assignment_late</mat-icon>
|
||||
<div>
|
||||
<div class="pan-decl__title">PAN Verification Failed — Take Declaration</div>
|
||||
<div class="pan-decl__subtitle">PAN {{ data.panNumber }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="pan-decl__body">
|
||||
<div class="pan-decl__reason">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ data.failureMessage }}</span>
|
||||
</div>
|
||||
|
||||
@if (!data.unverifiable) {
|
||||
<p class="pan-decl__intro">
|
||||
The Income Tax check matches on <strong>name and date of birth together</strong> and does not report which of
|
||||
the two differs. Enter the details exactly as printed on the customer's PAN card so the declaration records what
|
||||
actually does not match.
|
||||
</p>
|
||||
|
||||
<!-- Comparison: what we submitted vs what the PAN card says -->
|
||||
<div class="pan-decl__compare">
|
||||
<div class="pan-decl__compare-head">
|
||||
<span class="lbl"></span>
|
||||
<span class="col">Submitted</span>
|
||||
<span class="col">As on PAN card</span>
|
||||
<span class="res"></span>
|
||||
</div>
|
||||
|
||||
<div class="pan-decl__compare-row" [class.mismatch]="nameMismatch">
|
||||
<span class="lbl">Name</span>
|
||||
<span class="col submitted">{{ data.submittedName || '—' }}</span>
|
||||
<span class="col">
|
||||
<mat-form-field appearance="outline" class="pan-decl__field">
|
||||
<mat-label>Name on PAN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="nameOnPan"
|
||||
(ngModelChange)="onCompare()"
|
||||
placeholder="e.g. RAJESH KUMAR SHARMA"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
<span class="res">
|
||||
@if (compared && nameOnPan.trim()) {
|
||||
@if (nameMismatch) {
|
||||
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pan-decl__compare-row" [class.mismatch]="dobMismatch">
|
||||
<span class="lbl">Date of Birth</span>
|
||||
<span class="col submitted">{{ data.submittedDob || '—' }}</span>
|
||||
<span class="col">
|
||||
<mat-form-field appearance="outline" class="pan-decl__field">
|
||||
<mat-label>DOB on PAN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="dobPicker"
|
||||
[max]="today"
|
||||
[(ngModel)]="dobOnPan"
|
||||
(ngModelChange)="onCompare()"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
<span class="res">
|
||||
@if (compared && dobOnPan) {
|
||||
@if (dobMismatch) {
|
||||
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What the declaration will state -->
|
||||
<div
|
||||
class="pan-decl__verdict"
|
||||
[class.pan-decl__verdict--pending]="!comparisonComplete"
|
||||
[class.pan-decl__verdict--clean]="nothingToDeclare"
|
||||
>
|
||||
<span class="pan-decl__verdict-label">Does not match:</span>
|
||||
<span class="pan-decl__verdict-value">{{ mismatchLabel }}</span>
|
||||
</div>
|
||||
|
||||
@if (!comparisonComplete) {
|
||||
<p class="pan-decl__note">Enter both PAN-card values to see exactly which detail differs.</p>
|
||||
}
|
||||
|
||||
@if (nothingToDeclare) {
|
||||
<p class="pan-decl__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
The PAN-card details you entered are the same as the ones submitted, so there is nothing to declare. Correct
|
||||
the client's name or date of birth on the General step and verify PAN again.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="pan-decl__intro">
|
||||
PAN could not be checked against the Income Tax records. Record the declaration taken from the customer so the
|
||||
client can be created without a KYC exception being raised for PAN.
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- The signed paper is the declaration; it is uploaded, not described. -->
|
||||
<div class="pan-decl__upload">
|
||||
<div class="pan-decl__upload-label">Signed declaration</div>
|
||||
<p class="pan-decl__upload-help">
|
||||
Get the declaration signed by the customer and upload the scanned copy. It is filed against the client as the
|
||||
proof that holds the PAN exception off.
|
||||
</p>
|
||||
|
||||
@if (!hasDeclarationFile) {
|
||||
<label class="pan-decl__dropzone">
|
||||
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
|
||||
<mat-icon class="pan-decl__dropzone-icon">cloud_upload</mat-icon>
|
||||
<span class="pan-decl__dropzone-text">Click to upload signed declaration</span>
|
||||
<span class="pan-decl__dropzone-hint">PDF, JPG or PNG — up to 5 MB</span>
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pan-decl__file">
|
||||
<mat-icon class="pan-decl__file-icon">description</mat-icon>
|
||||
<span class="pan-decl__file-meta">
|
||||
<span class="pan-decl__file-name">{{ fileName }}</span>
|
||||
<span class="pan-decl__file-sub">{{ fileSizeLabel }}</span>
|
||||
</span>
|
||||
<label class="pan-decl__file-replace">
|
||||
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
|
||||
Replace
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pan-decl__file-remove"
|
||||
aria-label="Remove file"
|
||||
(click)="removeDeclarationFile()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (fileError) {
|
||||
<p class="pan-decl__file-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ fileError }}</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="pan-decl__actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
Save Declaration
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.pan-decl {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.pan-decl__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pan-decl__header-icon {
|
||||
color: #d97706;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pan-decl__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pan-decl__subtitle {
|
||||
font-size: 12px;
|
||||
color: #b45309;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.pan-decl__body {
|
||||
padding: 18px 24px !important;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pan-decl__reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// ── Submitted vs PAN-card comparison ───────────────────────────────────────
|
||||
.pan-decl__compare {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pan-decl__compare-head,
|
||||
.pan-decl__compare-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr 1fr 36px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.pan-decl__compare-head {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__compare-row {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// A row the declaration will name — tinted so the officer can see the finding at a glance.
|
||||
&.mismatch {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.lbl {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.submitted {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__field {
|
||||
width: 100%;
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.res {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.res-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.res-icon--ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.res-icon--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
// ── Verdict ────────────────────────────────────────────────────────────────
|
||||
.pan-decl__verdict {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
|
||||
&--pending {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
&--clean {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__verdict-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__verdict-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pan-decl__note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__warn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signed declaration upload ──────────────────────────────────────────────
|
||||
.pan-decl__upload {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.pan-decl__upload-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__upload-help {
|
||||
margin: 4px 0 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 18px 24px;
|
||||
background: #fff;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pan-decl__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.pan-decl__file-icon {
|
||||
color: #16a34a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pan-decl__file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.pan-decl__file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pan-decl__file-sub {
|
||||
font-size: 11px;
|
||||
color: #4d7c69;
|
||||
}
|
||||
|
||||
.pan-decl__file-replace {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__file-remove {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__file-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #b91c1c;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__actions {
|
||||
padding: 10px 24px 16px !important;
|
||||
margin: 0 !important;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Which client details differ from the PAN card. */
|
||||
export type PanMismatchField = 'NAME' | 'DOB';
|
||||
|
||||
export interface PanDeclarationDialogData {
|
||||
/** Name submitted on the General step — the value sent to the Income Tax check. */
|
||||
submittedName: string;
|
||||
/** Date of birth submitted on the General step, in the tenant's display format. */
|
||||
submittedDob: string;
|
||||
/** PAN being declared against. */
|
||||
panNumber: string;
|
||||
/** Message the verification came back with, shown so the officer knows why they are here. */
|
||||
failureMessage: string;
|
||||
/**
|
||||
* True when the check failed on something other than name/DOB (invalid PAN, vendor unreachable).
|
||||
* The PAN-card fields then have nothing to compare against, so the mismatch reads NONE and the
|
||||
* officer only has to record why the declaration is being taken.
|
||||
*/
|
||||
unverifiable?: boolean;
|
||||
/** Existing declaration, when the officer re-opens one already taken. */
|
||||
existing?: PanDeclarationResult | null;
|
||||
}
|
||||
|
||||
export interface PanDeclarationResult {
|
||||
/** 'NAME', 'DOB', 'NAME,DOB', or 'NONE' when PAN could not be checked at all. */
|
||||
mismatchFields: string;
|
||||
nameOnPan: string | null;
|
||||
dobOnPan: Date | null;
|
||||
/**
|
||||
* The scanned signed declaration, to be filed against the client. Null when an existing
|
||||
* declaration was re-opened and the officer did not replace the document already on file.
|
||||
*/
|
||||
file: File | null;
|
||||
/** Name of the declaration document on record — what the KYC record keeps as the reference. */
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the customer declaration that stands in for a passing PAN check.
|
||||
*
|
||||
* The Income Tax API matches on name *and* date of birth and tells us only that the pair failed —
|
||||
* never which half. So rather than show the officer a dead-end error, this dialog asks them to key
|
||||
* in the two values printed on the physical PAN card in front of them. Comparing those against what
|
||||
* was submitted is what produces a specific, defensible "NAME does not match" on the declaration,
|
||||
* and the PAN-card values are kept alongside it as the evidence for that statement.
|
||||
*
|
||||
* Name comparison is deliberately loose about case and internal spacing — "RAJESH KUMAR" and
|
||||
* "Rajesh Kumar" are the same name, and flagging them as a mismatch would put a declaration on file
|
||||
* that says something untrue.
|
||||
*
|
||||
* The declaration itself is taken as an upload rather than typed: what waives the PAN exception is
|
||||
* the paper the customer signed, so the scan of it is the record — an officer's typed note is not.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pan-declaration-dialog',
|
||||
templateUrl: './pan-declaration-dialog.component.html',
|
||||
styleUrls: ['./pan-declaration-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class PanDeclarationDialogComponent {
|
||||
data = inject<PanDeclarationDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject(MatDialogRef<PanDeclarationDialogComponent>);
|
||||
|
||||
/** Name exactly as printed on the PAN card. */
|
||||
nameOnPan = '';
|
||||
/** Date of birth exactly as printed on the PAN card. */
|
||||
dobOnPan: Date | null = null;
|
||||
|
||||
/** The scan the officer just picked. Null while an earlier upload is still the one on file. */
|
||||
declarationFile: File | null = null;
|
||||
/** Name of the document already filed, when re-opening a declaration that was taken before. */
|
||||
existingFileName = '';
|
||||
/** Why the picked file was rejected — shown in place of failing silently. */
|
||||
fileError = '';
|
||||
|
||||
/** Officer has not yet typed anything to compare against. */
|
||||
compared = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
readonly acceptedTypes = '.pdf,.jpg,.jpeg,.png';
|
||||
private readonly maxFileBytes = 5 * 1024 * 1024;
|
||||
private readonly acceptedExtensions = [
|
||||
'pdf',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
const existing = this.data.existing;
|
||||
if (existing) {
|
||||
this.nameOnPan = existing.nameOnPan || '';
|
||||
this.dobOnPan = existing.dobOnPan || null;
|
||||
this.existingFileName = existing.fileName || '';
|
||||
this.compared = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The document the declaration will be saved against — newly picked, or the one already filed. */
|
||||
get fileName(): string {
|
||||
return this.declarationFile?.name || this.existingFileName;
|
||||
}
|
||||
|
||||
get hasDeclarationFile(): boolean {
|
||||
return !!this.fileName;
|
||||
}
|
||||
|
||||
/** Size of the picked scan, or a note that the document on file is being kept as it is. */
|
||||
get fileSizeLabel(): string {
|
||||
if (!this.declarationFile) {
|
||||
return 'Already on file';
|
||||
}
|
||||
const kb = this.declarationFile.size / 1024;
|
||||
return kb < 1024 ? `${Math.max(1, Math.round(kb))} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
onDeclarationFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] || null;
|
||||
input.value = ''; // allow re-picking the same filename
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const extension = (file.name.split('.').pop() || '').toLowerCase();
|
||||
if (!this.acceptedExtensions.includes(extension)) {
|
||||
this.fileError = 'Upload the signed declaration as a PDF, JPG or PNG.';
|
||||
return;
|
||||
}
|
||||
if (file.size > this.maxFileBytes) {
|
||||
this.fileError = 'The file is larger than 5 MB. Scan at a lower resolution and try again.';
|
||||
return;
|
||||
}
|
||||
this.fileError = '';
|
||||
this.declarationFile = file;
|
||||
}
|
||||
|
||||
/** Drops the picked scan — and the one already filed, since neither may be left implied. */
|
||||
removeDeclarationFile(): void {
|
||||
this.declarationFile = null;
|
||||
this.existingFileName = '';
|
||||
this.fileError = '';
|
||||
}
|
||||
|
||||
/** Case- and spacing-insensitive: only a genuinely different name counts as a mismatch. */
|
||||
private normalizeName(value: string): string {
|
||||
return (value || '').trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
private formatDob(value: Date | null): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const dd = value.getDate().toString().padStart(2, '0');
|
||||
const mm = (value.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${dd}/${mm}/${value.getFullYear()}`;
|
||||
}
|
||||
|
||||
get nameMismatch(): boolean {
|
||||
if (this.data.unverifiable || !this.nameOnPan.trim()) {
|
||||
return false;
|
||||
}
|
||||
return this.normalizeName(this.nameOnPan) !== this.normalizeName(this.data.submittedName);
|
||||
}
|
||||
|
||||
get dobMismatch(): boolean {
|
||||
if (this.data.unverifiable || !this.dobOnPan) {
|
||||
return false;
|
||||
}
|
||||
// Compare on the rendered day/month/year so a time component on either value cannot matter.
|
||||
return this.formatDob(this.dobOnPan) !== this.normalizeDisplayDob(this.data.submittedDob);
|
||||
}
|
||||
|
||||
/** The submitted DOB arrives pre-formatted for display; reduce it to dd/MM/yyyy for comparison. */
|
||||
private normalizeDisplayDob(display: string): string {
|
||||
const parsed = new Date(display);
|
||||
return isNaN(parsed.getTime()) ? (display || '').trim() : this.formatDob(parsed);
|
||||
}
|
||||
|
||||
/** The fields the declaration will state as differing. */
|
||||
get mismatchFields(): PanMismatchField[] {
|
||||
const fields: PanMismatchField[] = [];
|
||||
if (this.nameMismatch) {
|
||||
fields.push('NAME');
|
||||
}
|
||||
if (this.dobMismatch) {
|
||||
fields.push('DOB');
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
get mismatchLabel(): string {
|
||||
if (this.data.unverifiable) {
|
||||
return 'PAN could not be verified';
|
||||
}
|
||||
const fields = this.mismatchFields;
|
||||
if (!fields.length) {
|
||||
return this.compared ? 'No difference found' : 'Not yet compared';
|
||||
}
|
||||
return fields.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')).join(' and ');
|
||||
}
|
||||
|
||||
/** Both PAN-card values must be keyed in before we can honestly say what differs. */
|
||||
get comparisonComplete(): boolean {
|
||||
return !!this.nameOnPan.trim() && !!this.dobOnPan;
|
||||
}
|
||||
|
||||
onCompare(): void {
|
||||
this.compared = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration must actually declare something. When the officer keys in PAN-card values that
|
||||
* match what was submitted, the mismatch is somewhere else — nothing to sign off here, and the
|
||||
* right move is to retry the verification rather than paper over it.
|
||||
*/
|
||||
get nothingToDeclare(): boolean {
|
||||
return !this.data.unverifiable && this.comparisonComplete && this.mismatchFields.length === 0;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
// The signed scan is the declaration — without it there is nothing to waive the exception on.
|
||||
if (!this.hasDeclarationFile) {
|
||||
return false;
|
||||
}
|
||||
if (this.data.unverifiable) {
|
||||
return true;
|
||||
}
|
||||
return this.comparisonComplete && this.mismatchFields.length > 0;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
const result: PanDeclarationResult = {
|
||||
mismatchFields: this.data.unverifiable ? 'NONE' : this.mismatchFields.join(','),
|
||||
nameOnPan: this.data.unverifiable ? null : this.nameOnPan.trim(),
|
||||
dobOnPan: this.data.unverifiable ? null : this.dobOnPan,
|
||||
file: this.declarationFile,
|
||||
fileName: this.fileName
|
||||
};
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,10 @@
|
||||
export interface Alert {
|
||||
type: string;
|
||||
message: string;
|
||||
/**
|
||||
* Errors are shown as a centred modal that has to be dismissed, because a corner toast that
|
||||
* disappears on its own is too easy to miss for something the user has to act on. Anything
|
||||
* without this keeps the unobtrusive snack bar.
|
||||
*/
|
||||
severity?: 'error';
|
||||
}
|
||||
|
||||
@@ -88,42 +88,38 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Resolved once and emitted in one place below, so every failure here is consistently tagged
|
||||
// as an error and therefore surfaces as a centred modal rather than a corner toast.
|
||||
let alert: { type: string; message: string } | null = null;
|
||||
if (status === 401 || (environment.oauth.enabled && status === 400)) {
|
||||
this.alertService.alert({ type: 'Authentication Error', message: 'Invalid User Details. Please try again!' });
|
||||
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
|
||||
} else if (status === 403 && errorMessage === 'The provided one time token is invalid') {
|
||||
this.alertService.alert({ type: 'Invalid Token', message: 'Invalid Token. Please try again!' });
|
||||
alert = { type: 'Invalid Token', message: 'Invalid Token. Please try again!' };
|
||||
} else if (status === 400) {
|
||||
this.alertService.alert({
|
||||
type: 'Bad Request',
|
||||
message: errorMessage || 'Invalid parameters were passed in the request!'
|
||||
});
|
||||
alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' };
|
||||
} else if (status === 403) {
|
||||
this.alertService.alert({
|
||||
type: 'Unauthorized Request',
|
||||
message: errorMessage || 'You are not authorized for this request!'
|
||||
});
|
||||
alert = { type: 'Unauthorized Request', message: errorMessage || 'You are not authorized for this request!' };
|
||||
} else if (status === 404) {
|
||||
{
|
||||
this.alertService.alert({
|
||||
type: this.translate.instant('error.resource.not.found'),
|
||||
message: errorMessage || 'Resource does not exist!'
|
||||
});
|
||||
}
|
||||
alert = {
|
||||
type: this.translate.instant('error.resource.not.found'),
|
||||
message: errorMessage || 'Resource does not exist!'
|
||||
};
|
||||
} else if (status === 500) {
|
||||
this.alertService.alert({
|
||||
type: 'Internal Server Error',
|
||||
message: 'Internal Server Error. Please try again later.'
|
||||
});
|
||||
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
|
||||
} else if (status === 501) {
|
||||
this.alertService.alert({
|
||||
alert = {
|
||||
type: this.translate.instant('error.resource.notImplemented.type'),
|
||||
message: this.translate.instant('error.resource.notImplemented.message')
|
||||
});
|
||||
};
|
||||
} else if (status === 503) {
|
||||
this.alertService.alert({
|
||||
alert = {
|
||||
type: 'Service Unavailable',
|
||||
message: errorMessage || 'The server is currently unable to handle the request. Please try after some time.'
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (alert) {
|
||||
this.alertService.alert({ ...alert, severity: 'error' });
|
||||
}
|
||||
|
||||
throw response;
|
||||
|
||||
@@ -354,11 +354,12 @@
|
||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||
</div>
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
|
||||
their own register below. -->
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="RAISED">Raised</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="WHITELISTED">Whitelisted</option>
|
||||
<option value="ESCALATED">Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -416,7 +417,7 @@
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts.</td>
|
||||
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -424,12 +425,62 @@
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision ·
|
||||
<span class="report-pill report-cleared">Cleared</span> whitelisted — no report filed ·
|
||||
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
|
||||
</p>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
|
||||
|
||||
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
|
||||
evidence recorded at review time. -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Cleared Alerts (Whitelisted)</h3>
|
||||
<p>Reviewed and closed as false positives — no STR filed. Retained with the evidence relied on.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Cleared By</th>
|
||||
<th>Evidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of amlWhitelistedAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>{{ alert.reviewedByUsername || '—' }}</td>
|
||||
<td>
|
||||
<span class="muted">{{ alert.reviewNotes || '—' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="fiu-empty">No alerts have been cleared.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-cleared">Cleared</span> whitelisted — no report filed, evidence retained for
|
||||
audit
|
||||
</p>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
|
||||
|
||||
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
|
||||
@@ -539,10 +539,14 @@ export class FiuComponent implements OnInit {
|
||||
: 'Suspicious — STR filed with FIU-IND and account escalated.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Always fetches every status. The triage table and the cleared register are two views of the
|
||||
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
|
||||
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
|
||||
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
|
||||
*/
|
||||
loadAmlAlerts(): void {
|
||||
this.amlService
|
||||
.getAlerts(this.amlAlertStatusFilter || undefined)
|
||||
.subscribe((data) => (this.amlAlerts = data || []));
|
||||
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
||||
}
|
||||
|
||||
loadAmlCtrReports(): void {
|
||||
@@ -558,8 +562,25 @@ export class FiuComponent implements OnInit {
|
||||
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
|
||||
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
|
||||
*/
|
||||
private reviewNotesOf(alert: any): string | null {
|
||||
const notes = (this.amlReviewNotes[alert.id] || '').trim();
|
||||
if (!notes) {
|
||||
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
|
||||
return null;
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
whitelistAlert(alert: any): void {
|
||||
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe({
|
||||
const notes = this.reviewNotesOf(alert);
|
||||
if (!notes) {
|
||||
return;
|
||||
}
|
||||
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
|
||||
next: () => {
|
||||
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
|
||||
this.loadAmlAlerts();
|
||||
@@ -569,7 +590,11 @@ export class FiuComponent implements OnInit {
|
||||
}
|
||||
|
||||
escalateAlert(alert: any): void {
|
||||
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe({
|
||||
const notes = this.reviewNotesOf(alert);
|
||||
if (!notes) {
|
||||
return;
|
||||
}
|
||||
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
|
||||
next: () => {
|
||||
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
|
||||
this.loadAmlAlerts();
|
||||
@@ -780,7 +805,8 @@ export class FiuComponent implements OnInit {
|
||||
return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
|
||||
}
|
||||
|
||||
get amlSuspiciousAlerts(): any[] {
|
||||
/** Behavioural (non-CTR) alerts. */
|
||||
private get behaviouralAlerts(): any[] {
|
||||
const ctrRuleCodes = [
|
||||
'CTR_THRESHOLD_10L',
|
||||
'STRUCTURED_CTR_10L_MONTHLY',
|
||||
@@ -789,6 +815,21 @@ export class FiuComponent implements OnInit {
|
||||
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
|
||||
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
|
||||
*/
|
||||
get amlSuspiciousAlerts(): any[] {
|
||||
return this.behaviouralAlerts.filter(
|
||||
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
|
||||
);
|
||||
}
|
||||
|
||||
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
|
||||
get amlWhitelistedAlerts(): any[] {
|
||||
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
|
||||
}
|
||||
|
||||
// -------------------- Head Office vs Branch scope --------------------
|
||||
/** Maps a FINnet-style internal branch code to the app-wide canonical branch code. */
|
||||
private canonicalBranchOf(branchCode: string): string | undefined {
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
<button class="alm-tab" [class.active]="activeTab === 'monthly'" (click)="setTab('monthly')">
|
||||
<fa-icon icon="calendar-alt"></fa-icon> Monthly Snapshot
|
||||
</button>
|
||||
<button class="alm-tab" [class.active]="activeTab === 'avgbal'" (click)="setTab('avgbal')">
|
||||
<fa-icon icon="table"></fa-icon> Avg. Liabilities & Assets
|
||||
</button>
|
||||
<button class="alm-tab" [class.active]="activeTab === 'returns'" (click)="setTab('returns')">
|
||||
<fa-icon icon="file-invoice"></fa-icon> Form ALM-1
|
||||
</button>
|
||||
@@ -344,6 +347,87 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ AVERAGE LIABILITIES AND ASSETS ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'avgbal'">
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">Average Liabilities and Assets Month-wise during the year</h3>
|
||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportAvgBalancesXls()">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printed-statement banner: institution and office come off the session, not the report. -->
|
||||
<div class="alm-stmt">
|
||||
<div class="alm-stmt-bank">{{ statementHeading }}</div>
|
||||
<div class="alm-stmt-title">Average Liabilities and Assets Month-wise during the year</div>
|
||||
|
||||
<div class="alm-tbl-wrap">
|
||||
<table class="alm-tbl alm-stmt-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">Month</th>
|
||||
<th rowspan="2" class="ta-r">Capital</th>
|
||||
<th rowspan="2" class="ta-r">Reserve/Prov<br />Exclude OIR</th>
|
||||
<th rowspan="2" class="ta-r">Deposits</th>
|
||||
<th rowspan="2" class="ta-r">Borrowings</th>
|
||||
<th rowspan="2" class="ta-r">Other Liabilities</th>
|
||||
<th rowspan="2" class="ta-r">Cash on Hand</th>
|
||||
<th colspan="2" class="ta-c">Bank Balances</th>
|
||||
<th rowspan="2" class="ta-r">Investments<br />including</th>
|
||||
<th rowspan="2" class="ta-r">Advances</th>
|
||||
<th rowspan="2" class="ta-r">Other Assets<br />Exclude IRA</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="ta-r">Current</th>
|
||||
<th class="ta-r">FDs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let r of avgBalanceRows">
|
||||
<td class="mono">
|
||||
<strong>{{ r.label }}</strong
|
||||
><br /><span class="alm-stmt-sub">{{ r.sub }}</span>
|
||||
</td>
|
||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="row-total" *ngFor="let r of [avgBalanceTotal, avgBalanceAverage]">
|
||||
<td>{{ r.label }}</td>
|
||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="alm-stmt-foot">
|
||||
<div>Print Date : {{ statementPrintedAt }}</div>
|
||||
<div>Report Ref No : {{ bank.reportRefNo }} User Code:{{ statementUserCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ RETURNS / FORM ALM-1 ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'returns'">
|
||||
<div class="alm-section">
|
||||
|
||||
@@ -597,6 +597,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* AVERAGE LIABILITIES AND ASSETS — laid out as the statement prints */
|
||||
.alm-stmt {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--alm-g200);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.alm-stmt-bank {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--alm-g900);
|
||||
}
|
||||
|
||||
.alm-stmt-title {
|
||||
margin: 2px 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--alm-g500);
|
||||
}
|
||||
|
||||
.alm-stmt-tbl {
|
||||
th,
|
||||
td {
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
td.ta-r {
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.alm-stmt-sub {
|
||||
font-size: 10px;
|
||||
color: var(--alm-g500);
|
||||
}
|
||||
|
||||
.alm-stmt-foot {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--alm-g200);
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
color: var(--alm-g500);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
|
||||
@@ -13,6 +13,8 @@ import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
|
||||
interface MaturityBucket {
|
||||
code: string;
|
||||
@@ -31,6 +33,37 @@ interface MonthSnapshot {
|
||||
cashOnHand: number;
|
||||
investments: number;
|
||||
otherAssets: number;
|
||||
// ── Columns the month-wise average statement adds to the snapshot above ──
|
||||
// The statement is a full balance sheet averaged over each month, so it needs the own-funds
|
||||
// and deposit sides the liquidity tabs do not use, and it splits bank balances from cash.
|
||||
/** Paid-up share capital. */
|
||||
capital: number;
|
||||
/** Total deposits — the FD component above is part of this figure. */
|
||||
deposits: number;
|
||||
otherLiabilities: number;
|
||||
/** Balances with other banks, current accounts. */
|
||||
bankCurrent: number;
|
||||
/** Balances with other banks, held as fixed deposits. */
|
||||
bankFds: number;
|
||||
advances: number;
|
||||
}
|
||||
|
||||
/** One row of the month-wise average statement — a month, or the Total / Average line. */
|
||||
interface AvgBalanceRow {
|
||||
label: string;
|
||||
/** Sub-label under the month, e.g. the year, as the printed statement prints it. */
|
||||
sub: string;
|
||||
capital: number;
|
||||
reserveProv: number;
|
||||
deposits: number;
|
||||
borrowings: number;
|
||||
otherLiabilities: number;
|
||||
cashOnHand: number;
|
||||
bankCurrent: number;
|
||||
bankFds: number;
|
||||
investments: number;
|
||||
advances: number;
|
||||
otherAssets: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -50,8 +83,10 @@ interface MonthSnapshot {
|
||||
})
|
||||
export class AlmReportDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<AlmReportDialogComponent>);
|
||||
private branchScope = inject(BranchScopeService);
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'returns' = 'dashboard';
|
||||
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns' = 'dashboard';
|
||||
|
||||
bank = {
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
@@ -95,7 +130,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 462800,
|
||||
cashOnHand: 8420,
|
||||
investments: 184600,
|
||||
otherAssets: 12480
|
||||
otherAssets: 12480,
|
||||
capital: 24200,
|
||||
deposits: 602800,
|
||||
otherLiabilities: 28400,
|
||||
bankCurrent: 18900,
|
||||
bankFds: 46200,
|
||||
advances: 372400
|
||||
},
|
||||
{
|
||||
month: 'MAY-2025',
|
||||
@@ -104,7 +145,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 468200,
|
||||
cashOnHand: 8680,
|
||||
investments: 186400,
|
||||
otherAssets: 12620
|
||||
otherAssets: 12620,
|
||||
capital: 24280,
|
||||
deposits: 608600,
|
||||
otherLiabilities: 28560,
|
||||
bankCurrent: 19240,
|
||||
bankFds: 46600,
|
||||
advances: 376800
|
||||
},
|
||||
{
|
||||
month: 'JUN-2025',
|
||||
@@ -113,7 +160,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 472400,
|
||||
cashOnHand: 9120,
|
||||
investments: 188200,
|
||||
otherAssets: 12840
|
||||
otherAssets: 12840,
|
||||
capital: 24360,
|
||||
deposits: 613400,
|
||||
otherLiabilities: 28720,
|
||||
bankCurrent: 19580,
|
||||
bankFds: 47000,
|
||||
advances: 381200
|
||||
},
|
||||
{
|
||||
month: 'JUL-2025',
|
||||
@@ -122,7 +175,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 478600,
|
||||
cashOnHand: 9280,
|
||||
investments: 190400,
|
||||
otherAssets: 13020
|
||||
otherAssets: 13020,
|
||||
capital: 24460,
|
||||
deposits: 619800,
|
||||
otherLiabilities: 28880,
|
||||
bankCurrent: 19860,
|
||||
bankFds: 47400,
|
||||
advances: 385600
|
||||
},
|
||||
{
|
||||
month: 'AUG-2025',
|
||||
@@ -131,7 +190,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 482400,
|
||||
cashOnHand: 9420,
|
||||
investments: 192800,
|
||||
otherAssets: 13280
|
||||
otherAssets: 13280,
|
||||
capital: 24540,
|
||||
deposits: 624200,
|
||||
otherLiabilities: 29040,
|
||||
bankCurrent: 20180,
|
||||
bankFds: 47800,
|
||||
advances: 390200
|
||||
},
|
||||
{
|
||||
month: 'SEP-2025',
|
||||
@@ -140,7 +205,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 488200,
|
||||
cashOnHand: 9640,
|
||||
investments: 195200,
|
||||
otherAssets: 13420
|
||||
otherAssets: 13420,
|
||||
capital: 24620,
|
||||
deposits: 630400,
|
||||
otherLiabilities: 29200,
|
||||
bankCurrent: 20460,
|
||||
bankFds: 48200,
|
||||
advances: 394800
|
||||
},
|
||||
{
|
||||
month: 'OCT-2025',
|
||||
@@ -149,7 +220,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 492600,
|
||||
cashOnHand: 9820,
|
||||
investments: 197600,
|
||||
otherAssets: 13620
|
||||
otherAssets: 13620,
|
||||
capital: 24720,
|
||||
deposits: 635200,
|
||||
otherLiabilities: 29380,
|
||||
bankCurrent: 20740,
|
||||
bankFds: 48600,
|
||||
advances: 399400
|
||||
},
|
||||
{
|
||||
month: 'NOV-2025',
|
||||
@@ -158,7 +235,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 498400,
|
||||
cashOnHand: 9980,
|
||||
investments: 200200,
|
||||
otherAssets: 13820
|
||||
otherAssets: 13820,
|
||||
capital: 24800,
|
||||
deposits: 641000,
|
||||
otherLiabilities: 29540,
|
||||
bankCurrent: 21020,
|
||||
bankFds: 49000,
|
||||
advances: 404200
|
||||
},
|
||||
{
|
||||
month: 'DEC-2025',
|
||||
@@ -167,7 +250,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 504200,
|
||||
cashOnHand: 10240,
|
||||
investments: 202800,
|
||||
otherAssets: 14040
|
||||
otherAssets: 14040,
|
||||
capital: 24900,
|
||||
deposits: 646800,
|
||||
otherLiabilities: 29720,
|
||||
bankCurrent: 21340,
|
||||
bankFds: 49400,
|
||||
advances: 409000
|
||||
},
|
||||
{
|
||||
month: 'JAN-2026',
|
||||
@@ -176,7 +265,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 508800,
|
||||
cashOnHand: 10420,
|
||||
investments: 205200,
|
||||
otherAssets: 14240
|
||||
otherAssets: 14240,
|
||||
capital: 24980,
|
||||
deposits: 651600,
|
||||
otherLiabilities: 29880,
|
||||
bankCurrent: 21620,
|
||||
bankFds: 49800,
|
||||
advances: 413800
|
||||
},
|
||||
{
|
||||
month: 'FEB-2026',
|
||||
@@ -185,7 +280,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 514600,
|
||||
cashOnHand: 10620,
|
||||
investments: 207800,
|
||||
otherAssets: 14460
|
||||
otherAssets: 14460,
|
||||
capital: 25060,
|
||||
deposits: 657400,
|
||||
otherLiabilities: 30040,
|
||||
bankCurrent: 21900,
|
||||
bankFds: 50200,
|
||||
advances: 418600
|
||||
},
|
||||
{
|
||||
month: 'MAR-2026',
|
||||
@@ -194,7 +295,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 520400,
|
||||
cashOnHand: 10820,
|
||||
investments: 210400,
|
||||
otherAssets: 14680
|
||||
otherAssets: 14680,
|
||||
capital: 25160,
|
||||
deposits: 663200,
|
||||
otherLiabilities: 30220,
|
||||
bankCurrent: 22240,
|
||||
bankFds: 50600,
|
||||
advances: 423600
|
||||
}
|
||||
];
|
||||
|
||||
@@ -256,6 +363,100 @@ export class AlmReportDialogComponent {
|
||||
return 'balanced';
|
||||
}
|
||||
|
||||
// ── Average Liabilities and Assets, month-wise ────────────────────────────
|
||||
//
|
||||
// The printed statement carries the institution and office it was taken for, and who took it.
|
||||
// Those come off the session rather than being typed into the report, so a branch login prints
|
||||
// its own office and its own user code instead of whatever the last person hard-coded.
|
||||
|
||||
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
|
||||
get statementHeading(): string {
|
||||
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
|
||||
get statementUserCode(): string {
|
||||
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
|
||||
}
|
||||
|
||||
/** Stamped when the tab is opened, so the footer records when the figures were taken off. */
|
||||
readonly statementPrintedAt = this.formatPrintDate(new Date());
|
||||
|
||||
/**
|
||||
* Month rows, then the Total and Average lines the statement closes with.
|
||||
*
|
||||
* Average is the mean of the months shown, not Total ÷ 12 — a statement printed part-way
|
||||
* through the year would otherwise divide by months that have not happened yet.
|
||||
*/
|
||||
get avgBalanceRows(): AvgBalanceRow[] {
|
||||
return this.monthly.map((m) => {
|
||||
const [
|
||||
name,
|
||||
year
|
||||
] = m.month.split('-');
|
||||
return {
|
||||
label: name,
|
||||
sub: year,
|
||||
capital: m.capital,
|
||||
reserveProv: m.reserveProv,
|
||||
deposits: m.deposits,
|
||||
borrowings: m.borrowings,
|
||||
otherLiabilities: m.otherLiabilities,
|
||||
cashOnHand: m.cashOnHand,
|
||||
bankCurrent: m.bankCurrent,
|
||||
bankFds: m.bankFds,
|
||||
investments: m.investments,
|
||||
advances: m.advances,
|
||||
otherAssets: m.otherAssets
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
|
||||
'capital',
|
||||
'reserveProv',
|
||||
'deposits',
|
||||
'borrowings',
|
||||
'otherLiabilities',
|
||||
'cashOnHand',
|
||||
'bankCurrent',
|
||||
'bankFds',
|
||||
'investments',
|
||||
'advances',
|
||||
'otherAssets'
|
||||
];
|
||||
|
||||
get avgBalanceTotal(): AvgBalanceRow {
|
||||
const rows = this.avgBalanceRows;
|
||||
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
|
||||
for (const key of this.avgBalanceColumns) {
|
||||
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
get avgBalanceAverage(): AvgBalanceRow {
|
||||
const rows = this.avgBalanceRows;
|
||||
const total = this.avgBalanceTotal;
|
||||
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
|
||||
for (const key of this.avgBalanceColumns) {
|
||||
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
||||
private formatPrintDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const hours24 = d.getHours();
|
||||
const meridiem = hours24 < 12 ? 'AM' : 'PM';
|
||||
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||||
return (
|
||||
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
||||
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Monthly totals helpers */
|
||||
monthTotalLiab(m: MonthSnapshot): number {
|
||||
return m.reserveProv + m.borrowings + m.fds;
|
||||
@@ -407,6 +608,49 @@ export class AlmReportDialogComponent {
|
||||
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
|
||||
}
|
||||
|
||||
/** Average Liabilities and Assets — the printed statement, column for column. */
|
||||
exportAvgBalancesXls(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([this.statementHeading]);
|
||||
rows.push(['Average Liabilities and Assets Month-wise during the year']);
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'Month',
|
||||
'Capital',
|
||||
'Reserve/Prov Exclude OIR',
|
||||
'Deposits',
|
||||
'Borrowings',
|
||||
'Other Liabilities',
|
||||
'Cash on Hand',
|
||||
'Bank Balances Current',
|
||||
'Bank Balances FDs',
|
||||
'Investments including',
|
||||
'Advances',
|
||||
'Other Assets Exclude IRA'
|
||||
]);
|
||||
const line = (r: AvgBalanceRow): string[] => [
|
||||
`${r.label} ${r.sub}`.trim(),
|
||||
r.capital.toFixed(2),
|
||||
r.reserveProv.toFixed(2),
|
||||
r.deposits.toFixed(2),
|
||||
r.borrowings.toFixed(2),
|
||||
r.otherLiabilities.toFixed(2),
|
||||
r.cashOnHand.toFixed(2),
|
||||
r.bankCurrent.toFixed(2),
|
||||
r.bankFds.toFixed(2),
|
||||
r.investments.toFixed(2),
|
||||
r.advances.toFixed(2),
|
||||
r.otherAssets.toFixed(2)
|
||||
];
|
||||
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
|
||||
rows.push(line(this.avgBalanceTotal));
|
||||
rows.push(line(this.avgBalanceAverage));
|
||||
rows.push([]);
|
||||
rows.push([`Print Date : ${this.statementPrintedAt}`]);
|
||||
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
|
||||
this.saveTsv(rows, `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`);
|
||||
}
|
||||
|
||||
/** Maturity ladder CSV */
|
||||
exportLadderCsv(): void {
|
||||
const headers = [
|
||||
@@ -431,7 +675,7 @@ export class AlmReportDialogComponent {
|
||||
this.saveCsv(rows, `ALM-Ladder-${this.dateTag()}.csv`);
|
||||
}
|
||||
|
||||
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'returns'): void {
|
||||
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns'): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
close(): void {
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>{{ 'labels.heading.Session Timeout' | translate }}</h1>
|
||||
<mat-dialog-content>{{ 'labels.text.Session timed out after a period of inactivity' | translate }}.</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-raised-button color="primary" mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
|
||||
<mat-dialog-content>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; padding-top: 8px">
|
||||
<p>{{ 'labels.text.Session timed out after a period of inactivity' | translate }}.</p>
|
||||
<p>
|
||||
For your security, you will be logged out in
|
||||
<strong style="color: #f44336; font-size: 1.1em">{{ countdown }}</strong> seconds.
|
||||
</p>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end" style="gap: 8px">
|
||||
<button mat-button color="warn" (click)="logoutNow()">Logout</button>
|
||||
<button mat-raised-button color="primary" (click)="keepSessionActive()">Stay Logged In</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogClose,
|
||||
MAT_DIALOG_DATA
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
@@ -30,6 +31,43 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class SessionTimeoutDialogComponent {
|
||||
export class SessionTimeoutDialogComponent implements OnInit, OnDestroy {
|
||||
dialogRef = inject<MatDialogRef<SessionTimeoutDialogComponent>>(MatDialogRef);
|
||||
data = inject<{ countdownSeconds?: number }>(MAT_DIALOG_DATA, { optional: true });
|
||||
|
||||
countdown = this.data?.countdownSeconds ?? 60;
|
||||
private intervalId: any;
|
||||
|
||||
ngOnInit() {
|
||||
this.startCountdown();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private startCountdown() {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.countdown--;
|
||||
if (this.countdown <= 0) {
|
||||
this.clearTimer();
|
||||
this.dialogRef.close('logout');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
keepSessionActive() {
|
||||
this.dialogRef.close('keep-alive');
|
||||
}
|
||||
|
||||
logoutNow() {
|
||||
this.dialogRef.close('logout');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,66 +190,47 @@
|
||||
<div class="km-card">
|
||||
<div class="km-ch">
|
||||
<div class="km-ci km-ci-a"><fa-icon icon="calendar-alt"></fa-icon></div>
|
||||
<div class="km-ct">3-Notice Dispatch Timeline</div>
|
||||
<span class="km-ch-hint">Expiry: 30 Apr 2027 · Window: 31 Mar – 30 Apr</span>
|
||||
<div class="km-ct">Escalation Ladder — one channel at a time</div>
|
||||
<span class="km-ch-hint">
|
||||
{{ reKycCompletedCount }} of {{ reKycSteps.length }} dispatched · Expiry: 30 Apr 2027 · Window: 31 Mar –
|
||||
30 Apr
|
||||
</span>
|
||||
</div>
|
||||
<div class="km-cb">
|
||||
<div class="km-ntl">
|
||||
<div class="km-nt">
|
||||
<div class="km-nd-col">
|
||||
<div class="km-nd km-nd1">1</div>
|
||||
<div class="km-nl"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="km-nh">Notice 1 — Alert window opens</div>
|
||||
<div class="km-ns">31 March 2027 · Auto-scheduled · <span class="km-ok">✓ Delivered</span></div>
|
||||
<div class="km-ntags">
|
||||
<span class="km-cp km-cp-sms">SMS</span>
|
||||
<span class="km-cp km-cp-mail">Email</span>
|
||||
<!-- One channel per rung: the next is released only when the current one is dispatched. -->
|
||||
@for (s of reKycSteps; track s.step; let i = $index) {
|
||||
<div class="km-nt" [class.km-nt-locked]="s.status === 'LOCKED'">
|
||||
<div class="km-nd-col">
|
||||
<div class="km-nd" [ngClass]="'km-nd' + s.notice">{{ s.step }}</div>
|
||||
<div class="km-nl"></div>
|
||||
</div>
|
||||
<div>
|
||||
@if (isFirstOfNotice(i)) {
|
||||
<div class="km-nnotice">Notice {{ s.notice }}</div>
|
||||
}
|
||||
<div class="km-nh">Step {{ s.step }} — {{ s.channel }}</div>
|
||||
<div class="km-ns">
|
||||
{{ s.date }} · {{ s.offset }} ·
|
||||
<span [ngClass]="reKycStatusClass(s.status)">{{ reKycStatusLabel(s.status) }}</span>
|
||||
</div>
|
||||
<div class="km-ntags">
|
||||
<span class="km-cp" [ngClass]="s.chip">{{ s.channel }}</span>
|
||||
<span class="km-nchdetail">{{ s.detail }}</span>
|
||||
</div>
|
||||
@if (s.status === 'PENDING') {
|
||||
<div class="km-row-actions">
|
||||
<button class="km-btn km-btn-t" (click)="dispatchReKycStep(s.step)">
|
||||
Dispatch {{ s.channel }}
|
||||
</button>
|
||||
<button class="km-btn km-btn-ot" (click)="dispatchReKycStep(s.step, true)">
|
||||
Mark failed — escalate
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="km-nt">
|
||||
<div class="km-nd-col">
|
||||
<div class="km-nd km-nd2">2</div>
|
||||
<div class="km-nl"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="km-nh">Notice 2 — Follow-up</div>
|
||||
<div class="km-ns">10 April 2027 · +10 days · <span class="km-pending">⏳ Pending</span></div>
|
||||
<div class="km-ntags">
|
||||
<span class="km-cp km-cp-sms">SMS</span>
|
||||
<span class="km-cp km-cp-mail">Email</span>
|
||||
<span class="km-cp km-cp-ivr">IVR Call</span>
|
||||
<span class="km-cp km-cp-post">Reg. Post</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="km-nt">
|
||||
<div class="km-nd-col">
|
||||
<div class="km-nd km-nd3">3</div>
|
||||
<div class="km-nl"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="km-nh">Notice 3 — Final notice</div>
|
||||
<div class="km-ns">20 April 2027 · +20 days · <span class="km-fail">✗ Not dispatched</span></div>
|
||||
<div class="km-ntags">
|
||||
<span class="km-cp km-cp-sms">SMS</span>
|
||||
<span class="km-cp km-cp-mail">Email</span>
|
||||
<span class="km-cp km-cp-ivr">IVR</span>
|
||||
<span class="km-cp km-cp-post">Reg. Post</span>
|
||||
<span class="km-cp km-cp-vis">Home Visit</span>
|
||||
</div>
|
||||
<div class="km-row-actions">
|
||||
<button class="km-btn km-btn-t" (click)="toast('Notice 3 scheduled for 20 Apr 2027')">
|
||||
Schedule Notice 3
|
||||
</button>
|
||||
<button class="km-btn km-btn-ot" (click)="toast('Video KYC link added to Notice 3')">
|
||||
+ Video KYC Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="km-nt">
|
||||
<div class="km-nd-col">
|
||||
<div class="km-nd km-ndf">!</div>
|
||||
@@ -295,39 +276,26 @@
|
||||
<div class="km-card">
|
||||
<div class="km-ch">
|
||||
<div class="km-ci km-ci-b"><fa-icon icon="broadcast-tower"></fa-icon></div>
|
||||
<div class="km-ct">Channels — Notice 3</div>
|
||||
<div class="km-ct">Channel Sequence</div>
|
||||
</div>
|
||||
<!-- Mirrors the ladder: exactly one channel is live, so this is a progress read-out rather
|
||||
than a set of switches that could all be turned on at once. -->
|
||||
<div class="km-cb km-channels">
|
||||
<div class="km-channel km-channel-on" (click)="toast('SMS toggled')">
|
||||
<div class="km-channel-i">📱</div>
|
||||
<div class="km-channel-n">SMS</div>
|
||||
<div class="km-channel-d">Registered mobile</div>
|
||||
</div>
|
||||
<div class="km-channel km-channel-on" (click)="toast('Email toggled')">
|
||||
<div class="km-channel-i">📧</div>
|
||||
<div class="km-channel-n">Email</div>
|
||||
<div class="km-channel-d">Registered email</div>
|
||||
</div>
|
||||
<div class="km-channel km-channel-on" (click)="toast('IVR toggled')">
|
||||
<div class="km-channel-i">📞</div>
|
||||
<div class="km-channel-n">IVR Call</div>
|
||||
<div class="km-channel-d">Outbound IVR</div>
|
||||
</div>
|
||||
<div class="km-channel km-channel-on" (click)="toast('Reg Post toggled')">
|
||||
<div class="km-channel-i">📮</div>
|
||||
<div class="km-channel-n">Reg. Post</div>
|
||||
<div class="km-channel-d">India Post</div>
|
||||
</div>
|
||||
<div class="km-channel" (click)="toast('Branch visit toggled')">
|
||||
<div class="km-channel-i">🏦</div>
|
||||
<div class="km-channel-n">Home Visit</div>
|
||||
<div class="km-channel-d">Manual</div>
|
||||
</div>
|
||||
<div class="km-channel" (click)="toast('Video KYC toggled')">
|
||||
<div class="km-channel-i">📹</div>
|
||||
<div class="km-channel-n">Video KYC</div>
|
||||
<div class="km-channel-d">V-CIP app</div>
|
||||
</div>
|
||||
@for (s of reKycSteps; track s.step) {
|
||||
<div
|
||||
class="km-channel"
|
||||
[class.km-channel-on]="s.status === 'DELIVERED'"
|
||||
[class.km-channel-live]="s.status === 'PENDING'"
|
||||
[class.km-channel-off]="s.status === 'LOCKED'"
|
||||
[title]="reKycStatusLabel(s.status)"
|
||||
>
|
||||
<div class="km-channel-n">{{ s.step }}. {{ s.channel }}</div>
|
||||
<div class="km-channel-d">{{ s.detail }}</div>
|
||||
<div class="km-channel-s" [ngClass]="reKycStatusClass(s.status)">
|
||||
{{ reKycStatusLabel(s.status) }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -734,8 +702,9 @@
|
||||
<div class="km-page-head">
|
||||
<span class="km-page-title">KYC Exceptions Dashboard</span>
|
||||
<div class="km-actions">
|
||||
<button class="km-btn km-btn-g" (click)="toast('Exporting exception report...')">
|
||||
<fa-icon icon="download"></fa-icon> Export Exceptions
|
||||
<button class="km-btn km-btn-g" [disabled]="exportingExceptions" (click)="exportExceptions()">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
{{ exportingExceptions ? 'Preparing…' : 'Export Exceptions' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -743,6 +743,57 @@
|
||||
border-color: #c4b5fd;
|
||||
}
|
||||
|
||||
/* Escalation ladder: a rung waiting on an earlier channel is dimmed so the one actionable
|
||||
dispatch is unmistakable. */
|
||||
.km-nt-locked {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.km-locked {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.km-nnotice {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.km-nchdetail {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* The one live channel is outlined; completed ones stay green, waiting ones recede. */
|
||||
.km-channel-live {
|
||||
border-color: var(--km-amber, #f59e0b) !important;
|
||||
background: #fffbeb !important;
|
||||
box-shadow: 0 0 0 2px rgb(245 158 11 / 18%);
|
||||
}
|
||||
|
||||
.km-channel-off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.km-channel-s {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Sequence cards are read-only, so they must not invite a click the way the old toggles did. */
|
||||
.km-channels .km-channel {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.km-cp-post {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { ClientsService } from '../clients/clients.service';
|
||||
import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export';
|
||||
|
||||
interface KmNotice {
|
||||
no: string;
|
||||
@@ -997,6 +998,290 @@ export class KycManagementComponent implements OnInit {
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 2800);
|
||||
}
|
||||
|
||||
// ── Re-KYC dispatch ladder ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Re-KYC outreach is one channel at a time, escalating only when the previous channel has been
|
||||
* dispatched — not a bundle of channels fired at once. The regulatory 3-notice structure is kept
|
||||
* as the grouping (notice), while each row is a single dispatch, which is how it actually happens
|
||||
* at the branch.
|
||||
*
|
||||
* status: DELIVERED / FAILED are done, PENDING is the one dispatch that may go out now, LOCKED is
|
||||
* waiting on an earlier channel.
|
||||
*/
|
||||
reKycSteps: {
|
||||
step: number;
|
||||
notice: number;
|
||||
channel: string;
|
||||
detail: string;
|
||||
date: string;
|
||||
offset: string;
|
||||
status: 'DELIVERED' | 'FAILED' | 'PENDING' | 'LOCKED';
|
||||
chip: string;
|
||||
}[] = [
|
||||
{
|
||||
step: 1,
|
||||
notice: 1,
|
||||
channel: 'SMS',
|
||||
detail: 'Registered mobile',
|
||||
date: '31 March 2027',
|
||||
offset: 'Window opens',
|
||||
status: 'DELIVERED',
|
||||
chip: 'km-cp-sms'
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
notice: 1,
|
||||
channel: 'Email',
|
||||
detail: 'Registered email',
|
||||
date: '3 April 2027',
|
||||
offset: '+3 days',
|
||||
status: 'DELIVERED',
|
||||
chip: 'km-cp-mail'
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
notice: 2,
|
||||
channel: 'IVR Call',
|
||||
detail: 'Outbound IVR',
|
||||
date: '10 April 2027',
|
||||
offset: '+10 days',
|
||||
status: 'PENDING',
|
||||
chip: 'km-cp-ivr'
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
notice: 2,
|
||||
channel: 'Reg. Post',
|
||||
detail: 'India Post, tracked',
|
||||
date: '14 April 2027',
|
||||
offset: '+14 days',
|
||||
status: 'LOCKED',
|
||||
chip: 'km-cp-post'
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
notice: 3,
|
||||
channel: 'Home Visit',
|
||||
detail: 'Field officer, manual',
|
||||
date: '20 April 2027',
|
||||
offset: '+20 days',
|
||||
status: 'LOCKED',
|
||||
chip: 'km-cp-vis'
|
||||
},
|
||||
{
|
||||
step: 6,
|
||||
notice: 3,
|
||||
channel: 'Video KYC',
|
||||
detail: 'V-CIP link',
|
||||
date: '25 April 2027',
|
||||
offset: '+25 days',
|
||||
status: 'LOCKED',
|
||||
chip: 'km-cp-vid'
|
||||
}
|
||||
];
|
||||
|
||||
/** The single dispatch that may go out now — nothing later is actionable until it is done. */
|
||||
get currentReKycStep(): number | null {
|
||||
return this.reKycSteps.find((s) => s.status === 'PENDING')?.step ?? null;
|
||||
}
|
||||
|
||||
get reKycCompletedCount(): number {
|
||||
return this.reKycSteps.filter((s) => s.status === 'DELIVERED' || s.status === 'FAILED').length;
|
||||
}
|
||||
|
||||
/** First row of a notice carries the notice heading; the rest indent under it. */
|
||||
isFirstOfNotice(index: number): boolean {
|
||||
return index === 0 || this.reKycSteps[index - 1].notice !== this.reKycSteps[index].notice;
|
||||
}
|
||||
|
||||
reKycStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'DELIVERED':
|
||||
return '✓ Dispatched';
|
||||
case 'FAILED':
|
||||
return '✗ Failed — escalated';
|
||||
case 'PENDING':
|
||||
return '⏳ Due now';
|
||||
default:
|
||||
return '🔒 Waiting on previous channel';
|
||||
}
|
||||
}
|
||||
|
||||
reKycStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'DELIVERED':
|
||||
return 'km-ok';
|
||||
case 'FAILED':
|
||||
return 'km-fail';
|
||||
case 'PENDING':
|
||||
return 'km-pending';
|
||||
default:
|
||||
return 'km-locked';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the due channel dispatched and releases only the next one, so the ladder can never skip a
|
||||
* rung. A failure still advances — the point of escalation is that the next channel is tried.
|
||||
*/
|
||||
dispatchReKycStep(step: number, failed = false): void {
|
||||
const index = this.reKycSteps.findIndex((s) => s.step === step);
|
||||
if (index < 0 || this.reKycSteps[index].status !== 'PENDING') {
|
||||
return;
|
||||
}
|
||||
const current = this.reKycSteps[index];
|
||||
current.status = failed ? 'FAILED' : 'DELIVERED';
|
||||
const next = this.reKycSteps[index + 1];
|
||||
if (next && next.status === 'LOCKED') {
|
||||
next.status = 'PENDING';
|
||||
}
|
||||
this.toast(
|
||||
failed
|
||||
? `${current.channel} failed — escalated to ${next ? next.channel : 'freeze workflow'}`
|
||||
: `${current.channel} dispatched${next ? ` · next: ${next.channel} on ${next.date}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
// ── Exceptions export ──────────────────────────────────────────────────────
|
||||
|
||||
/** True while the workbook is being built, so the button cannot be double-fired. */
|
||||
exportingExceptions = false;
|
||||
|
||||
/**
|
||||
* Exports the Exceptions Register to Excel: one sheet for pending, one for resolved, mirroring
|
||||
* the columns on screen. Contact attempts are flattened into a single cell per row rather than a
|
||||
* third sheet — an auditor reading a row wants the outreach trail beside it, not a lookup.
|
||||
*/
|
||||
async exportExceptions(): Promise<void> {
|
||||
if (this.exportingExceptions) {
|
||||
return;
|
||||
}
|
||||
const sheets: ExportSheetSpec[] = [];
|
||||
if (this.pendingExceptions.length) {
|
||||
sheets.push(this.exceptionSheet('Pending Exceptions', this.pendingExceptions, false));
|
||||
}
|
||||
if (this.resolvedExceptions.length) {
|
||||
sheets.push(this.exceptionSheet('Resolved Exceptions', this.resolvedExceptions, true));
|
||||
}
|
||||
if (!sheets.length) {
|
||||
this.toast('There are no exceptions to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.exportingExceptions = true;
|
||||
try {
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
await exportWorkbook(sheets, `KYC-Exceptions-${stamp}`);
|
||||
this.toast('Exception report downloaded.');
|
||||
} catch {
|
||||
// A failed export must say so — the old stub toasted success unconditionally.
|
||||
this.toast('Could not build the exception report. Please try again.');
|
||||
} finally {
|
||||
this.exportingExceptions = false;
|
||||
}
|
||||
}
|
||||
|
||||
private exceptionSheet(sheetName: string, rows: KmClient[], resolved: boolean): ExportSheetSpec {
|
||||
const headers = [
|
||||
'Client',
|
||||
'Account No',
|
||||
'Branch',
|
||||
'Exception Reason',
|
||||
'Opened On',
|
||||
'Risk',
|
||||
'Contact Status',
|
||||
'Last Contacted',
|
||||
'Attempts',
|
||||
'Contact Log',
|
||||
'Mobile',
|
||||
'Email',
|
||||
'PAN',
|
||||
'PAN Status',
|
||||
'Aadhaar',
|
||||
'Aadhaar Status',
|
||||
...(resolved ? [
|
||||
'Resolved On',
|
||||
'Resolution Remarks'
|
||||
] : [])
|
||||
];
|
||||
|
||||
const body = rows.map((c) => {
|
||||
const history = c.contactHistory || [];
|
||||
const last = history.length ? history[history.length - 1] : null;
|
||||
return [
|
||||
c.name || '',
|
||||
c.account || '',
|
||||
c.contact?.branch || '',
|
||||
c.exceptionReason || '',
|
||||
c.exceptionDate || '',
|
||||
c.riskLabel || '',
|
||||
history.length ? 'Contacted' : 'Not Contacted',
|
||||
last?.date || '',
|
||||
// Plain count, not a formatted number — exportWorkbook applies #,##0.00 to numbers and
|
||||
// "3.00 attempts" reads as a bug.
|
||||
String(history.length),
|
||||
this.contactLogText(history),
|
||||
c.contact?.mobile || '',
|
||||
c.contact?.email || '',
|
||||
c.kyc?.pan || '',
|
||||
c.kyc?.panStatus || '',
|
||||
c.kyc?.aadhaar || '',
|
||||
c.kyc?.aadhaarStatus || '',
|
||||
...(resolved ? [
|
||||
c.exceptionResolvedOn || '',
|
||||
c.exceptionRemarks || ''
|
||||
] : [])
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
sheetName,
|
||||
title: `KYC Exceptions Register — ${sheetName}`,
|
||||
subtitle: `${rows.length} exception(s) · exported ${new Date().toLocaleString('en-IN')}`,
|
||||
headers,
|
||||
rows: body,
|
||||
widths: [
|
||||
26,
|
||||
18,
|
||||
16,
|
||||
34,
|
||||
14,
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
10,
|
||||
60,
|
||||
16,
|
||||
26,
|
||||
14,
|
||||
14,
|
||||
16,
|
||||
16,
|
||||
...(resolved ? [
|
||||
14,
|
||||
40
|
||||
] : [])
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per attempt, carrying the delivery outcome so a failed notice is never read as sent.
|
||||
* Separated with " ; " rather than newlines: the shared sheet builder does not set wrapText on
|
||||
* body cells, so embedded newlines would collapse into one cramped line in Excel.
|
||||
*/
|
||||
private contactLogText(history: KmContactLog[]): string {
|
||||
return history
|
||||
.map((log) => {
|
||||
const outcome = log.deliveryStatus ? ` [${log.deliveryStatus}]` : '';
|
||||
const officer = log.officer ? ` — ${log.officer}` : '';
|
||||
const notes = log.notes ? `: ${log.notes}` : '';
|
||||
return `${log.date} (${log.action})${notes}${officer}${outcome}`;
|
||||
})
|
||||
.join(' ; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a "Take Action" click. Opens the Home Visit dialog if the action is
|
||||
* a home-visit scheduling action; otherwise falls back to a toast confirmation.
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="rule-dialog-title">
|
||||
<mat-icon>add_moderator</mat-icon>
|
||||
Add AML Rule
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="rule-dialog-content">
|
||||
<form [formGroup]="ruleForm" class="rule-form" autocomplete="off">
|
||||
<div *ngIf="errorMessage" class="rule-form-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="rule-field-full">
|
||||
<mat-label>Rule Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="2"
|
||||
formControlName="ruleName"
|
||||
placeholder="e.g. Cash deposits exceeding the monthly threshold"
|
||||
></textarea>
|
||||
<mat-error *ngIf="ruleForm.get('ruleName')?.hasError('required')">Rule description is required</mat-error>
|
||||
<mat-error *ngIf="ruleForm.get('ruleName')?.hasError('maxlength')">Keep this under 100 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rule-field-full">
|
||||
<mat-label>Rule Reason / Criteria</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
formControlName="reason"
|
||||
placeholder="Why the rule exists and what it looks for"
|
||||
></textarea>
|
||||
<mat-error *ngIf="ruleForm.get('reason')?.hasError('required')">Reason is required</mat-error>
|
||||
<mat-error *ngIf="ruleForm.get('reason')?.hasError('maxlength')">Keep this under 500 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="rule-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Risk Category</mat-label>
|
||||
<mat-select formControlName="riskCategory">
|
||||
<mat-option *ngFor="let r of riskCategories" [value]="r.value">{{ r.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Applies Over</mat-label>
|
||||
<mat-select formControlName="txnWindowDays">
|
||||
<mat-option *ngFor="let w of windowOptions" [value]="w.value">{{ w.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Minimum Limit</mat-label>
|
||||
<span matPrefix class="rule-field-prefix">₹</span>
|
||||
<input matInput type="number" min="0" formControlName="txnMinLimitAmount" />
|
||||
<mat-hint>Leave blank for a non-amount rule</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Maximum Limit</mat-label>
|
||||
<span matPrefix class="rule-field-prefix">₹</span>
|
||||
<input matInput type="number" min="0" formControlName="txnMaxLimitAmount" />
|
||||
<mat-hint>Blank means no upper limit</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Rule Type</mat-label>
|
||||
<mat-select formControlName="txnRuleType">
|
||||
<mat-option *ngFor="let t of ruleTypes" [value]="t">{{ t }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Applies To</mat-label>
|
||||
<mat-select formControlName="txnAppliesTo">
|
||||
<mat-option *ngFor="let a of appliesToOptions" [value]="a">{{ a }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>AML Check Status</mat-label>
|
||||
<mat-select formControlName="amlCheckStatus">
|
||||
<mat-option *ngFor="let s of amlCheckStatuses" [value]="s">{{ s }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Banned Entity Match</mat-label>
|
||||
<mat-select formControlName="bannedEntityMatch">
|
||||
<mat-option *ngFor="let y of yesNo" [value]="y">{{ y }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div *ngIf="bandInverted" class="rule-form-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
|
||||
</div>
|
||||
|
||||
<p class="rule-form-note">
|
||||
Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.
|
||||
</p>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="rule-dialog-actions">
|
||||
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="ruleForm.invalid || bandInverted || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
<mat-spinner *ngIf="submitting" diameter="18" class="rule-submit-spinner"></mat-spinner>
|
||||
{{ submitting ? 'Saving…' : 'Add Rule' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.rule-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1a237e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rule-dialog-content {
|
||||
padding-top: 8px;
|
||||
min-width: 560px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.rule-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rule-field-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
.rule-field-prefix {
|
||||
color: #64748b;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rule-form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-form-note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rule-dialog-actions {
|
||||
padding: 8px 24px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-submit-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.rule-dialog-content {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.rule-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../../clients/clients.service';
|
||||
|
||||
/**
|
||||
* Authoring form for a new AML catalog rule.
|
||||
*
|
||||
* The screen the officer came from renders one flat table, but a rule is stored across two
|
||||
* tables: the profile rule in m_client_risk_rule and, when a transaction band or observation
|
||||
* window is given, an evaluable counterpart in m_aml_transaction_rule. That split is the
|
||||
* backend's concern — this form stays shaped like the table it was opened from.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-add-aml-rule-dialog',
|
||||
templateUrl: './add-aml-rule-dialog.component.html',
|
||||
styleUrls: ['./add-aml-rule-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class AddAmlRuleDialogComponent {
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private clientsService = inject(ClientsService);
|
||||
private dialogRef = inject(MatDialogRef<AddAmlRuleDialogComponent>);
|
||||
|
||||
submitting = false;
|
||||
/** Server-side rejection, surfaced in the dialog so the officer keeps their input. */
|
||||
errorMessage = '';
|
||||
|
||||
readonly riskCategories = [
|
||||
{ value: 3, label: 'High Risk' },
|
||||
{ value: 2, label: 'Medium Risk' },
|
||||
{ value: 1, label: 'Low Risk' }
|
||||
];
|
||||
|
||||
/** Mirrors the "Applies Over" column: window in days, per-transaction being no window. */
|
||||
readonly windowOptions = [
|
||||
{ value: 0, label: 'Per transaction' },
|
||||
{ value: 1, label: 'Daily' },
|
||||
{ value: 7, label: 'Weekly' },
|
||||
{ value: 30, label: 'Monthly' }
|
||||
];
|
||||
|
||||
readonly ruleTypes = [
|
||||
'THRESHOLD',
|
||||
'STRUCTURING',
|
||||
'VELOCITY'
|
||||
];
|
||||
readonly appliesToOptions = [
|
||||
'ALL',
|
||||
'SAVINGS',
|
||||
'LOAN',
|
||||
'TRANSFER'
|
||||
];
|
||||
readonly amlCheckStatuses = [
|
||||
'CLEAR',
|
||||
'FLAGGED',
|
||||
'PENDING'
|
||||
];
|
||||
readonly yesNo = [
|
||||
'NO',
|
||||
'YES'
|
||||
];
|
||||
|
||||
ruleForm = this.formBuilder.group({
|
||||
ruleName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(100)
|
||||
]
|
||||
],
|
||||
reason: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(500)
|
||||
]
|
||||
],
|
||||
riskCategory: [
|
||||
3,
|
||||
Validators.required
|
||||
],
|
||||
txnWindowDays: [0],
|
||||
txnRuleType: ['THRESHOLD'],
|
||||
txnAppliesTo: ['ALL'],
|
||||
txnMinLimitAmount: [
|
||||
null as
|
||||
| number
|
||||
| null,
|
||||
Validators.min(0)
|
||||
],
|
||||
txnMaxLimitAmount: [
|
||||
null as
|
||||
| number
|
||||
| null,
|
||||
Validators.min(0)
|
||||
],
|
||||
amlCheckStatus: ['FLAGGED'],
|
||||
bannedEntityMatch: ['NO'],
|
||||
willfulDefaulter: ['NO'],
|
||||
ecgcSalFlag: ['NO'],
|
||||
dormantFlag: ['ACTIVE']
|
||||
});
|
||||
|
||||
/** Mirrors the backend band check so the officer is told before the round trip. */
|
||||
get bandInverted(): boolean {
|
||||
const min = this.ruleForm.value.txnMinLimitAmount;
|
||||
const max = this.ruleForm.value.txnMaxLimitAmount;
|
||||
return min != null && max != null && Number(max) < Number(min);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.ruleForm.invalid || this.bandInverted) {
|
||||
this.ruleForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
this.clientsService.createClientRiskRule(this.buildPayload()).subscribe({
|
||||
next: (rule) => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(rule);
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = this.readError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private buildPayload(): any {
|
||||
const v = this.ruleForm.value;
|
||||
const amount = (n: any) => (n === null || n === undefined || n === '' ? null : Number(n));
|
||||
return {
|
||||
ruleName: (v.ruleName || '').trim(),
|
||||
reason: (v.reason || '').trim(),
|
||||
riskCategory: v.riskCategory,
|
||||
amlCheckStatus: v.amlCheckStatus,
|
||||
bannedEntityMatch: v.bannedEntityMatch,
|
||||
willfulDefaulter: v.willfulDefaulter,
|
||||
ecgcSalFlag: v.ecgcSalFlag,
|
||||
dormantFlag: v.dormantFlag,
|
||||
// A window of 0 means per-transaction, which the backend stores as a null window rather
|
||||
// than a zero-day one.
|
||||
txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null,
|
||||
txnRuleType: v.txnRuleType,
|
||||
txnAppliesTo: v.txnAppliesTo,
|
||||
txnMinLimitAmount: amount(v.txnMinLimitAmount),
|
||||
txnMaxLimitAmount: amount(v.txnMaxLimitAmount)
|
||||
};
|
||||
}
|
||||
|
||||
/** Fineract returns field-level failures under errors[]; fall back to the envelope message. */
|
||||
private readError(err: any): string {
|
||||
const body = err?.error;
|
||||
const first = body?.errors?.[0];
|
||||
return (
|
||||
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not save the rule.'
|
||||
);
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="flagged-toolbar">
|
||||
<div class="flagged-live">
|
||||
<span class="live-dot" [class.paused]="!live"></span>
|
||||
<span class="live-label">{{ live ? 'Live' : 'Paused' }}</span>
|
||||
<span *ngIf="lastUpdated" class="live-updated">Updated {{ lastUpdated | date: 'HH:mm:ss' }}</span>
|
||||
<mat-spinner *ngIf="loading" diameter="16" class="live-spinner"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div class="flagged-actions">
|
||||
<button
|
||||
*ngIf="newAlertCount > 0"
|
||||
type="button"
|
||||
class="new-alert-pill"
|
||||
matTooltip="Clear the new-alert highlight"
|
||||
(click)="acknowledgeNew()"
|
||||
>
|
||||
<mat-icon>notifications_active</mat-icon>
|
||||
{{ newAlertCount }} new
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matTooltip]="live ? 'Pause live updates' : 'Resume live updates'"
|
||||
(click)="toggleLive()"
|
||||
>
|
||||
<mat-icon>{{ live ? 'pause_circle' : 'play_circle' }}</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loadFailed" class="flagged-error">
|
||||
<mat-icon>cloud_off</mat-icon>
|
||||
<span>Could not reach the alert feed. Retrying automatically.</span>
|
||||
</div>
|
||||
|
||||
<!-- Narrow the feed by where each alert sits in the review workflow -->
|
||||
<div class="status-filters">
|
||||
<span class="status-filters-label">Status</span>
|
||||
<button
|
||||
*ngFor="let s of statusFilters"
|
||||
type="button"
|
||||
class="status-chip"
|
||||
[class.active]="activeStatus === s.key"
|
||||
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
|
||||
(click)="setStatus(s.key)"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span class="status-chip-n">{{ statusCount(s.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Severity mix of what the table is showing -->
|
||||
<div class="risk-summary">
|
||||
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
|
||||
<span class="risk-tile-n">{{ tier.count }}</span>
|
||||
<span class="risk-tile-l">{{ tier.label }}</span>
|
||||
</div>
|
||||
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
|
||||
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
|
||||
<span class="risk-tile-l">Unclassified</span>
|
||||
</div>
|
||||
<div class="risk-tile risk-total">
|
||||
<span class="risk-tile-n">{{ summaryTotal }}</span>
|
||||
<span class="risk-tile-l">Total Flagged</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
|
||||
<!-- When the engine raised the alert -->
|
||||
<ng-container matColumnDef="raisedOn">
|
||||
<th mat-header-cell *matHeaderCellDef>Raised On</th>
|
||||
<td mat-cell *matCellDef="let alert" class="raised-cell">
|
||||
<span class="raised-time">{{
|
||||
alert.raisedOnDate ? (alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—'
|
||||
}}</span>
|
||||
<span *ngIf="isNew(alert)" class="new-badge">NEW</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Which transaction tripped the rule -->
|
||||
<ng-container matColumnDef="transaction">
|
||||
<th mat-header-cell *matHeaderCellDef>Transaction</th>
|
||||
<td mat-cell *matCellDef="let alert">
|
||||
<span [ngClass]="txnTypeClass(alert.transactionType)">{{ alert.transactionType }}</span>
|
||||
<span class="txn-id">#{{ alert.transactionId }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="client">
|
||||
<th mat-header-cell *matHeaderCellDef>Client</th>
|
||||
<td mat-cell *matCellDef="let alert">
|
||||
<div class="stacked-cell">
|
||||
<span>{{ alert.clientDisplayName || '—' }}</span>
|
||||
<span *ngIf="alert.clientId" class="cell-sub">ID {{ alert.clientId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="rule">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Triggered</th>
|
||||
<td mat-cell *matCellDef="let alert" class="rule-cell">
|
||||
<div class="stacked-cell">
|
||||
<span class="rule-code">{{ alert.ruleCode }}</span>
|
||||
<span class="cell-sub">{{ alert.ruleDescription }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Amount</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-right amount-cell">{{ amount(alert) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Status</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-center">
|
||||
<span [ngClass]="statusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
|
||||
<ng-container *ngIf="canReview(alert); else reviewed">
|
||||
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
|
||||
so without this a user who cannot review still sees buttons that only fail on click. -->
|
||||
<div class="action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn action-whitelist"
|
||||
matTooltip="Clear this transaction with no report"
|
||||
(click)="review(alert, 'WHITELIST')"
|
||||
>
|
||||
<mat-icon>verified</mat-icon>
|
||||
Whitelist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn action-suspicious"
|
||||
matTooltip="Raise as suspicious — opens an STR case"
|
||||
(click)="review(alert, 'ESCALATE')"
|
||||
>
|
||||
<mat-icon>report</mat-icon>
|
||||
Suspicious
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #reviewed>
|
||||
<div class="reviewed-cell">
|
||||
<span class="reviewed-by">
|
||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||
</span>
|
||||
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
||||
<mat-icon
|
||||
*ngIf="alert.reviewNotes"
|
||||
class="reviewed-evidence-icon"
|
||||
[matTooltip]="alert.reviewNotes"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
description
|
||||
</mat-icon>
|
||||
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
|
||||
→ STR
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
|
||||
|
||||
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
|
||||
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
||||
<span *ngIf="!activeStatus"
|
||||
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
|
||||
>
|
||||
<span *ngIf="activeStatus">No alerts in this status.</span>
|
||||
</div>
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.flagged-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 0 14px;
|
||||
}
|
||||
|
||||
.flagged-live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* Pulses while the feed is polling; goes flat and grey when paused. */
|
||||
.live-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 0 0 rgb(22 163 74 / 60%);
|
||||
animation: live-pulse 2s infinite;
|
||||
|
||||
&.paused {
|
||||
background: #94a3b8;
|
||||
animation: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgb(22 163 74 / 60%);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgb(22 163 74 / 0%);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgb(22 163 74 / 0%);
|
||||
}
|
||||
}
|
||||
|
||||
.live-label {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
color: #16a34a;
|
||||
|
||||
.live-dot.paused + & {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.live-updated {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.live-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.flagged-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.new-alert-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #fdba74;
|
||||
border-radius: 999px;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #ffedd5;
|
||||
}
|
||||
}
|
||||
|
||||
.flagged-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chips that narrow the feed by review status. */
|
||||
.status-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
|
||||
.status-filters-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1a237e;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1a237e;
|
||||
border-color: #1a237e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.status-chip-n {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
.status-chip.active & {
|
||||
background: rgb(255 255 255 / 22%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Severity mix of the visible rows. Colours match the Rules Master risk badges so the same
|
||||
category reads the same across both tabs. */
|
||||
.risk-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.risk-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.risk-tile-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.risk-tile-l {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.risk-tile.risk-high {
|
||||
border-left-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tile.risk-medium {
|
||||
border-left-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tile.risk-low {
|
||||
border-left-color: #16a34a;
|
||||
background: #f0fdf4;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tile.risk-none {
|
||||
border-left-color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tile.risk-total {
|
||||
border-left-color: #1a237e;
|
||||
background: #f8fafc;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #1a237e;
|
||||
}
|
||||
}
|
||||
|
||||
.flagged-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
th {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* A newly arrived alert stays tinted until acknowledged. */
|
||||
.row-new {
|
||||
background: #fffbeb;
|
||||
|
||||
&:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
.col-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.raised-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.raised-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.new-badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.txn-type {
|
||||
display: inline-block;
|
||||
padding: 3px 9px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
|
||||
&.type-savings {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&.type-loan {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
&.type-transfer {
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
.txn-id {
|
||||
margin-left: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Two-line cells stack inside a wrapper div. Making the <td> itself a flex container drops its
|
||||
display:table-cell, which collapses the row and pushes later columns onto their own line. */
|
||||
.stacked-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cell-sub {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rule-code {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rule-cell {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-status {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&.status-raised {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.status-under_review {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&.status-escalated {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.status-whitelisted {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 11px;
|
||||
margin: 2px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-whitelist {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
|
||||
&:hover {
|
||||
background: #dcfce7;
|
||||
border-color: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
.action-suspicious {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
.reviewed-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reviewed-by {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.reviewed-evidence-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #64748b;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* Mirrors the FIU-IND STR register badge, so the same state reads the same in both screens. */
|
||||
.str-routed {
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::ng-deep .evidence-tooltip {
|
||||
max-width: 320px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-alerts-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
color: #64748b;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.flagged-paginator {
|
||||
background: #fff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subject, timer, of } from 'rxjs';
|
||||
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
import { AlertService } from '../../../core/alert/alert.service';
|
||||
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
||||
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
||||
|
||||
/**
|
||||
* Live feed of transactions the AML rule engine has flagged.
|
||||
*
|
||||
* The engine writes an m_aml_alert row synchronously as each savings/loan/transfer transaction
|
||||
* posts (AmlDomainServiceImpl -> AmlRuleEvaluationService), but there is no push channel out of
|
||||
* the platform — no websocket or SSE endpoint exists. So "real time" here is a short poll of
|
||||
* GET /aml/alerts. Alerts therefore surface within one poll interval of the transaction, not on
|
||||
* the same instant it posts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-flagged-transactions',
|
||||
templateUrl: './aml-flagged-transactions.component.html',
|
||||
styleUrls: ['./aml-flagged-transactions.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
|
||||
private static readonly POLL_INTERVAL_MS = 15000;
|
||||
|
||||
alerts = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'raisedOn',
|
||||
'transaction',
|
||||
'client',
|
||||
'rule',
|
||||
'amount',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/** Every alert as returned, before the status filter narrows the table. */
|
||||
private allAlerts: any[] = [];
|
||||
|
||||
readonly statusFilters: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'All alerts' },
|
||||
{ key: 'RAISED', label: 'Raised' },
|
||||
{ key: 'UNDER_REVIEW', label: 'Under review' },
|
||||
{ key: 'ESCALATED', label: 'Escalated' },
|
||||
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
||||
];
|
||||
activeStatus = '';
|
||||
private statusCounts: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
|
||||
* of the rule that fired, resolved server-side from the originating catalog entry — the same
|
||||
* field the Rules Master tab shows in its Risk Category column.
|
||||
*/
|
||||
riskSummary: { label: string; count: number; cls: string }[] = [];
|
||||
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
|
||||
unclassifiedCount = 0;
|
||||
|
||||
live = true;
|
||||
loading = false;
|
||||
/** Null until the first successful poll, so the header does not claim a stale time. */
|
||||
lastUpdated: Date | null = null;
|
||||
loadFailed = false;
|
||||
|
||||
/** Ids already on screen, so a later poll can tell genuinely new alerts from repeats. */
|
||||
private seenIds = new Set<number>();
|
||||
/** Alerts that arrived after the officer started watching — highlighted until acknowledged. */
|
||||
newAlertIds = new Set<number>();
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetch();
|
||||
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
|
||||
.pipe(
|
||||
// Skip while paused, and while the browser tab is in the background — a hidden tab
|
||||
// polling every 15s is pure waste, and the next visible tick catches up anyway.
|
||||
filter(() => this.live && !document.hidden),
|
||||
// Always fetch unfiltered: the status chips need counts for every bucket, and filtering
|
||||
// client-side keeps a chip switch instant instead of a round trip.
|
||||
switchMap(() => this.amlService.getAlerts().pipe(catchError(() => of(null)))),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((data) => {
|
||||
if (data) {
|
||||
this.applyAlerts(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/** Manual refresh, and the initial load. */
|
||||
fetch(): void {
|
||||
this.loading = true;
|
||||
this.amlService
|
||||
.getAlerts()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.loading = false;
|
||||
this.applyAlerts(data || []);
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.loadFailed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleLive(): void {
|
||||
this.live = !this.live;
|
||||
if (this.live) {
|
||||
// Resuming should show current state immediately rather than after a full interval.
|
||||
this.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(key: string): void {
|
||||
this.activeStatus = key;
|
||||
this.applyStatusFilter();
|
||||
}
|
||||
|
||||
statusCount(key: string): number {
|
||||
return this.statusCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
get newAlertCount(): number {
|
||||
return this.newAlertIds.size;
|
||||
}
|
||||
|
||||
/** Clears the NEW highlight once the officer has looked at them. */
|
||||
acknowledgeNew(): void {
|
||||
this.newAlertIds.clear();
|
||||
}
|
||||
|
||||
isNew(alert: any): boolean {
|
||||
return this.newAlertIds.has(alert.id);
|
||||
}
|
||||
|
||||
private applyAlerts(data: any[]): void {
|
||||
this.loadFailed = false;
|
||||
// A live feed reads newest-first. The API returns ascending when a status filter is applied
|
||||
// and unordered otherwise, so ordering here is what makes the newest alert visible on page 1.
|
||||
// Sorting needs the parsed date: raisedOn itself is an array, and new Date([...]) is NaN,
|
||||
// which would silently make the comparator a no-op.
|
||||
const list = (data || [])
|
||||
.map((a) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
|
||||
.sort((a, b) => (b.raisedOnDate?.getTime() ?? 0) - (a.raisedOnDate?.getTime() ?? 0));
|
||||
|
||||
const firstLoad = this.lastUpdated === null;
|
||||
if (!firstLoad) {
|
||||
// Accumulate rather than replace: two quiet polls in a row must not clear a highlight the
|
||||
// officer has not looked at yet.
|
||||
for (const alert of list) {
|
||||
if (!this.seenIds.has(alert.id)) {
|
||||
this.newAlertIds.add(alert.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.seenIds = new Set(list.map((a) => a.id));
|
||||
|
||||
this.allAlerts = list;
|
||||
this.recomputeStatusCounts();
|
||||
this.applyStatusFilter();
|
||||
this.lastUpdated = new Date();
|
||||
}
|
||||
|
||||
private applyStatusFilter(): void {
|
||||
this.alerts.data = this.activeStatus
|
||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
||||
: this.allAlerts;
|
||||
if (!this.alerts.paginator && this.paginator) {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
this.recomputeRiskSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
|
||||
* disagreed with the table underneath it would be worse than no summary.
|
||||
*/
|
||||
private recomputeRiskSummary(): void {
|
||||
const pool = this.alerts.data;
|
||||
const tiers = [
|
||||
{ key: 3, label: 'High Risk', cls: 'risk-high' },
|
||||
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
|
||||
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
|
||||
];
|
||||
this.riskSummary = tiers.map((t) => ({
|
||||
label: t.label,
|
||||
cls: t.cls,
|
||||
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
|
||||
}));
|
||||
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter(
|
||||
(a) => ![
|
||||
1,
|
||||
2,
|
||||
3
|
||||
].includes(Number(a.riskCategory))
|
||||
).length;
|
||||
}
|
||||
|
||||
get summaryTotal(): number {
|
||||
return this.alerts.data.length;
|
||||
}
|
||||
|
||||
private recomputeStatusCounts(): void {
|
||||
this.statusCounts = { '': this.allAlerts.length };
|
||||
for (const s of this.statusFilters) {
|
||||
if (s.key) {
|
||||
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
return 'alert-status status-' + (status || 'raised').toLowerCase();
|
||||
}
|
||||
|
||||
txnTypeClass(type: string): string {
|
||||
return 'txn-type type-' + (type || 'unknown').toLowerCase();
|
||||
}
|
||||
|
||||
amount(alert: any): string {
|
||||
const value = Number(alert.amount) || 0;
|
||||
return '₹' + value.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fineract's Gson LocalDateTimeAdapter emits [year, month, day, hour, minute, second] rather
|
||||
* than an ISO string, so binding raisedOn straight into the date pipe renders nothing. Month is
|
||||
* 1-based in that array and 0-based in Date. The string branch is a fallback in case the
|
||||
* serializer is ever configured to emit ISO.
|
||||
*/
|
||||
private toDate(value: any): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const [
|
||||
y,
|
||||
M,
|
||||
d,
|
||||
H = 0,
|
||||
m = 0,
|
||||
s = 0
|
||||
] = value;
|
||||
if (y == null || M == null || d == null) {
|
||||
return null;
|
||||
}
|
||||
return new Date(y, M - 1, d, H, m, s);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an open alert can be disposed of. The backend's whitelist/escalate set state
|
||||
* unconditionally, so nothing there stops an already-escalated alert being escalated again and
|
||||
* opening a second STR case — this gate is what prevents it.
|
||||
*/
|
||||
canReview(alert: any): boolean {
|
||||
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
|
||||
}
|
||||
|
||||
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const dialogRef = this.dialog.open(AmlReviewAlertDialogComponent, {
|
||||
width: '620px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alert, disposition }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: 'WHITELIST' | 'ESCALATE' | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: result === 'WHITELIST' ? 'Alert Whitelisted' : 'Alert Escalated',
|
||||
message:
|
||||
result === 'WHITELIST'
|
||||
? `${alert.transactionType} #${alert.transactionId} cleared with no report.`
|
||||
: `${alert.transactionType} #${alert.transactionId} escalated — an STR case is now open.`
|
||||
});
|
||||
// The status changed server side, and escalation also re-rates the client, so re-read
|
||||
// rather than patching the row locally.
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="review-dialog-title" [class.escalate]="!isWhitelist">
|
||||
<mat-icon>{{ isWhitelist ? 'verified' : 'report' }}</mat-icon>
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="review-dialog-content">
|
||||
<!-- Restate exactly which transaction is being disposed of -->
|
||||
<div class="review-summary">
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Transaction</span>
|
||||
<span class="review-value">
|
||||
{{ data.alert.transactionType }} <span class="review-mono">#{{ data.alert.transactionId }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Client</span>
|
||||
<span class="review-value">
|
||||
{{ data.alert.clientDisplayName || '—' }}
|
||||
<span *ngIf="data.alert.clientId" class="review-muted">(ID {{ data.alert.clientId }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Amount</span>
|
||||
<span class="review-value review-mono">{{ amount() }}</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Rule</span>
|
||||
<span class="review-value">
|
||||
{{ data.alert.ruleCode }}
|
||||
<span class="review-muted">— {{ data.alert.ruleDescription }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Raised On</span>
|
||||
<span class="review-value">{{ data.alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm:ss' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Escalation is not a reversible bookkeeping change; say what it sets in motion -->
|
||||
<div *ngIf="!isWhitelist" class="review-warning">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>
|
||||
This opens an STR case for the Principal Officer and re-rates the client's risk category. The case carries a
|
||||
7-working-day FIU filing deadline.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isWhitelist" class="review-note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>The alert is closed with no report filed. The reason below is retained as the audit trail.</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="errorMessage" class="review-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="reviewForm" autocomplete="off">
|
||||
<!-- Evidence classification only applies to clearing an alert -->
|
||||
<ng-container *ngIf="isWhitelist">
|
||||
<div class="evidence-block" [class.evidence-block-invalid]="evidenceMissing">
|
||||
<span class="evidence-legend">Evidence relied on — tick all that apply</span>
|
||||
<div formArrayName="evidence" class="evidence-options">
|
||||
<mat-checkbox *ngFor="let t of evidenceTypes; let i = index" [formControlName]="i" class="evidence-option">
|
||||
{{ t }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<span *ngIf="evidenceMissing" class="evidence-error">Tick at least one form of evidence</span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="review-notes-field">
|
||||
<mat-label>Document / Reference No.</mat-label>
|
||||
<input matInput formControlName="evidenceRef" placeholder="e.g. Invoice INV-4471, Sale deed 12/2026" />
|
||||
<mat-hint>Optional — identifies the documents held on file</mat-hint>
|
||||
</mat-form-field>
|
||||
</ng-container>
|
||||
|
||||
<mat-form-field appearance="outline" class="review-notes-field">
|
||||
<mat-label>{{ notesLabel }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="4"
|
||||
formControlName="notes"
|
||||
[placeholder]="
|
||||
isWhitelist
|
||||
? 'What the evidence shows and how it accounts for this transaction'
|
||||
: 'The pattern or behaviour that makes this transaction suspicious'
|
||||
"
|
||||
></textarea>
|
||||
<mat-error *ngIf="reviewForm.get('notes')?.hasError('required')">
|
||||
Required — this is the audit record for the decision
|
||||
</mat-error>
|
||||
<mat-error *ngIf="reviewForm.get('notes')?.hasError('maxlength')">
|
||||
Keep this under {{ detailMax }} characters
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- No guessing about what is retained: show the exact string that will be stored. -->
|
||||
<div *ngIf="composedNotes" class="review-stored">
|
||||
<span class="review-stored-label">Stored on the alert record</span>
|
||||
<code class="review-stored-value">{{ composedNotes }}</code>
|
||||
<span class="review-stored-hint">
|
||||
Saved to <code>m_aml_alert.review_notes</code> against alert #{{ data.alert.id }}, and shown in the NOTES column
|
||||
of the FIU‑IND STR register.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="composedTooLong" class="review-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>Too long to store — untick some evidence, or shorten the detail or reference.</span>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="review-dialog-actions">
|
||||
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[color]="isWhitelist ? 'primary' : 'warn'"
|
||||
[disabled]="reviewForm.invalid || composedTooLong || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
<mat-spinner *ngIf="submitting" diameter="18" class="review-spinner"></mat-spinner>
|
||||
{{ submitting ? 'Saving…' : confirmLabel }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.review-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
|
||||
&.escalate {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.review-dialog-content {
|
||||
padding-top: 8px;
|
||||
min-width: 520px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.review-summary {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.review-summary-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.review-label {
|
||||
flex: 0 0 96px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.review-value {
|
||||
flex: 1 1 auto;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.review-muted {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.review-mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.review-warning,
|
||||
.review-note,
|
||||
.review-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-warning {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.review-note {
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.review-error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Multi-select evidence. Bordered so the group reads as one required field rather than a
|
||||
loose row of checkboxes. */
|
||||
.evidence-block {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.evidence-block-invalid {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-legend {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.evidence-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px 12px;
|
||||
}
|
||||
|
||||
.evidence-option {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.evidence-error {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.review-notes-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Shows the officer the exact string the review will persist. */
|
||||
.review-stored {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.review-stored-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.review-stored-value {
|
||||
font-size: 12.5px;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.review-stored-hint {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
|
||||
code {
|
||||
background: #e2e8f0;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-dialog-actions {
|
||||
padding: 8px 24px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.review-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.review-dialog-content {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.review-summary-row {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.evidence-options {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
ReactiveFormsModule
|
||||
} from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
|
||||
export interface AmlReviewDialogData {
|
||||
alert: any;
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of one engine alert: clear it (WHITELIST) or raise it as suspicious (ESCALATE).
|
||||
*
|
||||
* The alert being actioned is restated in full here rather than relying on the officer having
|
||||
* read the right table row — the two outcomes are not symmetric. WHITELIST closes the alert with
|
||||
* no report; ESCALATE opens an STR case and re-rates the client's risk, which is not something to
|
||||
* trigger from a stray click on a table. Notes are mandatory because they are the audit trail for
|
||||
* why a flagged transaction was cleared.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-review-alert-dialog',
|
||||
templateUrl: './aml-review-alert-dialog.component.html',
|
||||
styleUrls: ['./aml-review-alert-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class AmlReviewAlertDialogComponent {
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private dialogRef = inject(MatDialogRef<AmlReviewAlertDialogComponent>);
|
||||
readonly data = inject<AmlReviewDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
/**
|
||||
* What supports clearing the alert. The platform has no evidence table — m_aml_alert.review_notes
|
||||
* (VARCHAR(1000)) is the only field the review writes — so the classification is folded into that
|
||||
* one string rather than invented as a column the API cannot persist.
|
||||
*/
|
||||
readonly evidenceTypes = [
|
||||
'Customer explanation obtained',
|
||||
'Source of funds documented',
|
||||
'KYC / identity re-verified',
|
||||
'Consistent with declared income or turnover',
|
||||
'Established business or salary pattern',
|
||||
'Duplicate or system-generated alert',
|
||||
'Other'
|
||||
];
|
||||
|
||||
/** Leaves headroom for the composed prefix inside the 1000-char column. */
|
||||
private static readonly DETAIL_MAX = 800;
|
||||
private static readonly NOTES_COLUMN_MAX = 1000;
|
||||
|
||||
/** One checkbox per evidence type; a clearing decision can rest on several at once. */
|
||||
reviewForm = this.formBuilder.group({
|
||||
evidence: this.formBuilder.array(this.evidenceTypes.map(() => false)),
|
||||
evidenceRef: [
|
||||
'',
|
||||
Validators.maxLength(100)
|
||||
],
|
||||
notes: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(AmlReviewAlertDialogComponent.DETAIL_MAX)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Evidence classification is meaningless on an escalation — that path records grounds for
|
||||
// suspicion, not proof of innocence — so it is only required when clearing an alert.
|
||||
if (this.isWhitelist) {
|
||||
this.evidenceArray.addValidators(AmlReviewAlertDialogComponent.atLeastOneChecked);
|
||||
this.evidenceArray.updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
|
||||
/** FormArray of booleans, so "none ticked" has to be checked across the whole array. */
|
||||
private static atLeastOneChecked(control: AbstractControl): ValidationErrors | null {
|
||||
const boxes = control as FormArray;
|
||||
return boxes.controls.some((c) => c.value === true) ? null : { required: true };
|
||||
}
|
||||
|
||||
get evidenceArray(): FormArray {
|
||||
return this.reviewForm.get('evidence') as FormArray;
|
||||
}
|
||||
|
||||
get selectedEvidence(): string[] {
|
||||
return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
|
||||
}
|
||||
|
||||
get evidenceMissing(): boolean {
|
||||
return this.isWhitelist && this.evidenceArray.invalid && this.evidenceArray.touched;
|
||||
}
|
||||
|
||||
get isWhitelist(): boolean {
|
||||
return this.data.disposition === 'WHITELIST';
|
||||
}
|
||||
|
||||
get detailMax(): number {
|
||||
return AmlReviewAlertDialogComponent.DETAIL_MAX;
|
||||
}
|
||||
|
||||
/** Exactly what will be written to the alert record, shown before the officer commits. */
|
||||
get composedNotes(): string {
|
||||
const v = this.reviewForm.value;
|
||||
const detail = (v.notes || '').trim();
|
||||
if (!this.isWhitelist) {
|
||||
return detail ? `Grounds: ${detail}` : '';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
const selected = this.selectedEvidence;
|
||||
if (selected.length) {
|
||||
parts.push(`Evidence: ${selected.join('; ')}`);
|
||||
}
|
||||
if (v.evidenceRef) {
|
||||
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
|
||||
}
|
||||
const head = parts.join(' | ');
|
||||
return head && detail ? `${head} — ${detail}` : head || detail;
|
||||
}
|
||||
|
||||
get composedTooLong(): boolean {
|
||||
return this.composedNotes.length > AmlReviewAlertDialogComponent.NOTES_COLUMN_MAX;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
|
||||
}
|
||||
|
||||
get confirmLabel(): string {
|
||||
return this.isWhitelist ? 'Whitelist' : 'Escalate to STR';
|
||||
}
|
||||
|
||||
get notesLabel(): string {
|
||||
return this.isWhitelist ? 'Evidence detail' : 'What makes this transaction suspicious?';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.reviewForm.invalid || this.composedTooLong) {
|
||||
this.reviewForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(this.data.disposition);
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = this.readError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
amount(): string {
|
||||
const value = Number(this.data.alert?.amount) || 0;
|
||||
return '₹' + value.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
private readError(err: any): string {
|
||||
const body = err?.error;
|
||||
const first = body?.errors?.[0];
|
||||
return (
|
||||
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not record the review.'
|
||||
);
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="ra-intro">
|
||||
<mat-icon class="ra-intro-icon">policy</mat-icon>
|
||||
<span>
|
||||
Customers the engine has flagged more than once. One alert is a question about a transaction — a pattern is a
|
||||
question about the customer, which is what gets reviewed and recorded here.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- What counts as "regularly" is a policy call, so it is set on screen rather than baked in -->
|
||||
<div class="ra-controls">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ra-control">
|
||||
<mat-label>Flag after</mat-label>
|
||||
<mat-select [(ngModel)]="minAlerts" (ngModelChange)="load()">
|
||||
<mat-option *ngFor="let t of thresholdOptions" [value]="t">{{ t }}+ alerts</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ra-control">
|
||||
<mat-label>Within</mat-label>
|
||||
<mat-select [(ngModel)]="windowDays" (ngModelChange)="load()">
|
||||
<mat-option *ngFor="let w of windowOptions" [value]="w.value">{{ w.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox [(ngModel)]="onlyNeedingReview" (ngModelChange)="load()" class="ra-check">
|
||||
Only awaiting review
|
||||
</mat-checkbox>
|
||||
|
||||
<span class="ra-spacer"></span>
|
||||
|
||||
<span *ngIf="needingReviewCount > 0" class="ra-pending-pill">
|
||||
<mat-icon>pending_actions</mat-icon>
|
||||
{{ needingReviewCount }} awaiting review
|
||||
</span>
|
||||
<mat-spinner *ngIf="loading" diameter="18"></mat-spinner>
|
||||
<button mat-icon-button type="button" matTooltip="Refresh" [disabled]="loading" (click)="load()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loadFailed" class="ra-error">
|
||||
<mat-icon>cloud_off</mat-icon>
|
||||
<span>Could not load the review queue.</span>
|
||||
</div>
|
||||
|
||||
<!-- Named on screen, because sample rows must never be mistaken for engine output on an AML queue -->
|
||||
<div *ngIf="usingDemoData && rows.data.length > 0" class="ra-demo">
|
||||
<mat-icon>science</mat-icon>
|
||||
<span>
|
||||
Showing <strong>sample data</strong> — the AML engine returned no recurring alerts for this threshold and window.
|
||||
These rows are for demonstration only and are not reportable.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="rows" class="ra-table mat-elevation-z1" multiTemplateDataRows>
|
||||
<ng-container matColumnDef="customer">
|
||||
<th mat-header-cell *matHeaderCellDef>Customer</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="ra-stacked">
|
||||
<span class="ra-name">{{ row.clientDisplayName }}</span>
|
||||
<span class="ra-sub">
|
||||
ID {{ row.clientId }}<span *ngIf="row.accountNo"> · {{ row.accountNo }}</span> · KYC risk
|
||||
<span [ngClass]="riskClass(row.clientRiskCategory)">{{ riskLabel(row.clientRiskCategory) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="alertCount">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Alerts</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-center">
|
||||
<span class="ra-count">{{ row.alertCount }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="severity">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Severity</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-center">
|
||||
<div class="ra-stacked ra-stacked-center">
|
||||
<span class="ra-high" [class.ra-high-zero]="row.highRiskCount === 0">{{ row.highRiskCount }} High</span>
|
||||
<span class="ra-sub">{{ row.openCount }} open · {{ row.escalatedCount }} escalated</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="rules">
|
||||
<th mat-header-cell *matHeaderCellDef>Rules Tripped</th>
|
||||
<td mat-cell *matCellDef="let row" class="ra-rules-cell">
|
||||
<div class="ra-stacked">
|
||||
<span class="ra-name">{{ row.distinctRules }} distinct</span>
|
||||
<span class="ra-sub" [matTooltip]="row.ruleCodes?.join(', ')">{{ row.ruleCodes?.join(', ') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="totalAmount">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Total Value</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-right ra-amount">{{ amount(row.totalAmount) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="lastAlert">
|
||||
<th mat-header-cell *matHeaderCellDef>Activity</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="ra-stacked">
|
||||
<span>{{ row.lastAlertOnDate ? (row.lastAlertOnDate | date: 'dd MMM yyyy') : '—' }}</span>
|
||||
<span class="ra-sub">
|
||||
first {{ row.firstAlertOnDate ? (row.firstAlertOnDate | date: 'dd MMM yyyy') : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="reviewState">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Review</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-center">
|
||||
<span *ngIf="row.needsReview && !row.lastReviewedOn" class="ra-state ra-state-new">Never reviewed</span>
|
||||
<span
|
||||
*ngIf="row.needsReview && row.lastReviewedOn"
|
||||
class="ra-state ra-state-again"
|
||||
[matTooltip]="
|
||||
'Reviewed at ' + row.alertCountAtLastReview + ' alert(s); ' + row.alertCount + ' now — new activity since.'
|
||||
"
|
||||
>
|
||||
New activity
|
||||
</span>
|
||||
<div *ngIf="!row.needsReview" class="ra-stacked ra-stacked-center">
|
||||
<span class="ra-state ra-state-done">{{ row.lastReviewOutcome }}</span>
|
||||
<span class="ra-sub" [matTooltip]="row.lastReviewNotes">
|
||||
{{ row.lastReviewedByUsername }} · {{ row.lastReviewedOnDate | date: 'dd MMM' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-center ra-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ra-btn ra-btn-detail"
|
||||
[matTooltip]="isExpanded(row) ? 'Hide transactions' : 'Show the triggering transactions'"
|
||||
(click)="toggleExpanded(row)"
|
||||
>
|
||||
<mat-icon>{{ isExpanded(row) ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ row.alertCount }} txns
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ra-btn ra-btn-review"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
matTooltip="Record why this customer keeps surfacing"
|
||||
(click)="review(row)"
|
||||
>
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
Review
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Inline detail: the transactions behind the count, so the reviewer never has to guess -->
|
||||
<ng-container matColumnDef="detail">
|
||||
<td mat-cell *matCellDef="let row" [attr.colspan]="displayedColumns.length" class="ra-detail-cell">
|
||||
<div class="ra-detail" *ngIf="isExpanded(row)">
|
||||
<table class="ra-detail-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raised On</th>
|
||||
<th>Transaction</th>
|
||||
<th>Rule</th>
|
||||
<th class="col-right">Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Review Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let a of row.alerts">
|
||||
<td>{{ a.raisedOnDate ? (a.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—' }}</td>
|
||||
<td>{{ a.transactionType }} #{{ a.transactionId }}</td>
|
||||
<td>
|
||||
{{ a.ruleCode }}
|
||||
<span class="ra-sub">{{ a.ruleDescription }}</span>
|
||||
</td>
|
||||
<td class="col-right ra-amount">{{ amount(a.amount) }}</td>
|
||||
<td>{{ a.status }}</td>
|
||||
<td class="ra-sub">{{ a.reviewNotes || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="ra-row"
|
||||
[class.ra-row-pending]="row.needsReview"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['detail']" class="ra-detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<div *ngIf="rows.data.length === 0 && !loading" class="ra-empty">
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
<span>
|
||||
No customer has reached {{ minAlerts }} alerts in the selected window. Lower the threshold or widen the window to
|
||||
see borderline cases.
|
||||
</span>
|
||||
</div>
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.ra-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ra-intro-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.ra-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.ra-control {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.ra-check {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ra-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ra-pending-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #fdba74;
|
||||
border-radius: 999px;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
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;
|
||||
border-spacing: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
th {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
padding: 14px 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* Anything still owed a decision is tinted, so the queue reads at a glance. */
|
||||
.ra-row-pending {
|
||||
background: #fffbeb;
|
||||
|
||||
&:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
.col-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ra-stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ra-stacked-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ra-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ra-sub {
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ra-rules-cell {
|
||||
max-width: 260px;
|
||||
|
||||
.ra-sub {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-count {
|
||||
display: inline-block;
|
||||
min-width: 30px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: #1a237e;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ra-high {
|
||||
font-weight: 700;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
|
||||
&.ra-high-zero {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-amount {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ra-risk {
|
||||
font-weight: 700;
|
||||
|
||||
&.ra-risk-low {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.ra-risk-medium {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.ra-risk-high {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.ra-risk-none {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-state {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
|
||||
&.ra-state-new {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.ra-state-again {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.ra-state-done {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ra-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 11px;
|
||||
margin: 2px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-btn-detail {
|
||||
border-color: #cbd5e1;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
|
||||
&:hover {
|
||||
border-color: #1a237e;
|
||||
color: #1a237e;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-btn-review {
|
||||
border-color: #c7d2fe;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
|
||||
&:hover {
|
||||
background: #e0e7ff;
|
||||
border-color: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
/* Detail row is always rendered by multiTemplateDataRows; the padding collapses when empty so a
|
||||
collapsed row leaves no gap. */
|
||||
.ra-detail-cell {
|
||||
padding: 0 !important;
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ra-detail {
|
||||
padding: 12px 16px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.ra-detail-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
th {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
color: #1e293b;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
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.
|
||||
*
|
||||
* A single alert is a transaction question and is handled on the Flagged Transactions tab. This tab
|
||||
* asks a different question — is this *customer's* pattern of behaviour acceptable — which no single
|
||||
* alert can answer. Reviews are recorded against the alert count seen, so a customer leaves the
|
||||
* queue once reviewed and only returns when the engine flags them again.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-recurring-alerts',
|
||||
templateUrl: './aml-recurring-alerts.component.html',
|
||||
styleUrls: ['./aml-recurring-alerts.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlRecurringAlertsComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
|
||||
rows = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'customer',
|
||||
'alertCount',
|
||||
'severity',
|
||||
'rules',
|
||||
'totalAmount',
|
||||
'lastAlert',
|
||||
'reviewState',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/**
|
||||
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD, which already treats 3 cumulative
|
||||
* AML events as the point a customer's risk rating is reconsidered.
|
||||
*/
|
||||
minAlerts = 3;
|
||||
windowDays = 90;
|
||||
onlyNeedingReview = false;
|
||||
|
||||
readonly thresholdOptions = [
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
10
|
||||
];
|
||||
readonly windowOptions = [
|
||||
{ value: 30, label: 'Last 30 days' },
|
||||
{ value: 90, label: 'Last 90 days' },
|
||||
{ value: 180, label: 'Last 6 months' },
|
||||
{ value: 365, label: 'Last 12 months' }
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadFailed = false;
|
||||
this.expandedClientId = null;
|
||||
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);
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.loadFailed = true;
|
||||
this.usingDemoData = true;
|
||||
this.rows.data = this.mapRows(this.demoRows());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Parses the date fields the template renders. Shared by engine rows and the sample set. */
|
||||
private mapRows(rows: any[]): any[] {
|
||||
return rows.map((r: any) => ({
|
||||
...r,
|
||||
firstAlertOnDate: this.toDate(r.firstAlertOn),
|
||||
lastAlertOnDate: this.toDate(r.lastAlertOn),
|
||||
lastReviewedOnDate: this.toDate(r.lastReviewedOn),
|
||||
alerts: (r.alerts || []).map((a: any) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
isExpanded(row: any): boolean {
|
||||
return this.expandedClientId === row.clientId;
|
||||
}
|
||||
|
||||
get needingReviewCount(): number {
|
||||
return this.rows.data.filter((r) => r.needsReview).length;
|
||||
}
|
||||
|
||||
review(row: any): void {
|
||||
const dialogRef = this.dialog.open(AmlRecurringReviewDialogComponent, {
|
||||
width: '780px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { row, windowDays: this.windowDays }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((outcome: string | undefined) => {
|
||||
if (!outcome) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: 'Review Recorded',
|
||||
message: `${row.clientDisplayName} reviewed as ${outcome}.`
|
||||
});
|
||||
// Re-read: the review changes needsReview, and the count recorded server-side is what the
|
||||
// row must now reflect.
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
amount(value: any): string {
|
||||
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
riskLabel(category: any): string {
|
||||
switch (Number(category)) {
|
||||
case 1:
|
||||
return 'Low';
|
||||
case 2:
|
||||
return 'Medium';
|
||||
case 3:
|
||||
return 'High';
|
||||
default:
|
||||
return 'Unrated';
|
||||
}
|
||||
}
|
||||
|
||||
riskClass(category: any): string {
|
||||
switch (Number(category)) {
|
||||
case 1:
|
||||
return 'ra-risk ra-risk-low';
|
||||
case 2:
|
||||
return 'ra-risk ra-risk-medium';
|
||||
case 3:
|
||||
return 'ra-risk ra-risk-high';
|
||||
default:
|
||||
return 'ra-risk ra-risk-none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fineract's Gson adapter emits LocalDateTime as [y, M, d, H, m, s] rather than an ISO string, so
|
||||
* the date pipe renders nothing unless it is converted first. Month is 1-based there, 0-based in Date.
|
||||
*/
|
||||
private toDate(value: any): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const [
|
||||
y,
|
||||
M,
|
||||
d,
|
||||
H = 0,
|
||||
m = 0,
|
||||
s = 0
|
||||
] = value;
|
||||
if (y == null || M == null || d == null) {
|
||||
return null;
|
||||
}
|
||||
return new Date(y, M - 1, d, H, m, s);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="rr-dialog-title">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
Review Repeat Activity — {{ data.row.clientDisplayName }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="rr-dialog-content">
|
||||
<div class="rr-summary">
|
||||
<div class="rr-summary-row">
|
||||
<span class="rr-label">Customer</span>
|
||||
<span class="rr-value">
|
||||
{{ data.row.clientDisplayName }}
|
||||
<span class="rr-muted"
|
||||
>(ID {{ data.row.clientId }}{{ data.row.accountNo ? ' · ' + data.row.accountNo : '' }})</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rr-summary-row">
|
||||
<span class="rr-label">Alerts</span>
|
||||
<span class="rr-value">
|
||||
<strong>{{ data.row.alertCount }}</strong> in the last {{ data.windowDays }} days ·
|
||||
{{ data.row.highRiskCount }} High risk · {{ data.row.distinctRules }} distinct rule(s)
|
||||
</span>
|
||||
</div>
|
||||
<div class="rr-summary-row">
|
||||
<span class="rr-label">Value</span>
|
||||
<span class="rr-value rr-mono">{{ amount(data.row.totalAmount) }}</span>
|
||||
</div>
|
||||
<div class="rr-summary-row">
|
||||
<span class="rr-label">Rules</span>
|
||||
<span class="rr-value">{{ data.row.ruleCodes?.join(', ') || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The triggering transactions, so the decision is made against the evidence -->
|
||||
<div class="rr-alerts">
|
||||
<span class="rr-alerts-legend">Triggering transactions</span>
|
||||
<div class="rr-alerts-scroll">
|
||||
<table class="rr-alerts-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raised On</th>
|
||||
<th>Transaction</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let a of data.row.alerts">
|
||||
<td>{{ a.raisedOnDate | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||
<td>{{ a.transactionType }} #{{ a.transactionId }}</td>
|
||||
<td>{{ a.ruleCode }}</td>
|
||||
<td class="ta-r rr-mono">{{ amount(a.amount) }}</td>
|
||||
<td>{{ a.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="data.row.lastReviewedOn" class="rr-prior">
|
||||
<mat-icon>history</mat-icon>
|
||||
<span>
|
||||
Last reviewed by <strong>{{ data.row.lastReviewedByUsername }}</strong> on
|
||||
{{ data.row.lastReviewedOnDate | date: 'dd MMM yyyy' }} at {{ data.row.alertCountAtLastReview }} alert(s) —
|
||||
{{ data.row.lastReviewOutcome }}. {{ data.row.lastReviewNotes }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="errorMessage" class="rr-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="reviewForm" autocomplete="off">
|
||||
<div class="rr-outcome">
|
||||
<span class="rr-outcome-legend">Conclusion</span>
|
||||
<mat-radio-group formControlName="outcome" class="rr-outcome-options">
|
||||
<mat-radio-button *ngFor="let o of outcomes" [value]="o.value" class="rr-outcome-option">
|
||||
{{ o.label }}
|
||||
<span class="rr-muted">— {{ o.hint }}</span>
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="rr-notes-field">
|
||||
<mat-label>Why does this customer keep surfacing?</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="4"
|
||||
formControlName="notes"
|
||||
placeholder="What the pattern is, what was checked, and what was decided"
|
||||
></textarea>
|
||||
<mat-error *ngIf="reviewForm.get('notes')?.hasError('required')">
|
||||
Required — this is the audit record for the decision
|
||||
</mat-error>
|
||||
<mat-error *ngIf="reviewForm.get('notes')?.hasError('maxlength')">Keep this under 1000 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<p class="rr-note">
|
||||
Recorded against the current count of {{ data.row.alertCount }} alert(s). The customer returns to this queue if
|
||||
the engine flags them again.
|
||||
</p>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="rr-dialog-actions">
|
||||
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="reviewForm.invalid || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
<mat-spinner *ngIf="submitting" diameter="18" class="rr-spinner"></mat-spinner>
|
||||
{{ submitting ? 'Saving…' : 'Record Review' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.rr-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1a237e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rr-dialog-content {
|
||||
padding-top: 8px;
|
||||
min-width: 620px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.rr-summary {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rr-summary-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.rr-label {
|
||||
flex: 0 0 84px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rr-value {
|
||||
flex: 1 1 auto;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.rr-muted {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rr-mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rr-alerts {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rr-alerts-legend,
|
||||
.rr-outcome-legend {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Capped so a customer with twenty alerts cannot push the buttons off screen. */
|
||||
.rr-alerts-scroll {
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rr-alerts-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rr-prior,
|
||||
.rr-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-prior {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.rr-error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rr-outcome {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rr-outcome-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-outcome-option {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rr-notes-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rr-note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rr-dialog-actions {
|
||||
padding: 8px 24px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (width <= 700px) {
|
||||
.rr-dialog-content {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.rr-summary-row {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
|
||||
export interface AmlRecurringReviewDialogData {
|
||||
row: any;
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records why a customer keeps tripping the AML engine.
|
||||
*
|
||||
* This is a judgement on the customer's pattern, not on any single transaction — the individual
|
||||
* alerts are still disposed of one by one on the Flagged Transactions tab. Choosing ESCALATED here
|
||||
* documents the reviewer's conclusion; it does not itself open an STR case, because a case is
|
||||
* opened against a specific alert.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-recurring-review-dialog',
|
||||
templateUrl: './aml-recurring-review-dialog.component.html',
|
||||
styleUrls: ['./aml-recurring-review-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatRadioModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class AmlRecurringReviewDialogComponent {
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private dialogRef = inject(MatDialogRef<AmlRecurringReviewDialogComponent>);
|
||||
readonly data = inject<AmlRecurringReviewDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
readonly outcomes = [
|
||||
{ value: 'CLEARED', label: 'Cleared', hint: 'Behaviour explained and acceptable' },
|
||||
{ value: 'MONITOR', label: 'Keep monitoring', hint: 'Not conclusive — watch this customer more closely' },
|
||||
{ value: 'ESCALATED', label: 'Escalate', hint: 'Warrants an STR case; escalate the alerts individually' }
|
||||
];
|
||||
|
||||
reviewForm = this.formBuilder.group({
|
||||
outcome: [
|
||||
'MONITOR',
|
||||
Validators.required
|
||||
],
|
||||
notes: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(1000)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
if (this.reviewForm.invalid) {
|
||||
this.reviewForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
this.amlService
|
||||
.reviewRecurringAlert(
|
||||
this.data.row.clientId,
|
||||
this.reviewForm.value.outcome as 'CLEARED' | 'MONITOR' | 'ESCALATED',
|
||||
(this.reviewForm.value.notes || '').trim(),
|
||||
this.data.windowDays
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(this.reviewForm.value.outcome);
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = this.readError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
amount(value: any): string {
|
||||
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
private readError(err: any): string {
|
||||
const body = err?.error;
|
||||
const first = body?.errors?.[0];
|
||||
return (
|
||||
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not record the review.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,119 +18,192 @@
|
||||
</mat-card-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-flat-button type="button" class="add-rule-button" (click)="addRule()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Rule
|
||||
</button>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content class="aml-card-content">
|
||||
<!-- Separate the rules by the window they are measured over -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Applies over</span>
|
||||
<button
|
||||
*ngFor="let p of periodFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activePeriod === p.key"
|
||||
[disabled]="periodCount(p.key) === 0"
|
||||
(click)="setPeriod(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<mat-tab-group class="aml-tabs" animationDuration="0ms">
|
||||
<mat-tab label="Rules Master">
|
||||
<div class="aml-tab-body">
|
||||
<!-- Free-text search across every column the table renders -->
|
||||
<div class="aml-search-bar">
|
||||
<mat-form-field appearance="outline" class="aml-search-field" subscriptSizing="dynamic">
|
||||
<mat-label>Search rules</mat-label>
|
||||
<mat-icon matPrefix class="aml-search-icon">search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Rule, reason, risk, limit, window, status…"
|
||||
[ngModel]="searchTerm"
|
||||
(ngModelChange)="applySearch($event)"
|
||||
/>
|
||||
<button
|
||||
*ngIf="searchTerm"
|
||||
matSuffix
|
||||
mat-icon-button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
matTooltip="Clear search"
|
||||
(click)="clearSearch()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<span *ngIf="isSearching" class="aml-search-count">
|
||||
Matching <strong>{{ matchedRules }}</strong> of {{ totalRules }} rules
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
|
||||
<!-- Order Position Column -->
|
||||
<ng-container matColumnDef="orderPosition">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Rule Srno</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">{{ element.orderPosition }}</td>
|
||||
</ng-container>
|
||||
<!-- Separate the rules by the window they are measured over -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Applies over</span>
|
||||
<button
|
||||
*ngFor="let p of periodFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activePeriod === p.key"
|
||||
[disabled]="periodCount(p.key) === 0 && activePeriod !== p.key"
|
||||
(click)="setPeriod(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rule Name Column -->
|
||||
<ng-container matColumnDef="ruleName">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
|
||||
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
|
||||
</ng-container>
|
||||
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
|
||||
<!-- Order Position Column -->
|
||||
<ng-container matColumnDef="orderPosition">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Rule Srno</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">{{ element.orderPosition }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Risk Category Column -->
|
||||
<ng-container matColumnDef="riskCategory">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Risk Category</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span class="risk-badge" [ngClass]="getRiskColorClass(element.riskCategory)">
|
||||
<span class="dot"></span>
|
||||
{{ getRiskCategoryLabel(element.riskCategory) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<!-- Rule Name Column -->
|
||||
<ng-container matColumnDef="ruleName">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
|
||||
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- What the min/max figures are measured against -->
|
||||
<ng-container matColumnDef="limitBasis">
|
||||
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
|
||||
<td mat-cell *matCellDef="let element" class="basis-cell">
|
||||
{{ getLimitBasis(element) }}
|
||||
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
|
||||
{{ getNonNumericCriterion(element) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<!-- Risk Category Column -->
|
||||
<ng-container matColumnDef="riskCategory">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Risk Category</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span class="risk-badge" [ngClass]="getRiskColorClass(element.riskCategory)">
|
||||
<span class="dot"></span>
|
||||
{{ getRiskCategoryLabel(element.riskCategory) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Minimum Limit Column -->
|
||||
<ng-container matColumnDef="minLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
|
||||
</ng-container>
|
||||
<!-- What the min/max figures are measured against -->
|
||||
<ng-container matColumnDef="limitBasis">
|
||||
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
|
||||
<td mat-cell *matCellDef="let element" class="basis-cell">
|
||||
{{ getLimitBasis(element) }}
|
||||
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
|
||||
{{ getNonNumericCriterion(element) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Maximum Limit Column -->
|
||||
<ng-container matColumnDef="maxLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
|
||||
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<!-- Minimum Limit Column -->
|
||||
<ng-container matColumnDef="minLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Observation window the rule is measured over -->
|
||||
<ng-container matColumnDef="appliesOver">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<!-- Maximum Limit Column -->
|
||||
<ng-container matColumnDef="maxLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
|
||||
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Reason Column -->
|
||||
<ng-container matColumnDef="reason">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
|
||||
<td mat-cell *matCellDef="let element" class="reason-cell">{{ element.reason }}</td>
|
||||
</ng-container>
|
||||
<!-- Observation window the rule is measured over -->
|
||||
<ng-container matColumnDef="appliesOver">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- AML Check Status Column -->
|
||||
<ng-container matColumnDef="amlCheckStatus">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">AML Check Status</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span class="compliance-badge" [ngClass]="'status-' + (element.amlCheckStatus || 'clear').toLowerCase()">
|
||||
{{ element.amlCheckStatus || 'CLEAR' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<!-- Reason Column -->
|
||||
<ng-container matColumnDef="reason">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
|
||||
<td mat-cell *matCellDef="let element" class="reason-cell">{{ element.reason }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Banned Entity Match Column -->
|
||||
<ng-container matColumnDef="bannedEntityMatch">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Banned Match</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span class="compliance-badge" [ngClass]="'match-' + (element.bannedEntityMatch || 'no').toLowerCase()">
|
||||
{{ element.bannedEntityMatch || 'NO' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<!-- AML Check Status Column -->
|
||||
<ng-container matColumnDef="amlCheckStatus">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">AML Check Status</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span
|
||||
class="compliance-badge"
|
||||
[ngClass]="'status-' + (element.amlCheckStatus || 'clear').toLowerCase()"
|
||||
>
|
||||
{{ element.amlCheckStatus || 'CLEAR' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||
</table>
|
||||
<!-- Banned Entity Match Column -->
|
||||
<ng-container matColumnDef="bannedEntityMatch">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Banned Match</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span
|
||||
class="compliance-badge"
|
||||
[ngClass]="'match-' + (element.bannedEntityMatch || 'no').toLowerCase()"
|
||||
>
|
||||
{{ element.bannedEntityMatch || 'NO' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Mat Paginator for standard pagination -->
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="aml-paginator"></mat-paginator>
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||
</table>
|
||||
|
||||
<div *ngIf="rules.data.length === 0" class="no-rules-message">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>No AML rules found in the database.</span>
|
||||
</div>
|
||||
<!-- Mat Paginator for standard pagination -->
|
||||
<mat-paginator
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
showFirstLastButtons
|
||||
class="aml-paginator"
|
||||
></mat-paginator>
|
||||
|
||||
<div *ngIf="rules.filteredData.length === 0" class="no-rules-message">
|
||||
<mat-icon>{{ isSearching ? 'search_off' : 'info_outline' }}</mat-icon>
|
||||
<span *ngIf="!isSearching">No AML rules found in the database.</span>
|
||||
<ng-container *ngIf="isSearching">
|
||||
<span>No rules match “{{ searchTerm }}”.</span>
|
||||
<button mat-stroked-button type="button" (click)="clearSearch()">Clear search</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Live feed of what the engine has actually flagged. Lazily instantiated via
|
||||
matTabContent so the poll only runs while the officer is watching this tab. -->
|
||||
<mat-tab label="Flagged Transactions">
|
||||
<ng-template matTabContent>
|
||||
<div class="aml-tab-body">
|
||||
<mifosx-aml-flagged-transactions></mifosx-aml-flagged-transactions>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Customers who keep tripping the engine, queued for an employee to review why. -->
|
||||
<mat-tab label="Repeat Customers">
|
||||
<ng-template matTabContent>
|
||||
<div class="aml-tab-body">
|
||||
<mifosx-aml-recurring-alerts></mifosx-aml-recurring-alerts>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,28 @@
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
color: #fff;
|
||||
padding: 24px;
|
||||
|
||||
/* Title on the left, the add action pinned to the right edge of the banner. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-rule-button {
|
||||
flex: 0 0 auto;
|
||||
background: #38bdf8;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #0ea5e9;
|
||||
}
|
||||
}
|
||||
|
||||
.aml-header-content {
|
||||
@@ -58,6 +80,18 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Rules Master / Flagged Transactions. Padding lives on the body rather than the group so the
|
||||
tab header rule still spans the full card width. */
|
||||
.aml-tabs {
|
||||
::ng-deep .mat-mdc-tab-body-wrapper {
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.aml-tab-body {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.aml-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
@@ -187,6 +221,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Free-text search over the rule list. */
|
||||
.aml-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
|
||||
.aml-search-field {
|
||||
flex: 1 1 340px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.aml-search-icon {
|
||||
color: #64748b;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.aml-search-count {
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #1a237e;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chips that separate the rule list by window. */
|
||||
.period-filters {
|
||||
display: flex;
|
||||
|
||||
@@ -14,10 +14,20 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
import { AlertService } from '../../core/alert/alert.service';
|
||||
import { AddAmlRuleDialogComponent } from './add-aml-rule-dialog/add-aml-rule-dialog.component';
|
||||
import { AmlFlaggedTransactionsComponent } from './aml-flagged-transactions/aml-flagged-transactions.component';
|
||||
import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurring-alerts.component';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-aml-rules',
|
||||
@@ -31,11 +41,21 @@ import { ClientsService } from '../../clients/clients.service';
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatPaginatorModule
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatTooltipModule,
|
||||
MatDialogModule,
|
||||
MatTabsModule,
|
||||
FormsModule,
|
||||
AmlFlaggedTransactionsComponent,
|
||||
AmlRecurringAlertsComponent
|
||||
]
|
||||
})
|
||||
export class AmlRulesComponent implements OnInit {
|
||||
private clientsService = inject(ClientsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
|
||||
rules = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
@@ -65,9 +85,27 @@ export class AmlRulesComponent implements OnInit {
|
||||
];
|
||||
activePeriod = '';
|
||||
|
||||
/** Free-text search, applied on top of the period filter. */
|
||||
searchTerm = '';
|
||||
|
||||
/**
|
||||
* Rule count per period bucket, keyed by period. Precomputed on every filter change
|
||||
* rather than counted from the template — the chips are re-read on each change
|
||||
* detection pass and re-scanning every rule six times over is wasted work.
|
||||
*/
|
||||
private periodCounts: Record<string, number> = {};
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
ngOnInit(): void {
|
||||
// The visible table is the rendered text, not the raw row — a reader searching
|
||||
// "high risk" or "monthly" is matching on labels this component derives, so the
|
||||
// predicate has to search the same strings it puts on screen.
|
||||
this.rules.filterPredicate = (rule: any, filter: string) => this.matchesSearch(rule, filter);
|
||||
this.loadRules();
|
||||
}
|
||||
|
||||
private loadRules(): void {
|
||||
this.clientsService.getClientRiskRules().subscribe({
|
||||
next: (data) => {
|
||||
this.allRules = data || [];
|
||||
@@ -78,15 +116,65 @@ export class AmlRulesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
addRule(): void {
|
||||
const dialogRef = this.dialog.open(AddAmlRuleDialogComponent, {
|
||||
width: '760px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
// The officer has typed a full rule by the time they reach the buttons; a stray
|
||||
// backdrop click should not discard it.
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((created: any) => {
|
||||
if (!created) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: 'AML Rule Created',
|
||||
message: `Rule "${created.ruleName}" added to the catalog.`
|
||||
});
|
||||
// Re-fetch rather than splice the new rule in: order_position is assigned server side
|
||||
// and the transaction-rule join decides the Applies Over / limit columns.
|
||||
this.loadRules();
|
||||
});
|
||||
}
|
||||
|
||||
setPeriod(key: string): void {
|
||||
this.activePeriod = key;
|
||||
this.applyPeriodFilter();
|
||||
}
|
||||
|
||||
applySearch(value: string): void {
|
||||
this.searchTerm = value;
|
||||
this.rules.filter = value.trim().toLowerCase();
|
||||
this.recomputePeriodCounts();
|
||||
if (this.rules.paginator) {
|
||||
this.rules.paginator.firstPage();
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.applySearch('');
|
||||
}
|
||||
|
||||
get totalRules(): number {
|
||||
return this.allRules.length;
|
||||
}
|
||||
|
||||
get matchedRules(): number {
|
||||
return this.rules.filteredData.length;
|
||||
}
|
||||
|
||||
get isSearching(): boolean {
|
||||
return !!this.rules.filter;
|
||||
}
|
||||
|
||||
private applyPeriodFilter(): void {
|
||||
this.rules.data = this.activePeriod
|
||||
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
|
||||
: this.allRules;
|
||||
this.recomputePeriodCounts();
|
||||
if (this.rules.paginator) {
|
||||
this.rules.paginator.firstPage();
|
||||
}
|
||||
@@ -94,7 +182,58 @@ export class AmlRulesComponent implements OnInit {
|
||||
|
||||
/** How many rules fall in each period bucket — shown on the filter chips. */
|
||||
periodCount(key: string): number {
|
||||
return key ? this.allRules.filter((r) => this.periodKey(r) === key).length : this.allRules.length;
|
||||
return this.periodCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts are taken over the search results, not the whole set, so a chip never
|
||||
* advertises rules the current search has filtered out.
|
||||
*/
|
||||
private recomputePeriodCounts(): void {
|
||||
const filter = this.rules.filter;
|
||||
const pool = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
|
||||
this.periodCounts = { '': pool.length };
|
||||
for (const p of this.periodFilters) {
|
||||
if (p.key) {
|
||||
this.periodCounts[p.key] = pool.filter((r) => this.periodKey(r) === p.key).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every term must match, so "high monthly" narrows instead of widening. */
|
||||
private matchesSearch(rule: any, filter: string): boolean {
|
||||
const terms = filter.split(/\s+/).filter((t) => t.length > 0);
|
||||
if (terms.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const haystack = this.searchableText(rule);
|
||||
return terms.every((t) => haystack.includes(t));
|
||||
}
|
||||
|
||||
/** Everything the row displays, flattened into one lower-case string. */
|
||||
private searchableText(rule: any): string {
|
||||
const min = this.getMinLimit(rule);
|
||||
const max = this.getMaxLimit(rule);
|
||||
return [
|
||||
rule.orderPosition,
|
||||
rule.ruleName,
|
||||
rule.reason,
|
||||
this.getRiskCategoryLabel(rule.riskCategory),
|
||||
this.getLimitBasis(rule),
|
||||
this.getNonNumericCriterion(rule),
|
||||
min,
|
||||
max,
|
||||
// Amounts are rendered grouped ("₹10,00,000"); also index the bare digits so
|
||||
// typing 1000000 finds them.
|
||||
min.replace(/\D/g, ''),
|
||||
max.replace(/\D/g, ''),
|
||||
this.periodLabel(rule),
|
||||
rule.amlCheckStatus || 'CLEAR',
|
||||
rule.bannedEntityMatch || 'NO'
|
||||
]
|
||||
.filter((v) => v != null && v !== '')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="ed-icon">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
</div>
|
||||
<h2 class="ed-title">We encountered an issue. Please try again.</h2>
|
||||
<h2 class="ed-title">{{ title }}</h2>
|
||||
@if (!showAsCode) {
|
||||
<p class="ed-message">{{ displayData }}</p>
|
||||
}
|
||||
|
||||
@@ -45,10 +45,17 @@ export class ErrorDialogComponent {
|
||||
* @param {DomSanitizer} sanitizer Service to sanitize HTML content.
|
||||
*/
|
||||
constructor() {
|
||||
const data = this.data;
|
||||
|
||||
// Guard for non-string data to avoid runtime error
|
||||
this.showAsCode = typeof data === 'string' && data.startsWith('<pre><code>');
|
||||
this.showAsCode = this.displayData.startsWith('<pre><code>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading for the dialog. Callers passing a plain string (the long-standing contract) keep the
|
||||
* generic wording; callers with a specific failure to name can pass { title, message }.
|
||||
*/
|
||||
get title(): string {
|
||||
const title = typeof this.data === 'object' && this.data !== null ? this.data.title : null;
|
||||
return title || 'We encountered an issue. Please try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +66,9 @@ export class ErrorDialogComponent {
|
||||
if (typeof this.data === 'string') {
|
||||
return this.data;
|
||||
}
|
||||
if (typeof this.data === 'object' && this.data !== null && 'message' in this.data) {
|
||||
return this.data.message ?? '';
|
||||
}
|
||||
// Convert non-string data to string representation for display
|
||||
return this.data != null ? JSON.stringify(this.data) : '';
|
||||
}
|
||||
|
||||
+3
-3
@@ -104,8 +104,8 @@
|
||||
{{ element.displayName }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="clientId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Client ID' | translate }}</th>
|
||||
<ng-container matColumnDef="cifNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.CIF Number' | translate }}</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let element"
|
||||
@@ -113,7 +113,7 @@
|
||||
[routerLink]="['../../clients', element.id]"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{{ element.id }}
|
||||
{{ element.cifNumber }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="staff">
|
||||
|
||||
+57
-12
@@ -33,6 +33,7 @@ import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicke
|
||||
/** Custom Services */
|
||||
import { TasksService } from '../../tasks.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
@@ -74,6 +75,7 @@ export class ClientApprovalComponent implements OnInit {
|
||||
private dateUtils = inject(Dates);
|
||||
private settingsService = inject(SettingsService);
|
||||
private tasksService = inject(TasksService);
|
||||
private alertService = inject(AlertService);
|
||||
|
||||
/** Current page of pending clients (from the server), grouped by office for display */
|
||||
groupedClients: any = {};
|
||||
@@ -89,7 +91,7 @@ export class ClientApprovalComponent implements OnInit {
|
||||
displayedColumns: string[] = [
|
||||
'select',
|
||||
'name',
|
||||
'clientId',
|
||||
'cifNumber',
|
||||
'staff'
|
||||
];
|
||||
|
||||
@@ -221,11 +223,15 @@ export class ClientApprovalComponent implements OnInit {
|
||||
const data = {
|
||||
title: 'Enter Clients Activation Date',
|
||||
layout: { addButtonText: 'Confirm' },
|
||||
formfields: formfields
|
||||
formfields: formfields,
|
||||
// The date is pre-filled with today, so the form is already valid on open. Without this the
|
||||
// dialog stays pristine and Confirm is disabled until the user needlessly re-picks the date.
|
||||
pristine: false
|
||||
};
|
||||
const clientApprovalDialogRef = this.dialog.open(FormDialogComponent, { data });
|
||||
clientApprovalDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
// Cancel / the X button close with no value, so response is undefined there.
|
||||
if (response?.data) {
|
||||
this.bulkClientApproval(response.data);
|
||||
}
|
||||
});
|
||||
@@ -240,31 +246,70 @@ export class ClientApprovalComponent implements OnInit {
|
||||
activationDate,
|
||||
locale
|
||||
};
|
||||
const selectedAccounts = this.selection.selected.length;
|
||||
const listSelectedAccounts = this.selection.selected;
|
||||
let activatedAccounts = 0;
|
||||
this.batchRequests = [];
|
||||
let reqId = 1;
|
||||
// Keep the client each request belongs to, so a rejection can name who it was for.
|
||||
const clientsByRequestId = new Map<number, any>();
|
||||
listSelectedAccounts.forEach((element: any) => {
|
||||
const url = 'clients/' + element.id + '?command=activate';
|
||||
const bodyData = JSON.stringify(formData);
|
||||
const batchData = { requestId: reqId++, relativeUrl: url, method: 'POST', body: bodyData };
|
||||
clientsByRequestId.set(batchData.requestId, element);
|
||||
this.batchRequests.push(batchData);
|
||||
});
|
||||
this.tasksService.submitBatchData(this.batchRequests).subscribe((response: any) => {
|
||||
response.forEach((responseEle: any) => {
|
||||
if (responseEle.statusCode === '200') {
|
||||
let activatedAccounts = 0;
|
||||
const failures: string[] = [];
|
||||
|
||||
(response ?? []).forEach((responseEle: any) => {
|
||||
// The batch API returns statusCode as a number, so the old `=== '200'` string comparison
|
||||
// never matched: successes were never counted (no refresh) and rejections were dropped
|
||||
// entirely, which made a failed approval look like nothing had happened at all.
|
||||
if (Number(responseEle.statusCode) === 200) {
|
||||
activatedAccounts++;
|
||||
responseEle.body = JSON.parse(responseEle.body);
|
||||
if (selectedAccounts === activatedAccounts) {
|
||||
this.selection.clear();
|
||||
this.reload();
|
||||
}
|
||||
} else {
|
||||
const client = clientsByRequestId.get(Number(responseEle.requestId));
|
||||
const name = client?.displayName ?? `Client ${client?.id ?? ''}`.trim();
|
||||
failures.push(`${name}: ${this.batchErrorMessage(responseEle)}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (failures.length) {
|
||||
this.alertService.alert({
|
||||
type: 'Client Approval Failed',
|
||||
severity: 'error',
|
||||
message:
|
||||
(activatedAccounts ? `${activatedAccounts} approved. ` : '') +
|
||||
`${failures.length} could not be approved — ${failures.join('; ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh whenever anything changed, rather than only when every single one succeeded —
|
||||
// a partial batch previously left the approved clients sitting in the list.
|
||||
if (activatedAccounts) {
|
||||
this.selection.clear();
|
||||
this.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Pulls the human-readable reason out of a failed batch entry's JSON body. */
|
||||
private batchErrorMessage(responseEle: any): string {
|
||||
try {
|
||||
const body = typeof responseEle.body === 'string' ? JSON.parse(responseEle.body) : responseEle.body;
|
||||
return (
|
||||
body?.errors?.[0]?.defaultUserMessage ||
|
||||
body?.errors?.[0]?.developerMessage ||
|
||||
body?.defaultUserMessage ||
|
||||
body?.developerMessage ||
|
||||
`request failed with status ${responseEle.statusCode}`
|
||||
);
|
||||
} catch {
|
||||
return `request failed with status ${responseEle.statusCode}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetches the current page after a bulk approval.
|
||||
*/
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<div class="tdsr__filters">
|
||||
<label class="tdsr__filter">
|
||||
<span>Financial year</span>
|
||||
<select [(ngModel)]="filter.fy">
|
||||
<select [(ngModel)]="filter.fy" (ngModelChange)="onFilterChanged()">
|
||||
@for (year of financialYears; track year) {
|
||||
<option [value]="year">FY {{ year }}</option>
|
||||
}
|
||||
@@ -90,7 +90,7 @@
|
||||
@if (canSelectBranch) {
|
||||
<label class="tdsr__filter">
|
||||
<span>Branch</span>
|
||||
<select [(ngModel)]="filter.branchCode">
|
||||
<select [(ngModel)]="filter.branchCode" (ngModelChange)="onFilterChanged()">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
<label class="tdsr__filter">
|
||||
<span>Quarter</span>
|
||||
<select [(ngModel)]="filter.quarter">
|
||||
<select [(ngModel)]="filter.quarter" (ngModelChange)="onFilterChanged()">
|
||||
<option [ngValue]="0">Whole year</option>
|
||||
@for (q of quarters; track q) {
|
||||
<option [ngValue]="q">{{ quarterLabel[q] }}</option>
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
<label class="tdsr__filter">
|
||||
<span>Threshold band</span>
|
||||
<select [(ngModel)]="filter.band">
|
||||
<select [(ngModel)]="filter.band" (ngModelChange)="onFilterChanged()">
|
||||
<option value="">Both bands</option>
|
||||
<option value="GENERAL">General (below 60)</option>
|
||||
<option value="SENIOR">Senior citizen (60+)</option>
|
||||
@@ -120,16 +120,21 @@
|
||||
|
||||
<label class="tdsr__filter tdsr__filter--wide">
|
||||
<span>Customer / CIF / PAN</span>
|
||||
<input type="text" [(ngModel)]="filter.search" placeholder="name, CIF or PAN" />
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="filter.search"
|
||||
(ngModelChange)="onFilterChanged()"
|
||||
placeholder="name, CIF or PAN"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="tdsr__check">
|
||||
<input type="checkbox" [(ngModel)]="filter.crossedOnly" />
|
||||
<input type="checkbox" [(ngModel)]="filter.crossedOnly" (ngModelChange)="onFilterChanged()" />
|
||||
<span>Threshold crossed only</span>
|
||||
</label>
|
||||
|
||||
<label class="tdsr__check">
|
||||
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" />
|
||||
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" (ngModelChange)="onFilterChanged()" />
|
||||
<span>Near threshold only</span>
|
||||
</label>
|
||||
|
||||
@@ -138,6 +143,26 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Tabs Selector -->
|
||||
<div class="tdsr__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tdsr__tab"
|
||||
[class.tdsr__tab--active]="selectedTab === 'paying'"
|
||||
(click)="selectTab('paying')"
|
||||
>
|
||||
Paying TDS ({{ payingRows.length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tdsr__tab"
|
||||
[class.tdsr__tab--active]="selectedTab === 'not-paying'"
|
||||
(click)="selectTab('not-paying')"
|
||||
>
|
||||
Not Paying TDS ({{ notPayingRows.length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="tdsr__card">
|
||||
<div class="tdsr__table-wrap">
|
||||
@@ -160,7 +185,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track trackRow($index, row)) {
|
||||
@for (row of pagedRows; track trackRow($index, row)) {
|
||||
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
|
||||
<td>
|
||||
<div class="tdsr__name">{{ row.deductee.name }}</div>
|
||||
@@ -311,15 +336,70 @@
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (rows.length === 0) {
|
||||
@if (visibleRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="13" class="tdsr__empty">
|
||||
<fa-icon icon="info-circle"></fa-icon> No deductees match the current filters.
|
||||
<fa-icon icon="info-circle"></fa-icon> No customers in this section match the filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (visibleRows.length > 0) {
|
||||
<div class="tdsr__pager">
|
||||
<div class="tdsr__pager-l">
|
||||
<span>
|
||||
Showing <strong>{{ rangeStart | number }}–{{ rangeEnd | number }}</strong> of
|
||||
<strong>{{ visibleRows.length | number }}</strong>
|
||||
{{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
|
||||
</span>
|
||||
<label class="tdsr__pager-sizel">
|
||||
Rows
|
||||
<select class="tdsr__pager-size" [ngModel]="pageSize" (ngModelChange)="onPageSizeChange($event)">
|
||||
@for (size of pageSizeOptions; track size) {
|
||||
<option [ngValue]="size">{{ size || 'All' }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (pageCount > 1) {
|
||||
<div class="tdsr__pager-r">
|
||||
<button type="button" class="tdsr__pager-btn" (click)="goToPage(0)" [disabled]="currentPage === 0">
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tdsr__pager-btn"
|
||||
(click)="goToPage(currentPage - 1)"
|
||||
[disabled]="currentPage === 0"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<fa-icon icon="chevron-left"></fa-icon>
|
||||
</button>
|
||||
<span class="tdsr__pager-cur">Page {{ currentPage + 1 | number }} of {{ pageCount | number }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="tdsr__pager-btn"
|
||||
(click)="goToPage(currentPage + 1)"
|
||||
[disabled]="currentPage >= pageCount - 1"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<fa-icon icon="chevron-right"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tdsr__pager-btn"
|
||||
(click)="goToPage(pageCount - 1)"
|
||||
[disabled]="currentPage >= pageCount - 1"
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,30 @@ export class TdsInterestReportComponent implements OnInit {
|
||||
/** Which customer's quarter-wise detail is open. */
|
||||
expandedId: string | null = null;
|
||||
exporting = false;
|
||||
selectedTab: 'paying' | 'not-paying' = 'paying';
|
||||
|
||||
/** Rows per page; 0 shows the whole list. */
|
||||
readonly pageSizeOptions = [
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
0
|
||||
];
|
||||
pageSize = 25;
|
||||
pageIndex = 0;
|
||||
|
||||
/**
|
||||
* Row and summary computation is memoised against the filter, because every getter below is
|
||||
* read many times per change-detection cycle (the KPI block alone reads `summary` a dozen
|
||||
* times) and recomputing the whole book on each read is what made this table slow enough to
|
||||
* need paging in the first place.
|
||||
*/
|
||||
private rowsCacheKey = '';
|
||||
private allRows: TdsInterestRow[] = [];
|
||||
private paying: TdsInterestRow[] = [];
|
||||
private notPaying: TdsInterestRow[] = [];
|
||||
private summaryCacheKey = '';
|
||||
private cachedSummary: TdsInterestSummary | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
// A branch login sees only its own book and cannot widen the scope.
|
||||
@@ -94,12 +118,107 @@ export class TdsInterestReportComponent implements OnInit {
|
||||
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
|
||||
}
|
||||
|
||||
/** Recomputes the book only when the filter actually changed. Side-effect free. */
|
||||
private ensureRows(): void {
|
||||
const key = JSON.stringify(this.filter);
|
||||
if (key === this.rowsCacheKey) {
|
||||
return;
|
||||
}
|
||||
this.rowsCacheKey = key;
|
||||
this.allRows = tdsInterestRows(this.filter);
|
||||
this.paying = this.allRows.filter((r) => r.schedule.totalTds > 0);
|
||||
this.notPaying = this.allRows.filter((r) => r.schedule.totalTds === 0);
|
||||
}
|
||||
|
||||
get rows(): TdsInterestRow[] {
|
||||
return tdsInterestRows(this.filter);
|
||||
this.ensureRows();
|
||||
return this.allRows;
|
||||
}
|
||||
|
||||
get payingRows(): TdsInterestRow[] {
|
||||
this.ensureRows();
|
||||
return this.paying;
|
||||
}
|
||||
|
||||
get notPayingRows(): TdsInterestRow[] {
|
||||
this.ensureRows();
|
||||
return this.notPaying;
|
||||
}
|
||||
|
||||
get summary(): TdsInterestSummary {
|
||||
return tdsInterestSummary(this.filter);
|
||||
const key = JSON.stringify(this.filter);
|
||||
if (key !== this.summaryCacheKey || !this.cachedSummary) {
|
||||
this.summaryCacheKey = key;
|
||||
this.cachedSummary = tdsInterestSummary(this.filter);
|
||||
}
|
||||
return this.cachedSummary;
|
||||
}
|
||||
|
||||
/** Every row of the tab on screen, before paging. */
|
||||
get visibleRows(): TdsInterestRow[] {
|
||||
return this.selectedTab === 'paying' ? this.payingRows : this.notPayingRows;
|
||||
}
|
||||
|
||||
get pageCount(): number {
|
||||
if (!this.pageSize) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, Math.ceil(this.visibleRows.length / this.pageSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamped rather than assigned, so a filter that shrinks the list can never leave the table
|
||||
* rendering an out-of-range page, and nothing mutates state during change detection.
|
||||
*/
|
||||
get currentPage(): number {
|
||||
return Math.min(this.pageIndex, this.pageCount - 1);
|
||||
}
|
||||
|
||||
/** The slice actually rendered. */
|
||||
get pagedRows(): TdsInterestRow[] {
|
||||
const rows = this.visibleRows;
|
||||
if (!this.pageSize) {
|
||||
return rows;
|
||||
}
|
||||
const start = this.currentPage * this.pageSize;
|
||||
return rows.slice(start, start + this.pageSize);
|
||||
}
|
||||
|
||||
get rangeStart(): number {
|
||||
if (!this.visibleRows.length) {
|
||||
return 0;
|
||||
}
|
||||
return this.pageSize ? this.currentPage * this.pageSize + 1 : 1;
|
||||
}
|
||||
|
||||
get rangeEnd(): number {
|
||||
const total = this.visibleRows.length;
|
||||
return this.pageSize ? Math.min(total, (this.currentPage + 1) * this.pageSize) : total;
|
||||
}
|
||||
|
||||
goToPage(page: number): void {
|
||||
this.pageIndex = Math.max(0, Math.min(page, this.pageCount - 1));
|
||||
// The open quarter-wise detail belongs to a row that is no longer on screen.
|
||||
this.expandedId = null;
|
||||
}
|
||||
|
||||
/** Keeps the first row currently on screen in view when the page size changes. */
|
||||
onPageSizeChange(size: number): void {
|
||||
const anchor = this.currentPage * (this.pageSize || 1);
|
||||
this.pageSize = Number(size);
|
||||
this.pageIndex = this.pageSize ? Math.floor(anchor / this.pageSize) : 0;
|
||||
this.expandedId = null;
|
||||
}
|
||||
|
||||
/** A different filter is a different list, so paging starts over. */
|
||||
onFilterChanged(): void {
|
||||
this.pageIndex = 0;
|
||||
this.expandedId = null;
|
||||
}
|
||||
|
||||
selectTab(tab: 'paying' | 'not-paying'): void {
|
||||
this.selectedTab = tab;
|
||||
this.onFilterChanged();
|
||||
}
|
||||
|
||||
get hasFilters(): boolean {
|
||||
@@ -121,11 +240,13 @@ export class TdsInterestReportComponent implements OnInit {
|
||||
crossedOnly: false,
|
||||
nearThresholdOnly: false
|
||||
};
|
||||
this.onFilterChanged();
|
||||
}
|
||||
|
||||
/** Jump straight to the customers worth chasing a declaration from. */
|
||||
showNearThreshold(): void {
|
||||
this.filter = { ...this.filter, crossedOnly: false, nearThresholdOnly: true };
|
||||
this.onFilterChanged();
|
||||
}
|
||||
|
||||
toggleExpanded(row: TdsInterestRow): void {
|
||||
|
||||
@@ -544,3 +544,106 @@
|
||||
color: var(--tdsr-blue);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tab Switcher ── */
|
||||
.tdsr__tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--tdsr-line);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.tdsr__tab {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--tdsr-muted);
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--tdsr-ink);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--tdsr-blue);
|
||||
border-bottom-color: var(--tdsr-blue);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pager ── */
|
||||
.tdsr__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--tdsr-line);
|
||||
background: var(--tdsr-bg);
|
||||
font-size: 12px;
|
||||
color: var(--tdsr-muted);
|
||||
|
||||
strong {
|
||||
color: var(--tdsr-ink);
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__pager-l,
|
||||
.tdsr__pager-r {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tdsr__pager-sizel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdsr__pager-size {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: var(--tdsr-ink);
|
||||
}
|
||||
|
||||
.tdsr__pager-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 30px;
|
||||
height: 28px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--tdsr-ink);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--tdsr-bg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__pager-cur {
|
||||
padding: 0 2px;
|
||||
font-weight: 700;
|
||||
color: var(--tdsr-ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { SettingsService } from './settings/settings.service';
|
||||
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
|
||||
import { IdleTimeoutService } from './home/timeout-dialog/idle-timeout.service';
|
||||
import { SessionTimeoutDialogComponent } from './home/timeout-dialog/session-timeout-dialog.component';
|
||||
import { ErrorDialogComponent } from './shared/error-dialog/error-dialog.component';
|
||||
|
||||
/** Custom Items */
|
||||
import { Alert } from './core/alert/alert.model';
|
||||
@@ -229,6 +230,16 @@ export class WebAppComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Setup alerts
|
||||
this.alertService.alertEvent.subscribe((alertEvent: Alert) => {
|
||||
if (alertEvent.severity === 'error') {
|
||||
// Errors go to a centred modal instead of the corner snack bar: they need to be read and
|
||||
// acknowledged, and a toast that self-dismisses after 2s was being missed entirely.
|
||||
this.dialog.open(ErrorDialogComponent, {
|
||||
data: { title: alertEvent.type, message: alertEvent.message },
|
||||
autoFocus: true,
|
||||
restoreFocus: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.snackBar.open(`${alertEvent.message}`, 'Close', {
|
||||
duration: 2000,
|
||||
horizontalPosition: 'right',
|
||||
@@ -264,14 +275,23 @@ export class WebAppComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
this.idle.$onSessionTimeout.subscribe(() => {
|
||||
this.alertService.alert({
|
||||
type: 'Session timeout',
|
||||
message: this.translateService.instant('labels.text.Session timed out')
|
||||
const warningCountdown = (environment.session.timeout as any).warningCountdown || 60;
|
||||
const dialogRef = this.dialog.open(SessionTimeoutDialogComponent, {
|
||||
disableClose: true,
|
||||
data: { countdownSeconds: warningCountdown }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result === 'keep-alive') {
|
||||
this.idle.start();
|
||||
} else {
|
||||
this.alertService.alert({
|
||||
type: 'Session timeout',
|
||||
message: this.translateService.instant('labels.text.Session timed out')
|
||||
});
|
||||
this.logout();
|
||||
}
|
||||
});
|
||||
this.dialog.open(SessionTimeoutDialogComponent);
|
||||
setTimeout(() => {
|
||||
this.logout();
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@
|
||||
window['env']['waitTimeForCOBCatchUp'] = '';
|
||||
|
||||
// Time in milliseconds for Session idle timeout, default 300000 seconds
|
||||
window['env']['sessionIdleTimeout'] = '0';
|
||||
window['env']['sessionIdleTimeout'] = '';
|
||||
|
||||
// OAuth Server Enabled
|
||||
window['env']['oauthServerEnabled'] = false;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260811',
|
||||
'hash': '01e86173'
|
||||
'hash': '7b895a0e'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
@@ -74,7 +74,8 @@ export const environment = {
|
||||
waitTimeForCOBCatchUp: loadedEnv['waitTimeForCOBCatchUp'] || 30,
|
||||
session: {
|
||||
timeout: {
|
||||
idleTimeout: loadedEnv['sessionIdleTimeout'] || 300000 // 5 minutes
|
||||
idleTimeout: loadedEnv['sessionIdleTimeout'] || 540000, // 9 minutes
|
||||
warningCountdown: 60 // 60 seconds warning countdown
|
||||
}
|
||||
},
|
||||
httpCacheEnabled: loadedEnv.httpCacheEnabled || false,
|
||||
|
||||
@@ -76,7 +76,8 @@ export const environment = {
|
||||
waitTimeForCOBCatchUp: loadedEnv.waitTimeForCOBCatchUp || 30,
|
||||
session: {
|
||||
timeout: {
|
||||
idleTimeout: loadedEnv.sessionIdleTimeout || 300000 // 5 minutes
|
||||
idleTimeout: loadedEnv.sessionIdleTimeout || 540000, // 9 minutes
|
||||
warningCountdown: 60 // 60 seconds warning countdown
|
||||
}
|
||||
},
|
||||
httpCacheEnabled: loadedEnv.httpCacheEnabled || false,
|
||||
|
||||
Reference in New Issue
Block a user