Add CC accounts tracking, AML rule trial and branch approval fixes
This commit is contained in:
+23
-2
@@ -79,12 +79,25 @@
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Applies To</mat-label>
|
||||
<mat-label>Account Type</mat-label>
|
||||
<mat-select formControlName="txnAppliesTo">
|
||||
<mat-option *ngFor="let a of appliesToOptions" [value]="a">{{ a }}</mat-option>
|
||||
<mat-option *ngFor="let a of accountTypes" [value]="a.value">{{ a.label }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Which accounts the rule watches</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Direction</mat-label>
|
||||
<mat-select formControlName="txnDirection">
|
||||
<mat-option *ngFor="let d of directionOptions" [value]="d.value">{{ d.label }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Credit = paisa aaya, Debit = paisa gaya</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="rule-cash-only">
|
||||
<mat-checkbox formControlName="txnCashOnly">Cash transactions only</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>AML Check Status</mat-label>
|
||||
<mat-select formControlName="amlCheckStatus">
|
||||
@@ -105,6 +118,14 @@
|
||||
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isCcRule" class="rule-form-info">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>
|
||||
This rule will be listed under the CC chip. It does not fire on live transactions yet: the engine is not yet fed
|
||||
current-account transactions.
|
||||
</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>
|
||||
|
||||
+29
@@ -86,3 +86,32 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.rule-cash-only {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.rule-form-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 4px solid #1d4ed8;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
+29
-5
@@ -13,6 +13,7 @@ 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 { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
@@ -41,6 +42,7 @@ import { ClientsService } from '../../../clients/clients.service';
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
@@ -74,11 +76,23 @@ export class AddAmlRuleDialogComponent {
|
||||
'STRUCTURING',
|
||||
'VELOCITY'
|
||||
];
|
||||
readonly appliesToOptions = [
|
||||
'ALL',
|
||||
'SAVINGS',
|
||||
'LOAN',
|
||||
'TRANSFER'
|
||||
/**
|
||||
* Which kind of account the rule is for. CC is a Cash Credit account (a current account with an overdraft): the engine is
|
||||
* not yet fed current-account transactions, so a CC rule is listed under the CC chip but does not fire until it is.
|
||||
*/
|
||||
readonly accountTypes = [
|
||||
{ value: 'ALL', label: 'All accounts' },
|
||||
{ value: 'SAVINGS', label: 'Savings' },
|
||||
{ value: 'LOAN', label: 'Loan' },
|
||||
{ value: 'TRANSFER', label: 'Transfer' },
|
||||
{ value: 'CURRENT', label: 'CC (Cash Credit)' }
|
||||
];
|
||||
|
||||
/** Which way the money moves for the rule to apply. Left open, it applies to both. */
|
||||
readonly directionOptions = [
|
||||
{ value: '', label: 'Credit and debit (both)' },
|
||||
{ value: 'CREDIT', label: 'Credit only (money comes in)' },
|
||||
{ value: 'DEBIT', label: 'Debit only (money goes out)' }
|
||||
];
|
||||
readonly amlCheckStatuses = [
|
||||
'CLEAR',
|
||||
@@ -112,6 +126,8 @@ export class AddAmlRuleDialogComponent {
|
||||
txnWindowDays: [0],
|
||||
txnRuleType: ['THRESHOLD'],
|
||||
txnAppliesTo: ['ALL'],
|
||||
txnDirection: [''],
|
||||
txnCashOnly: [false],
|
||||
txnMinLimitAmount: [
|
||||
null as
|
||||
| number
|
||||
@@ -131,6 +147,11 @@ export class AddAmlRuleDialogComponent {
|
||||
dormantFlag: ['ACTIVE']
|
||||
});
|
||||
|
||||
/** A CC rule is catalogued now and starts firing once current-account transactions reach the engine. */
|
||||
get isCcRule(): boolean {
|
||||
return this.ruleForm.value.txnAppliesTo === 'CURRENT';
|
||||
}
|
||||
|
||||
/** Mirrors the backend band check so the officer is told before the round trip. */
|
||||
get bandInverted(): boolean {
|
||||
const min = this.ruleForm.value.txnMinLimitAmount;
|
||||
@@ -179,6 +200,9 @@ export class AddAmlRuleDialogComponent {
|
||||
txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null,
|
||||
txnRuleType: v.txnRuleType,
|
||||
txnAppliesTo: v.txnAppliesTo,
|
||||
txnDirection: v.txnDirection ? v.txnDirection : null,
|
||||
txnCashOnly: !!v.txnCashOnly,
|
||||
ccRelevant: v.txnAppliesTo === 'CURRENT',
|
||||
txnMinLimitAmount: amount(v.txnMinLimitAmount),
|
||||
txnMaxLimitAmount: amount(v.txnMaxLimitAmount)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
<!--
|
||||
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="cc-intro">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
<div>
|
||||
A Cash Credit (CC) account is a current account that may draw an overdraft against a sanctioned limit. For each one,
|
||||
this shows how much came in and how much went out, against the turnover the customer declared and the limit the bank
|
||||
sanctioned. It is read from the posted transactions, so it is live now, while the scenarios for CC are still being
|
||||
added to the engine.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-controls">
|
||||
<label class="cc-field">
|
||||
<span>Window</span>
|
||||
<select [(ngModel)]="days" (ngModelChange)="load()">
|
||||
@for (w of windows; track w.days) {
|
||||
<option [ngValue]="w.days">{{ w.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span class="cc-asof">
|
||||
@if (asOf) {
|
||||
{{ from | date: 'dd MMM yyyy' }} to {{ asOf | date: 'dd MMM yyyy' }}
|
||||
}
|
||||
</span>
|
||||
<button type="button" class="cc-refresh" (click)="load()" matTooltip="Reload">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cc-cards">
|
||||
<div class="cc-card cc-card--in">
|
||||
<div class="cc-card__n">₹{{ totalIn | number: '1.2-2' }}</div>
|
||||
<div class="cc-card__l">Came in</div>
|
||||
</div>
|
||||
<div class="cc-card cc-card--out">
|
||||
<div class="cc-card__n">₹{{ totalOut | number: '1.2-2' }}</div>
|
||||
<div class="cc-card__l">Went out</div>
|
||||
</div>
|
||||
<div class="cc-card">
|
||||
<div class="cc-card__n">{{ shown.length }}</div>
|
||||
<div class="cc-card__l">CC accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cc-note">Loading the CC account figures…</div>
|
||||
} @else if (error) {
|
||||
<div class="cc-note cc-note--warn">{{ error }}</div>
|
||||
} @else if (!shown.length) {
|
||||
<div class="cc-note">
|
||||
No CC account in your branch yet. A CC account is a current account opened on a product that allows an overdraft
|
||||
(Client page, Applications, New CC Account).
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cc-table-wrap">
|
||||
<table class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Customer / Account</th>
|
||||
<th>Branch</th>
|
||||
<th class="r">Limit</th>
|
||||
<th class="r">Drawn</th>
|
||||
<th class="r">Declared / month</th>
|
||||
<th class="r">Came in</th>
|
||||
<th class="r">Went out</th>
|
||||
<th class="r">Net</th>
|
||||
<th class="r">In ÷ declared</th>
|
||||
<th class="r">Cash in</th>
|
||||
<th>Last txn</th>
|
||||
<th>CC rules</th>
|
||||
<th class="r">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of shown; track a.accountId; let i = $index) {
|
||||
<tr class="cc-row" (click)="openTrail(a, 'analysis')">
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td>
|
||||
<div class="cc-name">
|
||||
{{ a.clientName }}
|
||||
<span class="cc-badge" [class.cc-badge--cc]="a.isCc">{{ a.isCc ? 'CC' : 'Current' }}</span>
|
||||
</div>
|
||||
<div class="cc-sub mono">{{ a.accountNo }}</div>
|
||||
</td>
|
||||
<td>{{ a.branchName || '—' }}</td>
|
||||
<td class="r">{{ a.isCc ? '₹' + (a.sanctionedLimit | number: '1.0-0') : '—' }}</td>
|
||||
<td class="r">{{ a.limitDrawnPercent != null ? a.limitDrawnPercent + '%' : '—' }}</td>
|
||||
<td class="r">
|
||||
{{
|
||||
a.declaredMonthlyTurnover != null ? '₹' + (a.declaredMonthlyTurnover | number: '1.0-0') : 'not declared'
|
||||
}}
|
||||
</td>
|
||||
<td class="r in">
|
||||
₹{{ a.credits | number: '1.2-2' }}
|
||||
<div class="cc-sub">{{ a.creditCount }} txn</div>
|
||||
</td>
|
||||
<td class="r out">
|
||||
₹{{ a.debits | number: '1.2-2' }}
|
||||
<div class="cc-sub">{{ a.debitCount }} txn</div>
|
||||
</td>
|
||||
<td class="r" [class.in]="a.net > 0" [class.out]="a.net < 0">{{ rs(a.net) }}</td>
|
||||
<td class="r">
|
||||
@if (a.creditsVsDeclared != null) {
|
||||
<span
|
||||
class="cc-ratio"
|
||||
[class.cc-ratio--hi]="isSpike(a.creditsVsDeclared)"
|
||||
[matTooltip]="isSpike(a.creditsVsDeclared) ? 'Well above the declared turnover for this window' : ''"
|
||||
>
|
||||
{{ a.creditsVsDeclared }}×
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td class="r">{{ a.cashCreditPercent != null ? a.cashCreditPercent + '%' : '—' }}</td>
|
||||
<td>{{ a.lastTxnDate ? (a.lastTxnDate | date: 'dd MMM yyyy') : '—' }}</td>
|
||||
<td
|
||||
class="cc-rules-cell"
|
||||
(click)="openTrail(a, 'rules'); $event.stopPropagation()"
|
||||
title="See which CC rules this account hits"
|
||||
>
|
||||
<span class="cc-hit" [class.cc-hit--on]="a.rulesHit > 0">{{ a.rulesHit }} hit</span>
|
||||
<div class="cc-sub">{{ a.rulesNoHit }} no hit · {{ a.rulesNotChecked }} not checked</div>
|
||||
</td>
|
||||
<td class="r cc-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cc-trail-btn cc-trail-btn--main"
|
||||
(click)="openTrail(a, 'analysis'); $event.stopPropagation()"
|
||||
>
|
||||
Analysis
|
||||
</button>
|
||||
<button type="button" class="cc-trail-btn" (click)="openTrail(a, 'trail'); $event.stopPropagation()">
|
||||
Trail
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cc-foot">
|
||||
Busiest first. “In ÷ declared” compares what came in with the turnover the customer declared, scaled to the window;
|
||||
{{ spikeMultiple }}× or more is highlighted for a look. It is an indicative starting value, not a finding.
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Where one account's money came from and went, and what it adds up to. Opened from a row above; declared here so it
|
||||
keeps this component's state and styles. Credits are written Cr and debits Dr, as in the bank's books. -->
|
||||
<ng-template #trailDialog>
|
||||
<div class="cc-dialog">
|
||||
@if (trailLoading) {
|
||||
<div class="cc-note">Loading the account's money trail…</div>
|
||||
} @else if (trailError) {
|
||||
<div class="cc-note cc-note--warn">{{ trailError }}</div>
|
||||
<div class="cc-dialog__bar"><button type="button" class="cc-btn" (click)="closeTrail()">Close</button></div>
|
||||
} @else if (trail) {
|
||||
<div class="cc-dialog__head">
|
||||
<div>
|
||||
<div class="cc-dialog__title">
|
||||
{{ trail.clientName }}
|
||||
<span class="cc-badge" [class.cc-badge--cc]="trail.isCc">{{ trail.isCc ? 'CC' : 'Current' }}</span>
|
||||
</div>
|
||||
<div class="cc-sub mono">{{ trail.accountNo }} · {{ trail.branchName }}</div>
|
||||
<div class="cc-sub">
|
||||
{{ trail.from | date: 'dd MMM yyyy' }} to {{ trail.asOf | date: 'dd MMM yyyy' }} · Sanctioned limit ₹{{
|
||||
trail.sanctionedLimit | number: '1.0-0'
|
||||
}}
|
||||
· Closing balance <strong>{{ drcr(trail.balance) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="cc-btn" (click)="closeTrail()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="cc-legend">
|
||||
<span><strong class="in">Cr (credit)</strong> = money received into the account.</span>
|
||||
<span><strong class="out">Dr (debit)</strong> = money paid out of the account.</span>
|
||||
<span
|
||||
>On a CC account, a <strong class="out">Dr balance</strong> means the customer has drawn on the overdraft,
|
||||
that is, owes the bank.</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="cc-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="cc-tab"
|
||||
[class.cc-tab--on]="trailView === 'analysis'"
|
||||
(click)="trailView = 'analysis'"
|
||||
>
|
||||
Analysis
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cc-tab"
|
||||
[class.cc-tab--on]="trailView === 'rules'"
|
||||
(click)="trailView = 'rules'; ruleFilter = 'HIT'"
|
||||
>
|
||||
CC rules ({{ ruleCount('HIT') }} hit)
|
||||
</button>
|
||||
<button type="button" class="cc-tab" [class.cc-tab--on]="trailView === 'trail'" (click)="trailView = 'trail'">
|
||||
Every transaction ({{ trail.transactions.length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (trailView === 'rules') {
|
||||
<!-- Which CC rules this account hits, and which it does not -->
|
||||
<div class="cc-rulesbar">
|
||||
<button type="button" class="cc-chip" [class.cc-chip--on]="ruleFilter === 'HIT'" (click)="ruleFilter = 'HIT'">
|
||||
Hit <span class="cc-chip__n">{{ ruleCount('HIT') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cc-chip"
|
||||
[class.cc-chip--on]="ruleFilter === 'NO_HIT'"
|
||||
(click)="ruleFilter = 'NO_HIT'"
|
||||
>
|
||||
No hit <span class="cc-chip__n">{{ ruleCount('NO_HIT') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cc-chip"
|
||||
[class.cc-chip--on]="ruleFilter === 'NOT_CHECKED'"
|
||||
(click)="ruleFilter = 'NOT_CHECKED'"
|
||||
>
|
||||
Not checked <span class="cc-chip__n">{{ ruleCount('NOT_CHECKED') }}</span>
|
||||
</button>
|
||||
<button type="button" class="cc-chip" [class.cc-chip--on]="ruleFilter === 'ALL'" (click)="ruleFilter = 'ALL'">
|
||||
All <span class="cc-chip__n">{{ ruleCount('') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="cc-hint cc-hint--top">
|
||||
<strong>Hit</strong> = this account's transactions trigger the rule. <strong>No hit</strong> = the rule was
|
||||
checked and not triggered. <strong>Not checked</strong> = the rule needs an officer's judgement or data the
|
||||
system does not hold yet, so it is not shown as passed.
|
||||
</p>
|
||||
<div class="cc-table-wrap">
|
||||
<table class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rule</th>
|
||||
<th>Kind</th>
|
||||
<th>Risk</th>
|
||||
<th>Result</th>
|
||||
<th>Why</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredRuleChecks; track r.ruleId) {
|
||||
<tr>
|
||||
<td>
|
||||
{{ r.name }}
|
||||
@if (r.onTrial) {
|
||||
<span class="cc-trial">TRIAL</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.kind }}</td>
|
||||
<td>{{ riskLabel(r.riskCategory) }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="cc-res"
|
||||
[class.cc-res--hit]="r.status === 'HIT'"
|
||||
[class.cc-res--ok]="r.status === 'NO_HIT'"
|
||||
[class.cc-res--na]="r.status === 'NOT_CHECKED'"
|
||||
>
|
||||
{{ r.status === 'HIT' ? 'HIT' : r.status === 'NO_HIT' ? 'No hit' : 'Not checked' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cc-sub-cell">{{ r.detail }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="cc-empty">No rule in this group.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else if (trailView === 'analysis') {
|
||||
<!-- In words -->
|
||||
<div class="cc-story">
|
||||
<div class="cc-story__title">In short</div>
|
||||
@for (line of story; track $index) {
|
||||
<p>{{ line }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- The figures -->
|
||||
<div class="cc-kpis">
|
||||
<div class="cc-kpi cc-kpi--in">
|
||||
<div class="cc-kpi__t">Total credits (Cr)</div>
|
||||
<div class="cc-kpi__n">₹{{ trail.summary.in | number: '1.2-2' }}</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{ trail.summary.inCount }} credit{{ trail.summary.inCount === 1 ? '' : 's' }} · money received
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-kpi cc-kpi--out">
|
||||
<div class="cc-kpi__t">Total debits (Dr)</div>
|
||||
<div class="cc-kpi__n">₹{{ trail.summary.out | number: '1.2-2' }}</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{ trail.summary.outCount }} debit{{ trail.summary.outCount === 1 ? '' : 's' }} · money paid out
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-kpi">
|
||||
<div class="cc-kpi__t">Net (Cr − Dr)</div>
|
||||
<div class="cc-kpi__n" [class.in]="trail.summary.net > 0" [class.out]="trail.summary.net < 0">
|
||||
{{ drcr(trail.summary.net) }}
|
||||
</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{
|
||||
trail.summary.net < 0
|
||||
? 'Debits were more than credits'
|
||||
: trail.summary.net > 0
|
||||
? 'Credits were more than debits'
|
||||
: 'Credits equal debits'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-kpi">
|
||||
<div class="cc-kpi__t">Closing balance</div>
|
||||
<div class="cc-kpi__n" [class.in]="trail.balance > 0" [class.out]="trail.balance < 0">
|
||||
{{ drcr(trail.balance) }}
|
||||
</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{
|
||||
trail.balance < 0
|
||||
? 'Overdrawn: the customer owes the bank'
|
||||
: trail.balance > 0
|
||||
? "The customer's own money"
|
||||
: 'Nil'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-kpi">
|
||||
<div class="cc-kpi__t">Lowest balance</div>
|
||||
<div class="cc-kpi__n">{{ drcr(trail.summary.lowestBalance, '1.0-0') }}</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{
|
||||
trail.summary.peakLimitUsedPercent != null
|
||||
? trail.summary.peakLimitUsedPercent + '% of the limit used at the worst point'
|
||||
: 'Never overdrawn'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-facts">
|
||||
<span
|
||||
>Active days: <strong>{{ trail.summary.activeDays }}</strong></span
|
||||
>
|
||||
<span
|
||||
>Average credit:
|
||||
<strong>{{
|
||||
trail.summary.averageIn != null ? '₹' + (trail.summary.averageIn | number: '1.0-0') : '—'
|
||||
}}</strong></span
|
||||
>
|
||||
<span
|
||||
>Average debit:
|
||||
<strong>{{
|
||||
trail.summary.averageOut != null ? '₹' + (trail.summary.averageOut | number: '1.0-0') : '—'
|
||||
}}</strong></span
|
||||
>
|
||||
<span>
|
||||
Declared turnover for the window:
|
||||
<strong>{{
|
||||
trail.summary.declaredForWindow != null
|
||||
? '₹' + (trail.summary.declaredForWindow | number: '1.0-0')
|
||||
: 'not declared'
|
||||
}}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Credits ÷ declared:
|
||||
<strong>{{ trail.summary.creditsVsDeclared != null ? trail.summary.creditsVsDeclared + '×' : '—' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- What stands out -->
|
||||
<div class="cc-group__title cc-group__title--pad">What stands out</div>
|
||||
<div class="cc-obs">
|
||||
@for (o of trail.observations; track $index) {
|
||||
<div class="cc-ob" [ngClass]="'cc-ob--' + o.level.toLowerCase()">
|
||||
<span class="cc-ob__lvl">{{ o.level }}</span>
|
||||
<div>
|
||||
<div class="cc-ob__t">{{ o.title }}</div>
|
||||
<div class="cc-ob__d">{{ o.detail }}</div>
|
||||
@if (o.why) {
|
||||
<div class="cc-ob__w"><strong>Why it matters:</strong> {{ o.why }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cc-groups">
|
||||
<!-- By mode -->
|
||||
<div class="cc-group">
|
||||
<div class="cc-group__title">By mode of transaction</div>
|
||||
<table class="cc-mini">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mode</th>
|
||||
<th class="r">Credit (Cr)</th>
|
||||
<th class="r">Debit (Dr)</th>
|
||||
<th class="r">Net</th>
|
||||
<th class="r">Txns</th>
|
||||
<th class="r">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of trail.byMode; track m.label) {
|
||||
<tr>
|
||||
<td>{{ m.label }}</td>
|
||||
<td class="r in">₹{{ m.in | number: '1.0-0' }}</td>
|
||||
<td class="r out">₹{{ m.out | number: '1.0-0' }}</td>
|
||||
<td class="r">{{ drcr(m.in - m.out, '1.0-0') }}</td>
|
||||
<td class="r">{{ m.count }}</td>
|
||||
<td class="r">
|
||||
<span class="cc-bar"><span class="cc-bar__fill" [style.width.%]="m.sharePercent"></span></span>
|
||||
{{ m.sharePercent }}%
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="cc-empty">No transactions in this window.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="cc-hint">Share = this mode's part of all the money that moved (credits + debits).</p>
|
||||
</div>
|
||||
|
||||
<!-- By the party on the other side -->
|
||||
<div class="cc-group">
|
||||
<div class="cc-group__title">Who the money came from (Cr) and went to (Dr)</div>
|
||||
<table class="cc-mini">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account / Bank</th>
|
||||
<th class="r">Credit (Cr)</th>
|
||||
<th class="r">Debit (Dr)</th>
|
||||
<th class="r">Net</th>
|
||||
<th class="r">Txns</th>
|
||||
<th class="r">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of trail.byCounterparty; track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="mono">{{ p.label }}</span>
|
||||
<div class="cc-sub">{{ p.sub || '' }}</div>
|
||||
</td>
|
||||
<td class="r in">₹{{ p.in | number: '1.0-0' }}</td>
|
||||
<td class="r out">₹{{ p.out | number: '1.0-0' }}</td>
|
||||
<td class="r">{{ drcr(p.in - p.out, '1.0-0') }}</td>
|
||||
<td class="r">{{ p.count }}</td>
|
||||
<td class="r">
|
||||
<span class="cc-bar"><span class="cc-bar__fill" [style.width.%]="p.sharePercent"></span></span>
|
||||
{{ p.sharePercent }}%
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="cc-empty">—</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="cc-hint">Cr from a party = it paid this account. Dr to a party = this account paid it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Week by week -->
|
||||
<div class="cc-group__title cc-group__title--pad">Week by week</div>
|
||||
<table class="cc-mini cc-weeks">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Week starting</th>
|
||||
<th class="r">Credit (Cr)</th>
|
||||
<th class="r">Debit (Dr)</th>
|
||||
<th class="r">Net for the week</th>
|
||||
<th class="r">Txns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (w of trail.timeline; track w.label) {
|
||||
<tr>
|
||||
<td>{{ w.label | date: 'dd MMM yyyy' }}</td>
|
||||
<td class="r in">₹{{ w.in | number: '1.0-0' }}</td>
|
||||
<td class="r out">₹{{ w.out | number: '1.0-0' }}</td>
|
||||
<td class="r">{{ drcr(w.in - w.out, '1.0-0') }}</td>
|
||||
<td class="r">{{ w.count }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- The biggest two -->
|
||||
<div class="cc-big">
|
||||
@if (trail.summary.largestIn) {
|
||||
<div class="cc-big__item">
|
||||
Largest credit (Cr): <strong class="in">₹{{ trail.summary.largestIn.amount | number: '1.2-2' }}</strong
|
||||
>, received from {{ trail.summary.largestIn.party }} by {{ trail.summary.largestIn.mode }} on
|
||||
{{ trail.summary.largestIn.date | date: 'dd MMM yyyy' }}.
|
||||
</div>
|
||||
}
|
||||
@if (trail.summary.largestOut) {
|
||||
<div class="cc-big__item">
|
||||
Largest debit (Dr): <strong class="out">₹{{ trail.summary.largestOut.amount | number: '1.2-2' }}</strong
|
||||
>, paid to {{ trail.summary.largestOut.party }} by {{ trail.summary.largestOut.mode }} on
|
||||
{{ trail.summary.largestOut.date | date: 'dd MMM yyyy' }}.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="cc-foot">
|
||||
Observations use fixed starting values (for example 3× the declared turnover, a credit mostly paid out again
|
||||
within 3 days, one party above half of all the money moved). They point at something worth a look; they are
|
||||
not findings, and the bank's compliance team sets the real thresholds.
|
||||
</p>
|
||||
} @else {
|
||||
<div class="cc-table-wrap">
|
||||
<table class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Cr / Dr</th>
|
||||
<th>Mode</th>
|
||||
<th>Other side</th>
|
||||
<th>Reference</th>
|
||||
<th class="r">Credit (Cr)</th>
|
||||
<th class="r">Debit (Dr)</th>
|
||||
<th class="r">Balance after</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of trail.transactions; track t.id) {
|
||||
<tr>
|
||||
<td>{{ t.date | date: 'dd MMM yyyy' }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="cc-dir"
|
||||
[class.cc-dir--in]="t.direction === 'IN'"
|
||||
[class.cc-dir--out]="t.direction === 'OUT'"
|
||||
>
|
||||
{{ t.direction === 'IN' ? 'Cr · received' : 'Dr · paid out' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ t.mode }}
|
||||
@if (t.chequeNo) {
|
||||
<div class="cc-sub">No. {{ t.chequeNo }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ t.counterpartyAccount || '—' }}</span>
|
||||
<div class="cc-sub">
|
||||
{{ t.counterpartyBank || '' }}{{ t.counterpartyIfsc ? ' · ' + t.counterpartyIfsc : '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ t.reference || '—' }}</span>
|
||||
<div class="cc-sub">{{ t.remarks }}</div>
|
||||
</td>
|
||||
<td class="r in">{{ t.direction === 'IN' ? '₹' + (t.amount | number: '1.2-2') : '' }}</td>
|
||||
<td class="r out">{{ t.direction === 'OUT' ? '₹' + (t.amount | number: '1.2-2') : '' }}</td>
|
||||
<td class="r">{{ drcr(t.balanceAfter) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="cc-empty">No transactions in this window.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cc-foot">
|
||||
“Mode” and the other side are what the payment recorded: cash, cheque (by its number), and NEFT, RTGS, IMPS,
|
||||
UPI, demand draft or internal transfer where the payment says so. Where it recorded nothing, it shows as “Not
|
||||
recorded”. “Balance after” is the account's balance once that transaction was posted: Dr when overdrawn, Cr
|
||||
when in credit.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.cc-intro {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.cc-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cc-asof {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cc-refresh {
|
||||
margin-left: auto;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cc-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cc-card {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #1a237e;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
&--in {
|
||||
border-left-color: #15803d;
|
||||
}
|
||||
|
||||
&--out {
|
||||
border-left-color: #b91c1c;
|
||||
}
|
||||
|
||||
&__n {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__l {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-note {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
|
||||
&--warn {
|
||||
border-color: #fcd34d;
|
||||
background: #fffbeb;
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-table-wrap {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 12px 6px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 6px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.r {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.in {
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.out {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-sub {
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.cc-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
|
||||
&--cc {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-ratio {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
font-weight: 700;
|
||||
|
||||
&--hi {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-foot {
|
||||
margin: 10px 2px 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cc-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-trail-btn,
|
||||
.cc-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #1e3a8a;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* The money-trail popup. */
|
||||
.cc-dialog {
|
||||
padding: 20px 28px 24px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cc-group {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&__title {
|
||||
padding: 8px 10px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--pad {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cc-mini {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 6px 10px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 7px 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.r {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.in {
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.out {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-empty {
|
||||
padding: 12px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cc-dir {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
&--in {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--out {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* The two buttons are stacked so the Details column stays narrow and the table fits the screen. */
|
||||
.cc-actions {
|
||||
width: 84px;
|
||||
|
||||
.cc-trail-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cc-trail-btn + .cc-trail-btn {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-trail-btn--main {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cc-tab {
|
||||
padding: 8px 14px;
|
||||
border: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
&--on {
|
||||
border-bottom-color: #1d4ed8;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cc-kpi {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #1a237e;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
&--in {
|
||||
border-left-color: #15803d;
|
||||
}
|
||||
|
||||
&--out {
|
||||
border-left-color: #b91c1c;
|
||||
}
|
||||
|
||||
&__n {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.in {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.out {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
&__l {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-obs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cc-ob {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
|
||||
&__lvl {
|
||||
flex-shrink: 0;
|
||||
min-width: 58px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__t {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__d {
|
||||
margin-top: 1px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--high {
|
||||
border-left-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
|
||||
.cc-ob__lvl {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
border-left-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
|
||||
.cc-ob__lvl {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-left-color: #16a34a;
|
||||
background: #f0fdf4;
|
||||
|
||||
.cc-ob__lvl {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cc-bar {
|
||||
display: inline-block;
|
||||
width: 54px;
|
||||
height: 6px;
|
||||
margin-right: 6px;
|
||||
border-radius: 3px;
|
||||
background: #e2e8f0;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
|
||||
&__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-weeks {
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cc-big {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
|
||||
.in {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.out {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 22px;
|
||||
margin: 0 0 14px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
|
||||
.in {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.out {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-story {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 4px solid #1d4ed8;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
|
||||
&__title {
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-kpi__t {
|
||||
margin-bottom: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cc-facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 26px;
|
||||
margin: -4px 0 16px;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
|
||||
strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-ob__w {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cc-hint {
|
||||
margin: 0;
|
||||
padding: 6px 10px 8px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cc-rules-cell {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-hit {
|
||||
display: inline-block;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
|
||||
&--on {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-rulesbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
&--on {
|
||||
border-color: #1d4ed8;
|
||||
background: #1d4ed8;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__n {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: rgb(0 0 0 / 10%);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-hint--top {
|
||||
padding: 2px 2px 10px;
|
||||
}
|
||||
|
||||
.cc-res {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
|
||||
&--hit {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--na {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-trial {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cc-sub-cell {
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* A negative amount stays on one line: -₹1,00,000.00, not a minus on one line and the amount on the next. */
|
||||
.cc-table td.r {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, ViewChild, TemplateRef } from '@angular/core';
|
||||
import { CommonModule, formatNumber, formatDate } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
|
||||
/**
|
||||
* How much came into and went out of each Cash Credit account, against what the customer declared and what the bank
|
||||
* sanctioned. Read from the posted current-account transactions, so it works without the AML engine being fed them.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-cc-tracking',
|
||||
templateUrl: './aml-cc-tracking.component.html',
|
||||
styleUrls: ['./aml-cc-tracking.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class AmlCcTrackingComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/** The money trail of one account, in a popup. */
|
||||
@ViewChild('trailDialog') trailDialogTpl!: TemplateRef<unknown>;
|
||||
private trailRef: MatDialogRef<unknown> | null = null;
|
||||
trail: any = null;
|
||||
trailLoading = false;
|
||||
/** What the popup is showing: the analysis, or every transaction. */
|
||||
trailView: 'analysis' | 'rules' | 'trail' = 'analysis';
|
||||
/** Which of the account's CC rule checks to list: all of them, or only those that hit, did not hit, or were not checked. */
|
||||
ruleFilter: 'ALL' | 'HIT' | 'NO_HIT' | 'NOT_CHECKED' = 'ALL';
|
||||
trailError = '';
|
||||
|
||||
readonly windows = [
|
||||
{ days: 7, label: 'Last 7 days' },
|
||||
{ days: 30, label: 'Last 30 days' },
|
||||
{ days: 90, label: 'Last 90 days' }
|
||||
];
|
||||
days = 30;
|
||||
|
||||
loading = false;
|
||||
error = '';
|
||||
asOf = '';
|
||||
from = '';
|
||||
accounts: any[] = [];
|
||||
|
||||
/** A credit or debit this many times the declared turnover for the window is flagged for a look. Indicative only. */
|
||||
readonly spikeMultiple = 3;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.amlService.getCcTracking(this.days).subscribe({
|
||||
next: (res) => {
|
||||
this.loading = false;
|
||||
this.accounts = res?.accounts || [];
|
||||
this.asOf = res?.asOf || '';
|
||||
this.from = res?.from || '';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.loading = false;
|
||||
this.accounts = [];
|
||||
this.error = err?.error?.defaultUserMessage || 'Could not load the CC account figures.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The server returns only Cash Credit accounts. */
|
||||
get shown(): any[] {
|
||||
return this.accounts;
|
||||
}
|
||||
|
||||
get totalIn(): number {
|
||||
return this.shown.reduce((sum, a) => sum + (Number(a.credits) || 0), 0);
|
||||
}
|
||||
|
||||
get totalOut(): number {
|
||||
return this.shown.reduce((sum, a) => sum + (Number(a.debits) || 0), 0);
|
||||
}
|
||||
|
||||
/** Opens where one account's money came from and went: each transaction, its mode, and the party on the other side. */
|
||||
openTrail(account: any, view: 'analysis' | 'rules' | 'trail' = 'analysis'): void {
|
||||
this.trailView = view;
|
||||
this.ruleFilter = view === 'rules' ? 'HIT' : 'ALL';
|
||||
this.trail = null;
|
||||
this.trailError = '';
|
||||
this.trailLoading = true;
|
||||
this.amlService.getCcAccountTrail(account.accountId, this.days).subscribe({
|
||||
next: (res) => {
|
||||
this.trail = res;
|
||||
this.trailLoading = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.trailLoading = false;
|
||||
this.trailError = err?.error?.defaultUserMessage || 'Could not load the money trail for this account.';
|
||||
}
|
||||
});
|
||||
this.trailRef?.close();
|
||||
this.trailRef = this.dialog.open(this.trailDialogTpl, {
|
||||
width: '1500px',
|
||||
maxWidth: '98vw',
|
||||
height: '94vh',
|
||||
maxHeight: '96vh',
|
||||
autoFocus: false
|
||||
});
|
||||
this.trailRef.afterClosed().subscribe(() => (this.trailRef = null));
|
||||
}
|
||||
|
||||
/** How many of the open account's CC rule checks have the given result. */
|
||||
ruleCount(status: string): number {
|
||||
return (this.trail?.ruleChecks || []).filter((r: any) => !status || r.status === status).length;
|
||||
}
|
||||
|
||||
/** The open account's CC rule checks, narrowed to the chosen result. */
|
||||
get filteredRuleChecks(): any[] {
|
||||
const all: any[] = this.trail?.ruleChecks || [];
|
||||
return this.ruleFilter === 'ALL' ? all : all.filter((r) => r.status === this.ruleFilter);
|
||||
}
|
||||
|
||||
riskLabel(category: number): string {
|
||||
return category === 3 ? 'High' : category === 2 ? 'Medium' : category === 1 ? 'Low' : '—';
|
||||
}
|
||||
|
||||
closeTrail(): void {
|
||||
this.trailRef?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* A balance the way a bank writes it: Dr for a debit balance (on a CC account, the overdraft drawn - the customer owes
|
||||
* the bank), Cr for a credit balance (the customer's own money), and a bare amount for nil.
|
||||
*/
|
||||
drcr(value: number | string | null | undefined, digits = '1.2-2'): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const n = Number(value);
|
||||
if (n === 0) return `₹${formatNumber(0, 'en-US', digits)}`;
|
||||
return `₹${formatNumber(Math.abs(n), 'en-US', digits)} ${n < 0 ? 'Dr' : 'Cr'}`;
|
||||
}
|
||||
|
||||
/** The account's activity told in plain words, from the same figures the cards and tables show. */
|
||||
get story(): string[] {
|
||||
const t = this.trail;
|
||||
if (!t?.summary) return [];
|
||||
const s = t.summary;
|
||||
const day = (iso: string) => formatDate(iso, 'dd MMM yyyy', 'en-US');
|
||||
const money = (n: number) => `₹${formatNumber(Math.abs(Number(n)), 'en-US', '1.2-2')}`;
|
||||
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(
|
||||
`From ${day(t.from)} to ${day(t.asOf)}, this ${t.isCc ? 'Cash Credit' : 'current'} account received ${money(s.in)} (Cr) in ` +
|
||||
`${plural(s.inCount, 'credit')} and paid out ${money(s.out)} (Dr) in ${plural(s.outCount, 'debit')}.`
|
||||
);
|
||||
const net = Number(s.net);
|
||||
lines.push(
|
||||
net < 0
|
||||
? `Debits were ${money(net)} more than credits, so more money went out than came in.`
|
||||
: net > 0
|
||||
? `Credits were ${money(net)} more than debits, so more money came in than went out.`
|
||||
: 'Credits and debits were equal.'
|
||||
);
|
||||
const bal = Number(t.balance);
|
||||
const limit = Number(t.sanctionedLimit) || 0;
|
||||
if (bal < 0) {
|
||||
lines.push(
|
||||
`The account now stands at ${money(bal)} Dr: it is overdrawn, which means the customer has used ${money(bal)} of the ` +
|
||||
`bank's money${limit > 0 ? `, ${((-bal * 100) / limit).toFixed(1)}% of the ${money(limit)} sanctioned limit` : ''}.`
|
||||
);
|
||||
} else if (bal > 0) {
|
||||
lines.push(
|
||||
`The account now stands at ${money(bal)} Cr: the customer's own money is in it and none of the limit is in use.`
|
||||
);
|
||||
} else {
|
||||
lines.push('The account now stands at nil.');
|
||||
}
|
||||
if (s.lowestBalance != null && Number(s.lowestBalance) < 0) {
|
||||
lines.push(
|
||||
`At its lowest it was ${money(s.lowestBalance)} Dr${s.peakLimitUsedPercent != null ? `, which is ${s.peakLimitUsedPercent}% of the limit` : ''}.`
|
||||
);
|
||||
}
|
||||
const mode = t.byMode?.[0];
|
||||
if (mode) {
|
||||
lines.push(`Most of the money moved by ${mode.label} (${mode.sharePercent}% of everything).`);
|
||||
}
|
||||
const party = (t.byCounterparty || []).find((p: any) => p.label !== 'Not recorded');
|
||||
if (party) {
|
||||
lines.push(
|
||||
`The party handling the most money was ${party.label}${party.sub ? ' (' + party.sub + ')' : ''}: ` +
|
||||
`${money(party.in)} Cr from it and ${money(party.out)} Dr to it.`
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** An amount with its sign in front of the rupee sign: -₹1,00,000.00, not ₹-1,00,000.00. */
|
||||
rs(value: number | string | null | undefined, digits = '1.2-2'): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const n = Number(value);
|
||||
return `${n < 0 ? '-' : ''}₹${formatNumber(Math.abs(n), 'en-US', digits)}`;
|
||||
}
|
||||
|
||||
/** True when a side of the account's activity is well beyond what the customer declared for the window. */
|
||||
isSpike(ratio: number | null): boolean {
|
||||
return ratio != null && ratio >= this.spikeMultiple;
|
||||
}
|
||||
}
|
||||
+139
-148
@@ -303,153 +303,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- The expanded pane. One cell spanning the table, collapsed to zero height until its row is
|
||||
opened, so the detail sits directly under the row it belongs to. -->
|
||||
<ng-container matColumnDef="expandedDetail">
|
||||
<!-- No header cell: this column never appears in the header row, only in its own data row. -->
|
||||
<td mat-cell *matCellDef="let alert" [attr.colspan]="displayedColumns.length" class="detail-cell">
|
||||
<!-- The pane is inside a row that toggles on click, so anything interactive in here must keep its own
|
||||
clicks from reaching it — otherwise ticking a rule closes the pane. -->
|
||||
<div class="detail-pane" [class.detail-open]="isExpanded(alert)" (click)="$event.stopPropagation()">
|
||||
@if (isExpanded(alert)) {
|
||||
<div class="detail-inner">
|
||||
<div class="detail-head">
|
||||
<span
|
||||
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
|
||||
{{ amount(alert) }}</span
|
||||
>
|
||||
@if (selectableRulesOf(alert).length > 1) {
|
||||
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
|
||||
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Governing rule first: it is the one the alert was raised under, and the one the
|
||||
disposition is recorded against. -->
|
||||
@for (rule of matchedRules(alert); track rule.ruleCode) {
|
||||
<div
|
||||
class="detail-rule"
|
||||
[class.detail-rule-governing]="rule.governing"
|
||||
[class.detail-rule-decided]="rule.disposition"
|
||||
>
|
||||
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
|
||||
customer's business and the sixth is not, and those are different decisions. A rule already
|
||||
decided shows its verdict instead. -->
|
||||
@if (isRuleSelectable(rule)) {
|
||||
<mat-checkbox
|
||||
[checked]="isRuleSelected(rule)"
|
||||
(change)="toggleRuleSelected(rule)"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
></mat-checkbox>
|
||||
} @else if (!isGradedRule(rule)) {
|
||||
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
|
||||
about the transaction, so there is nothing here to tick. -->
|
||||
<mat-icon
|
||||
class="rule-decided-icon decided-statutory"
|
||||
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
gavel
|
||||
</mat-icon>
|
||||
} @else {
|
||||
<mat-icon
|
||||
class="rule-decided-icon"
|
||||
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
|
||||
[class.decided-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
|
||||
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
|
||||
"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
{{
|
||||
!rule.directlyDecided
|
||||
? 'remove_circle_outline'
|
||||
: rule.disposition === 'ESCALATE'
|
||||
? 'report'
|
||||
: 'verified'
|
||||
}}
|
||||
</mat-icon>
|
||||
}
|
||||
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
|
||||
severityLabel(rule.riskCategory)
|
||||
}}</span>
|
||||
<div class="detail-rule-text">
|
||||
<span class="detail-rule-code">
|
||||
{{ rule.ruleCode }}
|
||||
@if (rule.governing) {
|
||||
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
|
||||
>governing</span
|
||||
>
|
||||
}
|
||||
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
|
||||
transaction being unreported. -->
|
||||
@if (!isGradedRule(rule)) {
|
||||
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
|
||||
→ CTR filing
|
||||
</span>
|
||||
}
|
||||
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
|
||||
because an audit of the finding turns on which one they put their name to. -->
|
||||
@if (rule.disposition) {
|
||||
<span
|
||||
class="rule-verdict"
|
||||
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
|
||||
[class.verdict-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
rule.directlyDecided
|
||||
? ruleDispositionLabel(rule) +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
|
||||
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
|
||||
"
|
||||
>
|
||||
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
|
||||
clearing five of six rules leaves the case open on the sixth. -->
|
||||
@if (selectedRuleCount > 0) {
|
||||
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<span class="rule-action-count"
|
||||
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
|
||||
>
|
||||
<span class="bulk-spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="reviewSelectedRules(alert, 'WHITELIST')"
|
||||
>
|
||||
Whitelist
|
||||
</button>
|
||||
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
|
||||
Suspicious
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-foot">
|
||||
@if (isHighSeverity(alert)) {
|
||||
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
|
||||
} @else if (!alert.highestSeverity) {
|
||||
A statutory return rather than a graded suspicion — decided on its own.
|
||||
} @else {
|
||||
Every rule here is low or medium, so this transaction can be decided together with others.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
@@ -541,9 +394,147 @@
|
||||
[class.row-expanded]="isExpanded(row)"
|
||||
(click)="toggleExpanded(row)"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<!-- Every rule a transaction tripped, in a popup rather than a pane pushed in under the row. Declared here so it keeps
|
||||
this component's state and styles; opened by toggleExpanded(). -->
|
||||
<ng-template #rulesDialog>
|
||||
<div class="rules-dialog">
|
||||
<button type="button" class="rules-dialog-close" mat-icon-button mat-dialog-close aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
@if (expandedAlert; as alert) {
|
||||
<div class="detail-inner">
|
||||
<div class="detail-head">
|
||||
<span
|
||||
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
|
||||
{{ amount(alert) }}</span
|
||||
>
|
||||
@if (selectableRulesOf(alert).length > 1) {
|
||||
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
|
||||
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Governing rule first: it is the one the alert was raised under, and the one the
|
||||
disposition is recorded against. -->
|
||||
@for (rule of matchedRules(alert); track rule.ruleCode) {
|
||||
<div
|
||||
class="detail-rule"
|
||||
[class.detail-rule-governing]="rule.governing"
|
||||
[class.detail-rule-decided]="rule.disposition"
|
||||
>
|
||||
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
|
||||
customer's business and the sixth is not, and those are different decisions. A rule already
|
||||
decided shows its verdict instead. -->
|
||||
@if (isRuleSelectable(rule)) {
|
||||
<mat-checkbox
|
||||
[checked]="isRuleSelected(rule)"
|
||||
(change)="toggleRuleSelected(rule)"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
></mat-checkbox>
|
||||
} @else if (!isGradedRule(rule)) {
|
||||
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
|
||||
about the transaction, so there is nothing here to tick. -->
|
||||
<mat-icon
|
||||
class="rule-decided-icon decided-statutory"
|
||||
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
gavel
|
||||
</mat-icon>
|
||||
} @else {
|
||||
<mat-icon
|
||||
class="rule-decided-icon"
|
||||
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
|
||||
[class.decided-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
|
||||
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
|
||||
"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
{{
|
||||
!rule.directlyDecided
|
||||
? 'remove_circle_outline'
|
||||
: rule.disposition === 'ESCALATE'
|
||||
? 'report'
|
||||
: 'verified'
|
||||
}}
|
||||
</mat-icon>
|
||||
}
|
||||
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
|
||||
severityLabel(rule.riskCategory)
|
||||
}}</span>
|
||||
<div class="detail-rule-text">
|
||||
<span class="detail-rule-code">
|
||||
{{ rule.ruleCode }}
|
||||
@if (rule.governing) {
|
||||
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
|
||||
>governing</span
|
||||
>
|
||||
}
|
||||
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
|
||||
transaction being unreported. -->
|
||||
@if (!isGradedRule(rule)) {
|
||||
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
|
||||
→ CTR filing
|
||||
</span>
|
||||
}
|
||||
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
|
||||
because an audit of the finding turns on which one they put their name to. -->
|
||||
@if (rule.disposition) {
|
||||
<span
|
||||
class="rule-verdict"
|
||||
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
|
||||
[class.verdict-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
rule.directlyDecided
|
||||
? ruleDispositionLabel(rule) + (rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
|
||||
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
|
||||
"
|
||||
>
|
||||
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
|
||||
clearing five of six rules leaves the case open on the sixth. -->
|
||||
@if (selectedRuleCount > 0) {
|
||||
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<span class="rule-action-count"
|
||||
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
|
||||
>
|
||||
<span class="bulk-spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
|
||||
<button mat-stroked-button color="primary" type="button" (click)="reviewSelectedRules(alert, 'WHITELIST')">
|
||||
Whitelist
|
||||
</button>
|
||||
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
|
||||
Suspicious
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-foot">
|
||||
@if (isHighSeverity(alert)) {
|
||||
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
|
||||
} @else if (!alert.highestSeverity) {
|
||||
A statutory return rather than a graded suspicion — decided on its own.
|
||||
} @else {
|
||||
Every rule here is low or medium, so this transaction can be decided together with others.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
|
||||
paginator before the data source has bound to it leaves that 0 in force, which pages every row
|
||||
out of the table. -->
|
||||
|
||||
+27
-1
@@ -6,6 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.flagged-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -744,11 +750,13 @@
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -1011,3 +1019,21 @@
|
||||
color: #1e293b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The rules popup. Its content is the same markup the inline pane used, so it only needs room and a close button. */
|
||||
.rules-dialog {
|
||||
position: relative;
|
||||
padding: 8px 0 0;
|
||||
|
||||
.detail-inner {
|
||||
padding: 18px 24px 22px;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rules-dialog-close {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
+18
-3
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild, TemplateRef } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -167,9 +167,24 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
||||
* and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
|
||||
*/
|
||||
toggleExpanded(alert: any): void {
|
||||
this.expandedId = this.expandedId === alert.id ? null : alert.id;
|
||||
// Ticks belong to the alert they were made on, not to whichever pane is open next.
|
||||
this.expandedId = alert.id;
|
||||
// Ticks belong to the alert they were made on, not to whichever popup is open next.
|
||||
this.selectedRuleCodes.clear();
|
||||
this.dialog
|
||||
.open(this.rulesDialogTpl, { width: '780px', maxWidth: '95vw', maxHeight: '90vh', autoFocus: false })
|
||||
.afterClosed()
|
||||
.subscribe(() => {
|
||||
this.expandedId = null;
|
||||
this.selectedRuleCodes.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/** The popup that lists every rule the open alert tripped. */
|
||||
@ViewChild('rulesDialog') rulesDialogTpl!: TemplateRef<unknown>;
|
||||
|
||||
/** The alert the popup is showing, looked up fresh so a feed refresh behind it does not leave it stale. */
|
||||
get expandedAlert(): any {
|
||||
return this.alerts.data.find((a: any) => a.id === this.expandedId) ?? null;
|
||||
}
|
||||
|
||||
isExpanded(alert: any): boolean {
|
||||
|
||||
+6
@@ -6,6 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ra-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -58,6 +58,38 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- A newly added rule is on trial, listed first, until an officer marks it normal -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Status</span>
|
||||
<button
|
||||
*ngFor="let s of statusFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activeStatus === s.key"
|
||||
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
|
||||
(click)="setStatus(s.key)"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span class="period-chip-n">{{ statusCount(s.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Separate the rules by the kind of account they watch -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Account</span>
|
||||
<button
|
||||
*ngFor="let a of accountFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activeAccount === a.key"
|
||||
[disabled]="accountCount(a.key) === 0 && activeAccount !== a.key"
|
||||
(click)="setAccount(a.key)"
|
||||
>
|
||||
{{ a.label }}
|
||||
<span class="period-chip-n">{{ accountCount(a.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Separate the rules by the window they are measured over -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Applies over</span>
|
||||
@@ -84,7 +116,15 @@
|
||||
<!-- 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>
|
||||
<td mat-cell *matCellDef="let element" class="rule-name-cell">
|
||||
{{ element.ruleName }}
|
||||
<span
|
||||
*ngIf="element.onTrial"
|
||||
class="trial-badge"
|
||||
[title]="'On trial since ' + (element.trialStartedOn | date: 'dd MMM yyyy')"
|
||||
>TRIAL · day {{ trialDays(element) + 1 }}</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Risk Category Column -->
|
||||
@@ -163,8 +203,24 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Trial: a newly added rule is on trial until an officer marks it normal -->
|
||||
<ng-container matColumnDef="trial">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Trial</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<button *ngIf="element.onTrial" mat-stroked-button type="button" (click)="markNormal(element)">
|
||||
Mark as normal
|
||||
</button>
|
||||
<span *ngIf="!element.onTrial" class="trial-normal">Normal</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="rule-row"
|
||||
[class.rule-row--trial]="row.onTrial"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<!-- Mat Paginator for standard pagination -->
|
||||
@@ -203,6 +259,15 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- How much each CC account has taken in and paid out. Lazily instantiated so it only loads when opened. -->
|
||||
<mat-tab label="CC Accounts">
|
||||
<ng-template matTabContent>
|
||||
<div class="aml-tab-body">
|
||||
<mifosx-aml-cc-tracking></mifosx-aml-cc-tracking>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
|
||||
.aml-card-content {
|
||||
padding: 24px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Rules Master / Flagged Transactions. Padding lives on the body rather than the group so the
|
||||
@@ -86,10 +87,25 @@
|
||||
::ng-deep .mat-mdc-tab-body-wrapper {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Material makes each tab body a horizontal scroller, so the few pixels the tables overshoot by showed up as a
|
||||
scrollbar under every tab. The tables are width: 100% and their cells wrap, so nothing real sits out there. */
|
||||
::ng-deep .mat-mdc-tab-body-content {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-tab-body-wrapper,
|
||||
::ng-deep .mat-mdc-tab-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.aml-tab-body {
|
||||
padding-top: 16px;
|
||||
|
||||
/* The tab's own box, in case something inside it still overshoots by a few pixels. */
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.aml-table {
|
||||
@@ -426,3 +442,26 @@
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* A rule on trial: listed first, tinted, with a badge, until an officer marks it normal. */
|
||||
.trial-badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-row--trial {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.trial-normal {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AlertService } from '../../core/alert/alert.service';
|
||||
import { AddAmlRuleDialogComponent } from './add-aml-rule-dialog/add-aml-rule-dialog.component';
|
||||
import { AmlFlaggedTransactionsComponent } from './aml-flagged-transactions/aml-flagged-transactions.component';
|
||||
import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurring-alerts.component';
|
||||
import { AmlCcTrackingComponent } from './aml-cc-tracking/aml-cc-tracking.component';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-aml-rules',
|
||||
@@ -49,7 +50,8 @@ import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurrin
|
||||
MatTabsModule,
|
||||
FormsModule,
|
||||
AmlFlaggedTransactionsComponent,
|
||||
AmlRecurringAlertsComponent
|
||||
AmlRecurringAlertsComponent,
|
||||
AmlCcTrackingComponent
|
||||
]
|
||||
})
|
||||
export class AmlRulesComponent implements OnInit {
|
||||
@@ -68,7 +70,8 @@ export class AmlRulesComponent implements OnInit {
|
||||
'appliesOver',
|
||||
'reason',
|
||||
'amlCheckStatus',
|
||||
'bannedEntityMatch'
|
||||
'bannedEntityMatch',
|
||||
'trial'
|
||||
];
|
||||
|
||||
/** Every rule as returned by the API, before the period filter is applied. */
|
||||
@@ -85,6 +88,26 @@ export class AmlRulesComponent implements OnInit {
|
||||
];
|
||||
activePeriod = '';
|
||||
|
||||
/**
|
||||
* Which kind of account the rules are for. CC is a Cash Credit account: a current account allowed to draw an
|
||||
* overdraft against a sanctioned limit. The chip lists every scenario that watches such an account, whatever its
|
||||
* coverage status, so the officer sees what is and is not yet being checked.
|
||||
*/
|
||||
readonly accountFilters: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'All accounts' },
|
||||
{ key: 'CC', label: 'CC' }
|
||||
];
|
||||
activeAccount = '';
|
||||
|
||||
/** Whether to list only the rules still on trial. */
|
||||
readonly statusFilters: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'All rules' },
|
||||
{ key: 'TRIAL', label: 'On trial' }
|
||||
];
|
||||
activeStatus = '';
|
||||
private statusCounts: Record<string, number> = {};
|
||||
private accountCounts: Record<string, number> = {};
|
||||
|
||||
/** Free-text search, applied on top of the period filter. */
|
||||
searchTerm = '';
|
||||
|
||||
@@ -108,7 +131,15 @@ export class AmlRulesComponent implements OnInit {
|
||||
private loadRules(): void {
|
||||
this.clientsService.getClientRiskRules().subscribe({
|
||||
next: (data) => {
|
||||
this.allRules = data || [];
|
||||
// A rule on trial is listed first, the newest first; the rest keep the catalog order.
|
||||
const all: any[] = data || [];
|
||||
const trial = all
|
||||
.filter((r) => r.onTrial)
|
||||
.sort((a, b) => String(b.trialStartedOn || '').localeCompare(String(a.trialStartedOn || '')) || b.id - a.id);
|
||||
this.allRules = [
|
||||
...trial,
|
||||
...all.filter((r) => !r.onTrial)
|
||||
];
|
||||
this.applyPeriodFilter();
|
||||
this.rules.paginator = this.paginator;
|
||||
},
|
||||
@@ -145,6 +176,48 @@ export class AmlRulesComponent implements OnInit {
|
||||
this.applyPeriodFilter();
|
||||
}
|
||||
|
||||
setStatus(key: string): void {
|
||||
this.activeStatus = key;
|
||||
this.applyPeriodFilter();
|
||||
}
|
||||
|
||||
/** How many rules are on each status chip — shown on the chips. */
|
||||
statusCount(key: string): number {
|
||||
return this.statusCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
/** Ends a rule's trial: it stays in the catalog as a normal rule. */
|
||||
markNormal(rule: any): void {
|
||||
this.clientsService.markClientRiskRuleNormal(rule.id).subscribe({
|
||||
next: () => {
|
||||
this.alertService.alert({ type: 'AML Rule', message: `"${rule.ruleName}" is now a normal rule.` });
|
||||
this.loadRules();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Days a rule has been on trial, from the date it was added. */
|
||||
trialDays(rule: any): number {
|
||||
if (!rule?.trialStartedOn) return 0;
|
||||
const started = new Date(String(rule.trialStartedOn));
|
||||
return Math.max(0, Math.floor((Date.now() - started.getTime()) / 86400000));
|
||||
}
|
||||
|
||||
setAccount(key: string): void {
|
||||
this.activeAccount = key;
|
||||
this.applyPeriodFilter();
|
||||
}
|
||||
|
||||
/** How many rules belong to each account chip — shown on the chips. */
|
||||
accountCount(key: string): number {
|
||||
return this.accountCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
/** Whether a rule is for the account kind the officer has picked. */
|
||||
private inAccountScope(rule: any): boolean {
|
||||
return !this.activeAccount || (this.activeAccount === 'CC' && !!rule.ccRelevant);
|
||||
}
|
||||
|
||||
applySearch(value: string): void {
|
||||
this.searchTerm = value;
|
||||
this.rules.filter = value.trim().toLowerCase();
|
||||
@@ -171,9 +244,12 @@ export class AmlRulesComponent implements OnInit {
|
||||
}
|
||||
|
||||
private applyPeriodFilter(): void {
|
||||
this.rules.data = this.activePeriod
|
||||
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
|
||||
: this.allRules;
|
||||
this.rules.data = this.allRules.filter(
|
||||
(r) =>
|
||||
this.inAccountScope(r) &&
|
||||
(!this.activeStatus || !!r.onTrial) &&
|
||||
(!this.activePeriod || this.periodKey(r) === this.activePeriod)
|
||||
);
|
||||
this.recomputePeriodCounts();
|
||||
if (this.rules.paginator) {
|
||||
this.rules.paginator.firstPage();
|
||||
@@ -191,7 +267,12 @@ export class AmlRulesComponent implements OnInit {
|
||||
*/
|
||||
private recomputePeriodCounts(): void {
|
||||
const filter = this.rules.filter;
|
||||
const pool = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
|
||||
const searched = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
|
||||
// The account chips count within the search and the chosen period; the period chips within the chosen account.
|
||||
const inPeriod = searched.filter((r) => !this.activePeriod || this.periodKey(r) === this.activePeriod);
|
||||
this.accountCounts = { '': inPeriod.length, CC: inPeriod.filter((r) => !!r.ccRelevant).length };
|
||||
this.statusCounts = { '': searched.length, TRIAL: searched.filter((r) => !!r.onTrial).length };
|
||||
const pool = searched.filter((r) => this.inAccountScope(r) && (!this.activeStatus || !!r.onTrial));
|
||||
this.periodCounts = { '': pool.length };
|
||||
for (const p of this.periodFilters) {
|
||||
if (p.key) {
|
||||
|
||||
Reference in New Issue
Block a user