feat: add database export, welcome kit dialog, and DIP/sanctions updates
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+6
-14
@@ -44,7 +44,7 @@
|
|||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"ngx-image-cropper": "^9.1.6",
|
"ngx-image-cropper": "^9.1.6",
|
||||||
"ngx-mat-select-search": "^8.0.2",
|
"ngx-mat-select-search": "^8.0.2",
|
||||||
"pdfjs-dist": "^5.6.205",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"signature_pad": "^5.1.3",
|
"signature_pad": "^5.1.3",
|
||||||
"tinymce": "^8.2.2",
|
"tinymce": "^8.2.2",
|
||||||
@@ -19273,13 +19273,6 @@
|
|||||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@@ -20207,16 +20200,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "5.6.205",
|
"version": "5.7.284",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
|
||||||
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
|
"integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
"node": ">=22.13.0 || >=24"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.96",
|
"@napi-rs/canvas": "^0.1.100"
|
||||||
"node-readable-to-web-readable-stream": "^0.4.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@
|
|||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"ngx-image-cropper": "^9.1.6",
|
"ngx-image-cropper": "^9.1.6",
|
||||||
"ngx-mat-select-search": "^8.0.2",
|
"ngx-mat-select-search": "^8.0.2",
|
||||||
"pdfjs-dist": "^5.6.205",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"signature_pad": "^5.1.3",
|
"signature_pad": "^5.1.3",
|
||||||
"tinymce": "^8.2.2",
|
"tinymce": "^8.2.2",
|
||||||
|
|||||||
@@ -41,6 +41,26 @@
|
|||||||
>
|
>
|
||||||
{{ address.isActive ? 'Active' : 'Inactive' }}
|
{{ address.isActive ? 'Active' : 'Inactive' }}
|
||||||
</span>
|
</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-title>
|
||||||
<mat-panel-description class="addr-panel__desc">
|
<mat-panel-description class="addr-panel__desc">
|
||||||
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : ''
|
{{ 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 {
|
&__desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $gray-500 !important;
|
color: $gray-500 !important;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from '@angular/material/expansion';
|
} from '@angular/material/expansion';
|
||||||
import { MatDivider } from '@angular/material/divider';
|
import { MatDivider } from '@angular/material/divider';
|
||||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatExpansionPanelTitle,
|
MatExpansionPanelTitle,
|
||||||
MatExpansionPanelDescription,
|
MatExpansionPanelDescription,
|
||||||
MatDivider,
|
MatDivider,
|
||||||
MatSlideToggle
|
MatSlideToggle,
|
||||||
|
MatTooltip
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AddressTabComponent {
|
export class AddressTabComponent {
|
||||||
@@ -68,6 +70,11 @@ export class AddressTabComponent {
|
|||||||
clientAddressTemplate: any;
|
clientAddressTemplate: any;
|
||||||
/** Client Id */
|
/** Client Id */
|
||||||
clientId: string;
|
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
|
* @param {ActivatedRoute} route Activated Route
|
||||||
@@ -82,10 +89,48 @@ export class AddressTabComponent {
|
|||||||
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
||||||
this.clientAddressTemplate = data.clientAddressTemplateData;
|
this.clientAddressTemplate = data.clientAddressTemplateData;
|
||||||
this.clientId = this.route.parent.snapshot.paramMap.get('clientId');
|
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.
|
* Adds a client address.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -168,58 +168,78 @@
|
|||||||
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
||||||
</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">
|
||||||
<div class="cv-cell__label">DIP Status</div>
|
<div class="cv-cell__label">DIP Status</div>
|
||||||
<div class="cv-cell__value">
|
<div class="cv-cell__value">
|
||||||
<span
|
<span
|
||||||
class="cv-badge"
|
class="cv-badge"
|
||||||
[class.cv-badge--green]="
|
[class.cv-badge--red]="isDipFlagged(clientViewData.dipLiveStatus)"
|
||||||
clientViewData.dipVerified === true ||
|
[class.cv-badge--green]="!isDipFlagged(clientViewData.dipLiveStatus) && isDipClear(clientViewData)"
|
||||||
clientViewData.dipVerified === 'true' ||
|
[class.cv-badge--yellow]="!isDipFlagged(clientViewData.dipLiveStatus) && !isDipClear(clientViewData)"
|
||||||
clientViewData.dipVerified === 1
|
|
||||||
"
|
|
||||||
[class.cv-badge--yellow]="
|
|
||||||
clientViewData.dipVerified !== true &&
|
|
||||||
clientViewData.dipVerified !== 'true' &&
|
|
||||||
clientViewData.dipVerified !== 1
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{
|
{{ dipStatusLabel(clientViewData) }}
|
||||||
clientViewData.dipVerified === true ||
|
|
||||||
clientViewData.dipVerified === 'true' ||
|
|
||||||
clientViewData.dipVerified === 1
|
|
||||||
? 'Verified'
|
|
||||||
: 'Pending'
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 → Sanctions Screening & 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">
|
||||||
<div class="cv-cell__label">Sanctions Screening</div>
|
<div class="cv-cell__label">Sanctions Screening</div>
|
||||||
<div class="cv-cell__value">
|
<div class="cv-cell__value">
|
||||||
<span
|
<span
|
||||||
class="cv-badge"
|
class="cv-badge"
|
||||||
[class.cv-badge--green]="
|
[class.cv-badge--green]="!clientViewData.sanctionsAlertStatus && isNameCleared(clientViewData)"
|
||||||
clientViewData.nameVerified === true ||
|
[class.cv-badge--yellow]="
|
||||||
clientViewData.nameVerified === 'true' ||
|
clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW' ||
|
||||||
clientViewData.nameVerified === 1
|
(!clientViewData.sanctionsAlertStatus && !isNameCleared(clientViewData))
|
||||||
"
|
|
||||||
[class.cv-badge--red]="
|
|
||||||
clientViewData.nameVerified === false ||
|
|
||||||
clientViewData.nameVerified === 'false' ||
|
|
||||||
clientViewData.nameVerified === 0
|
|
||||||
"
|
"
|
||||||
|
[class.cv-badge--red]="clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH'"
|
||||||
>
|
>
|
||||||
{{
|
{{ sanctionsHeaderLabel(clientViewData) }}
|
||||||
clientViewData.nameVerified === true ||
|
|
||||||
clientViewData.nameVerified === 'true' ||
|
|
||||||
clientViewData.nameVerified === 1
|
|
||||||
? 'Clear'
|
|
||||||
: 'Pending'
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -410,9 +430,11 @@
|
|||||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||||
{{ 'labels.buttons.Add Charge' | translate }}
|
{{ 'labels.buttons.Add Charge' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
@if (isActive()) {
|
||||||
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||||
Welcome Kit
|
Welcome Kit
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
<button mat-menu-item (click)="doAction('Create Collateral')">
|
<button mat-menu-item (click)="doAction('Create Collateral')">
|
||||||
{{ 'labels.buttons.Create Collateral' | translate }}
|
{{ 'labels.buttons.Create Collateral' | translate }}
|
||||||
</button>
|
</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 {
|
.actions-menu {
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,54 @@ import { formatTabLabel } from 'app/shared/utils/format-tab-label.util';
|
|||||||
})
|
})
|
||||||
export class ClientsViewComponent implements OnInit {
|
export class ClientsViewComponent implements OnInit {
|
||||||
complianceHideClientData = environment.complianceHideClientData;
|
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 *
|
* 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() {
|
private dispatchWelcomeKit() {
|
||||||
|
if (!this.isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
||||||
data: { clientId: this.clientViewData.id },
|
data: {
|
||||||
width: '600px'
|
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) => {
|
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
+231
-44
@@ -5,90 +5,277 @@
|
|||||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
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/.
|
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">
|
<mat-dialog-content class="mat-typography wk-content">
|
||||||
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
|
<div class="wk-loading" *ngIf="isLoading">
|
||||||
<mat-spinner diameter="40"></mat-spinner>
|
<mat-spinner diameter="40"></mat-spinner>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="!isLoading">
|
<ng-container *ngIf="!isLoading">
|
||||||
<!-- Tracking view if dispatches already exist -->
|
<!-- Who the kit is going to -->
|
||||||
<div
|
<section class="wk-client">
|
||||||
class="dispatch-tracking-panel"
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Existing dispatches, newest first -->
|
||||||
|
<section
|
||||||
|
class="wk-dispatch"
|
||||||
*ngFor="let dispatch of activeDispatches; let i = index"
|
*ngFor="let dispatch of activeDispatches; let i = index"
|
||||||
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
|
[class.wk-dispatch--past]="i > 0"
|
||||||
>
|
>
|
||||||
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
|
<header class="wk-dispatch__head">
|
||||||
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
|
<span class="wk-dispatch__label">{{ i === 0 ? 'Current Dispatch' : 'Previous Dispatch' }}</span>
|
||||||
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
|
<span class="wk-chip" [ngClass]="'wk-chip--' + (dispatch.status | lowercase)">
|
||||||
Download Details
|
<mat-icon class="wk-chip__icon">
|
||||||
|
{{
|
||||||
|
dispatch.status === 'DELIVERED'
|
||||||
|
? 'verified'
|
||||||
|
: dispatch.status === 'RETURNED'
|
||||||
|
? 'gpp_bad'
|
||||||
|
: 'local_shipping'
|
||||||
|
}}
|
||||||
|
</mat-icon>
|
||||||
|
{{ verificationLabel(dispatch.status) }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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>
|
</button>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
<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>
|
||||||
|
|
||||||
<!-- Create form if they can dispatch a new one -->
|
<!-- New dispatch -->
|
||||||
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
|
<section class="wk-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
|
||||||
<h3>Create New Dispatch</h3>
|
<h3 class="wk-form__title">
|
||||||
<div fxLayout="column" fxLayoutGap="16px">
|
{{ activeDispatches.length > 0 ? 'Dispatch a Replacement Kit' : 'Create New Dispatch' }}
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
</h3>
|
||||||
<mat-label>Delivery Address</mat-label>
|
|
||||||
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
|
<!-- The kit goes to one of the addresses captured at client creation; it is chosen, never typed. -->
|
||||||
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
|
<ng-container *ngIf="addressChoices.length > 0">
|
||||||
Delivery Address is required
|
<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-error>
|
||||||
</mat-form-field>
|
</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-label>Delivery Mode</mat-label>
|
||||||
<mat-select formControlName="deliveryMode" required>
|
<mat-select formControlName="deliveryMode" required>
|
||||||
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
|
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">{{ mode }}</mat-option>
|
||||||
{{ mode }}
|
|
||||||
</mat-option>
|
|
||||||
</mat-select>
|
</mat-select>
|
||||||
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
||||||
Delivery Mode is required
|
Delivery Mode is required
|
||||||
</mat-error>
|
</mat-error>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field
|
<mat-form-field appearance="outline">
|
||||||
appearance="outline"
|
<mat-label>Dispatched On</mat-label>
|
||||||
class="full-width"
|
<input
|
||||||
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
|
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>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="wk-full" *ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'">
|
||||||
<mat-label>Specify Other Mode</mat-label>
|
<mat-label>Specify Other Mode</mat-label>
|
||||||
<input matInput formControlName="deliveryModeOtherText" required />
|
<input matInput formControlName="deliveryModeOtherText" required />
|
||||||
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
||||||
Please specify the delivery mode
|
Please specify the delivery mode
|
||||||
</mat-error>
|
</mat-error>
|
||||||
</mat-form-field>
|
</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>
|
</div>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!selectedAddress"
|
||||||
|
(click)="downloadAddressLabel()"
|
||||||
|
>
|
||||||
|
<mat-icon>print</mat-icon>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
</ng-container>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
<button mat-button mat-dialog-close>Close</button>
|
<button mat-button type="button" (click)="close()">Close</button>
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-flat-button
|
||||||
color="primary"
|
color="primary"
|
||||||
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
|
type="button"
|
||||||
|
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading && !outcomeFor"
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[disabled]="dispatchForm.invalid || isLoading"
|
[disabled]="dispatchForm.invalid || isLoading"
|
||||||
>
|
>
|
||||||
|
<mat-icon>send</mat-icon>
|
||||||
Dispatch
|
Dispatch
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
+350
-30
@@ -5,55 +5,375 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
.dispatch-tracking-panel {
|
|
||||||
background-color: #f5f5f5;
|
$wk-primary: #1f497d;
|
||||||
border-radius: 8px;
|
$wk-border: #e3e7ee;
|
||||||
padding: 16px;
|
$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;
|
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 {
|
&__avatar {
|
||||||
margin-top: 0;
|
display: flex;
|
||||||
margin-bottom: 12px;
|
align-items: center;
|
||||||
font-size: 1.1rem;
|
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 {
|
&__name {
|
||||||
margin: 6px 0;
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
&__meta {
|
||||||
padding: 4px 8px;
|
font-size: 0.8rem;
|
||||||
border-radius: 4px;
|
color: $wk-muted;
|
||||||
font-size: 0.85rem;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 500;
|
}
|
||||||
color: white;
|
}
|
||||||
|
|
||||||
&.status-dispatched {
|
/* ---------- dispatch panel ---------- */
|
||||||
background-color: #1976d2;
|
.wk-dispatch {
|
||||||
|
border: 1px solid $wk-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: $wk-surface;
|
||||||
|
|
||||||
|
&--past {
|
||||||
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.status-in_transit {
|
&__head {
|
||||||
background-color: #f57c00;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.status-delivered {
|
&__label {
|
||||||
background-color: #388e3c;
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.status-returned {
|
&__hint {
|
||||||
background-color: #d32f2f;
|
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 {
|
/* ---------- status chip ---------- */
|
||||||
h3 {
|
.wk-chip {
|
||||||
margin-top: 0;
|
display: inline-flex;
|
||||||
margin-bottom: 12px;
|
align-items: center;
|
||||||
font-size: 1.1rem;
|
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%;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+422
-32
@@ -12,12 +12,14 @@ import {
|
|||||||
MatDialogRef,
|
MatDialogRef,
|
||||||
MatDialogTitle,
|
MatDialogTitle,
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
MatDialogActions,
|
MatDialogActions
|
||||||
MatDialogClose
|
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { ClientsService } from '../../../clients.service';
|
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { DatePipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
@@ -32,32 +34,79 @@ import autoTable from 'jspdf-autotable';
|
|||||||
MatDialogTitle,
|
MatDialogTitle,
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
MatDialogActions,
|
MatDialogActions,
|
||||||
MatDialogClose,
|
MatProgressSpinnerModule,
|
||||||
MatProgressSpinnerModule
|
MatIcon
|
||||||
],
|
],
|
||||||
providers: [DatePipe]
|
providers: [DatePipe]
|
||||||
})
|
})
|
||||||
export class WelcomeKitDialogComponent implements OnInit {
|
export class WelcomeKitDialogComponent implements OnInit {
|
||||||
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
|
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 clientsService = inject(ClientsService);
|
||||||
private formBuilder = inject(FormBuilder);
|
private formBuilder = inject(FormBuilder);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
private datePipe = inject(DatePipe);
|
private datePipe = inject(DatePipe);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
|
||||||
isLoading = true;
|
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;
|
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({
|
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
|
Validators.required
|
||||||
],
|
],
|
||||||
deliveryMode: [
|
deliveryMode: [
|
||||||
'POSTMAN',
|
'POSTMAN',
|
||||||
Validators.required
|
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 = [
|
deliveryModes = [
|
||||||
'POSTMAN',
|
'POSTMAN',
|
||||||
'COURIER',
|
'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 {
|
ngOnInit(): void {
|
||||||
|
this.maxDate = this.settingsService.businessDate;
|
||||||
|
this.dispatchForm.patchValue({ dispatchedOnDate: this.maxDate });
|
||||||
|
this.outcomeForm.patchValue({ outcomeDate: this.maxDate });
|
||||||
this.fetchData();
|
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() {
|
fetchPrimaryAddress() {
|
||||||
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
|
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
|
||||||
next: (addresses: any) => {
|
next: (addresses: any) => {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
if (addresses && addresses.length > 0) {
|
this.addressesLoaded = true;
|
||||||
// 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);
|
|
||||||
|
|
||||||
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
|
const usable = (addresses || []).filter((a: any) => a && (a.addressLine1 || a.postalCode || a.city));
|
||||||
this.dispatchForm.patchValue({
|
this.addressChoices = usable
|
||||||
deliveryAddress: clientNameStr + parts.join(', ')
|
.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: () => {
|
error: () => {
|
||||||
this.isLoading = false;
|
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() {
|
get activeDispatches() {
|
||||||
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
|
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
|
||||||
return this.welcomeKitsData.dispatches;
|
return this.welcomeKitsData.dispatches;
|
||||||
@@ -138,36 +261,104 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
return;
|
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;
|
this.isLoading = true;
|
||||||
const formValue = this.dispatchForm.value;
|
const formValue = this.dispatchForm.value;
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
const today = new Date();
|
const locale = this.settingsService.language.code;
|
||||||
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
|
|
||||||
|
|
||||||
const payload = {
|
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,
|
deliveryMode: formValue.deliveryMode,
|
||||||
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
|
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
|
||||||
dispatchedOnDate: formattedDate,
|
trackingId: (formValue.trackingId || '').trim(),
|
||||||
locale: 'en',
|
dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat),
|
||||||
dateFormat: 'dd MMMM yyyy'
|
locale,
|
||||||
|
dateFormat
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
||||||
next: () => {
|
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();
|
this.fetchData();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (err: any) => {
|
||||||
this.isLoading = false;
|
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 {
|
getDisplayAddress(dispatch: any): string {
|
||||||
const address = dispatch.deliveryAddress || '';
|
const address = dispatch.deliveryAddress || '';
|
||||||
const name = this.welcomeKitsData?.clientName || '';
|
const name = this.clientName;
|
||||||
if (name && !address.startsWith(name)) {
|
if (name && !address.startsWith(name)) {
|
||||||
return `${name} — ${address}`;
|
return `${name} — ${address}`;
|
||||||
}
|
}
|
||||||
@@ -185,6 +376,9 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
const trackingId = dispatch.trackingId || 'N/A';
|
const trackingId = dispatch.trackingId || 'N/A';
|
||||||
const status = dispatch.status || 'N/A';
|
const status = dispatch.status || 'N/A';
|
||||||
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || '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] = [
|
const primaryColor: [number, number, number] = [
|
||||||
31,
|
31,
|
||||||
@@ -307,6 +501,14 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
[
|
[
|
||||||
'Dispatched On',
|
'Dispatched On',
|
||||||
dispatchedOn
|
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`);
|
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>
|
||||||
<div class="pd-section__body">
|
<div class="pd-section__body">
|
||||||
<div class="pd-grid">
|
<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">
|
||||||
<div class="pd-field__label">Mobile DIP Status</div>
|
<div class="pd-field__label">Mobile DIP Status</div>
|
||||||
<div class="pd-field__value">
|
<div class="pd-field__value">
|
||||||
@if (
|
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
|
||||||
clientViewData.mobileVerified === true ||
|
<span class="pd-verify-badge pd-verify-badge--flagged">
|
||||||
clientViewData.mobileVerified === 'true' ||
|
<i class="fa fa-exclamation-triangle"></i> {{ dipStatusLabel(clientViewData) }}
|
||||||
clientViewData.mobileVerified === 1 ||
|
</span>
|
||||||
clientViewData.dipVerified === true ||
|
} @else if (isDipClear(clientViewData)) {
|
||||||
clientViewData.dipVerified === 'true' ||
|
|
||||||
clientViewData.dipVerified === 1
|
|
||||||
) {
|
|
||||||
<span class="pd-verify-badge pd-verify-badge--verified">
|
<span class="pd-verify-badge pd-verify-badge--verified">
|
||||||
<i class="fa fa-check-circle"></i> Verified
|
<i class="fa fa-check-circle"></i> Verified
|
||||||
</span>
|
</span>
|
||||||
} @else {
|
} @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>
|
||||||
</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) {
|
@if (clientViewData.dipTimestamp) {
|
||||||
<div class="pd-field">
|
<div class="pd-field">
|
||||||
<div class="pd-field__label">DIP Verified On</div>
|
<div class="pd-field__label">DIP Verified On</div>
|
||||||
@@ -314,7 +349,12 @@
|
|||||||
<div class="pd-field">
|
<div class="pd-field">
|
||||||
<div class="pd-field__label">Alternate Mobile DIP Status</div>
|
<div class="pd-field__label">Alternate Mobile DIP Status</div>
|
||||||
<div class="pd-field__value">
|
<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 === 'true' ||
|
clientViewData.alternateDipVerified === 'true' ||
|
||||||
clientViewData.alternateDipVerified === 1
|
clientViewData.alternateDipVerified === 1
|
||||||
|
|||||||
@@ -248,9 +248,59 @@ $teal: #00897b;
|
|||||||
border: 1px solid #fde68a;
|
border: 1px solid #fde68a;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--danger {
|
&--danger,
|
||||||
|
&--flagged {
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
border: 1px solid #fecaca;
|
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;
|
displayName?: string;
|
||||||
mobileNo?: string;
|
mobileNo?: string;
|
||||||
emailAddress?: 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[];
|
dateOfBirth?: number[];
|
||||||
gender?: { id: number; name: string; active?: boolean };
|
gender?: { id: number; name: string; active?: boolean };
|
||||||
clientType?: { id: number; name: string; active?: boolean };
|
clientType?: { id: number; name: string; active?: boolean };
|
||||||
@@ -67,6 +75,91 @@ export class PersonalDataTabComponent {
|
|||||||
/** Client View Data */
|
/** Client View Data */
|
||||||
clientViewData!: ClientViewData;
|
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() {
|
constructor() {
|
||||||
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
|
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
|
||||||
this.clientViewData = data.clientViewData;
|
this.clientViewData = data.clientViewData;
|
||||||
|
|||||||
@@ -1015,8 +1015,8 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
|
updateWelcomeKitStatus(welcomeKitId: number, data: any): Observable<any> {
|
||||||
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
|
return this.http.put(`/welcomekit/${welcomeKitId}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
getKycExceptions(resolved = false): Observable<any[]> {
|
getKycExceptions(resolved = false): Observable<any[]> {
|
||||||
|
|||||||
@@ -1677,6 +1677,16 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
|
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|||||||
@@ -347,6 +347,69 @@
|
|||||||
|
|
||||||
<!-- STR -->
|
<!-- STR -->
|
||||||
@if (activeTab === '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">
|
<div class="fiu-section">
|
||||||
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { AmlService } from 'app/aml/aml.service';
|
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 { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||||
import {
|
import {
|
||||||
BRANCHES,
|
BRANCHES,
|
||||||
@@ -130,6 +131,7 @@ export class FiuComponent implements OnInit {
|
|||||||
private static readonly SEED_VERSION = '2026-01-cash-threshold';
|
private static readonly SEED_VERSION = '2026-01-cash-threshold';
|
||||||
|
|
||||||
private amlService = inject(AmlService);
|
private amlService = inject(AmlService);
|
||||||
|
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||||
private authenticationService = inject(AuthenticationService);
|
private authenticationService = inject(AuthenticationService);
|
||||||
/** Active tab */
|
/** Active tab */
|
||||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
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) --------------------
|
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
|
||||||
amlAlerts: any[] = [];
|
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 = '';
|
amlAlertStatusFilter = '';
|
||||||
amlReviewNotes: Record<number, string> = {};
|
amlReviewNotes: Record<number, string> = {};
|
||||||
|
|
||||||
@@ -320,6 +329,37 @@ export class FiuComponent implements OnInit {
|
|||||||
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
|
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 {
|
get isPrincipalOfficer(): boolean {
|
||||||
return this.authenticationService.isPrincipalOfficerUser();
|
return this.authenticationService.isPrincipalOfficerUser();
|
||||||
}
|
}
|
||||||
@@ -547,6 +587,7 @@ export class FiuComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
loadAmlAlerts(): void {
|
loadAmlAlerts(): void {
|
||||||
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
||||||
|
this.loadSanctionsStrCases();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAmlCtrReports(): void {
|
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 { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
|
||||||
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
|
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
|
||||||
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
||||||
|
import { DatabaseExportComponent } from './database-export/database-export.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -56,6 +57,11 @@ const routes: Routes = [
|
|||||||
component: HypothecationCertificateComponent,
|
component: HypothecationCertificateComponent,
|
||||||
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
|
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',
|
path: 'letter-mgmt/noc',
|
||||||
component: NocCertificateComponent,
|
component: NocCertificateComponent,
|
||||||
|
|||||||
@@ -47,7 +47,14 @@ import { DipLookupsService } from './dip-lookups.service';
|
|||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Client</span>
|
<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>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Looked Up</span>
|
<span class="detail-label">Looked Up</span>
|
||||||
@@ -172,6 +179,13 @@ import { DipLookupsService } from './dip-lookups.service';
|
|||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -56,12 +56,61 @@
|
|||||||
<button mat-stroked-button [class.active-filter]="pendingOnly" class="chip-pending" (click)="showPending()">
|
<button mat-stroked-button [class.active-filter]="pendingOnly" class="chip-pending" (click)="showPending()">
|
||||||
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Decision
|
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Decision
|
||||||
</button>
|
</button>
|
||||||
@if (searchNumber || activeStatus || pendingOnly) {
|
@if (searchNumber || activeStatus || pendingOnly || activeSource) {
|
||||||
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
|
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
|
||||||
<fa-icon icon="times"></fa-icon>
|
<fa-icon icon="times"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
@@ -90,13 +139,28 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="lookupSource">
|
<ng-container matColumnDef="lookupSource">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Source</th>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Checked During</th>
|
||||||
<td mat-cell *matCellDef="let row">{{ row.lookupSource || '—' }}</td>
|
<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>
|
||||||
|
|
||||||
<ng-container matColumnDef="clientId">
|
<ng-container matColumnDef="clientId">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client</th>
|
<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>
|
||||||
|
|
||||||
<ng-container matColumnDef="lookedUpOn">
|
<ng-container matColumnDef="lookedUpOn">
|
||||||
|
|||||||
@@ -79,6 +79,106 @@
|
|||||||
background-color: #b45309;
|
background-color: #b45309;
|
||||||
border-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 {
|
.result-badge {
|
||||||
@@ -108,10 +208,6 @@
|
|||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-btn {
|
.review-btn {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { NgClass, DatePipe } from '@angular/common';
|
import { NgClass, DatePipe } from '@angular/common';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
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 {
|
export class DipLookupsComponent implements OnInit {
|
||||||
private service = inject(DipLookupsService);
|
private service = inject(DipLookupsService);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
'mobileNumber',
|
'mobileNumber',
|
||||||
@@ -82,7 +84,16 @@ export class DipLookupsComponent implements OnInit {
|
|||||||
activeStatus: string | null = null;
|
activeStatus: string | null = null;
|
||||||
/** True while the table is showing the pending-decision queue rather than the full log. */
|
/** True while the table is showing the pending-decision queue rather than the full log. */
|
||||||
pendingOnly = false;
|
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;
|
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(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||||
@@ -107,10 +118,50 @@ export class DipLookupsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyRows(rows: any[]) {
|
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 = new MatTableDataSource(rows);
|
||||||
this.dataSource.paginator = this.paginator;
|
this.dataSource.paginator = this.paginator;
|
||||||
this.dataSource.sort = this.sort;
|
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) {
|
applySearch(value: string) {
|
||||||
@@ -135,6 +186,7 @@ export class DipLookupsComponent implements OnInit {
|
|||||||
this.searchNumber = '';
|
this.searchNumber = '';
|
||||||
this.activeStatus = null;
|
this.activeStatus = null;
|
||||||
this.pendingOnly = false;
|
this.pendingOnly = false;
|
||||||
|
this.activeSource = null;
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +217,35 @@ export class DipLookupsComponent implements OnInit {
|
|||||||
return flagged && !row?.overrideDecision;
|
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) {
|
review(row: any) {
|
||||||
this.dialog
|
this.dialog
|
||||||
.open(DipDecisionDialogComponent, { width: '560px', data: { ...row }, autoFocus: false })
|
.open(DipDecisionDialogComponent, { width: '560px', data: { ...row }, autoFocus: false })
|
||||||
|
|||||||
@@ -35,4 +35,13 @@ export class DipLookupsService {
|
|||||||
recordDecision(logId: number, decision: string, reason: string): Observable<any> {
|
recordDecision(logId: number, decision: string, reason: string): Observable<any> {
|
||||||
return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
|
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>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Client</span>
|
<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>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Screened On</span>
|
<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>
|
<div class="section-title">Reviewing Officer's Assessment</div>
|
||||||
@if (alert.disposition === 'PENDING_REVIEW') {
|
@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">
|
<div class="pending-note">
|
||||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
<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>
|
</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 {
|
} @else {
|
||||||
<div class="assessment-card" [class.assessment-confirmed]="alert.disposition === 'CONFIRMED_MATCH'">
|
<div class="assessment-card" [class.assessment-confirmed]="alert.disposition === 'CONFIRMED_MATCH'">
|
||||||
<div class="assessment-badge">{{ dispositionLabel }}</div>
|
<div class="assessment-badge">{{ dispositionLabel }}</div>
|
||||||
@@ -81,6 +120,18 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
|||||||
<span> · Debits frozen on account(s) {{ alert.frozenAccountIds }}</span>
|
<span> · Debits frozen on account(s) {{ alert.frozenAccountIds }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</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) {
|
@if (alert.disposition === 'CONFIRMED_MATCH' && alert.approvedBy) {
|
||||||
@@ -112,6 +163,20 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
|||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
<button mat-stroked-button (click)="close()" [disabled]="saving">Close</button>
|
<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) {
|
@if (canApprove) {
|
||||||
<button mat-raised-button color="primary" (click)="approve()" [disabled]="saving">
|
<button mat-raised-button color="primary" (click)="approve()" [disabled]="saving">
|
||||||
<fa-icon icon="check"></fa-icon>
|
<fa-icon icon="check"></fa-icon>
|
||||||
@@ -180,6 +245,13 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -189,6 +261,34 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
|||||||
margin: 16px 0 6px;
|
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 {
|
.pending-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -285,6 +385,16 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.field-input {
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.report-btn {
|
.report-btn {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -304,6 +414,20 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
|||||||
margin-top: 8px;
|
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 {
|
.error-note {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -339,6 +463,21 @@ export class SanctionsAlertReviewDialogComponent {
|
|||||||
saving = false;
|
saving = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
savedMessage = '';
|
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. */
|
/** Set when anything was persisted, so the caller knows to refresh the table. */
|
||||||
private changed = false;
|
private changed = false;
|
||||||
|
|
||||||
@@ -364,6 +503,44 @@ export class SanctionsAlertReviewDialogComponent {
|
|||||||
: 'Approve — Confirm & Freeze';
|
: '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() {
|
approve() {
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|||||||
@@ -47,9 +47,24 @@
|
|||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
[class.active-filter]="alertFilter === 'PENDING_REVIEW'"
|
[class.active-filter]="alertFilter === 'PENDING_REVIEW'"
|
||||||
class="chip-pending"
|
class="chip-pending"
|
||||||
|
matTooltip="Flagged screenings the reviewing officer has not assessed yet."
|
||||||
(click)="filterByAlert('PENDING_REVIEW')"
|
(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>
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
@@ -59,13 +74,50 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="folder-open" class="chip-icon"></fa-icon> All Alerts
|
<fa-icon icon="folder-open" class="chip-icon"></fa-icon> All Alerts
|
||||||
</button>
|
</button>
|
||||||
@if (searchName || activeFilter || alertFilter) {
|
@if (searchName || activeFilter || alertFilter || activeSource) {
|
||||||
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
|
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
|
||||||
<fa-icon icon="times"></fa-icon>
|
<fa-icon icon="times"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</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
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -120,6 +172,31 @@
|
|||||||
<td mat-cell *matCellDef="let log">{{ log.matchCount || 0 }}</td>
|
<td mat-cell *matCellDef="let log">{{ log.matchCount || 0 }}</td>
|
||||||
</ng-container>
|
</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">
|
<ng-container matColumnDef="screenedBy">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened By</th>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened By</th>
|
||||||
<td mat-cell *matCellDef="let log">{{ log.screenedBy }}</td>
|
<td mat-cell *matCellDef="let log">{{ log.screenedBy }}</td>
|
||||||
@@ -135,8 +212,16 @@
|
|||||||
<th mat-header-cell *matHeaderCellDef>Review</th>
|
<th mat-header-cell *matHeaderCellDef>Review</th>
|
||||||
<td mat-cell *matCellDef="let log">
|
<td mat-cell *matCellDef="let log">
|
||||||
@if (isAlert(log)) {
|
@if (isAlert(log)) {
|
||||||
<button mat-stroked-button color="primary" class="review-btn" (click)="reviewAlert(log)">
|
<!-- Labelled by what the row actually needs next, so the officer is not left guessing
|
||||||
<fa-icon icon="search"></fa-icon> Review
|
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>
|
</button>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -79,6 +79,79 @@
|
|||||||
background-color: #b45309;
|
background-color: #b45309;
|
||||||
border-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 {
|
.rescreen-btn {
|
||||||
@@ -123,6 +196,24 @@
|
|||||||
color: #9ca3af;
|
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 {
|
.review-btn {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export class SanctionsLogsComponent implements OnInit {
|
|||||||
'screeningResult',
|
'screeningResult',
|
||||||
'disposition',
|
'disposition',
|
||||||
'matchCount',
|
'matchCount',
|
||||||
|
'screeningSource',
|
||||||
|
'client',
|
||||||
'screenedBy',
|
'screenedBy',
|
||||||
'screenedAt',
|
'screenedAt',
|
||||||
'actions'
|
'actions'
|
||||||
@@ -83,8 +85,16 @@ export class SanctionsLogsComponent implements OnInit {
|
|||||||
* disposition) instead of the raw screening log. 'ALL' means every alert regardless of state.
|
* disposition) instead of the raw screening log. 'ALL' means every alert regardless of state.
|
||||||
*/
|
*/
|
||||||
alertFilter: string | null = null;
|
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;
|
loading = false;
|
||||||
rescreening = 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(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
@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
|
// The alert queue is a different endpoint, not a filter on the log list: it carries the
|
||||||
// disposition columns the review dialog needs.
|
// 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
|
const request$ = this.alertFilter
|
||||||
? this.sanctionsLogsService.getAlerts(this.alertFilter === 'ALL' ? undefined : this.alertFilter)
|
? this.sanctionsLogsService.getAlerts(serverFilter)
|
||||||
: this.sanctionsLogsService.getLogs(this.buildLogFilters());
|
: this.sanctionsLogsService.getLogs(this.buildLogFilters());
|
||||||
|
|
||||||
request$.subscribe({
|
request$.subscribe({
|
||||||
next: (data: any) => this.applyRows(data || []),
|
next: (data: any) => this.applyRows(this.postFilter(data || [])),
|
||||||
error: () => this.applyRows([])
|
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() {
|
private buildLogFilters() {
|
||||||
const filters: any = {};
|
const filters: any = {};
|
||||||
if (this.activeFilter) {
|
if (this.activeFilter) {
|
||||||
@@ -120,10 +152,50 @@ export class SanctionsLogsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyRows(rows: any[]) {
|
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 = new MatTableDataSource(rows);
|
||||||
this.dataSource.paginator = this.paginator;
|
this.dataSource.paginator = this.paginator;
|
||||||
this.dataSource.sort = this.sort;
|
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) {
|
applySearch(value: string) {
|
||||||
@@ -149,6 +221,7 @@ export class SanctionsLogsComponent implements OnInit {
|
|||||||
this.searchName = '';
|
this.searchName = '';
|
||||||
this.activeFilter = null;
|
this.activeFilter = null;
|
||||||
this.alertFilter = null;
|
this.alertFilter = null;
|
||||||
|
this.activeSource = null;
|
||||||
this.loadLogs();
|
this.loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,4 +59,18 @@ export class SanctionsLogsService {
|
|||||||
rescreenExistingClients(): Observable<any> {
|
rescreenExistingClients(): Observable<any> {
|
||||||
return this.http.post('/sanctions/rescreen', {});
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user