diff --git a/package-lock.json b/package-lock.json index 4ab0e12c8..592a9c0cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "moment": "^2.29.4", "ngx-image-cropper": "^9.1.6", "ngx-mat-select-search": "^8.0.2", - "pdfjs-dist": "^5.6.205", + "pdfjs-dist": "^5.7.284", "rxjs": "^7.8.2", "signature_pad": "^5.1.3", "tinymce": "^8.2.2", @@ -19273,13 +19273,6 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "license": "MIT" }, - "node_modules/node-readable-to-web-readable-stream": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", - "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", - "license": "MIT", - "optional": true - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -20207,16 +20200,15 @@ } }, "node_modules/pdfjs-dist": { - "version": "5.6.205", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", - "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", + "version": "5.7.284", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz", + "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==", "license": "Apache-2.0", "engines": { - "node": ">=20.19.0 || >=22.13.0 || >=24" + "node": ">=22.13.0 || >=24" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.96", - "node-readable-to-web-readable-stream": "^0.4.2" + "@napi-rs/canvas": "^0.1.100" } }, "node_modules/pend": { diff --git a/package.json b/package.json index d64b90449..3893f2fb0 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "moment": "^2.29.4", "ngx-image-cropper": "^9.1.6", "ngx-mat-select-search": "^8.0.2", - "pdfjs-dist": "^5.6.205", + "pdfjs-dist": "^5.7.284", "rxjs": "^7.8.2", "signature_pad": "^5.1.3", "tinymce": "^8.2.2", diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.html b/src/app/clients/clients-view/address-tab/address-tab.component.html index 857b76dc6..59cd22682 100644 --- a/src/app/clients/clients-view/address-tab/address-tab.component.html +++ b/src/app/clients/clients-view/address-tab/address-tab.component.html @@ -41,6 +41,26 @@ > {{ address.isActive ? 'Active' : 'Inactive' }} + + @if (verificationOf(address); as verification) { + + + {{ verification === 'VERIFIED' ? 'Verified' : 'Not Verified' }} + + } {{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : '' diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.scss b/src/app/clients/clients-view/address-tab/address-tab.component.scss index 65b357191..a65825a55 100644 --- a/src/app/clients/clients-view/address-tab/address-tab.component.scss +++ b/src/app/clients/clients-view/address-tab/address-tab.component.scss @@ -148,6 +148,32 @@ $red-light: #fce8e6; } } + /* Welcome kit outcome — only present on the address a kit was posted to. */ + &__verify { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + padding: 2px 10px; + border-radius: 10px; + white-space: nowrap; + + i { + font-size: 11px; + } + + &--yes { + background: $green-light; + color: $green; + } + + &--no { + background: $red-light; + color: $red; + } + } + &__desc { font-size: 12px; color: $gray-500 !important; diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.ts b/src/app/clients/clients-view/address-tab/address-tab.component.ts index 59e7dcb50..72a8a534a 100644 --- a/src/app/clients/clients-view/address-tab/address-tab.component.ts +++ b/src/app/clients/clients-view/address-tab/address-tab.component.ts @@ -33,6 +33,7 @@ import { } from '@angular/material/expansion'; import { MatDivider } from '@angular/material/divider'; import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -51,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatExpansionPanelTitle, MatExpansionPanelDescription, MatDivider, - MatSlideToggle + MatSlideToggle, + MatTooltip ] }) export class AddressTabComponent { @@ -68,6 +70,11 @@ export class AddressTabComponent { clientAddressTemplate: any; /** Client Id */ clientId: string; + /** + * Welcome kit outcome per address id. Only addresses a kit was actually posted to appear here: delivery + * proves the address is real, a returned kit proves it is not, and an address with no kit stays unmarked. + */ + addressVerification: Record = {}; /** * @param {ActivatedRoute} route Activated Route @@ -82,10 +89,48 @@ export class AddressTabComponent { this.clientAddressFieldConfig = data.clientAddressFieldConfig; this.clientAddressTemplate = data.clientAddressTemplateData; this.clientId = this.route.parent.snapshot.paramMap.get('clientId'); + this.loadWelcomeKitVerification(); } ); } + /** + * Marks addresses a welcome kit was posted to with the outcome of that delivery. The kit is the physical + * check on the address, so a delivered kit reads as verified and a returned one as not verified. + */ + private loadWelcomeKitVerification(): void { + if (!this.clientId) { + return; + } + this.clientService.getWelcomeKits(this.clientId).subscribe({ + next: (response: any) => { + const map: Record = {}; + // 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. */ diff --git a/src/app/clients/clients-view/clients-view.component.html b/src/app/clients/clients-view/clients-view.component.html index 43a1ffa89..f7f537f85 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -168,58 +168,78 @@
{{ clientViewData.occupation.name }}
} - @if (clientViewData.mobileNo && clientViewData.dipVerified !== null) { + + @if (clientViewData.mobileNo && (clientViewData.dipVerified !== null || clientViewData.dipLiveStatus)) {
DIP Status
- {{ - clientViewData.dipVerified === true || - clientViewData.dipVerified === 'true' || - clientViewData.dipVerified === 1 - ? 'Verified' - : 'Pending' - }} + {{ dipStatusLabel(clientViewData) }}
} - @if (clientViewData.nameVerified !== null) { + @if (isDipFlagged(clientViewData.dipLiveStatus)) { +
+
+ +
+ This mobile number is flagged on the DoT Digital Intelligence Platform. +
+ 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. +
+
+
+
+ } + + @if (clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH') { +
+
+ +
+ Confirmed UN Security Council sanctions match — accounts frozen. +
+ 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. +
+
+
+
+ } + + @if (clientViewData.nameVerified !== null || clientViewData.sanctionsAlertStatus) {
Sanctions Screening
- {{ - clientViewData.nameVerified === true || - clientViewData.nameVerified === 'true' || - clientViewData.nameVerified === 1 - ? 'Clear' - : 'Pending' - }} + {{ sanctionsHeaderLabel(clientViewData) }}
@@ -410,9 +430,11 @@ - + @if (isActive()) { + + } diff --git a/src/app/clients/clients-view/clients-view.component.scss b/src/app/clients/clients-view/clients-view.component.scss index 07b8e38b1..59e1a35fa 100644 --- a/src/app/clients/clients-view/clients-view.component.scss +++ b/src/app/clients/clients-view/clients-view.component.scss @@ -286,6 +286,39 @@ $accent: #0ea5e9; } } +// A DIP hit on an existing customer is not an account restriction, so it is shown as a standing +// advisory on the client rather than as a blocking state. +.cv-cell--full { + grid-column: 1 / -1; +} + +.dip-alert { + display: flex; + align-items: flex-start; + gap: 10px; + background: rgb(239 68 68 / 15%); + border: 1px solid rgb(239 68 68 / 40%); + border-left: 3px solid #ef4444; + border-radius: 6px; + padding: 10px 12px; + font-size: 12px; + color: #fecaca; + line-height: 1.5; + + &__icon { + color: #fca5a5; + font-size: 15px; + margin-top: 1px; + flex-shrink: 0; + } + + &__detail { + margin-top: 3px; + color: #fca5a5; + font-weight: 400; + } +} + .actions-menu { color: #fff !important; } diff --git a/src/app/clients/clients-view/clients-view.component.ts b/src/app/clients/clients-view/clients-view.component.ts index 0d914d5d5..a756ddca2 100644 --- a/src/app/clients/clients-view/clients-view.component.ts +++ b/src/app/clients/clients-view/clients-view.component.ts @@ -85,6 +85,54 @@ import { formatTabLabel } from 'app/shared/utils/format-tab-label.util'; }) export class ClientsViewComponent implements OnInit { complianceHideClientData = environment.complianceHideClientData; + + /** + * Whether the number's CURRENT DIP/MNRL status is a hit. Driven by the live lookup rather than + * the onboarding-time `dipVerified` flag, which never changes once the client is created. + */ + isDipFlagged(liveStatus: string | null | undefined): boolean { + return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR'; + } + + /** True only when the number is affirmatively clean — not merely "not flagged". */ + isDipClear(client: any): boolean { + if (client?.dipLiveStatus) { + return client.dipLiveStatus === 'CLEAN'; + } + return client?.dipVerified === true || client?.dipVerified === 'true' || client?.dipVerified === 1; + } + + /** True when onboarding screening cleared the name — the pre-alert baseline. */ + isNameCleared(client: any): boolean { + return client?.nameVerified === true || client?.nameVerified === 'true' || client?.nameVerified === 1; + } + + /** + * Header badge for sanctions. A live alert always wins over the onboarding flag: the flag says + * what was true the day the client joined, the alert says what is true now. + */ + sanctionsHeaderLabel(client: any): string { + switch (client?.sanctionsAlertStatus) { + case 'CONFIRMED_MATCH': + return 'Frozen — Sanctions Match'; + case 'PENDING_REVIEW': + return 'Under Review'; + case 'FALSE_POSITIVE': + return 'Cleared — False Positive'; + default: + return this.isNameCleared(client) ? 'Clear' : 'Pending'; + } + } + + dipStatusLabel(client: any): string { + const live = client?.dipLiveStatus; + if (live === 'SUSPECTED') return 'Flagged — Suspected'; + if (live === 'DISCONNECTED') return 'Flagged — Disconnected'; + if (live === 'SUSPECTED_AND_DISCONNECTED') return 'Flagged — Suspected & Disconnected'; + if (live === 'SERVICE_UNAVAILABLE') return 'Not Verified'; + if (live === 'CLEAN') return 'Verified'; + return this.isDipClear(client) ? 'Verified' : 'Pending'; + } /** * Mask a string, keeping first and last letter, masking the rest with * */ @@ -461,12 +509,24 @@ export class ClientsViewComponent implements OnInit { } /** - * Opens the Welcome Kit Dispatch dialog. + * Opens the Welcome Kit Dispatch dialog. The kit is only posted once the client is active, so the dialog is + * unreachable before then — the menu entry is hidden and this guard covers any other route into the action. */ private dispatchWelcomeKit() { + if (!this.isActive()) { + return; + } const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, { - data: { clientId: this.clientViewData.id }, - width: '600px' + data: { + clientId: this.clientViewData.id, + clientName: this.clientViewData.displayName, + accountNo: this.clientViewData.accountNo, + cifNumber: this.clientViewData.cifNumber, + mobileNo: this.clientViewData.mobileNo, + emailAddress: this.clientViewData.emailAddress, + officeName: this.clientViewData.officeName + }, + width: '680px' }); welcomeKitDialogRef.afterClosed().subscribe((response: any) => { if (response) { diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html index d7f979973..26605a65e 100644 --- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html +++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html @@ -5,90 +5,277 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

Welcome Kit Dispatch

+
+
+ card_giftcard +
+
+

Welcome Kit Dispatch

+

Posting the kit doubles as a physical address check for this client.

+
+
- -
+ +
- -
-
-

{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status

- + +
+
{{ (clientName || '?').charAt(0) }}
+
+
{{ clientName || 'Client' }}
+
Account No. {{ data.accountNo }}
-

Delivery Address: {{ getDisplayAddress(dispatch) }}

-

Tracking ID: {{ dispatch.trackingId }}

-

- Delivery Mode: {{ dispatch.deliveryMode }} - ({{ dispatch.deliveryModeOtherText }}) -

-

Dispatched On: {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}

-

- Status: - {{ dispatch.status }} -

-
+ -
+ +
+
+ {{ i === 0 ? 'Current Dispatch' : 'Previous Dispatch' }} + + + {{ + dispatch.status === 'DELIVERED' + ? 'verified' + : dispatch.status === 'RETURNED' + ? 'gpp_bad' + : 'local_shipping' + }} + + {{ verificationLabel(dispatch.status) }} + +
- -
-

Create New Dispatch

-
- - Delivery Address - - - Delivery Address is required +

{{ verificationHint(dispatch) }}

+ +
+
+
Delivery Address
+
{{ getDisplayAddress(dispatch) }}
+
+
+
Tracking ID
+
{{ dispatch.trackingId }}
+
+
+
Delivery Mode
+
+ {{ dispatch.deliveryMode }} + ({{ dispatch.deliveryModeOtherText }}) +
+
+
+
Dispatched On
+
{{ dispatch.dispatchedOnDate | date: 'mediumDate' }}
+
+
+
Delivered On
+
{{ dispatch.deliveredOnDate | date: 'mediumDate' }}
+
+
+
Returned On
+
{{ dispatch.returnedOnDate | date: 'mediumDate' }}
+
+
+
Remarks
+
{{ dispatch.remarks }}
+
+
+ + +
+

+ Mark this kit as + {{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }} +

+
+ + {{ outcomeStatus === 'DELIVERED' ? 'Delivered On' : 'Returned On' }} + + + + Date is required + + + + Remarks (optional) + + +
+
+ + +
+
+ +
+ + + + + + +
+
+ + +
+

+ {{ activeDispatches.length > 0 ? 'Dispatch a Replacement Kit' : 'Create New Dispatch' }} +

+ + + + + Deliver To + + + {{ address.type }} — {{ address.oneLine }} + + + Addresses recorded for this client. Update them on the Address tab if one is wrong. + + Select the address to deliver to - +
+ place +
+ {{ address.type }} + {{ clientName }} + {{ line }} +
+
+
+ +
+ error_outline + + No address is recorded for this client. Add one on the Address tab before dispatching a welcome kit. + +
+ +
+ Delivery Mode - - {{ mode }} - + {{ mode }} Delivery Mode is required - - Specify Other Mode - - - Please specify the delivery mode + + Dispatched On + + + + + Dispatch date is required
-
+ + + Specify Other Mode + + + Please specify the delivery mode + + + + +
+
+ Envelope address label + + Print this first — name, address, contact details and the welcome note, ready to fold into a window + envelope. + +
+ +
+ + + Tracking ID + + confirmation_number + Enter the consignment number printed on the delivery partner's receipt. + Tracking ID is required + + Tracking ID cannot exceed 100 characters + + + + + +

+ A welcome kit is already recorded for this client. Record its outcome above before dispatching another. +

- + diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.scss b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.scss index 4eea9cdf5..22c3d61b0 100644 --- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.scss +++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.scss @@ -5,55 +5,375 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.dispatch-tracking-panel { - background-color: #f5f5f5; - border-radius: 8px; - padding: 16px; + +$wk-primary: #1f497d; +$wk-border: #e3e7ee; +$wk-muted: #6b7280; +$wk-surface: #f7f9fc; + +/* ---------- header ---------- */ +.wk-header { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px 16px; + margin: 0; + border-bottom: 1px solid $wk-border; + + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex: 0 0 40px; + border-radius: 10px; + background: rgba($wk-primary, 0.1); + color: $wk-primary; + } + + &__title { + margin: 0; + font-size: 1.15rem; + font-weight: 600; + line-height: 1.3; + } + + &__subtitle { + margin: 2px 0 0; + font-size: 0.8rem; + color: $wk-muted; + } +} + +.wk-content { + padding-top: 16px; +} + +.wk-loading { + display: flex; + justify-content: center; + padding: 2.5rem 0; +} + +/* ---------- client identity ---------- */ +.wk-client { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; margin-bottom: 16px; + border-radius: 10px; + background: linear-gradient(135deg, rgba($wk-primary, 0.08), rgba($wk-primary, 0.02)); + border: 1px solid rgba($wk-primary, 0.15); - h3 { - margin-top: 0; - margin-bottom: 12px; - font-size: 1.1rem; + &__avatar { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + flex: 0 0 38px; + border-radius: 50%; + background: $wk-primary; + color: #fff; + font-weight: 600; + text-transform: uppercase; } - p { - margin: 6px 0; + &__name { + font-weight: 600; + font-size: 1rem; } - .status-badge { - padding: 4px 8px; - border-radius: 4px; - font-size: 0.85rem; - font-weight: 500; - color: white; + &__meta { + font-size: 0.8rem; + color: $wk-muted; + font-variant-numeric: tabular-nums; + } +} - &.status-dispatched { - background-color: #1976d2; - } +/* ---------- dispatch panel ---------- */ +.wk-dispatch { + border: 1px solid $wk-border; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 16px; + background: $wk-surface; - &.status-in_transit { - background-color: #f57c00; - } + &--past { + opacity: 0.75; + } - &.status-delivered { - background-color: #388e3c; - } + &__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; + } - &.status-returned { - background-color: #d32f2f; + &__label { + font-weight: 600; + font-size: 0.95rem; + } + + &__hint { + margin: 0 0 12px; + font-size: 0.8rem; + color: $wk-muted; + line-height: 1.45; + } + + &__actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + + button mat-icon { + margin-right: 4px; + font-size: 18px; + width: 18px; + height: 18px; } } } -.dispatch-create-form { - h3 { - margin-top: 0; - margin-bottom: 12px; - font-size: 1.1rem; +/* ---------- status chip ---------- */ +.wk-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + + &__icon { + font-size: 15px; + width: 15px; + height: 15px; + } + + &--dispatched { + background: rgb(25 118 210 / 12%); + color: #1565c0; + } + + &--delivered { + background: rgb(56 142 60 / 14%); + color: #2e7d32; + } + + &--returned { + background: rgb(211 47 47 / 12%); + color: #c62828; } } -.full-width { +/* ---------- fact grid ---------- */ +.wk-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 18px; + margin: 0; + + &__row { + min-width: 0; + + &--wide { + grid-column: 1 / -1; + } + + dt { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $wk-muted; + margin-bottom: 2px; + } + + dd { + margin: 0; + font-size: 0.875rem; + word-break: break-word; + } + } + + &__mono { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-weight: 600; + letter-spacing: 0.02em; + } +} + +/* ---------- outcome form ---------- */ +.wk-outcome { + margin-top: 14px; + padding: 14px; + border-radius: 8px; + background: #fff; + border: 1px dashed rgba($wk-primary, 0.4); + + &__title { + margin: 0 0 10px; + font-size: 0.875rem; + } + + &__fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0 14px; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } +} + +/* ---------- create form ---------- */ +.wk-form { + &__title { + margin: 0 0 12px; + font-size: 1rem; + font-weight: 600; + } + + &__row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0 14px; + } +} + +.wk-full { width: 100%; } + +.wk-address { + display: flex; + gap: 10px; + padding: 12px 14px; + margin-bottom: 16px; + border-radius: 8px; + background: $wk-surface; + border: 1px solid $wk-border; + + &__icon { + color: $wk-primary; + font-size: 20px; + width: 20px; + height: 20px; + flex: 0 0 20px; + } + + &__body { + display: flex; + flex-direction: column; + font-size: 0.85rem; + line-height: 1.5; + } + + &__type { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $wk-primary; + margin-bottom: 2px; + } + + &__name { + font-weight: 600; + } + + &__line { + color: $wk-muted; + } +} + +.wk-noaddress { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + margin-bottom: 16px; + border-radius: 8px; + background: rgb(211 47 47 / 6%); + border: 1px solid rgb(211 47 47 / 25%); + font-size: 0.83rem; + line-height: 1.5; + color: #b3261e; + + mat-icon { + font-size: 19px; + width: 19px; + height: 19px; + flex: 0 0 19px; + } +} + +/* ---------- envelope label call-to-action ---------- */ +.wk-label-cta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; + padding: 12px 14px; + margin-bottom: 18px; + border-radius: 8px; + background: rgba($wk-primary, 0.04); + border: 1px solid rgba($wk-primary, 0.18); + + &__text { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 240px; + } + + &__title { + font-weight: 600; + font-size: 0.875rem; + } + + &__hint { + font-size: 0.78rem; + color: $wk-muted; + line-height: 1.45; + } + + button mat-icon { + margin-right: 4px; + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.wk-empty { + margin: 4px 0 0; + padding: 12px 14px; + border-radius: 8px; + background: $wk-surface; + border: 1px solid $wk-border; + font-size: 0.85rem; + color: $wk-muted; +} + +mat-dialog-actions { + padding: 12px 24px 20px; + + button mat-icon { + margin-right: 4px; + font-size: 18px; + width: 18px; + height: 18px; + } +} diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts index b3d72602b..03dadc5c0 100644 --- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts @@ -12,12 +12,14 @@ import { MatDialogRef, MatDialogTitle, MatDialogContent, - MatDialogActions, - MatDialogClose + MatDialogActions } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatIcon } from '@angular/material/icon'; import { ClientsService } from '../../../clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { DatePipe } from '@angular/common'; import { jsPDF } from 'jspdf'; @@ -32,32 +34,79 @@ import autoTable from 'jspdf-autotable'; MatDialogTitle, MatDialogContent, MatDialogActions, - MatDialogClose, - MatProgressSpinnerModule + MatProgressSpinnerModule, + MatIcon ], providers: [DatePipe] }) export class WelcomeKitDialogComponent implements OnInit { private dialogRef = inject(MatDialogRef); - data: { clientId: string } = inject(MAT_DIALOG_DATA); + data: { + clientId: string; + clientName?: string; + accountNo?: string; + cifNumber?: string; + mobileNo?: string; + emailAddress?: string; + officeName?: string; + } = inject(MAT_DIALOG_DATA); private clientsService = inject(ClientsService); private formBuilder = inject(FormBuilder); private snackBar = inject(MatSnackBar); private datePipe = inject(DatePipe); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); isLoading = true; + /** Set once a dispatch or an outcome is saved, so the client view knows to reload on close. */ + private hasChanges = false; welcomeKitsData: any = null; + /** Addresses captured for the client, each formatted for display and selection. */ + addressChoices: { id: any; type: string; isActive: boolean; lines: string[]; oneLine: string }[] = []; + /** True once the address lookup has run, so the empty state only shows when there genuinely are none. */ + addressesLoaded = false; + /** Dispatch whose outcome is being recorded, or null while the tracking list is shown. */ + outcomeFor: any = null; + outcomeStatus: 'DELIVERED' | 'RETURNED' = 'DELIVERED'; + + maxDate = new Date(); + dispatchForm: FormGroup = this.formBuilder.group({ - deliveryAddress: [ - '', + // The kit goes to one of the addresses captured at client creation, never to a hand-typed one, so the + // dispatch record always traces back to an address the client actually gave. + addressId: [ + null, Validators.required ], deliveryMode: [ 'POSTMAN', Validators.required ], - deliveryModeOtherText: [''] + deliveryModeOtherText: [''], + trackingId: [ + '', + [ + Validators.required, + Validators.maxLength(100) + ] + ], + dispatchedOnDate: [ + new Date(), + Validators.required + ] }); + + outcomeForm: FormGroup = this.formBuilder.group({ + outcomeDate: [ + new Date(), + Validators.required + ], + remarks: [ + '', + Validators.maxLength(500) + ] + }); + deliveryModes = [ 'POSTMAN', 'COURIER', @@ -76,7 +125,37 @@ export class WelcomeKitDialogComponent implements OnInit { }); } + get clientName(): string { + return this.welcomeKitsData?.clientName || this.data.clientName || ''; + } + + /** The kit is a physical address check, so the outcome is phrased as verification rather than delivery. */ + verificationLabel(status: string): string { + switch (status) { + case 'DELIVERED': + return 'Verified'; + case 'RETURNED': + return 'Not Verified'; + default: + return 'Awaiting Verification'; + } + } + + verificationHint(dispatch: any): string { + switch (dispatch?.status) { + case 'DELIVERED': + return 'Kit delivered, so the address stands verified.'; + case 'RETURNED': + return 'Kit came back undelivered, so the address could not be verified.'; + default: + return 'Check this tracking ID on the delivery partner’s website or app, then record the outcome below.'; + } + } + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.dispatchForm.patchValue({ dispatchedOnDate: this.maxDate }); + this.outcomeForm.patchValue({ outcomeDate: this.maxDate }); this.fetchData(); } @@ -98,33 +177,77 @@ export class WelcomeKitDialogComponent implements OnInit { }); } + /** Order the address types are offered in: a kit is posted to the correspondence address when one exists. */ + private static readonly ADDRESS_TYPE_ORDER = [ + 'Correspondence', + 'Current / Residential', + 'Permanent Address', + 'Office / Work' + ]; + fetchPrimaryAddress() { this.clientsService.getClientAddressData(this.data.clientId).subscribe({ next: (addresses: any) => { this.isLoading = false; - if (addresses && addresses.length > 0) { - // Attempt to build a readable address from the first address returned - const primary = addresses[0]; - const parts = []; - if (primary.addressLine1) parts.push(primary.addressLine1); - if (primary.addressLine2) parts.push(primary.addressLine2); - if (primary.city) parts.push(primary.city); - if (primary.stateName) parts.push(primary.stateName); - if (primary.postalCode) parts.push(primary.postalCode); - if (primary.countryName) parts.push(primary.countryName); + this.addressesLoaded = true; - const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : ''; - this.dispatchForm.patchValue({ - deliveryAddress: clientNameStr + parts.join(', ') + const usable = (addresses || []).filter((a: any) => a && (a.addressLine1 || a.postalCode || a.city)); + this.addressChoices = usable + .map((a: any) => { + const lines = [ + a.addressLine1, + a.addressLine2, + a.addressLine3, + a.street, + a.townVillage, + a.city, + a.countyDistrict, + a.stateName || a.stateProvinceName, + a.postalCode, + a.countryName + ] + .map((part: any) => (part == null ? '' : String(part).trim())) + .filter((part: string) => part.length > 0); + + return { + id: a.addressId ?? a.id, + type: a.addressType || 'Address', + isActive: a.isActive !== false, + lines, + oneLine: lines.join(', ') + }; + }) + .sort((x: any, y: any) => { + // Active addresses first, then by how suitable the type is for posting. + if (x.isActive !== y.isActive) return x.isActive ? -1 : 1; + const order = WelcomeKitDialogComponent.ADDRESS_TYPE_ORDER; + const xi = order.indexOf(x.type); + const yi = order.indexOf(y.type); + return (xi === -1 ? order.length : xi) - (yi === -1 ? order.length : yi); }); + + if (this.addressChoices.length > 0) { + this.dispatchForm.patchValue({ addressId: this.addressChoices[0].id }); } }, error: () => { this.isLoading = false; + this.addressesLoaded = true; } }); } + /** The address the operator picked, or null before one is chosen. */ + get selectedAddress() { + const id = this.dispatchForm.get('addressId')?.value; + return this.addressChoices.find((a) => a.id === id) || null; + } + + /** Lines of the selected address, for the on-screen preview and the printed label. */ + get addressLines(): string[] { + return this.selectedAddress?.lines || []; + } + get activeDispatches() { if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) { return this.welcomeKitsData.dispatches; @@ -138,36 +261,104 @@ export class WelcomeKitDialogComponent implements OnInit { return; } + const chosen = this.selectedAddress; + if (!chosen) { + this.snackBar.open('Select the address the kit was posted to', 'Close', { duration: 3000 }); + return; + } + this.isLoading = true; const formValue = this.dispatchForm.value; - - const today = new Date(); - const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy'); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; const payload = { - deliveryAddress: formValue.deliveryAddress, + // Stored as text so the dispatch keeps the address exactly as it read on the day it went out, even if + // the client's address record is edited later. + deliveryAddress: `${chosen.type}: ${chosen.oneLine}`, + addressId: chosen.id, deliveryMode: formValue.deliveryMode, deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null, - dispatchedOnDate: formattedDate, - locale: 'en', - dateFormat: 'dd MMMM yyyy' + trackingId: (formValue.trackingId || '').trim(), + dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat), + locale, + dateFormat }; this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({ next: () => { - this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 }); + this.hasChanges = true; + this.snackBar.open('Welcome Kit dispatched successfully', 'Close', { duration: 3000 }); this.fetchData(); }, - error: () => { + error: (err: any) => { this.isLoading = false; - this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 }); + this.snackBar.open(this.errorMessage(err, 'Failed to dispatch welcome kit'), 'Close', { duration: 5000 }); } }); } + /** Opens the outcome form for a dispatch, pre-set to the outcome the operator clicked. */ + startOutcome(dispatch: any, status: 'DELIVERED' | 'RETURNED') { + this.outcomeFor = dispatch; + this.outcomeStatus = status; + this.outcomeForm.reset({ outcomeDate: this.maxDate, remarks: '' }); + } + + cancelOutcome() { + this.outcomeFor = null; + } + + /** Records what the operator found on the delivery partner's site: delivered verifies the address, returned does not. */ + submitOutcome() { + if (this.outcomeForm.invalid || !this.outcomeFor) { + this.outcomeForm.markAllAsTouched(); + return; + } + + this.isLoading = true; + const formValue = this.outcomeForm.value; + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + + const payload = { + status: this.outcomeStatus, + outcomeDate: this.dateUtils.formatDate(formValue.outcomeDate, dateFormat), + remarks: (formValue.remarks || '').trim() || null, + locale, + dateFormat + }; + + this.clientsService.updateWelcomeKitStatus(this.outcomeFor.id, payload).subscribe({ + next: () => { + this.hasChanges = true; + this.outcomeFor = null; + this.snackBar.open( + this.outcomeStatus === 'DELIVERED' ? 'Address marked as verified' : 'Address marked as not verified', + 'Close', + { duration: 3000 } + ); + this.fetchData(); + }, + error: (err: any) => { + this.isLoading = false; + this.snackBar.open(this.errorMessage(err, 'Failed to record the outcome'), 'Close', { duration: 5000 }); + } + }); + } + + close() { + this.dialogRef.close(this.hasChanges); + } + + /** Surfaces the backend's own message (duplicate tracking ID, inactive client) instead of a generic failure. */ + private errorMessage(err: any, fallback: string): string { + return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback; + } + getDisplayAddress(dispatch: any): string { const address = dispatch.deliveryAddress || ''; - const name = this.welcomeKitsData?.clientName || ''; + const name = this.clientName; if (name && !address.startsWith(name)) { return `${name} — ${address}`; } @@ -185,6 +376,9 @@ export class WelcomeKitDialogComponent implements OnInit { const trackingId = dispatch.trackingId || 'N/A'; const status = dispatch.status || 'N/A'; const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A'; + const verification = this.verificationLabel(dispatch.status); + const outcomeOn = + this.datePipe.transform(dispatch.deliveredOnDate || dispatch.returnedOnDate, 'mediumDate') || 'Awaited'; const primaryColor: [number, number, number] = [ 31, @@ -307,6 +501,14 @@ export class WelcomeKitDialogComponent implements OnInit { [ 'Dispatched On', dispatchedOn + ], + [ + 'Address Verification', + verification + ], + [ + 'Outcome Recorded On', + outcomeOn ] ] }); @@ -344,4 +546,192 @@ export class WelcomeKitDialogComponent implements OnInit { doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`); } + + /** + * Address label for the envelope. It is printed before the kit is posted — off the live form for a new + * dispatch, or off a saved record — so the operator can fold it into a window envelope and hand it over the + * counter. Layout is a single A4 sheet: the addressee block sits inside the window cut-out zone, and the + * welcome message sits below the fold where it stays hidden until the envelope is opened. + */ + downloadAddressLabel(dispatch?: any) { + const doc = new jsPDF(); + const pageWidth = doc.internal.pageSize.getWidth(); + + const primaryColor: [number, number, number] = [ + 31, + 73, + 125 + ]; + const grayColor: [number, number, number] = [ + 110, + 110, + 110 + ]; + const lineColor: [number, number, number] = [ + 205, + 212, + 222 + ]; + + const name = this.clientName || 'Client'; + // A saved dispatch keeps the address it actually went out with; a new one uses the address currently + // selected in the form. The saved value carries a "Type: " prefix that belongs on the record, not the envelope. + const addressLines = dispatch + ? String(dispatch.deliveryAddress || '') + .replace(/^[^:]+:\s*/, '') + .split(',') + .map((part: string) => part.trim()) + .filter((part: string) => part.length > 0) + : this.addressLines; + + // --- Sender block, top-left, as the return address --- + doc.setFillColor(...primaryColor); + doc.rect(0, 0, pageWidth, 26, 'F'); + doc.setFontSize(17); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(255, 255, 255); + doc.text('CBSNexora', 18, 14); + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.text('Core Banking Solution', 18, 20); + + doc.setFontSize(7.5); + doc.setTextColor(...grayColor); + doc.setFont('helvetica', 'bold'); + doc.text('FROM', 22, 37); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(8.5); + doc.setTextColor(95, 100, 110); + let fromY = 42.5; + const branch = this.data.officeName ? `CBSNexora — ${this.data.officeName}` : 'CBSNexora'; + [ + branch, + 'Customer Service Desk | support@cbsnexora.com | 1800-000-0000' + ].forEach((line) => { + doc.text(line, 22, fromY); + fromY += 4.6; + }); + + // --- Addressee card: name, address and the client's reference details in one block, sized so the whole + // card sits behind a standard window cut-out --- + const cardX = 22; + const cardY = 60; + const cardW = pageWidth - 44; + const padX = cardX + 9; + + const refs: string[] = []; + if (this.data.accountNo) refs.push(`Account No. ${this.data.accountNo}`); + if (this.data.cifNumber) refs.push(`CIF ${this.data.cifNumber}`); + if (this.data.mobileNo) refs.push(`Mobile ${this.data.mobileNo}`); + if (this.data.emailAddress) refs.push(`Email ${this.data.emailAddress}`); + if (dispatch?.trackingId) refs.push(`Tracking ID ${dispatch.trackingId}`); + + // Height is measured off the same offsets the text below uses, so the card closes a fixed margin under the + // last line instead of leaving a gap that grows with a short address. + const addrStartY = 27; + const addrEndY = addrStartY + (addressLines.length - 1) * 6.4; + const cardH = refs.length > 0 ? addrEndY + 5 + refs.length * 5 + 4 : addrEndY + 9; + + doc.setFillColor(252, 253, 255); + doc.setDrawColor(...lineColor); + doc.setLineWidth(0.3); + doc.roundedRect(cardX, cardY, cardW, cardH, 2, 2, 'FD'); + + // Accent spine marks the addressee edge without boxing the text in. + doc.setFillColor(...primaryColor); + doc.rect(cardX, cardY + 1, 1.6, cardH - 2, 'F'); + + doc.setFontSize(7); + doc.setTextColor(...grayColor); + doc.text('ALIGN THIS BLOCK WITH THE ENVELOPE WINDOW', cardX, cardY - 3.5); + + doc.setFontSize(7.5); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...primaryColor); + doc.text('TO', padX, cardY + 10); + + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(20, 20, 20); + doc.text(name, padX, cardY + 19); + + doc.setFontSize(11); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(45, 45, 45); + let addrY = cardY + addrStartY; + addressLines.forEach((line: string) => { + doc.text(line, padX, addrY); + addrY += 6.4; + }); + + // Reference details close the same card, separated by a hairline rather than a second frame. + if (refs.length > 0) { + const dividerY = cardY + addrEndY + 5; + doc.setDrawColor(...lineColor); + doc.setLineWidth(0.2); + doc.line(padX, dividerY, cardX + cardW - 9, dividerY); + + doc.setFontSize(8.5); + doc.setTextColor(...grayColor); + let refY = dividerY + 5; + refs.forEach((line) => { + doc.text(line, padX, refY); + refY += 5; + }); + } + + // --- Fold line: hairline broken around a centred label, so it guides the fold without competing with + // the address block above it --- + const foldY = cardY + cardH + 22; + const foldGap = 12; + doc.setDrawColor(226, 230, 236); + doc.setLineWidth(0.15); + doc.setLineDashPattern( + [ + 1, + 2.5 + ], + 0 + ); + doc.line(30, foldY, pageWidth / 2 - foldGap, foldY); + doc.line(pageWidth / 2 + foldGap, foldY, pageWidth - 30, foldY); + doc.setLineDashPattern([], 0); + doc.setFontSize(6.5); + doc.setTextColor(178, 184, 194); + doc.text('FOLD', pageWidth / 2, foldY + 1.6, { align: 'center' }); + + // --- Welcome message, below the fold --- + let msgY = foldY + 14; + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...primaryColor); + doc.text('Welcome to CBSNexora', 22, msgY); + msgY += 10; + + doc.setFontSize(10.5); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(40, 40, 40); + doc.text(`Dear ${name},`, 22, msgY); + msgY += 8; + + const body = [ + 'Thank you for opening your account with us. Enclosed is your Welcome Kit containing your account documentation and the essential materials you need to start banking with us.', + 'Please keep your account number and CIF safe, and do not share your banking credentials with anyone. Our staff will never ask you for your PIN, password or OTP.', + 'If anything in this kit is missing or incorrect, contact your branch or call our customer service desk and we will put it right.' + ]; + body.forEach((paragraph) => { + const lines = doc.splitTextToSize(paragraph, pageWidth - 44); + doc.text(lines, 22, msgY); + msgY += lines.length * 5.6 + 5; + }); + + msgY += 4; + doc.text('Warm regards,', 22, msgY); + msgY += 6; + doc.setFont('helvetica', 'bold'); + doc.text('CBSNexora Operations Team', 22, msgY); + + const stamp = this.datePipe.transform(new Date(), 'yyyyMMdd-HHmmss'); + doc.save(`WelcomeKit_Label_${this.data.accountNo || this.data.clientId}_${stamp}.pdf`); + } } diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html index a5643242e..287371407 100644 --- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html +++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html @@ -283,26 +283,61 @@
- +
Mobile DIP Status
- @if ( - clientViewData.mobileVerified === true || - clientViewData.mobileVerified === 'true' || - clientViewData.mobileVerified === 1 || - clientViewData.dipVerified === true || - clientViewData.dipVerified === 'true' || - clientViewData.dipVerified === 1 - ) { + @if (isDipFlagged(clientViewData.dipLiveStatus)) { + + {{ dipStatusLabel(clientViewData) }} + + } @else if (isDipClear(clientViewData)) { Verified } @else { - Pending + + {{ dipStatusLabel(clientViewData) }} + }
+ @if (isDipFlagged(clientViewData.dipLiveStatus)) { +
+
+ +
+ This mobile number is flagged on the DoT Digital Intelligence Platform. +
+ 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. +
+
+
+
+ } + + @if (clientViewData.sanctionsAlertStatus) { +
+
+ +
+ {{ sanctionsAlertHeadline(clientViewData) }} +
{{ sanctionsAlertDetail(clientViewData) }}
+
+
+
+ } @if (clientViewData.dipTimestamp) {
DIP Verified On
@@ -314,7 +349,12 @@
Alternate Mobile DIP Status
- @if ( + @if (isDipFlagged(clientViewData.alternateDipLiveStatus)) { + + {{ alternateDipStatusLabel(clientViewData) }} + + } @else if ( + clientViewData.alternateDipLiveStatus === 'CLEAN' || clientViewData.alternateDipVerified === true || clientViewData.alternateDipVerified === 'true' || clientViewData.alternateDipVerified === 1 diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.scss b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.scss index 1ca6f0ca9..2127ccfff 100644 --- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.scss +++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.scss @@ -248,9 +248,59 @@ $teal: #00897b; border: 1px solid #fde68a; } - &--danger { + &--danger, + &--flagged { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } } + +// A DIP hit on an existing customer restricts their NUMBER, not their account, so it reads as a +// standing advisory across the full width rather than as a blocked-account state. +.pd-field--full { + grid-column: 1 / -1; +} + +.pd-dip-alert { + display: flex; + align-items: flex-start; + gap: 10px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #dc2626; + border-radius: 6px; + padding: 10px 14px; + font-size: 13px; + color: #7f1d1d; + line-height: 1.5; + + i { + color: #dc2626; + font-size: 15px; + margin-top: 2px; + flex-shrink: 0; + } + + &__detail { + margin-top: 3px; + font-weight: 400; + color: #991b1b; + } + + // An unreviewed hit is not yet a finding, so it reads as amber rather than red. + &--review { + background: #fffbeb; + border-color: #fde68a; + border-left-color: #d97706; + color: #92400e; + + i { + color: #d97706; + } + + .pd-dip-alert__detail { + color: #b45309; + } + } +} diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.ts b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.ts index 5e00a532e..d3b71af7a 100644 --- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.ts +++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.ts @@ -32,6 +32,14 @@ interface ClientViewData { displayName?: string; mobileNo?: string; emailAddress?: string; + /** + * Current DIP/MNRL status resolved from the latest lookup, as opposed to the `dipVerified` + * flag captured when the client was onboarded. + */ + dipLiveStatus?: string; + alternateDipLiveStatus?: string; + /** Disposition of the latest flagged sanctions alert, or absent when the client carries none. */ + sanctionsAlertStatus?: string; dateOfBirth?: number[]; gender?: { id: number; name: string; active?: boolean }; clientType?: { id: number; name: string; active?: boolean }; @@ -67,6 +75,91 @@ export class PersonalDataTabComponent { /** Client View Data */ clientViewData!: ClientViewData; + /** + * Whether the number's CURRENT DIP/MNRL status is a hit. + * + * The onboarding-time `dipVerified` flag never changes, so a customer cleared when they joined + * kept reading "Verified" even after their number was reported for fraud or reassigned. These + * helpers read `dipLiveStatus`, which the API resolves from the most recent lookup. + */ + isDipFlagged(liveStatus: string | null | undefined): boolean { + return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR'; + } + + /** True only when the number is affirmatively clean — not merely "not flagged". */ + isDipClear(client: any): boolean { + if (client?.dipLiveStatus) { + return client.dipLiveStatus === 'CLEAN'; + } + return ( + client?.mobileVerified === true || + client?.mobileVerified === 'true' || + client?.mobileVerified === 1 || + client?.dipVerified === true || + client?.dipVerified === 'true' || + client?.dipVerified === 1 + ); + } + + dipStatusLabel(client: any): string { + return this.labelFor(client?.dipLiveStatus, this.isDipClear(client)); + } + + /** Headline for the sanctions banner, driven by how far the alert has progressed. */ + sanctionsAlertHeadline(client: any): string { + switch (client?.sanctionsAlertStatus) { + case 'CONFIRMED_MATCH': + return 'Confirmed UN Security Council sanctions match.'; + case 'FALSE_POSITIVE': + return 'Sanctions hit reviewed as a false positive.'; + default: + return 'Sanctions match awaiting Principal Officer review.'; + } + } + + sanctionsAlertDetail(client: any): string { + switch (client?.sanctionsAlertStatus) { + case 'CONFIRMED_MATCH': + return ( + 'Debits are frozen on this client’s deposit accounts and the case has been escalated. ' + + 'Report to FIU-IND and the nodal officer within 24 hours if not already done.' + ); + case 'FALSE_POSITIVE': + return 'Recorded as a different person from the listed individual. See the Sanctions Logs for the reasoning.'; + default: + return ( + 'This client matched the UN Security Council Consolidated List on a later screening. ' + + 'Review the alert under Reports → Sanctions Screening & Alerts before transacting.' + ); + } + } + + alternateDipStatusLabel(client: any): string { + const clear = + client?.alternateDipLiveStatus === 'CLEAN' || + client?.alternateDipVerified === true || + client?.alternateDipVerified === 'true' || + client?.alternateDipVerified === 1; + return this.labelFor(client?.alternateDipLiveStatus, clear); + } + + private labelFor(liveStatus: string | null | undefined, clear: boolean): string { + switch (liveStatus) { + case 'SUSPECTED': + return 'Flagged — Suspected'; + case 'DISCONNECTED': + return 'Flagged — Disconnected'; + case 'SUSPECTED_AND_DISCONNECTED': + return 'Flagged — Suspected & Disconnected'; + case 'SERVICE_UNAVAILABLE': + return 'Not Verified'; + case 'CLEAN': + return 'Verified'; + default: + return clear ? 'Verified' : 'Pending'; + } + } + constructor() { this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => { this.clientViewData = data.clientViewData; diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 2145f060a..1ce943e3f 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -1015,8 +1015,8 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/welcomekit`, data); } - updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable { - return this.http.put(`/welcomekit/${welcomeKitId}`, { status }); + updateWelcomeKitStatus(welcomeKitId: number, data: any): Observable { + return this.http.put(`/welcomekit/${welcomeKitId}`, data); } getKycExceptions(resolved = false): Observable { diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 49f3911e7..023c6c8b3 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1677,6 +1677,16 @@ + +
diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index 299bc6c7e..499752e34 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -347,6 +347,69 @@ @if (activeTab === 'str') { + + @if (sanctionsStrCases.length) { +
+
+
+

Sanctions STRs (UAPA s.51-A)

+

+ Confirmed matches against the UN Security Council Consolidated List. Accounts are frozen; the STR must + reach FIU-IND within 7 days of confirmation. +

+
+ +
+
+ + + + + + + + + + + + + + + @for (str of sanctionsStrCases; track str.id) { + + + + + + + + + + + } + +
AlertClientScreened NameListed AsConfirmed OnDueStatusSTR Reference
#{{ str.id }} + @if (str.clientId) { + {{ str.clientName || str.clientId }} + } @else { + Not onboarded + } + {{ str.screenedName }}{{ str.matchedNames || '—' }}{{ str.approvedOn | date: 'mediumDate' }}{{ str.strDueDate || '—' }} + {{ strStatusLabel(str) }} + {{ str.fiuStrReference || '—' }}
+
+
+ } +
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index 4bb75d87d..c2dc8081b 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { AmlService } from 'app/aml/aml.service'; +import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service'; import { BRANCHES, @@ -130,6 +131,7 @@ export class FiuComponent implements OnInit { private static readonly SEED_VERSION = '2026-01-cash-threshold'; private amlService = inject(AmlService); + private sanctionsLogsService = inject(SanctionsLogsService); private authenticationService = inject(AuthenticationService); /** Active tab */ activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; @@ -176,6 +178,13 @@ export class FiuComponent implements OnInit { // -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) -------------------- amlAlerts: any[] = []; + + /** + * Confirmed UNSC sanctions matches carrying an STR obligation. Held separately from amlAlerts + * because they are identity-driven rather than transaction-driven — see the template comment. + */ + sanctionsStrCases: any[] = []; + sanctionsStrFilter = ''; amlAlertStatusFilter = ''; amlReviewNotes: Record = {}; @@ -320,6 +329,37 @@ export class FiuComponent implements OnInit { return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS'; } + /** Loads confirmed sanctions matches that owe an STR. */ + loadSanctionsStrCases() { + this.sanctionsLogsService.getStrCases(this.sanctionsStrFilter || undefined).subscribe({ + next: (rows: any) => (this.sanctionsStrCases = rows || []), + // A failure here must not blank the AML side of the tab, which is unrelated. + error: () => (this.sanctionsStrCases = []) + }); + } + + strStatusLabel(str: any): string { + if (str?.strStatus === 'FILED') { + return 'FILED'; + } + return this.isStrOverdue(str) ? 'OVERDUE' : 'PENDING FILING'; + } + + strStatusClass(str: any): string { + if (str?.strStatus === 'FILED') { + return 'fiu-pill fiu-pill--ok'; + } + return this.isStrOverdue(str) ? 'fiu-pill fiu-pill--danger' : 'fiu-pill fiu-pill--warn'; + } + + /** Past its 7-day PMLA deadline and still unfiled — the state that actually needs chasing. */ + isStrOverdue(str: any): boolean { + if (!str?.strDueDate || str.strStatus === 'FILED') { + return false; + } + return new Date(str.strDueDate) < new Date(new Date().toDateString()); + } + get isPrincipalOfficer(): boolean { return this.authenticationService.isPrincipalOfficerUser(); } @@ -547,6 +587,7 @@ export class FiuComponent implements OnInit { */ loadAmlAlerts(): void { this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || [])); + this.loadSanctionsStrCases(); } loadAmlCtrReports(): void { diff --git a/src/app/letter-mgmt/database-export/database-export.component.html b/src/app/letter-mgmt/database-export/database-export.component.html new file mode 100644 index 000000000..ae897cf7a --- /dev/null +++ b/src/app/letter-mgmt/database-export/database-export.component.html @@ -0,0 +1,219 @@ + + +
+ + + +
+ +
+ This export contains the complete database + — 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. +
+
+ + +
+

Request a Full Export

+ + Reason for this export + + Recorded against the request and written into the archive. Minimum 10 characters. + A reason is required + + Please give at least 10 characters of context + + + +
+ +
+
+ + +
+
+ + + + +
+
+ + @if (loading) { + + } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Request#{{ row.id }}Status + {{ statusLabel(row) }} + Reason + {{ row.reason }} + @if (row.rejectionReason) { + Rejected: {{ row.rejectionReason }} + } + Requested By +
+ {{ row.requestedByUsername || '—' }} + {{ row.requestedOn | date: 'medium' }} +
+
Approved By + @if (row.approvedByUsername) { +
+ {{ row.approvedByUsername }} + {{ row.approvedOn | date: 'medium' }} +
+ } @else { + — + } +
Downloaded + @if (row.generatedOn) { +
+ {{ row.generatedOn | date: 'medium' }} + {{ formatSize(row.fileSizeBytes) }} · {{ row.rowCount || 0 }} rows +
+ } @else { + Not yet + } +
Action +
+ @if (isPending(row)) { + + + + + + } + @if (isDownloadable(row)) { + + } + @if (isWithheld(row)) { + + + Requester only + + } + @if (isSpent(row)) { + + + Download used + + } +
+
+ + @if (!loading && dataSource.data.length === 0) { +
+ +

No export requests yet.

+
+ } + + +
+
diff --git a/src/app/letter-mgmt/database-export/database-export.component.scss b/src/app/letter-mgmt/database-export/database-export.component.scss new file mode 100644 index 000000000..756307561 --- /dev/null +++ b/src/app/letter-mgmt/database-export/database-export.component.scss @@ -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; + } +} diff --git a/src/app/letter-mgmt/database-export/database-export.component.ts b/src/app/letter-mgmt/database-export/database-export.component.ts new file mode 100644 index 000000000..87747242a --- /dev/null +++ b/src/app/letter-mgmt/database-export/database-export.component.ts @@ -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(); + 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; + } +} diff --git a/src/app/letter-mgmt/database-export/database-export.service.ts b/src/app/letter-mgmt/database-export/database-export.service.ts new file mode 100644 index 000000000..42ff5263e --- /dev/null +++ b/src/app/letter-mgmt/database-export/database-export.service.ts @@ -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 { + 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 { + return this.http.post('/databaseexport', { reason }); + } + + approve(requestId: number): Observable { + return this.http.post(`/databaseexport/${requestId}/approve`, {}); + } + + reject(requestId: number, rejectionReason: string): Observable { + 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 { + return this.http.get(`/databaseexport/${requestId}/download`, { + responseType: 'blob' as 'json' + }) as Observable; + } +} diff --git a/src/app/letter-mgmt/letter-mgmt-routing.module.ts b/src/app/letter-mgmt/letter-mgmt-routing.module.ts index 1053bfbba..2f9a34202 100644 --- a/src/app/letter-mgmt/letter-mgmt-routing.module.ts +++ b/src/app/letter-mgmt/letter-mgmt-routing.module.ts @@ -18,6 +18,7 @@ import { Form60Component } from './certificates/form-60/form-60.component'; import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component'; import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component'; import { NocCertificateComponent } from './certificates/noc/noc.component'; +import { DatabaseExportComponent } from './database-export/database-export.component'; const routes: Routes = [ Route.withShell([ @@ -56,6 +57,11 @@ const routes: Routes = [ component: HypothecationCertificateComponent, data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' } }, + { + path: 'letter-mgmt/database-export', + component: DatabaseExportComponent, + data: { title: 'Database Export', breadcrumb: 'Database Export' } + }, { path: 'letter-mgmt/noc', component: NocCertificateComponent, diff --git a/src/app/reports/dip-lookups/dip-decision-dialog.component.ts b/src/app/reports/dip-lookups/dip-decision-dialog.component.ts index 408a421d2..cfbb0cdd7 100644 --- a/src/app/reports/dip-lookups/dip-decision-dialog.component.ts +++ b/src/app/reports/dip-lookups/dip-decision-dialog.component.ts @@ -47,7 +47,14 @@ import { DipLookupsService } from './dip-lookups.service';
Client - {{ row.clientId || 'Not onboarded' }} + + @if (row.clientId) { + {{ row.clientName || 'Client #' + row.clientId }} + {{ row.clientAccountNo || row.clientId }} + } @else { + Not onboarded + } +
Looked Up @@ -172,6 +179,13 @@ import { DipLookupsService } from './dip-lookups.service'; color: #dc2626; } + .detail-sub { + display: block; + font-size: 11px; + font-weight: 400; + color: #9ca3af; + } + .section-title { font-size: 11px; font-weight: 700; diff --git a/src/app/reports/dip-lookups/dip-lookups.component.html b/src/app/reports/dip-lookups/dip-lookups.component.html index 09ef230bc..b660e78ee 100644 --- a/src/app/reports/dip-lookups/dip-lookups.component.html +++ b/src/app/reports/dip-lookups/dip-lookups.component.html @@ -56,12 +56,61 @@ - @if (searchNumber || activeStatus || pendingOnly) { + @if (searchNumber || activeStatus || pendingOnly || activeSource) { }
+ + +
+ Checked during +
+ + + +
+
+ +
@if (loading) { @@ -90,13 +139,28 @@ - Source - {{ row.lookupSource || '—' }} + Checked During + + + + {{ sourceLabel(row) }} + + Client - {{ row.clientId || 'Not onboarded' }} + + @if (row.clientId) { + + + {{ row.clientName || 'Client #' + row.clientId }} + {{ row.clientAccountNo || row.clientId }} + + } @else { + Not onboarded + } + diff --git a/src/app/reports/dip-lookups/dip-lookups.component.scss b/src/app/reports/dip-lookups/dip-lookups.component.scss index 1a8c37aba..1e85b9701 100644 --- a/src/app/reports/dip-lookups/dip-lookups.component.scss +++ b/src/app/reports/dip-lookups/dip-lookups.component.scss @@ -79,6 +79,106 @@ background-color: #b45309; border-color: #b45309; } + + .chip-onboarding.active-filter { + background-color: #6d28d9; + border-color: #6d28d9; + } + + .chip-rescreen.active-filter { + background-color: #0f766e; + border-color: #0f766e; + } + + .chip-count { + margin-left: 6px; + padding: 1px 7px; + border-radius: 10px; + background-color: rgb(0 0 0 / 8%); + font-size: 11px; + font-weight: 700; + } + + .active-filter .chip-count { + background-color: rgb(255 255 255 / 25%); + } +} + +/* Which screening run a row came from — a different question from the result status chips. */ +.source-filter { + display: flex; + align-items: center; + gap: 10px; + padding-left: 14px; + margin-left: 6px; + border-left: 1px solid #dfe3ea; + + &__label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6b7280; + white-space: nowrap; + } +} + +.source-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + + &__icon { + font-size: 11px; + } + + &.source-onboarding { + background-color: rgb(109 40 217 / 10%); + color: #5b21b6; + } + + &.source-rescreen { + background-color: rgb(15 118 110 / 10%); + color: #0f766e; + } +} + +.client-link { + display: flex; + flex-direction: column; + text-decoration: none; + color: #1074b9; + font-weight: 600; + + &:hover { + text-decoration: underline; + } + + .client-acct { + font-size: 11px; + font-weight: 400; + color: #9ca3af; + } +} + +.muted { + color: #9ca3af; +} + +.rescreen-btn { + font-size: 13px; + border-radius: 20px; + margin-left: auto; + + .chip-icon { + margin-right: 4px; + font-size: 12px; + } } .result-badge { @@ -108,10 +208,6 @@ color: #b45309; } -.muted { - color: #9ca3af; -} - .review-btn { font-size: 12px; border-radius: 16px; diff --git a/src/app/reports/dip-lookups/dip-lookups.component.ts b/src/app/reports/dip-lookups/dip-lookups.component.ts index d8fad5230..dcbe8649b 100644 --- a/src/app/reports/dip-lookups/dip-lookups.component.ts +++ b/src/app/reports/dip-lookups/dip-lookups.component.ts @@ -25,6 +25,7 @@ import { import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { NgClass, DatePipe } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -66,6 +67,7 @@ import { DipDecisionDialogComponent } from './dip-decision-dialog.component'; export class DipLookupsComponent implements OnInit { private service = inject(DipLookupsService); private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); displayedColumns: string[] = [ 'mobileNumber', @@ -82,7 +84,16 @@ export class DipLookupsComponent implements OnInit { activeStatus: string | null = null; /** True while the table is showing the pending-decision queue rather than the full log. */ pendingOnly = false; + /** + * Which screening run the rows came from: 'ONBOARDING' for checks made while the client was being created, + * 'RESCREEN' for the periodic sweep over existing clients, null for both. Filtered here rather than on the + * API because the log endpoint only accepts a status filter. + */ + activeSource: 'ONBOARDING' | 'RESCREEN' | null = null; loading = false; + rescreening = false; + /** Full result set, kept so the source filter can be applied without another round trip. */ + private allRows: any[] = []; @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -107,10 +118,50 @@ export class DipLookupsComponent implements OnInit { } private applyRows(rows: any[]) { + this.allRows = rows; + this.refreshTable(); + this.loading = false; + } + + /** Applies the source filter over the rows already fetched. */ + private refreshTable() { + const rows = this.activeSource + ? this.allRows.filter((row) => (this.activeSource === 'RESCREEN' ? this.isRescreen(row) : !this.isRescreen(row))) + : this.allRows; + this.dataSource = new MatTableDataSource(rows); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; - this.loading = false; + } + + /** + * A row came from the periodic sweep when the backend stamped it PERIODIC_RESCREEN; everything else was + * looked up as the number was typed during client creation. + */ + isRescreen(row: any): boolean { + return (row?.lookupSource || '').toUpperCase().includes('RESCREEN'); + } + + sourceLabel(row: any): string { + return this.isRescreen(row) ? 'Re-screening' : 'Client Creation'; + } + + sourceClass(row: any): string { + return this.isRescreen(row) ? 'source-rescreen' : 'source-onboarding'; + } + + filterBySource(source: 'ONBOARDING' | 'RESCREEN' | null) { + this.activeSource = this.activeSource === source ? null : source; + this.refreshTable(); + } + + /** Row counts behind each source chip, so the split is visible before filtering. */ + get onboardingCount(): number { + return this.allRows.filter((row) => !this.isRescreen(row)).length; + } + + get rescreenCount(): number { + return this.allRows.filter((row) => this.isRescreen(row)).length; } applySearch(value: string) { @@ -135,6 +186,7 @@ export class DipLookupsComponent implements OnInit { this.searchNumber = ''; this.activeStatus = null; this.pendingOnly = false; + this.activeSource = null; this.load(); } @@ -165,6 +217,35 @@ export class DipLookupsComponent implements OnInit { return flagged && !row?.overrideDecision; } + /** + * Screens all existing clients' numbers. A customer onboarded with a clean number can have it + * reported for fraud or reassigned later, and nothing else in the system would ever notice. + */ + rescreenExistingClients() { + this.rescreening = true; + this.service.rescreenExistingClients().subscribe({ + next: (res: any) => { + this.rescreening = false; + // Report what was scanned as well as what was flagged: "0 flagged" out of 0 clients means + // nothing was checked, which is a very different situation from 0 hits out of 20. + const flagged = res?.numbersFlagged ?? 0; + const scanned = res?.clientsScanned ?? 0; + this.snackBar.open( + `Re-screening complete — ${flagged} number(s) flagged across ${scanned} active client(s).`, + 'Close', + { duration: 6000 } + ); + this.pendingOnly = true; + this.activeStatus = null; + this.load(); + }, + error: (err: any) => { + this.rescreening = false; + this.snackBar.open(err?.error?.defaultUserMessage || 'Re-screening failed.', 'Close', { duration: 6000 }); + } + }); + } + review(row: any) { this.dialog .open(DipDecisionDialogComponent, { width: '560px', data: { ...row }, autoFocus: false }) diff --git a/src/app/reports/dip-lookups/dip-lookups.service.ts b/src/app/reports/dip-lookups/dip-lookups.service.ts index bf4c24668..735343471 100644 --- a/src/app/reports/dip-lookups/dip-lookups.service.ts +++ b/src/app/reports/dip-lookups/dip-lookups.service.ts @@ -35,4 +35,13 @@ export class DipLookupsService { recordDecision(logId: number, decision: string, reason: string): Observable { 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 { + return this.http.post('/lookup/rescreen', {}); + } } diff --git a/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts b/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts index 3570653dd..3ef12b9cc 100644 --- a/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts +++ b/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts @@ -49,7 +49,14 @@ import { SanctionsLogsService } from './sanctions-logs.service';
Client - {{ alert.clientId || 'Not yet onboarded' }} + + @if (alert.clientId) { + {{ alert.clientName || 'Client #' + alert.clientId }} + {{ alert.clientAccountNo || alert.clientId }} + } @else { + Not yet onboarded + } +
Screened On @@ -59,10 +66,42 @@ import { SanctionsLogsService } from './sanctions-logs.service';
Reviewing Officer's Assessment
@if (alert.disposition === 'PENDING_REVIEW') { +
- 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.
+ @if (alert.matchedNames) { +
+ Listed as + {{ alert.matchedNames }} +
+ } + @if (showAssessForm) { +
+ + +
+ Minimum 10 characters — this text is the compliance record. +
+
+ } } @else {
{{ dispositionLabel }}
@@ -81,6 +120,18 @@ import { SanctionsLogsService } from './sanctions-logs.service'; · Debits frozen on account(s) {{ alert.frozenAccountIds }} }
+ + @if (alert.disposition === 'CONFIRMED_MATCH' && !alert.frozenAccountIds) { +
+ + No deposit account to freeze for this client. Onboarding and lending remain blocked, and the reporting + obligation below still applies. +
+ } } @if (alert.disposition === 'CONFIRMED_MATCH' && alert.approvedBy) { @@ -112,6 +163,20 @@ import { SanctionsLogsService } from './sanctions-logs.service'; + @if (needsAssessment && !showAssessForm) { + + + } + @if (needsAssessment && showAssessForm) { + + + } @if (canApprove) { + + - @if (searchName || activeFilter || alertFilter) { + @if (searchName || activeFilter || alertFilter || activeSource) { }
+ +
+ Screened during +
+ + + +
+
+ } diff --git a/src/app/reports/sanctions-logs/sanctions-logs.component.scss b/src/app/reports/sanctions-logs/sanctions-logs.component.scss index 88ee9f692..04320321e 100644 --- a/src/app/reports/sanctions-logs/sanctions-logs.component.scss +++ b/src/app/reports/sanctions-logs/sanctions-logs.component.scss @@ -79,6 +79,79 @@ background-color: #b45309; border-color: #b45309; } + + .chip-approval.active-filter { + background-color: #7c3aed; + border-color: #7c3aed; + } + + /* Indigo rather than the violet used by "Awaiting Approval", so the two chip groups stay distinguishable. */ + .chip-onboarding.active-filter { + background-color: #3730a3; + border-color: #3730a3; + } + + .chip-rescreen.active-filter { + background-color: #0f766e; + border-color: #0f766e; + } + + .chip-count { + margin-left: 6px; + padding: 1px 7px; + border-radius: 10px; + background-color: rgb(0 0 0 / 8%); + font-size: 11px; + font-weight: 700; + } + + .active-filter .chip-count { + background-color: rgb(255 255 255 / 25%); + } +} + +/* Which screening run a row came from — a different question from the result/alert chips. */ +.source-filter { + display: flex; + align-items: center; + gap: 10px; + padding-left: 14px; + margin-left: 6px; + border-left: 1px solid #dfe3ea; + + &__label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6b7280; + white-space: nowrap; + } +} + +.source-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + + &__icon { + font-size: 11px; + } + + &.source-onboarding { + background-color: rgb(55 48 163 / 10%); + color: #3730a3; + } + + &.source-rescreen { + background-color: rgb(15 118 110 / 10%); + color: #0f766e; + } } .rescreen-btn { @@ -123,6 +196,24 @@ color: #9ca3af; } +.client-link { + display: flex; + flex-direction: column; + text-decoration: none; + color: #1074b9; + font-weight: 600; + + &:hover { + text-decoration: underline; + } + + .client-acct { + font-size: 11px; + font-weight: 400; + color: #9ca3af; + } +} + .review-btn { font-size: 12px; border-radius: 16px; diff --git a/src/app/reports/sanctions-logs/sanctions-logs.component.ts b/src/app/reports/sanctions-logs/sanctions-logs.component.ts index bb2531c21..8d2b5cc32 100644 --- a/src/app/reports/sanctions-logs/sanctions-logs.component.ts +++ b/src/app/reports/sanctions-logs/sanctions-logs.component.ts @@ -70,6 +70,8 @@ export class SanctionsLogsComponent implements OnInit { 'screeningResult', 'disposition', 'matchCount', + 'screeningSource', + 'client', 'screenedBy', 'screenedAt', 'actions' @@ -83,8 +85,16 @@ export class SanctionsLogsComponent implements OnInit { * disposition) instead of the raw screening log. 'ALL' means every alert regardless of state. */ alertFilter: string | null = null; + /** + * Which screening run the rows came from: 'CLIENT_CREATION' for names screened while the client was being + * created, 'RESCREEN' for the periodic sweep over existing clients, null for both. Applied in the browser + * because neither the log nor the alert endpoint accepts a source filter. + */ + activeSource: 'CLIENT_CREATION' | 'RESCREEN' | null = null; loading = false; rescreening = false; + /** Full result set, kept so the source filter can be applied without another round trip. */ + private allRows: any[] = []; @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -98,16 +108,38 @@ export class SanctionsLogsComponent implements OnInit { // The alert queue is a different endpoint, not a filter on the log list: it carries the // disposition columns the review dialog needs. + // AWAITING_APPROVAL is a client-side view over all alerts rather than a disposition value: + // an alert waiting on the Principal Officer is one that HAS been assessed but not signed off, + // so it is identified by approvedBy being empty, not by its disposition. + const serverFilter = + this.alertFilter === 'ALL' || this.alertFilter === 'AWAITING_APPROVAL' ? undefined : this.alertFilter; + const request$ = this.alertFilter - ? this.sanctionsLogsService.getAlerts(this.alertFilter === 'ALL' ? undefined : this.alertFilter) + ? this.sanctionsLogsService.getAlerts(serverFilter) : this.sanctionsLogsService.getLogs(this.buildLogFilters()); request$.subscribe({ - next: (data: any) => this.applyRows(data || []), + next: (data: any) => this.applyRows(this.postFilter(data || [])), error: () => this.applyRows([]) }); } + /** Narrows the fetched alerts for views the API has no dedicated filter for. */ + private postFilter(rows: any[]): any[] { + if (this.alertFilter !== 'AWAITING_APPROVAL') { + return rows; + } + return rows.filter((row) => this.isAwaitingApproval(row)); + } + + /** + * True when the clerk has recorded an assessment and the Principal Officer has not yet approved + * it. This is the state in which the freeze is pending and nothing happens until someone acts. + */ + isAwaitingApproval(row: any): boolean { + return !!row?.disposition && row.disposition !== 'PENDING_REVIEW' && !row.approvedBy; + } + private buildLogFilters() { const filters: any = {}; if (this.activeFilter) { @@ -120,10 +152,50 @@ export class SanctionsLogsComponent implements OnInit { } private applyRows(rows: any[]) { + this.allRows = rows; + this.refreshTable(); + this.loading = false; + } + + /** Applies the source filter over the rows already fetched. */ + private refreshTable() { + const rows = this.activeSource + ? this.allRows.filter((row) => (this.activeSource === 'RESCREEN' ? this.isRescreen(row) : !this.isRescreen(row))) + : this.allRows; + this.dataSource = new MatTableDataSource(rows); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; - this.loading = false; + } + + /** + * A row came from the periodic sweep when the backend stamped it PERIODIC_RESCREEN; everything else was + * screened as the name was entered during client creation. + */ + isRescreen(row: any): boolean { + return (row?.screeningSource || '').toUpperCase().includes('RESCREEN'); + } + + sourceLabel(row: any): string { + return this.isRescreen(row) ? 'Re-screening' : 'Client Creation'; + } + + sourceClass(row: any): string { + return this.isRescreen(row) ? 'source-rescreen' : 'source-onboarding'; + } + + filterBySource(source: 'CLIENT_CREATION' | 'RESCREEN' | null) { + this.activeSource = this.activeSource === source ? null : source; + this.refreshTable(); + } + + /** Row counts behind each source chip, so the split is visible before filtering. */ + get creationCount(): number { + return this.allRows.filter((row) => !this.isRescreen(row)).length; + } + + get rescreenCount(): number { + return this.allRows.filter((row) => this.isRescreen(row)).length; } applySearch(value: string) { @@ -149,6 +221,7 @@ export class SanctionsLogsComponent implements OnInit { this.searchName = ''; this.activeFilter = null; this.alertFilter = null; + this.activeSource = null; this.loadLogs(); } diff --git a/src/app/reports/sanctions-logs/sanctions-logs.service.ts b/src/app/reports/sanctions-logs/sanctions-logs.service.ts index 4419a27ea..deb7d6f1d 100644 --- a/src/app/reports/sanctions-logs/sanctions-logs.service.ts +++ b/src/app/reports/sanctions-logs/sanctions-logs.service.ts @@ -59,4 +59,18 @@ export class SanctionsLogsService { rescreenExistingClients(): Observable { 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 { + let params = new HttpParams(); + if (status) { + params = params.set('status', status); + } + return this.http.get('/sanctions/str-cases', { params }); + } }