feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports

This commit is contained in:
2026-09-14 11:43:26 +05:30
parent 7afa9c7a50
commit 2124bb4b76
40 changed files with 3028 additions and 929 deletions
+12 -5
View File
@@ -7,6 +7,7 @@
*/
import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data';
import { toIsoDate } from '../core/utils/api-date';
/**
* Data layer for the Risk Review Report.
@@ -390,17 +391,23 @@ export interface LiveAmlAlert {
ruleDescription: string;
amount: number;
currencyCode: string;
raisedOn: string;
/** Fineract sends LocalDateTime as [y,m,d,h,mi,s], not a string. */
raisedOn: number[] | string;
status: string;
reviewedByUsername: string | null;
reviewedOn: string | null;
reviewedOn: number[] | string | null;
reviewNotes: string | null;
disposition: string | null;
}
/** Trims a LocalDateTime such as 2026-07-14T11:04:22 down to its date part. */
function isoDayOf(value: string | null | undefined): string {
return (value || '').slice(0, 10);
/**
* Fineract serialises LocalDateTime as an array — raisedOn arrives as
* [2026,9,11,0,0,0], not a string. Slicing that gave back an array rather than
* a date, so every live row carried an unusable alertDate and the period filter
* discarded it: the report read empty while the engine held alerts.
*/
function isoDayOf(value: unknown): string {
return toIsoDate(value);
}
/**
@@ -150,33 +150,62 @@
} @else {
<!-- SUMMARY -->
<div class="rr-kpis">
<div class="rr-kpi">
<!-- Each tile reads as a question, so each one answers it by narrowing the table to the rows
it counted. They were plain text before, which invited a click that did nothing and left
the reviewer to rebuild the same filter by hand from the dropdowns below. -->
<button type="button" class="rr-kpi" (click)="focusAllCustomers()" title="Show every customer in the period">
<div class="rr-kpi-n">{{ summary.customers }}</div>
<div class="rr-kpi-l">Customers for review</div>
<div class="rr-kpi-sub">
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
</div>
</div>
<div class="rr-kpi rr-kpi--blue">
</button>
<button
type="button"
class="rr-kpi rr-kpi--blue"
[class.rr-kpi--on]="openOnly"
(click)="focusOpenAlerts()"
title="Show only the cases still awaiting a decision"
>
<div class="rr-kpi-n">{{ summary.alerts }}</div>
<div class="rr-kpi-l">AML alerts triggered</div>
<div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div>
</div>
<div
</button>
<button
type="button"
class="rr-kpi rr-kpi--amber"
[class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0"
[class.rr-kpi--on]="direction !== ''"
(click)="focusRatingsToMove()"
[title]="
summary.upgradeCandidates + summary.downgradeCandidates === 0
? 'No rating needs moving in this period'
: 'Show the customers whose rating should move'
"
>
<div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div>
<div class="rr-kpi-l">Ratings to be moved</div>
<div class="rr-kpi-sub">
{{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward
</div>
</div>
<div class="rr-kpi" [class.rr-kpi--red]="summary.escalatedCustomers > 0">
</button>
<button
type="button"
class="rr-kpi"
[class.rr-kpi--red]="summary.escalatedCustomers > 0"
[class.rr-kpi--dim]="summary.escalatedCustomers === 0"
[class.rr-kpi--on]="status === 'ESCALATED_STR'"
(click)="focusEscalated()"
[title]="
summary.escalatedCustomers === 0
? 'No case has gone to the Principal Officer in this period'
: 'Show only the cases with the Principal Officer'
"
>
<div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div>
<div class="rr-kpi-l">Escalated to STR</div>
<div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div>
</div>
</button>
</div>
<!-- RULE MIX — doubles as a one-click filter -->
@@ -220,7 +249,7 @@
row does not have to repeat it in a Move To and a Position column. -->
@for (g of customerGroups; track g.key) {
<div class="rr-group" [class]="'rr-group--' + g.tone">
<button class="rr-group-head" (click)="toggleGroup(g.key)">
<button type="button" class="rr-group-head" (click)="toggleGroup(g.key)">
<fa-icon [icon]="collapsedGroups[g.key] ? 'caret-right' : 'caret-down'"></fa-icon>
<span class="rr-group-t">{{ g.title }}</span>
<span class="rr-group-n">{{ g.rows.length }}</span>
@@ -238,6 +267,7 @@
<th>Account Number</th>
<th>Branch</th>
<th>Rating on file</th>
<th>Review says</th>
<th class="num">Alerts</th>
<th>AML Rules Triggered</th>
<th class="num">Amount under review</th>
@@ -268,6 +298,20 @@
<td>
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
</td>
<!-- The verdict itself, on the row. The section heading says it once for the
whole group, but a reviewer reading a single row - or an exported sheet -
could not tell whether the rating was to be raised, lowered or left. The
reason sits under it rather than under the button, because it explains
this conclusion and not the act of opening the KYC screen. -->
<td class="rr-move-cell">
<span class="rr-move" [class]="'rr-move--' + c.direction">
<fa-icon [icon]="directionIcon(c)"></fa-icon>
{{ directionText(c) }}
</span>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
<td class="num">{{ c.alertCount }}</td>
<td class="rr-rules-cell">
@for (label of c.ruleLabels; track label) {
@@ -276,10 +320,11 @@
</td>
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
<td class="mono">{{ c.lastAlertDate }}</td>
<!-- The verdict is the section this row sits in, so the cell only carries the
button that acts on it, and the reason behind it. -->
<td class="rr-verdict-cell">
<!-- Only the control. The verdict and its reason moved to their own column,
so this no longer wraps to two lines under the button. -->
<td class="rr-action-cell">
<button
type="button"
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
(click)="openKyc(c, $event)"
@@ -287,15 +332,12 @@
>
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
</button>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
</tr>
@if (expanded === c.clientId) {
<tr class="rr-detail">
<td colspan="11">
<td colspan="12">
<!-- One block per alert: what the rule is, then the entries it fired on. -->
@for (a of c.alerts; track a.alertId) {
<div class="rr-case">
@@ -286,12 +286,61 @@
margin-bottom: 14px;
}
/* The verdict, on the row. Colour carries the same meaning as the section it sits under, so a
reviewer scanning the middle of a long table does not have to look back up to the heading. */
.rr-move {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
&--up {
background: #fef3c7;
color: #92400e;
}
&--down {
background: #dbeafe;
color: #1e40af;
}
&--same {
background: #f1f5f9;
color: var(--r-g500);
}
}
/* A button, so the tile is reachable by keyboard and reads as the control it is. The type
defaults that come with that — centred text, the button font, the UA border — are undone
here so it still looks like the panel it replaced. */
.rr-kpi {
display: block;
width: 100%;
background: #fff;
border: 1px solid var(--r-g200);
border-left: 4px solid var(--r-navy);
border-radius: 11px;
padding: 12px 16px;
text-align: left;
font: inherit;
color: inherit;
cursor: pointer;
transition:
box-shadow 0.15s ease,
border-color 0.15s ease;
&:hover {
box-shadow: 0 1px 6px rgb(15 23 42 / 12%);
}
&:focus-visible {
outline: 2px solid var(--r-blue);
outline-offset: 2px;
}
&--blue {
border-left-color: var(--r-blue);
@@ -307,6 +356,18 @@
&--dim {
border-left-color: var(--r-g300);
/* Nothing to narrow to, so the tile says so rather than accepting a click that does nothing. */
cursor: default;
&:hover {
box-shadow: none;
}
}
/* The filter this tile applies is currently on. */
&--on {
border-color: var(--r-navy);
box-shadow: inset 0 0 0 1px var(--r-navy);
}
}
@@ -502,9 +563,11 @@
}
td {
padding: 8px 10px;
padding: 11px 10px;
border-bottom: 1px solid var(--r-g100);
vertical-align: top;
/* Middle rather than top: one cell in the row carries a two-line reason, and top alignment
left every other value floating against the row above it. */
vertical-align: middle;
}
.num {
@@ -538,6 +601,8 @@
td {
padding: 12px 16px 16px;
/* The expanded block is a panel, not a value, so it starts at the top of its cell. */
vertical-align: top;
}
}
@@ -789,6 +854,19 @@
}
/* What the report says should happen to the rating */
/* The verdict and the sentence behind it. Wide enough that the reason wraps to two readable
lines instead of a narrow ragged column. */
.rr-move-cell {
min-width: 250px;
max-width: 330px;
}
/* Just the button, so the column is only as wide as the control it holds. */
.rr-action-cell {
width: 1%;
white-space: nowrap;
}
.rr-verdict-cell {
min-width: 210px;
max-width: 300px;
@@ -817,9 +895,11 @@
}
.rr-verdict-why {
font-size: 10.5px;
font-size: 11px;
line-height: 1.45;
color: var(--r-g500);
margin-top: 2px;
margin-top: 4px;
white-space: normal;
white-space: normal;
}
@@ -224,21 +224,35 @@ export class RiskReviewReportComponent implements OnInit {
return;
}
// The alert carries the client but not their account number or branch, so both columns read
// as an em dash on every live row. Both sit on the client record the KYC call already needs,
// so they are fetched alongside it rather than left blank on a compliance report.
forkJoin(
clientIds.map((id) =>
this.clients.getClientKyc(String(id), true).pipe(
map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })),
catchError(() => of({ id, risk: null as RiskLevel | null }))
forkJoin({
kyc: this.clients.getClientKyc(String(id), true).pipe(catchError(() => of(null))),
client: this.clients.getClientData(String(id), true).pipe(catchError(() => of(null)))
}).pipe(
map((r: any) => ({
id,
risk: riskFromCategory(r.kyc?.riskCategory),
accountNo: r.client?.accountNo || '',
branch: r.client?.officeName || ''
})),
catchError(() => of({ id, risk: null as RiskLevel | null, accountNo: '', branch: '' }))
)
)
).subscribe((results) => {
const byClient = new Map(
results.map((r) => [
r.id,
r.risk
])
);
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
const byClient = new Map(results.map((r) => [r.id, r]));
this.liveAlerts = rows.map((r) => {
const info = byClient.get(r.clientId);
return {
...r,
currentRisk: info?.risk ?? null,
accountNo: r.accountNo || info?.accountNo || '',
branchCode: r.branchCode || info?.branch || ''
};
});
this.compose();
this.loading = false;
});
@@ -389,8 +403,62 @@ export class RiskReviewReportComponent implements OnInit {
return groups.filter((g) => g.rows.length > 0);
}
/** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */
collapsedGroups: Record<string, boolean> = { same: true };
/**
* Collapsed sections, by group key. Every group starts open.
*
* 'No change needed' used to start closed on the reasoning that there was nothing to act on. On a
* report that often holds one group, that meant opening the page to a heading and a count and no
* rows — the customer was there, but reading the report took a click to discover it. The toggle
* stays for folding a long section away.
*/
collapsedGroups: Record<string, boolean> = {};
/**
* The summary tiles read as a question each — "which customers?", "which alerts still need a
* decision?" — so each one narrows the table to the rows it counted. They were plain text
* before, which invited a click that did nothing and left the reviewer to rebuild the same
* filter by hand from the dropdowns.
*
* Clicking an active tile clears it again, so a tile is never a one-way trip into a filter the
* reviewer then has to hunt for.
*/
focusAllCustomers(): void {
this.currentRisk = '';
this.direction = '';
this.openOnly = false;
this.expanded = null;
this.collapsedGroups = {};
}
focusOpenAlerts(): void {
this.openOnly = !this.openOnly;
this.direction = '';
this.expanded = null;
this.collapsedGroups = {};
}
/** Only the customers whose rating the review says should move, either way. */
focusRatingsToMove(): void {
// Nothing to narrow to when no rating needs moving; leaving the filter alone keeps the
// table showing what is actually there rather than emptying it.
if (this.summary.upgradeCandidates + this.summary.downgradeCandidates === 0) {
return;
}
this.direction = this.direction === 'up' ? 'down' : this.direction === 'down' ? '' : 'up';
this.openOnly = false;
this.expanded = null;
this.collapsedGroups = {};
}
focusEscalated(): void {
if (this.summary.escalatedCustomers === 0) {
return;
}
this.status = this.status === 'ESCALATED_STR' ? '' : 'ESCALATED_STR';
this.openOnly = false;
this.expanded = null;
this.collapsedGroups = {};
}
toggleGroup(key: string): void {
this.collapsedGroups[key] = !this.collapsedGroups[key];
@@ -513,6 +581,47 @@ export class RiskReviewReportComponent implements OnInit {
* An escalated case is worded as a review rather than a move: the rating is held
* pending the Principal Officer's conclusion, so there is nothing to apply yet.
*/
/**
* The verdict in the words a reviewer needs: which way the rating moves, and to what.
*
* An escalated case is deliberately not given a direction — the rating is held where it is
* until the Principal Officer reports back, so showing "raise to High" there would invite a
* change nobody is authorised to make yet.
*/
directionText(c: RiskCustomerRow): string {
if (c.escalated) {
return 'Held — with Principal Officer';
}
if (c.direction === 'up') {
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
}
if (c.direction === 'down') {
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
}
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
}
directionIcon(c: RiskCustomerRow): string {
if (c.escalated) {
return 'lock';
}
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
// unregistered icon throws at render and takes the whole row down with it.
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
}
/**
* A live row carries the office name straight off the client record, not a GL branch code, so
* it is already the label to print. Only a coded row needs the lookup.
*/
branchLabelOf(branch: string): string {
if (!branch) {
return '—';
}
const named = glBranchName(branch);
return named && named !== branch ? named : branch;
}
kycActionText(c: RiskCustomerRow): string {
if (c.escalated) {
return 'Open KYC';
@@ -689,7 +798,7 @@ export class RiskReviewReportComponent implements OnInit {
String(c.clientId),
c.customerName,
c.accountNo || '—',
c.branchCode ? glBranchName(c.branchCode) : '—',
this.branchLabelOf(c.branchCode),
this.riskText(c.currentRisk),
c.alertCount,
c.ruleLabels.join('; '),
@@ -749,7 +858,7 @@ export class RiskReviewReportComponent implements OnInit {
String(a.clientId),
a.customerName,
a.accountNo || '—',
a.branchCode ? glBranchName(a.branchCode) : '—',
this.branchLabelOf(a.branchCode),
this.riskText(a.currentRisk),
this.txnDetail(a),
a.rule.label,
@@ -815,7 +924,7 @@ export class RiskReviewReportComponent implements OnInit {
String(t.clientId),
t.customerName,
t.accountNo || '—',
t.branchCode ? glBranchName(t.branchCode) : '—',
this.branchLabelOf(t.branchCode),
this.riskText(t.currentRisk),
channelLabel(t.channel),
t.drCr,
+8 -1
View File
@@ -218,7 +218,14 @@ function recommendRating(
};
}
return { risk, reason: '' };
// The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
return {
risk,
reason: currentRisk
? `${sizes}. Consistent with ${RISK_LABEL[risk]} — the rating on file stands.`
: `${sizes}. Activity points at ${RISK_LABEL[risk]}; no rating on record to compare against.`
};
}
/** Applies every filter and joins each alert to its rule. */