feat: add database export, welcome kit dialog, and DIP/sanctions updates
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:52:28 +05:30
co-authored by Claude Opus 5
parent c18219fce5
commit 7ef75c902d
33 changed files with 3042 additions and 208 deletions
+6 -14
View File
@@ -44,7 +44,7 @@
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
"pdfjs-dist": "^5.6.205",
"pdfjs-dist": "^5.7.284",
"rxjs": "^7.8.2",
"signature_pad": "^5.1.3",
"tinymce": "^8.2.2",
@@ -19273,13 +19273,6 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -20207,16 +20200,15 @@
}
},
"node_modules/pdfjs-dist": {
"version": "5.6.205",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
"version": "5.7.284",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
"integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.96",
"node-readable-to-web-readable-stream": "^0.4.2"
"@napi-rs/canvas": "^0.1.100"
}
},
"node_modules/pend": {
+1 -1
View File
@@ -73,7 +73,7 @@
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
"pdfjs-dist": "^5.6.205",
"pdfjs-dist": "^5.7.284",
"rxjs": "^7.8.2",
"signature_pad": "^5.1.3",
"tinymce": "^8.2.2",
@@ -41,6 +41,26 @@
>
{{ address.isActive ? 'Active' : 'Inactive' }}
</span>
<!-- Only shown on the address a welcome kit was actually posted to. -->
@if (verificationOf(address); as verification) {
<span
class="addr-panel__verify"
[class.addr-panel__verify--yes]="verification === 'VERIFIED'"
[class.addr-panel__verify--no]="verification === 'NOT_VERIFIED'"
[matTooltip]="
verification === 'VERIFIED'
? 'Welcome kit was delivered to this address'
: 'Welcome kit sent here was returned undelivered'
"
>
<i
class="fa"
[class.fa-check-circle]="verification === 'VERIFIED'"
[class.fa-times-circle]="verification === 'NOT_VERIFIED'"
></i>
{{ verification === 'VERIFIED' ? 'Verified' : 'Not Verified' }}
</span>
}
</mat-panel-title>
<mat-panel-description class="addr-panel__desc">
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : ''
@@ -148,6 +148,32 @@ $red-light: #fce8e6;
}
}
/* Welcome kit outcome — only present on the address a kit was posted to. */
&__verify {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
white-space: nowrap;
i {
font-size: 11px;
}
&--yes {
background: $green-light;
color: $green;
}
&--no {
background: $red-light;
color: $red;
}
}
&__desc {
font-size: 12px;
color: $gray-500 !important;
@@ -33,6 +33,7 @@ import {
} from '@angular/material/expansion';
import { MatDivider } from '@angular/material/divider';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -51,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatExpansionPanelTitle,
MatExpansionPanelDescription,
MatDivider,
MatSlideToggle
MatSlideToggle,
MatTooltip
]
})
export class AddressTabComponent {
@@ -68,6 +70,11 @@ export class AddressTabComponent {
clientAddressTemplate: any;
/** Client Id */
clientId: string;
/**
* Welcome kit outcome per address id. Only addresses a kit was actually posted to appear here: delivery
* proves the address is real, a returned kit proves it is not, and an address with no kit stays unmarked.
*/
addressVerification: Record<string, 'VERIFIED' | 'NOT_VERIFIED'> = {};
/**
* @param {ActivatedRoute} route Activated Route
@@ -82,10 +89,48 @@ export class AddressTabComponent {
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
this.clientAddressTemplate = data.clientAddressTemplateData;
this.clientId = this.route.parent.snapshot.paramMap.get('clientId');
this.loadWelcomeKitVerification();
}
);
}
/**
* Marks addresses a welcome kit was posted to with the outcome of that delivery. The kit is the physical
* check on the address, so a delivered kit reads as verified and a returned one as not verified.
*/
private loadWelcomeKitVerification(): void {
if (!this.clientId) {
return;
}
this.clientService.getWelcomeKits(this.clientId).subscribe({
next: (response: any) => {
const map: Record<string, 'VERIFIED' | 'NOT_VERIFIED'> = {};
// Oldest first, so that when an address was posted to more than once the newest outcome wins.
const dispatches = [...(response?.dispatches || [])].reverse();
for (const dispatch of dispatches) {
if (dispatch?.addressId == null) {
continue;
}
if (dispatch.status === 'DELIVERED') {
map[dispatch.addressId] = 'VERIFIED';
} else if (dispatch.status === 'RETURNED') {
map[dispatch.addressId] = 'NOT_VERIFIED';
}
}
this.addressVerification = map;
},
// A client with no welcome kit permission or no kit yet simply shows no badge.
error: () => {
this.addressVerification = {};
}
});
}
/** Verification badge for an address, or null when no kit has been posted to it. */
verificationOf(address: any): 'VERIFIED' | 'NOT_VERIFIED' | null {
return this.addressVerification[address?.addressId] || null;
}
/**
* Adds a client address.
*/
@@ -168,58 +168,78 @@
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
</div>
}
@if (clientViewData.mobileNo && clientViewData.dipVerified !== null) {
<!--
dipLiveStatus reflects the LATEST lookup, not the flag frozen at onboarding. A customer
cleared when they joined can have their number reported for fraud or reassigned later —
this cell has to show that, otherwise the page keeps claiming "Verified" indefinitely.
-->
@if (clientViewData.mobileNo && (clientViewData.dipVerified !== null || clientViewData.dipLiveStatus)) {
<div class="cv-cell">
<div class="cv-cell__label">DIP Status</div>
<div class="cv-cell__value">
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
"
[class.cv-badge--yellow]="
clientViewData.dipVerified !== true &&
clientViewData.dipVerified !== 'true' &&
clientViewData.dipVerified !== 1
"
[class.cv-badge--red]="isDipFlagged(clientViewData.dipLiveStatus)"
[class.cv-badge--green]="!isDipFlagged(clientViewData.dipLiveStatus) && isDipClear(clientViewData)"
[class.cv-badge--yellow]="!isDipFlagged(clientViewData.dipLiveStatus) && !isDipClear(clientViewData)"
>
{{
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
? 'Verified'
: 'Pending'
}}
{{ dipStatusLabel(clientViewData) }}
</span>
</div>
</div>
}
@if (clientViewData.nameVerified !== null) {
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<div class="cv-cell cv-cell--full">
<div class="dip-alert">
<fa-icon icon="exclamation-triangle" class="dip-alert__icon"></fa-icon>
<div>
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
<div class="dip-alert__detail">
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
obtain a new number. Account operations are not restricted.
</div>
</div>
</div>
</div>
}
<!--
A frozen customer must be obvious the moment anyone opens them, not something a teller
discovers when a withdrawal is refused at the counter.
-->
@if (clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH') {
<div class="cv-cell cv-cell--full">
<div class="dip-alert">
<fa-icon icon="ban" class="dip-alert__icon"></fa-icon>
<div>
<strong>Confirmed UN Security Council sanctions match — accounts frozen.</strong>
<div class="dip-alert__detail">
Debits are blocked on this customer's deposit accounts and lending is barred. Do not discuss the
reason with the customer. See Reports &rarr; Sanctions Screening &amp; Alerts.
</div>
</div>
</div>
</div>
}
<!--
Driven by sanctionsAlertStatus (the live alert) rather than nameVerified, which records
the outcome at onboarding and never changes. A client confirmed against the UNSC list and
frozen afterwards was still reading "Clear" here — the single worst place to be wrong,
since this header is what anyone opening the customer sees first.
-->
@if (clientViewData.nameVerified !== null || clientViewData.sanctionsAlertStatus) {
<div class="cv-cell">
<div class="cv-cell__label">Sanctions Screening</div>
<div class="cv-cell__value">
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
"
[class.cv-badge--red]="
clientViewData.nameVerified === false ||
clientViewData.nameVerified === 'false' ||
clientViewData.nameVerified === 0
[class.cv-badge--green]="!clientViewData.sanctionsAlertStatus && isNameCleared(clientViewData)"
[class.cv-badge--yellow]="
clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW' ||
(!clientViewData.sanctionsAlertStatus && !isNameCleared(clientViewData))
"
[class.cv-badge--red]="clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH'"
>
{{
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
? 'Clear'
: 'Pending'
}}
{{ sanctionsHeaderLabel(clientViewData) }}
</span>
</div>
</div>
@@ -410,9 +430,11 @@
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
@if (isActive()) {
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
}
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
@@ -286,6 +286,39 @@ $accent: #0ea5e9;
}
}
// A DIP hit on an existing customer is not an account restriction, so it is shown as a standing
// advisory on the client rather than as a blocking state.
.cv-cell--full {
grid-column: 1 / -1;
}
.dip-alert {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgb(239 68 68 / 15%);
border: 1px solid rgb(239 68 68 / 40%);
border-left: 3px solid #ef4444;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
color: #fecaca;
line-height: 1.5;
&__icon {
color: #fca5a5;
font-size: 15px;
margin-top: 1px;
flex-shrink: 0;
}
&__detail {
margin-top: 3px;
color: #fca5a5;
font-weight: 400;
}
}
.actions-menu {
color: #fff !important;
}
@@ -85,6 +85,54 @@ import { formatTabLabel } from 'app/shared/utils/format-tab-label.util';
})
export class ClientsViewComponent implements OnInit {
complianceHideClientData = environment.complianceHideClientData;
/**
* Whether the number's CURRENT DIP/MNRL status is a hit. Driven by the live lookup rather than
* the onboarding-time `dipVerified` flag, which never changes once the client is created.
*/
isDipFlagged(liveStatus: string | null | undefined): boolean {
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
}
/** True only when the number is affirmatively clean — not merely "not flagged". */
isDipClear(client: any): boolean {
if (client?.dipLiveStatus) {
return client.dipLiveStatus === 'CLEAN';
}
return client?.dipVerified === true || client?.dipVerified === 'true' || client?.dipVerified === 1;
}
/** True when onboarding screening cleared the name — the pre-alert baseline. */
isNameCleared(client: any): boolean {
return client?.nameVerified === true || client?.nameVerified === 'true' || client?.nameVerified === 1;
}
/**
* Header badge for sanctions. A live alert always wins over the onboarding flag: the flag says
* what was true the day the client joined, the alert says what is true now.
*/
sanctionsHeaderLabel(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return 'Frozen — Sanctions Match';
case 'PENDING_REVIEW':
return 'Under Review';
case 'FALSE_POSITIVE':
return 'Cleared — False Positive';
default:
return this.isNameCleared(client) ? 'Clear' : 'Pending';
}
}
dipStatusLabel(client: any): string {
const live = client?.dipLiveStatus;
if (live === 'SUSPECTED') return 'Flagged — Suspected';
if (live === 'DISCONNECTED') return 'Flagged — Disconnected';
if (live === 'SUSPECTED_AND_DISCONNECTED') return 'Flagged — Suspected & Disconnected';
if (live === 'SERVICE_UNAVAILABLE') return 'Not Verified';
if (live === 'CLEAN') return 'Verified';
return this.isDipClear(client) ? 'Verified' : 'Pending';
}
/**
* Mask a string, keeping first and last letter, masking the rest with *
*/
@@ -461,12 +509,24 @@ export class ClientsViewComponent implements OnInit {
}
/**
* Opens the Welcome Kit Dispatch dialog.
* Opens the Welcome Kit Dispatch dialog. The kit is only posted once the client is active, so the dialog is
* unreachable before then — the menu entry is hidden and this guard covers any other route into the action.
*/
private dispatchWelcomeKit() {
if (!this.isActive()) {
return;
}
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
data: { clientId: this.clientViewData.id },
width: '600px'
data: {
clientId: this.clientViewData.id,
clientName: this.clientViewData.displayName,
accountNo: this.clientViewData.accountNo,
cifNumber: this.clientViewData.cifNumber,
mobileNo: this.clientViewData.mobileNo,
emailAddress: this.clientViewData.emailAddress,
officeName: this.clientViewData.officeName
},
width: '680px'
});
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
if (response) {
@@ -5,90 +5,277 @@
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
<div class="wk-header" mat-dialog-title>
<div class="wk-header__icon">
<mat-icon>card_giftcard</mat-icon>
</div>
<div class="wk-header__text">
<h2 class="wk-header__title">Welcome Kit Dispatch</h2>
<p class="wk-header__subtitle">Posting the kit doubles as a physical address check for this client.</p>
</div>
</div>
<mat-dialog-content class="mat-typography">
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
<mat-dialog-content class="mat-typography wk-content">
<div class="wk-loading" *ngIf="isLoading">
<mat-spinner diameter="40"></mat-spinner>
</div>
<ng-container *ngIf="!isLoading">
<!-- Tracking view if dispatches already exist -->
<div
class="dispatch-tracking-panel"
*ngFor="let dispatch of activeDispatches; let i = index"
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
>
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
Download Details
</button>
<!-- Who the kit is going to -->
<section class="wk-client">
<div class="wk-client__avatar">{{ (clientName || '?').charAt(0) }}</div>
<div class="wk-client__details">
<div class="wk-client__name">{{ clientName || 'Client' }}</div>
<div class="wk-client__meta" *ngIf="data.accountNo">Account No. {{ data.accountNo }}</div>
</div>
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
<p style="margin: 4px 0">
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
</p>
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
<p style="margin: 4px 0">
<strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p>
</div>
</section>
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
<!-- Existing dispatches, newest first -->
<section
class="wk-dispatch"
*ngFor="let dispatch of activeDispatches; let i = index"
[class.wk-dispatch--past]="i > 0"
>
<header class="wk-dispatch__head">
<span class="wk-dispatch__label">{{ i === 0 ? 'Current Dispatch' : 'Previous Dispatch' }}</span>
<span class="wk-chip" [ngClass]="'wk-chip--' + (dispatch.status | lowercase)">
<mat-icon class="wk-chip__icon">
{{
dispatch.status === 'DELIVERED'
? 'verified'
: dispatch.status === 'RETURNED'
? 'gpp_bad'
: 'local_shipping'
}}
</mat-icon>
{{ verificationLabel(dispatch.status) }}
</span>
</header>
<!-- Create form if they can dispatch a new one -->
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3>Create New Dispatch</h3>
<div fxLayout="column" fxLayoutGap="16px">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Address</mat-label>
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
Delivery Address is required
<p class="wk-dispatch__hint">{{ verificationHint(dispatch) }}</p>
<dl class="wk-facts">
<div class="wk-facts__row wk-facts__row--wide">
<dt>Delivery Address</dt>
<dd>{{ getDisplayAddress(dispatch) }}</dd>
</div>
<div class="wk-facts__row">
<dt>Tracking ID</dt>
<dd class="wk-facts__mono">{{ dispatch.trackingId }}</dd>
</div>
<div class="wk-facts__row">
<dt>Delivery Mode</dt>
<dd>
{{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'"
>({{ dispatch.deliveryModeOtherText }})</ng-container
>
</dd>
</div>
<div class="wk-facts__row">
<dt>Dispatched On</dt>
<dd>{{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row" *ngIf="dispatch.deliveredOnDate">
<dt>Delivered On</dt>
<dd>{{ dispatch.deliveredOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row" *ngIf="dispatch.returnedOnDate">
<dt>Returned On</dt>
<dd>{{ dispatch.returnedOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row wk-facts__row--wide" *ngIf="dispatch.remarks">
<dt>Remarks</dt>
<dd>{{ dispatch.remarks }}</dd>
</div>
</dl>
<!-- Outcome form, shown in place of the buttons once an outcome is picked -->
<div class="wk-outcome" *ngIf="outcomeFor?.id === dispatch.id" [formGroup]="outcomeForm">
<p class="wk-outcome__title">
Mark this kit as
<strong>{{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }}</strong>
</p>
<div class="wk-outcome__fields">
<mat-form-field appearance="outline">
<mat-label>{{ outcomeStatus === 'DELIVERED' ? 'Delivered On' : 'Returned On' }}</mat-label>
<input matInput [matDatepicker]="outcomePicker" [max]="maxDate" formControlName="outcomeDate" required />
<mat-datepicker-toggle matSuffix [for]="outcomePicker"></mat-datepicker-toggle>
<mat-datepicker #outcomePicker></mat-datepicker>
<mat-error *ngIf="outcomeForm.get('outcomeDate')?.hasError('required')"> Date is required </mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Remarks (optional)</mat-label>
<input
matInput
formControlName="remarks"
maxlength="500"
placeholder="What the delivery partner reported"
/>
</mat-form-field>
</div>
<div class="wk-outcome__actions">
<button mat-button type="button" (click)="cancelOutcome()">Cancel</button>
<button
mat-flat-button
type="button"
[color]="outcomeStatus === 'DELIVERED' ? 'primary' : 'warn'"
[disabled]="outcomeForm.invalid"
(click)="submitOutcome()"
>
Confirm {{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }}
</button>
</div>
</div>
<footer class="wk-dispatch__actions" *ngIf="outcomeFor?.id !== dispatch.id">
<button mat-stroked-button type="button" (click)="downloadAddressLabel(dispatch)">
<mat-icon>print</mat-icon>
Label
</button>
<button mat-stroked-button type="button" (click)="downloadPdf(dispatch)">
<mat-icon>download</mat-icon>
Details
</button>
<ng-container *ngIf="dispatch.status === 'DISPATCHED'">
<button mat-stroked-button color="warn" type="button" (click)="startOutcome(dispatch, 'RETURNED')">
<mat-icon>gpp_bad</mat-icon>
Not Verified
</button>
<button mat-flat-button color="primary" type="button" (click)="startOutcome(dispatch, 'DELIVERED')">
<mat-icon>verified</mat-icon>
Verified
</button>
</ng-container>
</footer>
</section>
<!-- New dispatch -->
<section class="wk-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3 class="wk-form__title">
{{ activeDispatches.length > 0 ? 'Dispatch a Replacement Kit' : 'Create New Dispatch' }}
</h3>
<!-- The kit goes to one of the addresses captured at client creation; it is chosen, never typed. -->
<ng-container *ngIf="addressChoices.length > 0">
<mat-form-field appearance="outline" class="wk-full">
<mat-label>Deliver To</mat-label>
<mat-select formControlName="addressId" required>
<mat-option *ngFor="let address of addressChoices" [value]="address.id">
{{ address.type }} — {{ address.oneLine }}
</mat-option>
</mat-select>
<mat-hint>Addresses recorded for this client. Update them on the Address tab if one is wrong.</mat-hint>
<mat-error *ngIf="dispatchForm.get('addressId')?.hasError('required')">
Select the address to deliver to
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<div class="wk-address" *ngIf="selectedAddress as address">
<mat-icon class="wk-address__icon">place</mat-icon>
<div class="wk-address__body">
<span class="wk-address__type">{{ address.type }}</span>
<span class="wk-address__name">{{ clientName }}</span>
<span class="wk-address__line" *ngFor="let line of address.lines">{{ line }}</span>
</div>
</div>
</ng-container>
<div class="wk-noaddress" *ngIf="addressesLoaded && addressChoices.length === 0">
<mat-icon>error_outline</mat-icon>
<span>
No address is recorded for this client. Add one on the Address tab before dispatching a welcome kit.
</span>
</div>
<div class="wk-form__row">
<mat-form-field appearance="outline">
<mat-label>Delivery Mode</mat-label>
<mat-select formControlName="deliveryMode" required>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
{{ mode }}
</mat-option>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">{{ mode }}</mat-option>
</mat-select>
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
Delivery Mode is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width"
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
>
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
<mat-form-field appearance="outline">
<mat-label>Dispatched On</mat-label>
<input
matInput
[matDatepicker]="dispatchPicker"
[max]="maxDate"
formControlName="dispatchedOnDate"
required
/>
<mat-datepicker-toggle matSuffix [for]="dispatchPicker"></mat-datepicker-toggle>
<mat-datepicker #dispatchPicker></mat-datepicker>
<mat-error *ngIf="dispatchForm.get('dispatchedOnDate')?.hasError('required')">
Dispatch date is required
</mat-error>
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="wk-full" *ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'">
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
</mat-error>
</mat-form-field>
<!-- Printed before the kit is posted, so the tracking ID below is filled in afterwards. -->
<div class="wk-label-cta">
<div class="wk-label-cta__text">
<span class="wk-label-cta__title">Envelope address label</span>
<span class="wk-label-cta__hint">
Print this first — name, address, contact details and the welcome note, ready to fold into a window
envelope.
</span>
</div>
<button
mat-stroked-button
color="primary"
type="button"
[disabled]="!selectedAddress"
(click)="downloadAddressLabel()"
>
<mat-icon>print</mat-icon>
Download
</button>
</div>
<mat-form-field appearance="outline" class="wk-full">
<mat-label>Tracking ID</mat-label>
<input matInput formControlName="trackingId" maxlength="100" required placeholder="e.g. EX123456789IN" />
<mat-icon matSuffix>confirmation_number</mat-icon>
<mat-hint>Enter the consignment number printed on the delivery partner's receipt.</mat-hint>
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('required')"> Tracking ID is required </mat-error>
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('maxlength')">
Tracking ID cannot exceed 100 characters
</mat-error>
</mat-form-field>
</section>
<!-- Nothing to do: a kit is already out and not yet resolved -->
<p class="wk-empty" *ngIf="!welcomeKitsData?.canDispatchNew && activeDispatches.length > 0">
A welcome kit is already recorded for this client. Record its outcome above before dispatching another.
</p>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
<button mat-button type="button" (click)="close()">Close</button>
<button
mat-raised-button
mat-flat-button
color="primary"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
type="button"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading && !outcomeFor"
(click)="submit()"
[disabled]="dispatchForm.invalid || isLoading"
>
<mat-icon>send</mat-icon>
Dispatch
</button>
</mat-dialog-actions>
@@ -5,55 +5,375 @@
* 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/.
*/
.dispatch-tracking-panel {
background-color: #f5f5f5;
border-radius: 8px;
padding: 16px;
$wk-primary: #1f497d;
$wk-border: #e3e7ee;
$wk-muted: #6b7280;
$wk-surface: #f7f9fc;
/* ---------- header ---------- */
.wk-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
margin: 0;
border-bottom: 1px solid $wk-border;
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex: 0 0 40px;
border-radius: 10px;
background: rgba($wk-primary, 0.1);
color: $wk-primary;
}
&__title {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
line-height: 1.3;
}
&__subtitle {
margin: 2px 0 0;
font-size: 0.8rem;
color: $wk-muted;
}
}
.wk-content {
padding-top: 16px;
}
.wk-loading {
display: flex;
justify-content: center;
padding: 2.5rem 0;
}
/* ---------- client identity ---------- */
.wk-client {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 10px;
background: linear-gradient(135deg, rgba($wk-primary, 0.08), rgba($wk-primary, 0.02));
border: 1px solid rgba($wk-primary, 0.15);
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
flex: 0 0 38px;
border-radius: 50%;
background: $wk-primary;
color: #fff;
font-weight: 600;
text-transform: uppercase;
}
p {
margin: 6px 0;
&__name {
font-weight: 600;
font-size: 1rem;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
color: white;
&__meta {
font-size: 0.8rem;
color: $wk-muted;
font-variant-numeric: tabular-nums;
}
}
&.status-dispatched {
background-color: #1976d2;
}
/* ---------- dispatch panel ---------- */
.wk-dispatch {
border: 1px solid $wk-border;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 16px;
background: $wk-surface;
&.status-in_transit {
background-color: #f57c00;
}
&--past {
opacity: 0.75;
}
&.status-delivered {
background-color: #388e3c;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
&.status-returned {
background-color: #d32f2f;
&__label {
font-weight: 600;
font-size: 0.95rem;
}
&__hint {
margin: 0 0 12px;
font-size: 0.8rem;
color: $wk-muted;
line-height: 1.45;
}
&__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.dispatch-create-form {
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
/* ---------- status chip ---------- */
.wk-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
&__icon {
font-size: 15px;
width: 15px;
height: 15px;
}
&--dispatched {
background: rgb(25 118 210 / 12%);
color: #1565c0;
}
&--delivered {
background: rgb(56 142 60 / 14%);
color: #2e7d32;
}
&--returned {
background: rgb(211 47 47 / 12%);
color: #c62828;
}
}
.full-width {
/* ---------- fact grid ---------- */
.wk-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
margin: 0;
&__row {
min-width: 0;
&--wide {
grid-column: 1 / -1;
}
dt {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $wk-muted;
margin-bottom: 2px;
}
dd {
margin: 0;
font-size: 0.875rem;
word-break: break-word;
}
}
&__mono {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-weight: 600;
letter-spacing: 0.02em;
}
}
/* ---------- outcome form ---------- */
.wk-outcome {
margin-top: 14px;
padding: 14px;
border-radius: 8px;
background: #fff;
border: 1px dashed rgba($wk-primary, 0.4);
&__title {
margin: 0 0 10px;
font-size: 0.875rem;
}
&__fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 14px;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}
/* ---------- create form ---------- */
.wk-form {
&__title {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 600;
}
&__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 14px;
}
}
.wk-full {
width: 100%;
}
.wk-address {
display: flex;
gap: 10px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 8px;
background: $wk-surface;
border: 1px solid $wk-border;
&__icon {
color: $wk-primary;
font-size: 20px;
width: 20px;
height: 20px;
flex: 0 0 20px;
}
&__body {
display: flex;
flex-direction: column;
font-size: 0.85rem;
line-height: 1.5;
}
&__type {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $wk-primary;
margin-bottom: 2px;
}
&__name {
font-weight: 600;
}
&__line {
color: $wk-muted;
}
}
.wk-noaddress {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 8px;
background: rgb(211 47 47 / 6%);
border: 1px solid rgb(211 47 47 / 25%);
font-size: 0.83rem;
line-height: 1.5;
color: #b3261e;
mat-icon {
font-size: 19px;
width: 19px;
height: 19px;
flex: 0 0 19px;
}
}
/* ---------- envelope label call-to-action ---------- */
.wk-label-cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 12px 14px;
margin-bottom: 18px;
border-radius: 8px;
background: rgba($wk-primary, 0.04);
border: 1px solid rgba($wk-primary, 0.18);
&__text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 240px;
}
&__title {
font-weight: 600;
font-size: 0.875rem;
}
&__hint {
font-size: 0.78rem;
color: $wk-muted;
line-height: 1.45;
}
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
.wk-empty {
margin: 4px 0 0;
padding: 12px 14px;
border-radius: 8px;
background: $wk-surface;
border: 1px solid $wk-border;
font-size: 0.85rem;
color: $wk-muted;
}
mat-dialog-actions {
padding: 12px 24px 20px;
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
@@ -12,12 +12,14 @@ import {
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
MatDialogActions
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatIcon } from '@angular/material/icon';
import { ClientsService } from '../../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DatePipe } from '@angular/common';
import { jsPDF } from 'jspdf';
@@ -32,32 +34,79 @@ import autoTable from 'jspdf-autotable';
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatProgressSpinnerModule
MatProgressSpinnerModule,
MatIcon
],
providers: [DatePipe]
})
export class WelcomeKitDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
data: { clientId: string } = inject(MAT_DIALOG_DATA);
data: {
clientId: string;
clientName?: string;
accountNo?: string;
cifNumber?: string;
mobileNo?: string;
emailAddress?: string;
officeName?: string;
} = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private datePipe = inject(DatePipe);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
isLoading = true;
/** Set once a dispatch or an outcome is saved, so the client view knows to reload on close. */
private hasChanges = false;
welcomeKitsData: any = null;
/** Addresses captured for the client, each formatted for display and selection. */
addressChoices: { id: any; type: string; isActive: boolean; lines: string[]; oneLine: string }[] = [];
/** True once the address lookup has run, so the empty state only shows when there genuinely are none. */
addressesLoaded = false;
/** Dispatch whose outcome is being recorded, or null while the tracking list is shown. */
outcomeFor: any = null;
outcomeStatus: 'DELIVERED' | 'RETURNED' = 'DELIVERED';
maxDate = new Date();
dispatchForm: FormGroup = this.formBuilder.group({
deliveryAddress: [
'',
// The kit goes to one of the addresses captured at client creation, never to a hand-typed one, so the
// dispatch record always traces back to an address the client actually gave.
addressId: [
null,
Validators.required
],
deliveryMode: [
'POSTMAN',
Validators.required
],
deliveryModeOtherText: ['']
deliveryModeOtherText: [''],
trackingId: [
'',
[
Validators.required,
Validators.maxLength(100)
]
],
dispatchedOnDate: [
new Date(),
Validators.required
]
});
outcomeForm: FormGroup = this.formBuilder.group({
outcomeDate: [
new Date(),
Validators.required
],
remarks: [
'',
Validators.maxLength(500)
]
});
deliveryModes = [
'POSTMAN',
'COURIER',
@@ -76,7 +125,37 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
get clientName(): string {
return this.welcomeKitsData?.clientName || this.data.clientName || '';
}
/** The kit is a physical address check, so the outcome is phrased as verification rather than delivery. */
verificationLabel(status: string): string {
switch (status) {
case 'DELIVERED':
return 'Verified';
case 'RETURNED':
return 'Not Verified';
default:
return 'Awaiting Verification';
}
}
verificationHint(dispatch: any): string {
switch (dispatch?.status) {
case 'DELIVERED':
return 'Kit delivered, so the address stands verified.';
case 'RETURNED':
return 'Kit came back undelivered, so the address could not be verified.';
default:
return 'Check this tracking ID on the delivery partner’s website or app, then record the outcome below.';
}
}
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.dispatchForm.patchValue({ dispatchedOnDate: this.maxDate });
this.outcomeForm.patchValue({ outcomeDate: this.maxDate });
this.fetchData();
}
@@ -98,33 +177,77 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
/** Order the address types are offered in: a kit is posted to the correspondence address when one exists. */
private static readonly ADDRESS_TYPE_ORDER = [
'Correspondence',
'Current / Residential',
'Permanent Address',
'Office / Work'
];
fetchPrimaryAddress() {
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
next: (addresses: any) => {
this.isLoading = false;
if (addresses && addresses.length > 0) {
// Attempt to build a readable address from the first address returned
const primary = addresses[0];
const parts = [];
if (primary.addressLine1) parts.push(primary.addressLine1);
if (primary.addressLine2) parts.push(primary.addressLine2);
if (primary.city) parts.push(primary.city);
if (primary.stateName) parts.push(primary.stateName);
if (primary.postalCode) parts.push(primary.postalCode);
if (primary.countryName) parts.push(primary.countryName);
this.addressesLoaded = true;
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
this.dispatchForm.patchValue({
deliveryAddress: clientNameStr + parts.join(', ')
const usable = (addresses || []).filter((a: any) => a && (a.addressLine1 || a.postalCode || a.city));
this.addressChoices = usable
.map((a: any) => {
const lines = [
a.addressLine1,
a.addressLine2,
a.addressLine3,
a.street,
a.townVillage,
a.city,
a.countyDistrict,
a.stateName || a.stateProvinceName,
a.postalCode,
a.countryName
]
.map((part: any) => (part == null ? '' : String(part).trim()))
.filter((part: string) => part.length > 0);
return {
id: a.addressId ?? a.id,
type: a.addressType || 'Address',
isActive: a.isActive !== false,
lines,
oneLine: lines.join(', ')
};
})
.sort((x: any, y: any) => {
// Active addresses first, then by how suitable the type is for posting.
if (x.isActive !== y.isActive) return x.isActive ? -1 : 1;
const order = WelcomeKitDialogComponent.ADDRESS_TYPE_ORDER;
const xi = order.indexOf(x.type);
const yi = order.indexOf(y.type);
return (xi === -1 ? order.length : xi) - (yi === -1 ? order.length : yi);
});
if (this.addressChoices.length > 0) {
this.dispatchForm.patchValue({ addressId: this.addressChoices[0].id });
}
},
error: () => {
this.isLoading = false;
this.addressesLoaded = true;
}
});
}
/** The address the operator picked, or null before one is chosen. */
get selectedAddress() {
const id = this.dispatchForm.get('addressId')?.value;
return this.addressChoices.find((a) => a.id === id) || null;
}
/** Lines of the selected address, for the on-screen preview and the printed label. */
get addressLines(): string[] {
return this.selectedAddress?.lines || [];
}
get activeDispatches() {
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
return this.welcomeKitsData.dispatches;
@@ -138,36 +261,104 @@ export class WelcomeKitDialogComponent implements OnInit {
return;
}
const chosen = this.selectedAddress;
if (!chosen) {
this.snackBar.open('Select the address the kit was posted to', 'Close', { duration: 3000 });
return;
}
this.isLoading = true;
const formValue = this.dispatchForm.value;
const today = new Date();
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload = {
deliveryAddress: formValue.deliveryAddress,
// Stored as text so the dispatch keeps the address exactly as it read on the day it went out, even if
// the client's address record is edited later.
deliveryAddress: `${chosen.type}: ${chosen.oneLine}`,
addressId: chosen.id,
deliveryMode: formValue.deliveryMode,
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
dispatchedOnDate: formattedDate,
locale: 'en',
dateFormat: 'dd MMMM yyyy'
trackingId: (formValue.trackingId || '').trim(),
dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat),
locale,
dateFormat
};
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
next: () => {
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
this.hasChanges = true;
this.snackBar.open('Welcome Kit dispatched successfully', 'Close', { duration: 3000 });
this.fetchData();
},
error: () => {
error: (err: any) => {
this.isLoading = false;
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
this.snackBar.open(this.errorMessage(err, 'Failed to dispatch welcome kit'), 'Close', { duration: 5000 });
}
});
}
/** Opens the outcome form for a dispatch, pre-set to the outcome the operator clicked. */
startOutcome(dispatch: any, status: 'DELIVERED' | 'RETURNED') {
this.outcomeFor = dispatch;
this.outcomeStatus = status;
this.outcomeForm.reset({ outcomeDate: this.maxDate, remarks: '' });
}
cancelOutcome() {
this.outcomeFor = null;
}
/** Records what the operator found on the delivery partner's site: delivered verifies the address, returned does not. */
submitOutcome() {
if (this.outcomeForm.invalid || !this.outcomeFor) {
this.outcomeForm.markAllAsTouched();
return;
}
this.isLoading = true;
const formValue = this.outcomeForm.value;
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload = {
status: this.outcomeStatus,
outcomeDate: this.dateUtils.formatDate(formValue.outcomeDate, dateFormat),
remarks: (formValue.remarks || '').trim() || null,
locale,
dateFormat
};
this.clientsService.updateWelcomeKitStatus(this.outcomeFor.id, payload).subscribe({
next: () => {
this.hasChanges = true;
this.outcomeFor = null;
this.snackBar.open(
this.outcomeStatus === 'DELIVERED' ? 'Address marked as verified' : 'Address marked as not verified',
'Close',
{ duration: 3000 }
);
this.fetchData();
},
error: (err: any) => {
this.isLoading = false;
this.snackBar.open(this.errorMessage(err, 'Failed to record the outcome'), 'Close', { duration: 5000 });
}
});
}
close() {
this.dialogRef.close(this.hasChanges);
}
/** Surfaces the backend's own message (duplicate tracking ID, inactive client) instead of a generic failure. */
private errorMessage(err: any, fallback: string): string {
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
}
getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || '';
const name = this.welcomeKitsData?.clientName || '';
const name = this.clientName;
if (name && !address.startsWith(name)) {
return `${name} — ${address}`;
}
@@ -185,6 +376,9 @@ export class WelcomeKitDialogComponent implements OnInit {
const trackingId = dispatch.trackingId || 'N/A';
const status = dispatch.status || 'N/A';
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
const verification = this.verificationLabel(dispatch.status);
const outcomeOn =
this.datePipe.transform(dispatch.deliveredOnDate || dispatch.returnedOnDate, 'mediumDate') || 'Awaited';
const primaryColor: [number, number, number] = [
31,
@@ -307,6 +501,14 @@ export class WelcomeKitDialogComponent implements OnInit {
[
'Dispatched On',
dispatchedOn
],
[
'Address Verification',
verification
],
[
'Outcome Recorded On',
outcomeOn
]
]
});
@@ -344,4 +546,192 @@ export class WelcomeKitDialogComponent implements OnInit {
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
}
/**
* Address label for the envelope. It is printed before the kit is posted — off the live form for a new
* dispatch, or off a saved record — so the operator can fold it into a window envelope and hand it over the
* counter. Layout is a single A4 sheet: the addressee block sits inside the window cut-out zone, and the
* welcome message sits below the fold where it stays hidden until the envelope is opened.
*/
downloadAddressLabel(dispatch?: any) {
const doc = new jsPDF();
const pageWidth = doc.internal.pageSize.getWidth();
const primaryColor: [number, number, number] = [
31,
73,
125
];
const grayColor: [number, number, number] = [
110,
110,
110
];
const lineColor: [number, number, number] = [
205,
212,
222
];
const name = this.clientName || 'Client';
// A saved dispatch keeps the address it actually went out with; a new one uses the address currently
// selected in the form. The saved value carries a "Type: " prefix that belongs on the record, not the envelope.
const addressLines = dispatch
? String(dispatch.deliveryAddress || '')
.replace(/^[^:]+:\s*/, '')
.split(',')
.map((part: string) => part.trim())
.filter((part: string) => part.length > 0)
: this.addressLines;
// --- Sender block, top-left, as the return address ---
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 26, 'F');
doc.setFontSize(17);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('CBSNexora', 18, 14);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('Core Banking Solution', 18, 20);
doc.setFontSize(7.5);
doc.setTextColor(...grayColor);
doc.setFont('helvetica', 'bold');
doc.text('FROM', 22, 37);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8.5);
doc.setTextColor(95, 100, 110);
let fromY = 42.5;
const branch = this.data.officeName ? `CBSNexora — ${this.data.officeName}` : 'CBSNexora';
[
branch,
'Customer Service Desk | support@cbsnexora.com | 1800-000-0000'
].forEach((line) => {
doc.text(line, 22, fromY);
fromY += 4.6;
});
// --- Addressee card: name, address and the client's reference details in one block, sized so the whole
// card sits behind a standard window cut-out ---
const cardX = 22;
const cardY = 60;
const cardW = pageWidth - 44;
const padX = cardX + 9;
const refs: string[] = [];
if (this.data.accountNo) refs.push(`Account No. ${this.data.accountNo}`);
if (this.data.cifNumber) refs.push(`CIF ${this.data.cifNumber}`);
if (this.data.mobileNo) refs.push(`Mobile ${this.data.mobileNo}`);
if (this.data.emailAddress) refs.push(`Email ${this.data.emailAddress}`);
if (dispatch?.trackingId) refs.push(`Tracking ID ${dispatch.trackingId}`);
// Height is measured off the same offsets the text below uses, so the card closes a fixed margin under the
// last line instead of leaving a gap that grows with a short address.
const addrStartY = 27;
const addrEndY = addrStartY + (addressLines.length - 1) * 6.4;
const cardH = refs.length > 0 ? addrEndY + 5 + refs.length * 5 + 4 : addrEndY + 9;
doc.setFillColor(252, 253, 255);
doc.setDrawColor(...lineColor);
doc.setLineWidth(0.3);
doc.roundedRect(cardX, cardY, cardW, cardH, 2, 2, 'FD');
// Accent spine marks the addressee edge without boxing the text in.
doc.setFillColor(...primaryColor);
doc.rect(cardX, cardY + 1, 1.6, cardH - 2, 'F');
doc.setFontSize(7);
doc.setTextColor(...grayColor);
doc.text('ALIGN THIS BLOCK WITH THE ENVELOPE WINDOW', cardX, cardY - 3.5);
doc.setFontSize(7.5);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('TO', padX, cardY + 10);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(20, 20, 20);
doc.text(name, padX, cardY + 19);
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.setTextColor(45, 45, 45);
let addrY = cardY + addrStartY;
addressLines.forEach((line: string) => {
doc.text(line, padX, addrY);
addrY += 6.4;
});
// Reference details close the same card, separated by a hairline rather than a second frame.
if (refs.length > 0) {
const dividerY = cardY + addrEndY + 5;
doc.setDrawColor(...lineColor);
doc.setLineWidth(0.2);
doc.line(padX, dividerY, cardX + cardW - 9, dividerY);
doc.setFontSize(8.5);
doc.setTextColor(...grayColor);
let refY = dividerY + 5;
refs.forEach((line) => {
doc.text(line, padX, refY);
refY += 5;
});
}
// --- Fold line: hairline broken around a centred label, so it guides the fold without competing with
// the address block above it ---
const foldY = cardY + cardH + 22;
const foldGap = 12;
doc.setDrawColor(226, 230, 236);
doc.setLineWidth(0.15);
doc.setLineDashPattern(
[
1,
2.5
],
0
);
doc.line(30, foldY, pageWidth / 2 - foldGap, foldY);
doc.line(pageWidth / 2 + foldGap, foldY, pageWidth - 30, foldY);
doc.setLineDashPattern([], 0);
doc.setFontSize(6.5);
doc.setTextColor(178, 184, 194);
doc.text('FOLD', pageWidth / 2, foldY + 1.6, { align: 'center' });
// --- Welcome message, below the fold ---
let msgY = foldY + 14;
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('Welcome to CBSNexora', 22, msgY);
msgY += 10;
doc.setFontSize(10.5);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
doc.text(`Dear ${name},`, 22, msgY);
msgY += 8;
const body = [
'Thank you for opening your account with us. Enclosed is your Welcome Kit containing your account documentation and the essential materials you need to start banking with us.',
'Please keep your account number and CIF safe, and do not share your banking credentials with anyone. Our staff will never ask you for your PIN, password or OTP.',
'If anything in this kit is missing or incorrect, contact your branch or call our customer service desk and we will put it right.'
];
body.forEach((paragraph) => {
const lines = doc.splitTextToSize(paragraph, pageWidth - 44);
doc.text(lines, 22, msgY);
msgY += lines.length * 5.6 + 5;
});
msgY += 4;
doc.text('Warm regards,', 22, msgY);
msgY += 6;
doc.setFont('helvetica', 'bold');
doc.text('CBSNexora Operations Team', 22, msgY);
const stamp = this.datePipe.transform(new Date(), 'yyyyMMdd-HHmmss');
doc.save(`WelcomeKit_Label_${this.data.accountNo || this.data.clientId}_${stamp}.pdf`);
}
}
@@ -283,26 +283,61 @@
</div>
<div class="pd-section__body">
<div class="pd-grid">
<!-- Primary Mobile DIP -->
<!--
Primary Mobile DIP. Driven by dipLiveStatus — the latest lookup — not by dipVerified,
which is frozen at onboarding and would keep reporting "Verified" long after the number
was reported for fraud or reassigned to someone else.
-->
<div class="pd-field">
<div class="pd-field__label">Mobile DIP Status</div>
<div class="pd-field__value">
@if (
clientViewData.mobileVerified === true ||
clientViewData.mobileVerified === 'true' ||
clientViewData.mobileVerified === 1 ||
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
) {
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<span class="pd-verify-badge pd-verify-badge--flagged">
<i class="fa fa-exclamation-triangle"></i> {{ dipStatusLabel(clientViewData) }}
</span>
} @else if (isDipClear(clientViewData)) {
<span class="pd-verify-badge pd-verify-badge--verified">
<i class="fa fa-check-circle"></i> Verified
</span>
} @else {
<span class="pd-verify-badge pd-verify-badge--pending"> <i class="fa fa-clock-o"></i> Pending </span>
<span class="pd-verify-badge pd-verify-badge--pending">
<i class="fa fa-clock-o"></i> {{ dipStatusLabel(clientViewData) }}
</span>
}
</div>
</div>
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<div class="pd-field pd-field--full">
<div class="pd-dip-alert">
<i class="fa fa-exclamation-triangle"></i>
<div>
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
<div class="pd-dip-alert__detail">
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
obtain a new number. Account operations are not restricted.
</div>
</div>
</div>
</div>
}
<!--
A sanctions alert raised after onboarding is far more serious than a DIP hit and must be
impossible to miss on the customer's own page, not only in the Principal Officer's queue.
-->
@if (clientViewData.sanctionsAlertStatus) {
<div class="pd-field pd-field--full">
<div
class="pd-dip-alert"
[class.pd-dip-alert--review]="clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW'"
>
<i class="fa fa-exclamation-triangle"></i>
<div>
<strong>{{ sanctionsAlertHeadline(clientViewData) }}</strong>
<div class="pd-dip-alert__detail">{{ sanctionsAlertDetail(clientViewData) }}</div>
</div>
</div>
</div>
}
@if (clientViewData.dipTimestamp) {
<div class="pd-field">
<div class="pd-field__label">DIP Verified On</div>
@@ -314,7 +349,12 @@
<div class="pd-field">
<div class="pd-field__label">Alternate Mobile DIP Status</div>
<div class="pd-field__value">
@if (
@if (isDipFlagged(clientViewData.alternateDipLiveStatus)) {
<span class="pd-verify-badge pd-verify-badge--flagged">
<i class="fa fa-exclamation-triangle"></i> {{ alternateDipStatusLabel(clientViewData) }}
</span>
} @else if (
clientViewData.alternateDipLiveStatus === 'CLEAN' ||
clientViewData.alternateDipVerified === true ||
clientViewData.alternateDipVerified === 'true' ||
clientViewData.alternateDipVerified === 1
@@ -248,9 +248,59 @@ $teal: #00897b;
border: 1px solid #fde68a;
}
&--danger {
&--danger,
&--flagged {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
}
// A DIP hit on an existing customer restricts their NUMBER, not their account, so it reads as a
// standing advisory across the full width rather than as a blocked-account state.
.pd-field--full {
grid-column: 1 / -1;
}
.pd-dip-alert {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 6px;
padding: 10px 14px;
font-size: 13px;
color: #7f1d1d;
line-height: 1.5;
i {
color: #dc2626;
font-size: 15px;
margin-top: 2px;
flex-shrink: 0;
}
&__detail {
margin-top: 3px;
font-weight: 400;
color: #991b1b;
}
// An unreviewed hit is not yet a finding, so it reads as amber rather than red.
&--review {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #92400e;
i {
color: #d97706;
}
.pd-dip-alert__detail {
color: #b45309;
}
}
}
@@ -32,6 +32,14 @@ interface ClientViewData {
displayName?: string;
mobileNo?: string;
emailAddress?: string;
/**
* Current DIP/MNRL status resolved from the latest lookup, as opposed to the `dipVerified`
* flag captured when the client was onboarded.
*/
dipLiveStatus?: string;
alternateDipLiveStatus?: string;
/** Disposition of the latest flagged sanctions alert, or absent when the client carries none. */
sanctionsAlertStatus?: string;
dateOfBirth?: number[];
gender?: { id: number; name: string; active?: boolean };
clientType?: { id: number; name: string; active?: boolean };
@@ -67,6 +75,91 @@ export class PersonalDataTabComponent {
/** Client View Data */
clientViewData!: ClientViewData;
/**
* Whether the number's CURRENT DIP/MNRL status is a hit.
*
* The onboarding-time `dipVerified` flag never changes, so a customer cleared when they joined
* kept reading "Verified" even after their number was reported for fraud or reassigned. These
* helpers read `dipLiveStatus`, which the API resolves from the most recent lookup.
*/
isDipFlagged(liveStatus: string | null | undefined): boolean {
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
}
/** True only when the number is affirmatively clean — not merely "not flagged". */
isDipClear(client: any): boolean {
if (client?.dipLiveStatus) {
return client.dipLiveStatus === 'CLEAN';
}
return (
client?.mobileVerified === true ||
client?.mobileVerified === 'true' ||
client?.mobileVerified === 1 ||
client?.dipVerified === true ||
client?.dipVerified === 'true' ||
client?.dipVerified === 1
);
}
dipStatusLabel(client: any): string {
return this.labelFor(client?.dipLiveStatus, this.isDipClear(client));
}
/** Headline for the sanctions banner, driven by how far the alert has progressed. */
sanctionsAlertHeadline(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return 'Confirmed UN Security Council sanctions match.';
case 'FALSE_POSITIVE':
return 'Sanctions hit reviewed as a false positive.';
default:
return 'Sanctions match awaiting Principal Officer review.';
}
}
sanctionsAlertDetail(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return (
'Debits are frozen on this client’s deposit accounts and the case has been escalated. ' +
'Report to FIU-IND and the nodal officer within 24 hours if not already done.'
);
case 'FALSE_POSITIVE':
return 'Recorded as a different person from the listed individual. See the Sanctions Logs for the reasoning.';
default:
return (
'This client matched the UN Security Council Consolidated List on a later screening. ' +
'Review the alert under Reports → Sanctions Screening & Alerts before transacting.'
);
}
}
alternateDipStatusLabel(client: any): string {
const clear =
client?.alternateDipLiveStatus === 'CLEAN' ||
client?.alternateDipVerified === true ||
client?.alternateDipVerified === 'true' ||
client?.alternateDipVerified === 1;
return this.labelFor(client?.alternateDipLiveStatus, clear);
}
private labelFor(liveStatus: string | null | undefined, clear: boolean): string {
switch (liveStatus) {
case 'SUSPECTED':
return 'Flagged — Suspected';
case 'DISCONNECTED':
return 'Flagged — Disconnected';
case 'SUSPECTED_AND_DISCONNECTED':
return 'Flagged — Suspected & Disconnected';
case 'SERVICE_UNAVAILABLE':
return 'Not Verified';
case 'CLEAN':
return 'Verified';
default:
return clear ? 'Verified' : 'Pending';
}
}
constructor() {
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
this.clientViewData = data.clientViewData;
+2 -2
View File
@@ -1015,8 +1015,8 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
updateWelcomeKitStatus(welcomeKitId: number, data: any): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, data);
}
getKycExceptions(resolved = false): Observable<any[]> {
+10
View File
@@ -1677,6 +1677,16 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
</button>
<!-- Full data export. Hidden unless the user can raise a request; the approval itself is a
separate permission the backend enforces. -->
<button
mat-menu-item
routerLink="/letter-mgmt/database-export"
tabindex="0"
*mifosxHasPermission="'READ_DATABASEEXPORT'"
>
<fa-icon icon="database" class="menu-item-icon" style="color: #b45309"></fa-icon> Database Export
</button>
</div>
</div>
</mat-menu>
+63
View File
@@ -347,6 +347,69 @@
<!-- STR -->
@if (activeTab === 'str') {
<!--
Sanctions STRs are a separate stream from the AML alerts below, not merged into them: an AML
alert is raised by a monetary transaction breaching a rule, whereas a sanctions STR is
triggered by who the customer is. Mixing them would put two different kinds of suspicion in
one queue and require inventing a transaction that never happened.
-->
@if (sanctionsStrCases.length) {
<div class="fiu-section">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Sanctions STRs (UAPA s.51-A)</h3>
<p>
Confirmed matches against the UN Security Council Consolidated List. Accounts are frozen; the STR must
reach FIU-IND within 7 days of confirmation.
</p>
</div>
<select class="fiu-inp" [(ngModel)]="sanctionsStrFilter" (ngModelChange)="loadSanctionsStrCases()">
<option value="">All</option>
<option value="PENDING_FILING">Pending filing</option>
<option value="FILED">Filed</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Alert</th>
<th>Client</th>
<th>Screened Name</th>
<th>Listed As</th>
<th>Confirmed On</th>
<th>Due</th>
<th>Status</th>
<th>STR Reference</th>
</tr>
</thead>
<tbody>
@for (str of sanctionsStrCases; track str.id) {
<tr>
<td class="mono">#{{ str.id }}</td>
<td>
@if (str.clientId) {
<a [routerLink]="['/clients', str.clientId, 'general']">{{ str.clientName || str.clientId }}</a>
} @else {
<span class="muted">Not onboarded</span>
}
</td>
<td>{{ str.screenedName }}</td>
<td>{{ str.matchedNames || '—' }}</td>
<td>{{ str.approvedOn | date: 'mediumDate' }}</td>
<td [class.str-overdue]="isStrOverdue(str)">{{ str.strDueDate || '—' }}</td>
<td>
<span [class]="strStatusClass(str)">{{ strStatusLabel(str) }}</span>
</td>
<td class="mono">{{ str.fiuStrReference || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
+41
View File
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service';
import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import {
BRANCHES,
@@ -130,6 +131,7 @@ export class FiuComponent implements OnInit {
private static readonly SEED_VERSION = '2026-01-cash-threshold';
private amlService = inject(AmlService);
private sanctionsLogsService = inject(SanctionsLogsService);
private authenticationService = inject(AuthenticationService);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -176,6 +178,13 @@ export class FiuComponent implements OnInit {
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
amlAlerts: any[] = [];
/**
* Confirmed UNSC sanctions matches carrying an STR obligation. Held separately from amlAlerts
* because they are identity-driven rather than transaction-driven — see the template comment.
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
@@ -320,6 +329,37 @@ export class FiuComponent implements OnInit {
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
}
/** Loads confirmed sanctions matches that owe an STR. */
loadSanctionsStrCases() {
this.sanctionsLogsService.getStrCases(this.sanctionsStrFilter || undefined).subscribe({
next: (rows: any) => (this.sanctionsStrCases = rows || []),
// A failure here must not blank the AML side of the tab, which is unrelated.
error: () => (this.sanctionsStrCases = [])
});
}
strStatusLabel(str: any): string {
if (str?.strStatus === 'FILED') {
return 'FILED';
}
return this.isStrOverdue(str) ? 'OVERDUE' : 'PENDING FILING';
}
strStatusClass(str: any): string {
if (str?.strStatus === 'FILED') {
return 'fiu-pill fiu-pill--ok';
}
return this.isStrOverdue(str) ? 'fiu-pill fiu-pill--danger' : 'fiu-pill fiu-pill--warn';
}
/** Past its 7-day PMLA deadline and still unfiled — the state that actually needs chasing. */
isStrOverdue(str: any): boolean {
if (!str?.strDueDate || str.strStatus === 'FILED') {
return false;
}
return new Date(str.strDueDate) < new Date(new Date().toDateString());
}
get isPrincipalOfficer(): boolean {
return this.authenticationService.isPrincipalOfficerUser();
}
@@ -547,6 +587,7 @@ export class FiuComponent implements OnInit {
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
this.loadSanctionsStrCases();
}
loadAmlCtrReports(): void {
@@ -0,0 +1,219 @@
<!--
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="container">
<div class="page-header">
<fa-icon icon="database" class="header-icon"></fa-icon>
<h2>Database Export</h2>
</div>
<!-- The export carries every client's data, so the consequence is stated before the form, not after. -->
<div class="warning-banner">
<fa-icon icon="exclamation-triangle" class="warning-banner__icon"></fa-icon>
<div>
<strong>This export contains the complete database</strong>
— every client's personal details, KYC documents, accounts and transactions. A request must be approved by an
administrator before the archive can be downloaded, and the approver cannot be the person who raised it. The
archive can only be downloaded by the user who raised the request, not by the approver, and an approval is valid
for a single download — taking another copy means raising a new request. Every request, approval and download is
recorded.
</div>
</div>
<!-- Raise a request -->
<div class="request-card" [formGroup]="requestForm">
<h3 class="request-card__title">Request a Full Export</h3>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Reason for this export</mat-label>
<textarea
matInput
formControlName="reason"
rows="3"
maxlength="1000"
placeholder="e.g. Statutory audit for FY 2025-26 requested by the RBI inspection team"
required
></textarea>
<mat-hint>Recorded against the request and written into the archive. Minimum 10 characters.</mat-hint>
<mat-error *ngIf="requestForm.get('reason')?.hasError('required')">A reason is required</mat-error>
<mat-error *ngIf="requestForm.get('reason')?.hasError('minlength')">
Please give at least 10 characters of context
</mat-error>
</mat-form-field>
<div class="request-card__actions">
<button
mat-flat-button
color="primary"
type="button"
[disabled]="requestForm.invalid || submitting"
(click)="submitRequest()"
>
<fa-icon icon="paper-plane" class="chip-icon"></fa-icon>
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
</button>
</div>
</div>
<!-- Request history -->
<div class="filters-row">
<div class="filter-chips">
<button mat-stroked-button [class.active-filter]="!activeStatus" (click)="filterByStatus(null)">
<fa-icon icon="list" class="chip-icon"></fa-icon> All
</button>
<button
mat-stroked-button
class="chip-pending"
[class.active-filter]="activeStatus === 'PENDING_APPROVAL'"
(click)="filterByStatus('PENDING_APPROVAL')"
>
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Approval
</button>
<button
mat-stroked-button
class="chip-approved"
[class.active-filter]="activeStatus === 'APPROVED'"
(click)="filterByStatus('APPROVED')"
>
<fa-icon icon="check-circle" class="chip-icon"></fa-icon> Approved
</button>
<button
mat-stroked-button
class="chip-rejected"
[class.active-filter]="activeStatus === 'REJECTED'"
(click)="filterByStatus('REJECTED')"
>
<fa-icon icon="times" class="chip-icon"></fa-icon> Rejected
</button>
</div>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
}
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Request</th>
<td mat-cell *matCellDef="let row">#{{ row.id }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
<td mat-cell *matCellDef="let row">
<span class="result-badge" [ngClass]="statusClass(row)">{{ statusLabel(row) }}</span>
</td>
</ng-container>
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Reason</th>
<td mat-cell *matCellDef="let row" class="reason-cell">
<span [matTooltip]="row.reason">{{ row.reason }}</span>
@if (row.rejectionReason) {
<span class="rejection-note">Rejected: {{ row.rejectionReason }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="requestedBy">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Requested By</th>
<td mat-cell *matCellDef="let row">
<div class="stacked">
<span>{{ row.requestedByUsername || '—' }}</span>
<span class="muted">{{ row.requestedOn | date: 'medium' }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="approvedBy">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved By</th>
<td mat-cell *matCellDef="let row">
@if (row.approvedByUsername) {
<div class="stacked">
<span>{{ row.approvedByUsername }}</span>
<span class="muted">{{ row.approvedOn | date: 'medium' }}</span>
</div>
} @else {
<span class="muted">—</span>
}
</td>
</ng-container>
<ng-container matColumnDef="generatedOn">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Downloaded</th>
<td mat-cell *matCellDef="let row">
@if (row.generatedOn) {
<div class="stacked">
<span>{{ row.generatedOn | date: 'medium' }}</span>
<span class="muted">{{ formatSize(row.fileSizeBytes) }} · {{ row.rowCount || 0 }} rows</span>
</div>
} @else {
<span class="muted">Not yet</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Action</th>
<td mat-cell *matCellDef="let row">
<div class="row-actions">
@if (isPending(row)) {
<!-- Only shown to users holding the approval permission; the backend enforces it regardless. -->
<ng-container *mifosxHasPermission="'APPROVE_DATABASEEXPORT'">
<button mat-stroked-button color="primary" (click)="approve(row)">
<fa-icon icon="check"></fa-icon> Approve
</button>
<button mat-stroked-button color="warn" (click)="reject(row)">
<fa-icon icon="times"></fa-icon> Reject
</button>
</ng-container>
}
@if (isDownloadable(row)) {
<button mat-flat-button color="primary" [disabled]="downloadingId === row.id" (click)="download(row)">
<fa-icon icon="download"></fa-icon>
{{ downloadingId === row.id ? 'Preparing…' : 'Download' }}
</button>
}
@if (isWithheld(row)) {
<span
class="muted withheld-note"
matTooltip="Approving an export authorises it. Only {{
row.requestedByUsername || 'the requester'
}} can download the archive."
>
<fa-icon icon="lock"></fa-icon>
Requester only
</span>
}
@if (isSpent(row)) {
<span
class="muted withheld-note"
matTooltip="An approval is valid for a single download. Raise a new request to take another copy."
>
<fa-icon icon="check"></fa-icon>
Download used
</span>
}
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
@if (!loading && dataSource.data.length === 0) {
<div class="no-data">
<fa-icon icon="folder-open" class="no-data-icon"></fa-icon>
<p>No export requests yet.</p>
</div>
}
<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -0,0 +1,213 @@
/**
* 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/.
*/
.container {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-icon {
color: #1074b9;
font-size: 20px;
}
}
/* The consequence of this screen is stated up front, not buried in a tooltip. */
.warning-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
margin-bottom: 20px;
border-radius: 8px;
background-color: rgb(180 83 9 / 7%);
border: 1px solid rgb(180 83 9 / 30%);
font-size: 13px;
line-height: 1.55;
color: #7c2d12;
&__icon {
color: #b45309;
font-size: 18px;
margin-top: 2px;
}
}
.request-card {
padding: 18px 20px;
margin-bottom: 22px;
border-radius: 10px;
background-color: #f7f9fc;
border: 1px solid #e3e7ee;
&__title {
margin: 0 0 14px;
font-size: 15px;
font-weight: 600;
}
&__actions {
display: flex;
justify-content: flex-end;
.chip-icon {
margin-right: 6px;
}
}
}
.full-width {
width: 100%;
}
.filters-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-chips {
display: flex;
align-items: center;
gap: 8px;
button {
font-size: 13px;
border-radius: 20px;
}
.chip-icon {
margin-right: 6px;
}
.active-filter {
background-color: #1074b9;
color: #fff;
border-color: #1074b9;
}
.chip-pending.active-filter {
background-color: #b45309;
border-color: #b45309;
}
.chip-approved.active-filter {
background-color: #388e3c;
border-color: #388e3c;
}
.chip-rejected.active-filter {
background-color: #d32f2f;
border-color: #d32f2f;
}
}
.result-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
&.badge-pending {
background-color: rgb(180 83 9 / 12%);
color: #b45309;
}
&.badge-approved {
background-color: rgb(56 142 60 / 14%);
color: #2e7d32;
}
&.badge-rejected {
background-color: rgb(211 47 47 / 12%);
color: #c62828;
}
&.badge-generated {
background-color: rgb(16 116 185 / 12%);
color: #1074b9;
}
}
.reason-cell {
max-width: 320px;
span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rejection-note {
margin-top: 2px;
font-size: 11px;
color: #c62828;
}
}
.stacked {
display: flex;
flex-direction: column;
line-height: 1.35;
}
.muted {
color: #6b7280;
font-size: 11px;
}
.row-actions {
display: flex;
gap: 8px;
align-items: center;
button {
font-size: 12px;
}
fa-icon {
margin-right: 5px;
}
}
/* Stands in for the download button on rows the viewer did not raise. */
.withheld-note {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-style: italic;
white-space: nowrap;
}
.no-data {
padding: 40px 0;
text-align: center;
color: #6b7280;
&-icon {
font-size: 30px;
margin-bottom: 10px;
opacity: 0.5;
}
}
@@ -0,0 +1,255 @@
/**
* 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, ViewChild, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DatabaseExportService } from './database-export.service';
/**
* Full database export, gated behind an approval. The requester states why the export is needed; an
* administrator with APPROVE_DATABASEEXPORT approves or rejects it, and only then can the archive be
* downloaded. The backend refuses self-approval, so the two roles are always two different people.
*/
@Component({
selector: 'mifosx-database-export',
templateUrl: './database-export.component.html',
styleUrls: ['./database-export.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatProgressBarModule,
MatTooltip,
NgClass,
DatePipe,
FaIconComponent
]
})
export class DatabaseExportComponent implements OnInit {
private service = inject(DatabaseExportService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private authenticationService = inject(AuthenticationService);
/** Used to keep the download to the requester; the backend enforces the same rule regardless. */
private currentUserId: number | null = this.authenticationService.getCredentials()?.userId ?? null;
displayedColumns: string[] = [
'id',
'status',
'reason',
'requestedBy',
'approvedBy',
'generatedOn',
'actions'
];
dataSource = new MatTableDataSource<any>();
loading = false;
submitting = false;
downloadingId: number | null = null;
activeStatus: string | null = null;
requestForm: FormGroup = this.formBuilder.group({
reason: [
'',
[
Validators.required,
Validators.minLength(10),
Validators.maxLength(1000)
]
]
});
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
ngOnInit() {
this.load();
}
load() {
this.loading = true;
this.service.getRequests(this.activeStatus || undefined).subscribe({
next: (rows: any) => this.applyRows(rows || []),
error: () => this.applyRows([])
});
}
private applyRows(rows: any[]) {
this.dataSource = new MatTableDataSource(rows);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.loading = false;
}
filterByStatus(status: string | null) {
this.activeStatus = this.activeStatus === status ? null : status;
this.load();
}
submitRequest() {
if (this.requestForm.invalid) {
this.requestForm.markAllAsTouched();
return;
}
this.submitting = true;
this.service.requestExport(this.requestForm.value.reason.trim()).subscribe({
next: () => {
this.submitting = false;
this.requestForm.reset({ reason: '' });
this.snackBar.open('Export request submitted for approval', 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => {
this.submitting = false;
this.snackBar.open(this.errorMessage(err, 'Failed to submit the request'), 'Close', { duration: 6000 });
}
});
}
approve(row: any) {
this.service.approve(row.id).subscribe({
next: () => {
this.snackBar.open(`Request #${row.id} approved`, 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Approval failed'), 'Close', { duration: 6000 })
});
}
reject(row: any) {
const reason = window.prompt(`Reject export request #${row.id}. Reason:`);
if (reason === null) {
return;
}
if (!reason.trim()) {
this.snackBar.open('A reason is required to reject a request', 'Close', { duration: 4000 });
return;
}
this.service.reject(row.id, reason.trim()).subscribe({
next: () => {
this.snackBar.open(`Request #${row.id} rejected`, 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Rejection failed'), 'Close', { duration: 6000 })
});
}
download(row: any) {
this.downloadingId = row.id;
this.service.download(row.id).subscribe({
next: (blob: Blob) => {
this.downloadingId = null;
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = row.fileName || `cbsnexora-database-export-${row.id}.zip`;
link.click();
window.URL.revokeObjectURL(url);
// The status moves to GENERATED once the archive leaves the server, so reflect that.
this.load();
},
error: async (err: any) => {
this.downloadingId = null;
// The request asked for a Blob, so an error body arrives as a Blob too and has to be read back
// as text before the platform's message can be pulled out of it.
let message = 'Download failed';
if (err?.error instanceof Blob) {
try {
const parsed = JSON.parse(await err.error.text());
message = parsed?.defaultUserMessage || parsed?.errors?.[0]?.defaultUserMessage || message;
} catch {
// Body was not JSON; keep the generic message.
}
} else {
message = this.errorMessage(err, message);
}
this.snackBar.open(message, 'Close', { duration: 6000 });
}
});
}
isPending(row: any): boolean {
return row?.status === 'PENDING_APPROVAL';
}
/**
* Approving an export authorises it; it does not entitle the approver to a copy. Only the user who raised
* the request — and stated the reason it is recorded against — can take the archive, and only once.
*/
isDownloadable(row: any): boolean {
return row?.status === 'APPROVED' && this.isRequester(row);
}
isRequester(row: any): boolean {
return this.currentUserId != null && Number(row?.requestedBy) === Number(this.currentUserId);
}
/** Shown in place of the button so an approver sees the archive is withheld by design, not by a fault. */
isWithheld(row: any): boolean {
return row?.status === 'APPROVED' && !this.isRequester(row);
}
/** The approval has been spent; another copy means another request. */
isSpent(row: any): boolean {
return row?.status === 'GENERATED';
}
statusLabel(row: any): string {
switch (row?.status) {
case 'PENDING_APPROVAL':
return 'Awaiting Approval';
case 'APPROVED':
return 'Approved';
case 'REJECTED':
return 'Rejected';
case 'GENERATED':
return 'Downloaded';
default:
return row?.status || '—';
}
}
statusClass(row: any): string {
switch (row?.status) {
case 'APPROVED':
return 'badge-approved';
case 'REJECTED':
return 'badge-rejected';
case 'GENERATED':
return 'badge-generated';
default:
return 'badge-pending';
}
}
formatSize(bytes: number): string {
if (!bytes) {
return '—';
}
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`;
}
private errorMessage(err: any, fallback: string): string {
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
}
}
@@ -0,0 +1,45 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** Approval-gated full export of the tenant database. */
@Injectable({ providedIn: 'root' })
export class DatabaseExportService {
private http = inject(HttpClient);
getRequests(status?: string): Observable<any> {
let params = new HttpParams();
if (status) {
params = params.set('status', status);
}
return this.http.get('/databaseexport', { params });
}
/** @param reason why the export is needed — recorded against the request and inside the archive. */
requestExport(reason: string): Observable<any> {
return this.http.post('/databaseexport', { reason });
}
approve(requestId: number): Observable<any> {
return this.http.post(`/databaseexport/${requestId}/approve`, {});
}
reject(requestId: number, rejectionReason: string): Observable<any> {
return this.http.post(`/databaseexport/${requestId}/reject`, { rejectionReason });
}
/** The archive is binary, so it bypasses the JSON interceptors and comes back as a Blob. */
download(requestId: number): Observable<Blob> {
return this.http.get(`/databaseexport/${requestId}/download`, {
responseType: 'blob' as 'json'
}) as Observable<Blob>;
}
}
@@ -18,6 +18,7 @@ import { Form60Component } from './certificates/form-60/form-60.component';
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
import { NocCertificateComponent } from './certificates/noc/noc.component';
import { DatabaseExportComponent } from './database-export/database-export.component';
const routes: Routes = [
Route.withShell([
@@ -56,6 +57,11 @@ const routes: Routes = [
component: HypothecationCertificateComponent,
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
},
{
path: 'letter-mgmt/database-export',
component: DatabaseExportComponent,
data: { title: 'Database Export', breadcrumb: 'Database Export' }
},
{
path: 'letter-mgmt/noc',
component: NocCertificateComponent,
@@ -47,7 +47,14 @@ import { DipLookupsService } from './dip-lookups.service';
</div>
<div class="detail-row">
<span class="detail-label">Client</span>
<span class="detail-value">{{ row.clientId || 'Not onboarded' }}</span>
<span class="detail-value">
@if (row.clientId) {
{{ row.clientName || 'Client #' + row.clientId }}
<span class="detail-sub">{{ row.clientAccountNo || row.clientId }}</span>
} @else {
Not onboarded
}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Looked Up</span>
@@ -172,6 +179,13 @@ import { DipLookupsService } from './dip-lookups.service';
color: #dc2626;
}
.detail-sub {
display: block;
font-size: 11px;
font-weight: 400;
color: #9ca3af;
}
.section-title {
font-size: 11px;
font-weight: 700;
@@ -56,12 +56,61 @@
<button mat-stroked-button [class.active-filter]="pendingOnly" class="chip-pending" (click)="showPending()">
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Decision
</button>
@if (searchNumber || activeStatus || pendingOnly) {
@if (searchNumber || activeStatus || pendingOnly || activeSource) {
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<!-- Separate question from the status chips above: not "what did the check say" but "when was it run". -->
<div class="source-filter">
<span class="source-filter__label">Checked during</span>
<div class="filter-chips">
<button
mat-stroked-button
[class.active-filter]="!activeSource"
matTooltip="Show lookups from both client creation and re-screening"
(click)="filterBySource(null)"
>
<fa-icon icon="list" class="chip-icon"></fa-icon> Both
</button>
<button
mat-stroked-button
class="chip-onboarding"
[class.active-filter]="activeSource === 'ONBOARDING'"
matTooltip="Numbers checked while the client was being created"
(click)="filterBySource('ONBOARDING')"
>
<fa-icon icon="user-plus" class="chip-icon"></fa-icon>
Client Creation
<span class="chip-count">{{ onboardingCount }}</span>
</button>
<button
mat-stroked-button
class="chip-rescreen"
[class.active-filter]="activeSource === 'RESCREEN'"
matTooltip="Existing clients swept by the periodic re-screening run"
(click)="filterBySource('RESCREEN')"
>
<fa-icon icon="sync" class="chip-icon"></fa-icon>
Re-screening
<span class="chip-count">{{ rescreenCount }}</span>
</button>
</div>
</div>
<button
mat-stroked-button
color="primary"
class="rescreen-btn"
[disabled]="rescreening"
matTooltip="Screen every existing client's mobile numbers against DIP/MNRL. Catches numbers reported for fraud or reassigned after the customer was onboarded."
(click)="rescreenExistingClients()"
>
<fa-icon icon="sync" class="chip-icon"></fa-icon>
{{ rescreening ? 'Re-screening…' : 'Re-screen Existing Clients' }}
</button>
</div>
@if (loading) {
@@ -90,13 +139,28 @@
</ng-container>
<ng-container matColumnDef="lookupSource">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Source</th>
<td mat-cell *matCellDef="let row">{{ row.lookupSource || '—' }}</td>
<th mat-header-cell *matHeaderCellDef mat-sort-header>Checked During</th>
<td mat-cell *matCellDef="let row">
<span class="source-badge" [ngClass]="sourceClass(row)">
<fa-icon [icon]="isRescreen(row) ? 'sync' : 'user-plus'" class="source-badge__icon"></fa-icon>
{{ sourceLabel(row) }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="clientId">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client</th>
<td mat-cell *matCellDef="let row">{{ row.clientId || 'Not onboarded' }}</td>
<td mat-cell *matCellDef="let row">
@if (row.clientId) {
<!-- Linked, so the officer can go straight to the customer the flagged number belongs to. -->
<a class="client-link" [routerLink]="['/clients', row.clientId, 'general']">
{{ row.clientName || 'Client #' + row.clientId }}
<span class="client-acct">{{ row.clientAccountNo || row.clientId }}</span>
</a>
} @else {
<span class="muted">Not onboarded</span>
}
</td>
</ng-container>
<ng-container matColumnDef="lookedUpOn">
@@ -79,6 +79,106 @@
background-color: #b45309;
border-color: #b45309;
}
.chip-onboarding.active-filter {
background-color: #6d28d9;
border-color: #6d28d9;
}
.chip-rescreen.active-filter {
background-color: #0f766e;
border-color: #0f766e;
}
.chip-count {
margin-left: 6px;
padding: 1px 7px;
border-radius: 10px;
background-color: rgb(0 0 0 / 8%);
font-size: 11px;
font-weight: 700;
}
.active-filter .chip-count {
background-color: rgb(255 255 255 / 25%);
}
}
/* Which screening run a row came from — a different question from the result status chips. */
.source-filter {
display: flex;
align-items: center;
gap: 10px;
padding-left: 14px;
margin-left: 6px;
border-left: 1px solid #dfe3ea;
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6b7280;
white-space: nowrap;
}
}
.source-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
&__icon {
font-size: 11px;
}
&.source-onboarding {
background-color: rgb(109 40 217 / 10%);
color: #5b21b6;
}
&.source-rescreen {
background-color: rgb(15 118 110 / 10%);
color: #0f766e;
}
}
.client-link {
display: flex;
flex-direction: column;
text-decoration: none;
color: #1074b9;
font-weight: 600;
&:hover {
text-decoration: underline;
}
.client-acct {
font-size: 11px;
font-weight: 400;
color: #9ca3af;
}
}
.muted {
color: #9ca3af;
}
.rescreen-btn {
font-size: 13px;
border-radius: 20px;
margin-left: auto;
.chip-icon {
margin-right: 4px;
font-size: 12px;
}
}
.result-badge {
@@ -108,10 +208,6 @@
color: #b45309;
}
.muted {
color: #9ca3af;
}
.review-btn {
font-size: 12px;
border-radius: 16px;
@@ -25,6 +25,7 @@ import {
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NgClass, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -66,6 +67,7 @@ import { DipDecisionDialogComponent } from './dip-decision-dialog.component';
export class DipLookupsComponent implements OnInit {
private service = inject(DipLookupsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
displayedColumns: string[] = [
'mobileNumber',
@@ -82,7 +84,16 @@ export class DipLookupsComponent implements OnInit {
activeStatus: string | null = null;
/** True while the table is showing the pending-decision queue rather than the full log. */
pendingOnly = false;
/**
* Which screening run the rows came from: 'ONBOARDING' for checks made while the client was being created,
* 'RESCREEN' for the periodic sweep over existing clients, null for both. Filtered here rather than on the
* API because the log endpoint only accepts a status filter.
*/
activeSource: 'ONBOARDING' | 'RESCREEN' | null = null;
loading = false;
rescreening = false;
/** Full result set, kept so the source filter can be applied without another round trip. */
private allRows: any[] = [];
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -107,10 +118,50 @@ export class DipLookupsComponent implements OnInit {
}
private applyRows(rows: any[]) {
this.allRows = rows;
this.refreshTable();
this.loading = false;
}
/** Applies the source filter over the rows already fetched. */
private refreshTable() {
const rows = this.activeSource
? this.allRows.filter((row) => (this.activeSource === 'RESCREEN' ? this.isRescreen(row) : !this.isRescreen(row)))
: this.allRows;
this.dataSource = new MatTableDataSource(rows);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.loading = false;
}
/**
* A row came from the periodic sweep when the backend stamped it PERIODIC_RESCREEN; everything else was
* looked up as the number was typed during client creation.
*/
isRescreen(row: any): boolean {
return (row?.lookupSource || '').toUpperCase().includes('RESCREEN');
}
sourceLabel(row: any): string {
return this.isRescreen(row) ? 'Re-screening' : 'Client Creation';
}
sourceClass(row: any): string {
return this.isRescreen(row) ? 'source-rescreen' : 'source-onboarding';
}
filterBySource(source: 'ONBOARDING' | 'RESCREEN' | null) {
this.activeSource = this.activeSource === source ? null : source;
this.refreshTable();
}
/** Row counts behind each source chip, so the split is visible before filtering. */
get onboardingCount(): number {
return this.allRows.filter((row) => !this.isRescreen(row)).length;
}
get rescreenCount(): number {
return this.allRows.filter((row) => this.isRescreen(row)).length;
}
applySearch(value: string) {
@@ -135,6 +186,7 @@ export class DipLookupsComponent implements OnInit {
this.searchNumber = '';
this.activeStatus = null;
this.pendingOnly = false;
this.activeSource = null;
this.load();
}
@@ -165,6 +217,35 @@ export class DipLookupsComponent implements OnInit {
return flagged && !row?.overrideDecision;
}
/**
* Screens all existing clients' numbers. A customer onboarded with a clean number can have it
* reported for fraud or reassigned later, and nothing else in the system would ever notice.
*/
rescreenExistingClients() {
this.rescreening = true;
this.service.rescreenExistingClients().subscribe({
next: (res: any) => {
this.rescreening = false;
// Report what was scanned as well as what was flagged: "0 flagged" out of 0 clients means
// nothing was checked, which is a very different situation from 0 hits out of 20.
const flagged = res?.numbersFlagged ?? 0;
const scanned = res?.clientsScanned ?? 0;
this.snackBar.open(
`Re-screening complete — ${flagged} number(s) flagged across ${scanned} active client(s).`,
'Close',
{ duration: 6000 }
);
this.pendingOnly = true;
this.activeStatus = null;
this.load();
},
error: (err: any) => {
this.rescreening = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Re-screening failed.', 'Close', { duration: 6000 });
}
});
}
review(row: any) {
this.dialog
.open(DipDecisionDialogComponent, { width: '560px', data: { ...row }, autoFocus: false })
@@ -35,4 +35,13 @@ export class DipLookupsService {
recordDecision(logId: number, decision: string, reason: string): Observable<any> {
return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
}
/**
* Screens every active client's mobile numbers against DIP/MNRL. Onboarding-time screening
* only sees a number as it is typed, so this is what catches numbers flagged or reassigned
* after the customer was onboarded.
*/
rescreenExistingClients(): Observable<any> {
return this.http.post('/lookup/rescreen', {});
}
}
@@ -49,7 +49,14 @@ import { SanctionsLogsService } from './sanctions-logs.service';
</div>
<div class="detail-row">
<span class="detail-label">Client</span>
<span class="detail-value">{{ alert.clientId || 'Not yet onboarded' }}</span>
<span class="detail-value">
@if (alert.clientId) {
{{ alert.clientName || 'Client #' + alert.clientId }}
<span class="detail-sub">{{ alert.clientAccountNo || alert.clientId }}</span>
} @else {
Not yet onboarded
}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Screened On</span>
@@ -59,10 +66,42 @@ import { SanctionsLogsService } from './sanctions-logs.service';
<div class="section-title">Reviewing Officer's Assessment</div>
@if (alert.disposition === 'PENDING_REVIEW') {
<!--
An alert raised by the periodic sweep has no onboarding flow behind it, so this dialog is
the only place it can ever be assessed. Without the form below such an alert was
permanently stuck: it told the reader to wait for a branch officer who does not exist.
-->
<div class="pending-note">
<fa-icon icon="exclamation-circle"></fa-icon>
Not yet reviewed. The branch officer must record an outcome before this can be approved.
Not yet reviewed. Compare the customer's identifiers against the listed particulars, then record the outcome
below.
</div>
@if (alert.matchedNames) {
<div class="matched-names">
<span class="matched-names__label">Listed as</span>
{{ alert.matchedNames }}
</div>
}
@if (showAssessForm) {
<div class="reason-form">
<label class="field-label">
{{
pendingDisposition === 'FALSE_POSITIVE'
? 'Why is this NOT the same person? State the identifiers you compared.'
: 'Record the basis on which this match is confirmed.'
}}
</label>
<textarea
class="field-input"
rows="3"
[(ngModel)]="assessmentReason"
placeholder="e.g. Customer DOB 12/03/1988 vs listed 1965; nationality IN vs listed AF; no identifier corroborates the listing."
></textarea>
<div class="field-hint" [class.field-error]="assessTouched && !assessmentValid">
Minimum 10 characters — this text is the compliance record.
</div>
</div>
}
} @else {
<div class="assessment-card" [class.assessment-confirmed]="alert.disposition === 'CONFIRMED_MATCH'">
<div class="assessment-badge">{{ dispositionLabel }}</div>
@@ -81,6 +120,18 @@ import { SanctionsLogsService } from './sanctions-logs.service';
<span> &middot; Debits frozen on account(s) {{ alert.frozenAccountIds }}</span>
}
</div>
<!--
An empty freeze is a legitimate outcome, not a failure — a client with no deposit account
has no balance to restrict. Saying so explicitly stops it reading as a freeze that
silently did not happen.
-->
@if (alert.disposition === 'CONFIRMED_MATCH' && !alert.frozenAccountIds) {
<div class="no-freeze-note">
<fa-icon icon="exclamation-circle"></fa-icon>
No deposit account to freeze for this client. Onboarding and lending remain blocked, and the reporting
obligation below still applies.
</div>
}
}
@if (alert.disposition === 'CONFIRMED_MATCH' && alert.approvedBy) {
@@ -112,6 +163,20 @@ import { SanctionsLogsService } from './sanctions-logs.service';
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button (click)="close()" [disabled]="saving">Close</button>
@if (needsAssessment && !showAssessForm) {
<button mat-stroked-button (click)="startAssessment('FALSE_POSITIVE')" [disabled]="saving">
Not the Same Person
</button>
<button mat-raised-button color="warn" (click)="startAssessment('CONFIRMED_MATCH')" [disabled]="saving">
Confirm Match
</button>
}
@if (needsAssessment && showAssessForm) {
<button mat-stroked-button (click)="cancelAssessment()" [disabled]="saving">Back</button>
<button mat-raised-button color="primary" (click)="submitAssessment()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save Assessment' }}
</button>
}
@if (canApprove) {
<button mat-raised-button color="primary" (click)="approve()" [disabled]="saving">
<fa-icon icon="check"></fa-icon>
@@ -180,6 +245,13 @@ import { SanctionsLogsService } from './sanctions-logs.service';
font-size: 13px;
}
.detail-sub {
display: block;
font-size: 11px;
font-weight: 400;
color: #9ca3af;
}
.section-title {
font-size: 11px;
font-weight: 700;
@@ -189,6 +261,34 @@ import { SanctionsLogsService } from './sanctions-logs.service';
margin: 16px 0 6px;
}
.matched-names {
font-size: 12px;
color: #7f1d1d;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 6px;
padding: 8px 12px;
margin-top: 8px;
line-height: 1.5;
}
.matched-names__label {
display: block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #dc2626;
}
.reason-form {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
}
.pending-note {
display: flex;
align-items: center;
@@ -285,6 +385,16 @@ import { SanctionsLogsService } from './sanctions-logs.service';
margin-top: 4px;
}
.field-error {
color: #dc2626;
font-weight: 600;
}
textarea.field-input {
font-family: inherit;
resize: vertical;
}
.report-btn {
align-self: flex-start;
margin-top: 8px;
@@ -304,6 +414,20 @@ import { SanctionsLogsService } from './sanctions-logs.service';
margin-top: 8px;
}
.no-freeze-note {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 8px 12px;
margin-top: 8px;
line-height: 1.5;
}
.error-note {
font-size: 12px;
font-weight: 600;
@@ -339,6 +463,21 @@ export class SanctionsAlertReviewDialogComponent {
saving = false;
errorMessage = '';
savedMessage = '';
/** Assessment capture for alerts that never passed through an onboarding flow. */
showAssessForm = false;
pendingDisposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH' | null = null;
assessmentReason = '';
assessTouched = false;
/** An unreviewed alert has to be assessable here — nothing else will ever reach it. */
get needsAssessment(): boolean {
return this.alert?.disposition === 'PENDING_REVIEW';
}
get assessmentValid(): boolean {
return this.assessmentReason.trim().length >= 10;
}
/** Set when anything was persisted, so the caller knows to refresh the table. */
private changed = false;
@@ -364,6 +503,44 @@ export class SanctionsAlertReviewDialogComponent {
: 'Approve — Confirm & Freeze';
}
startAssessment(disposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH') {
this.pendingDisposition = disposition;
this.showAssessForm = true;
this.assessTouched = false;
this.errorMessage = '';
}
cancelAssessment() {
this.showAssessForm = false;
this.pendingDisposition = null;
this.assessmentReason = '';
this.errorMessage = '';
}
submitAssessment() {
this.assessTouched = true;
if (!this.assessmentValid || !this.pendingDisposition) {
return;
}
this.saving = true;
this.errorMessage = '';
this.service.recordDisposition(this.alert.id, this.pendingDisposition, this.assessmentReason.trim()).subscribe({
next: (updated: any) => {
this.saving = false;
this.changed = true;
this.alert = updated || this.alert;
this.showAssessForm = false;
// Deliberately not auto-approved: the officer recording the assessment cannot also be the
// one who signs it off, so a second user still has to reopen this and approve.
this.savedMessage = 'Assessment recorded. A different officer must now approve it.';
},
error: (err: any) => {
this.saving = false;
this.errorMessage = this.extractMessage(err, 'Could not save the assessment.');
}
});
}
approve() {
this.saving = true;
this.errorMessage = '';
@@ -47,9 +47,24 @@
mat-stroked-button
[class.active-filter]="alertFilter === 'PENDING_REVIEW'"
class="chip-pending"
matTooltip="Flagged screenings the reviewing officer has not assessed yet."
(click)="filterByAlert('PENDING_REVIEW')"
>
<fa-icon icon="clock" class="chip-icon"></fa-icon> Pending Review
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Assessment
</button>
<!--
The Principal Officer's own queue. Without it an assessed alert dropped out of "Pending
Review" and the only way to reach it was to scan every alert — so the freeze it was waiting
on simply never happened.
-->
<button
mat-stroked-button
[class.active-filter]="alertFilter === 'AWAITING_APPROVAL'"
class="chip-approval"
matTooltip="Assessed by an officer and waiting for Principal Officer approval. Approving a confirmed match freezes the client's accounts."
(click)="filterByAlert('AWAITING_APPROVAL')"
>
<fa-icon icon="user-shield" class="chip-icon"></fa-icon> Awaiting Approval
</button>
<button
mat-stroked-button
@@ -59,13 +74,50 @@
>
<fa-icon icon="folder-open" class="chip-icon"></fa-icon> All Alerts
</button>
@if (searchName || activeFilter || alertFilter) {
@if (searchName || activeFilter || alertFilter || activeSource) {
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<!-- Separate question from the result/alert chips above: not "what did the screening say" but "when was it run". -->
<div class="source-filter">
<span class="source-filter__label">Screened during</span>
<div class="filter-chips">
<button
mat-stroked-button
[class.active-filter]="!activeSource"
matTooltip="Show screenings from both client creation and re-screening"
(click)="filterBySource(null)"
>
<fa-icon icon="list" class="chip-icon"></fa-icon> Both
</button>
<button
mat-stroked-button
class="chip-onboarding"
[class.active-filter]="activeSource === 'CLIENT_CREATION'"
matTooltip="Names screened while the client was being created"
(click)="filterBySource('CLIENT_CREATION')"
>
<fa-icon icon="user-plus" class="chip-icon"></fa-icon>
Client Creation
<span class="chip-count">{{ creationCount }}</span>
</button>
<button
mat-stroked-button
class="chip-rescreen"
[class.active-filter]="activeSource === 'RESCREEN'"
matTooltip="Existing clients swept by the periodic re-screening run"
(click)="filterBySource('RESCREEN')"
>
<fa-icon icon="sync" class="chip-icon"></fa-icon>
Re-screening
<span class="chip-count">{{ rescreenCount }}</span>
</button>
</div>
</div>
<button
mat-stroked-button
color="primary"
@@ -120,6 +172,31 @@
<td mat-cell *matCellDef="let log">{{ log.matchCount || 0 }}</td>
</ng-container>
<ng-container matColumnDef="screeningSource">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened During</th>
<td mat-cell *matCellDef="let log">
<span class="source-badge" [ngClass]="sourceClass(log)">
<fa-icon [icon]="isRescreen(log) ? 'sync' : 'user-plus'" class="source-badge__icon"></fa-icon>
{{ sourceLabel(log) }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="client">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client</th>
<td mat-cell *matCellDef="let log">
@if (log.clientId) {
<!-- Linked so the officer can go straight to the customer the alert belongs to. -->
<a class="client-link" [routerLink]="['/clients', log.clientId, 'general']">
{{ log.clientName || 'Client #' + log.clientId }}
<span class="client-acct">{{ log.clientAccountNo || log.clientId }}</span>
</a>
} @else {
<span class="muted">Not onboarded</span>
}
</td>
</ng-container>
<ng-container matColumnDef="screenedBy">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened By</th>
<td mat-cell *matCellDef="let log">{{ log.screenedBy }}</td>
@@ -135,8 +212,16 @@
<th mat-header-cell *matHeaderCellDef>Review</th>
<td mat-cell *matCellDef="let log">
@if (isAlert(log)) {
<button mat-stroked-button color="primary" class="review-btn" (click)="reviewAlert(log)">
<fa-icon icon="search"></fa-icon> Review
<!-- Labelled by what the row actually needs next, so the officer is not left guessing
whether opening it will ask them to assess or to approve. -->
<button
mat-stroked-button
[color]="isAwaitingApproval(log) ? 'warn' : 'primary'"
class="review-btn"
(click)="reviewAlert(log)"
>
<fa-icon icon="search"></fa-icon>
{{ isAwaitingApproval(log) ? 'Approve' : log.disposition === 'PENDING_REVIEW' ? 'Assess' : 'View' }}
</button>
}
</td>
@@ -79,6 +79,79 @@
background-color: #b45309;
border-color: #b45309;
}
.chip-approval.active-filter {
background-color: #7c3aed;
border-color: #7c3aed;
}
/* Indigo rather than the violet used by "Awaiting Approval", so the two chip groups stay distinguishable. */
.chip-onboarding.active-filter {
background-color: #3730a3;
border-color: #3730a3;
}
.chip-rescreen.active-filter {
background-color: #0f766e;
border-color: #0f766e;
}
.chip-count {
margin-left: 6px;
padding: 1px 7px;
border-radius: 10px;
background-color: rgb(0 0 0 / 8%);
font-size: 11px;
font-weight: 700;
}
.active-filter .chip-count {
background-color: rgb(255 255 255 / 25%);
}
}
/* Which screening run a row came from — a different question from the result/alert chips. */
.source-filter {
display: flex;
align-items: center;
gap: 10px;
padding-left: 14px;
margin-left: 6px;
border-left: 1px solid #dfe3ea;
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6b7280;
white-space: nowrap;
}
}
.source-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
&__icon {
font-size: 11px;
}
&.source-onboarding {
background-color: rgb(55 48 163 / 10%);
color: #3730a3;
}
&.source-rescreen {
background-color: rgb(15 118 110 / 10%);
color: #0f766e;
}
}
.rescreen-btn {
@@ -123,6 +196,24 @@
color: #9ca3af;
}
.client-link {
display: flex;
flex-direction: column;
text-decoration: none;
color: #1074b9;
font-weight: 600;
&:hover {
text-decoration: underline;
}
.client-acct {
font-size: 11px;
font-weight: 400;
color: #9ca3af;
}
}
.review-btn {
font-size: 12px;
border-radius: 16px;
@@ -70,6 +70,8 @@ export class SanctionsLogsComponent implements OnInit {
'screeningResult',
'disposition',
'matchCount',
'screeningSource',
'client',
'screenedBy',
'screenedAt',
'actions'
@@ -83,8 +85,16 @@ export class SanctionsLogsComponent implements OnInit {
* disposition) instead of the raw screening log. 'ALL' means every alert regardless of state.
*/
alertFilter: string | null = null;
/**
* Which screening run the rows came from: 'CLIENT_CREATION' for names screened while the client was being
* created, 'RESCREEN' for the periodic sweep over existing clients, null for both. Applied in the browser
* because neither the log nor the alert endpoint accepts a source filter.
*/
activeSource: 'CLIENT_CREATION' | 'RESCREEN' | null = null;
loading = false;
rescreening = false;
/** Full result set, kept so the source filter can be applied without another round trip. */
private allRows: any[] = [];
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -98,16 +108,38 @@ export class SanctionsLogsComponent implements OnInit {
// The alert queue is a different endpoint, not a filter on the log list: it carries the
// disposition columns the review dialog needs.
// AWAITING_APPROVAL is a client-side view over all alerts rather than a disposition value:
// an alert waiting on the Principal Officer is one that HAS been assessed but not signed off,
// so it is identified by approvedBy being empty, not by its disposition.
const serverFilter =
this.alertFilter === 'ALL' || this.alertFilter === 'AWAITING_APPROVAL' ? undefined : this.alertFilter;
const request$ = this.alertFilter
? this.sanctionsLogsService.getAlerts(this.alertFilter === 'ALL' ? undefined : this.alertFilter)
? this.sanctionsLogsService.getAlerts(serverFilter)
: this.sanctionsLogsService.getLogs(this.buildLogFilters());
request$.subscribe({
next: (data: any) => this.applyRows(data || []),
next: (data: any) => this.applyRows(this.postFilter(data || [])),
error: () => this.applyRows([])
});
}
/** Narrows the fetched alerts for views the API has no dedicated filter for. */
private postFilter(rows: any[]): any[] {
if (this.alertFilter !== 'AWAITING_APPROVAL') {
return rows;
}
return rows.filter((row) => this.isAwaitingApproval(row));
}
/**
* True when the clerk has recorded an assessment and the Principal Officer has not yet approved
* it. This is the state in which the freeze is pending and nothing happens until someone acts.
*/
isAwaitingApproval(row: any): boolean {
return !!row?.disposition && row.disposition !== 'PENDING_REVIEW' && !row.approvedBy;
}
private buildLogFilters() {
const filters: any = {};
if (this.activeFilter) {
@@ -120,10 +152,50 @@ export class SanctionsLogsComponent implements OnInit {
}
private applyRows(rows: any[]) {
this.allRows = rows;
this.refreshTable();
this.loading = false;
}
/** Applies the source filter over the rows already fetched. */
private refreshTable() {
const rows = this.activeSource
? this.allRows.filter((row) => (this.activeSource === 'RESCREEN' ? this.isRescreen(row) : !this.isRescreen(row)))
: this.allRows;
this.dataSource = new MatTableDataSource(rows);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.loading = false;
}
/**
* A row came from the periodic sweep when the backend stamped it PERIODIC_RESCREEN; everything else was
* screened as the name was entered during client creation.
*/
isRescreen(row: any): boolean {
return (row?.screeningSource || '').toUpperCase().includes('RESCREEN');
}
sourceLabel(row: any): string {
return this.isRescreen(row) ? 'Re-screening' : 'Client Creation';
}
sourceClass(row: any): string {
return this.isRescreen(row) ? 'source-rescreen' : 'source-onboarding';
}
filterBySource(source: 'CLIENT_CREATION' | 'RESCREEN' | null) {
this.activeSource = this.activeSource === source ? null : source;
this.refreshTable();
}
/** Row counts behind each source chip, so the split is visible before filtering. */
get creationCount(): number {
return this.allRows.filter((row) => !this.isRescreen(row)).length;
}
get rescreenCount(): number {
return this.allRows.filter((row) => this.isRescreen(row)).length;
}
applySearch(value: string) {
@@ -149,6 +221,7 @@ export class SanctionsLogsComponent implements OnInit {
this.searchName = '';
this.activeFilter = null;
this.alertFilter = null;
this.activeSource = null;
this.loadLogs();
}
@@ -59,4 +59,18 @@ export class SanctionsLogsService {
rescreenExistingClients(): Observable<any> {
return this.http.post('/sanctions/rescreen', {});
}
/**
* Confirmed matches owing an STR to FIU-IND — the sanctions stream on the FIU screen, kept
* apart from transaction-driven AML alerts.
*
* @param status PENDING_FILING | FILED, or omitted for all
*/
getStrCases(status?: string): Observable<any> {
let params = new HttpParams();
if (status) {
params = params.set('status', status);
}
return this.http.get('/sanctions/str-cases', { params });
}
}