Merge branch 'dev' into feature/bugfix
Conflicts resolved in: - src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html - src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts Kept dev's CKYC architecture (upload dialog, real per-artefact endpoints) and layered feature/bugfix's fixes on top: upload file validation, missing-file badge, silent preview download, and image/PDF view button. Also includes the CKYC "Image of Client" upload-state fix and the client-change state-leak fix in the same component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,19 @@
|
||||
|
||||
&--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 +564,12 @@
|
||||
}
|
||||
|
||||
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 +603,9 @@
|
||||
|
||||
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 +857,20 @@
|
||||
}
|
||||
|
||||
/* 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 +899,10 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -224,21 +224,40 @@ 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
|
||||
r
|
||||
])
|
||||
);
|
||||
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
|
||||
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 +408,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 +586,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 +803,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 +863,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 +929,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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user