Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-19 10:44:53 +05:30
33 changed files with 3042 additions and 208 deletions
@@ -41,6 +41,26 @@
>
{{ address.isActive ? 'Active' : 'Inactive' }}
</span>
<!-- Only shown on the address a welcome kit was actually posted to. -->
@if (verificationOf(address); as verification) {
<span
class="addr-panel__verify"
[class.addr-panel__verify--yes]="verification === 'VERIFIED'"
[class.addr-panel__verify--no]="verification === 'NOT_VERIFIED'"
[matTooltip]="
verification === 'VERIFIED'
? 'Welcome kit was delivered to this address'
: 'Welcome kit sent here was returned undelivered'
"
>
<i
class="fa"
[class.fa-check-circle]="verification === 'VERIFIED'"
[class.fa-times-circle]="verification === 'NOT_VERIFIED'"
></i>
{{ verification === 'VERIFIED' ? 'Verified' : 'Not Verified' }}
</span>
}
</mat-panel-title>
<mat-panel-description class="addr-panel__desc">
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : ''
@@ -148,6 +148,32 @@ $red-light: #fce8e6;
}
}
/* Welcome kit outcome — only present on the address a kit was posted to. */
&__verify {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
white-space: nowrap;
i {
font-size: 11px;
}
&--yes {
background: $green-light;
color: $green;
}
&--no {
background: $red-light;
color: $red;
}
}
&__desc {
font-size: 12px;
color: $gray-500 !important;
@@ -33,6 +33,7 @@ import {
} from '@angular/material/expansion';
import { MatDivider } from '@angular/material/divider';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -51,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatExpansionPanelTitle,
MatExpansionPanelDescription,
MatDivider,
MatSlideToggle
MatSlideToggle,
MatTooltip
]
})
export class AddressTabComponent {
@@ -68,6 +70,11 @@ export class AddressTabComponent {
clientAddressTemplate: any;
/** Client Id */
clientId: string;
/**
* Welcome kit outcome per address id. Only addresses a kit was actually posted to appear here: delivery
* proves the address is real, a returned kit proves it is not, and an address with no kit stays unmarked.
*/
addressVerification: Record<string, 'VERIFIED' | 'NOT_VERIFIED'> = {};
/**
* @param {ActivatedRoute} route Activated Route
@@ -82,10 +89,48 @@ export class AddressTabComponent {
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
this.clientAddressTemplate = data.clientAddressTemplateData;
this.clientId = this.route.parent.snapshot.paramMap.get('clientId');
this.loadWelcomeKitVerification();
}
);
}
/**
* Marks addresses a welcome kit was posted to with the outcome of that delivery. The kit is the physical
* check on the address, so a delivered kit reads as verified and a returned one as not verified.
*/
private loadWelcomeKitVerification(): void {
if (!this.clientId) {
return;
}
this.clientService.getWelcomeKits(this.clientId).subscribe({
next: (response: any) => {
const map: Record<string, 'VERIFIED' | 'NOT_VERIFIED'> = {};
// Oldest first, so that when an address was posted to more than once the newest outcome wins.
const dispatches = [...(response?.dispatches || [])].reverse();
for (const dispatch of dispatches) {
if (dispatch?.addressId == null) {
continue;
}
if (dispatch.status === 'DELIVERED') {
map[dispatch.addressId] = 'VERIFIED';
} else if (dispatch.status === 'RETURNED') {
map[dispatch.addressId] = 'NOT_VERIFIED';
}
}
this.addressVerification = map;
},
// A client with no welcome kit permission or no kit yet simply shows no badge.
error: () => {
this.addressVerification = {};
}
});
}
/** Verification badge for an address, or null when no kit has been posted to it. */
verificationOf(address: any): 'VERIFIED' | 'NOT_VERIFIED' | null {
return this.addressVerification[address?.addressId] || null;
}
/**
* Adds a client address.
*/
@@ -168,58 +168,78 @@
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
</div>
}
@if (clientViewData.mobileNo && clientViewData.dipVerified !== null) {
<!--
dipLiveStatus reflects the LATEST lookup, not the flag frozen at onboarding. A customer
cleared when they joined can have their number reported for fraud or reassigned later —
this cell has to show that, otherwise the page keeps claiming "Verified" indefinitely.
-->
@if (clientViewData.mobileNo && (clientViewData.dipVerified !== null || clientViewData.dipLiveStatus)) {
<div class="cv-cell">
<div class="cv-cell__label">DIP Status</div>
<div class="cv-cell__value">
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
"
[class.cv-badge--yellow]="
clientViewData.dipVerified !== true &&
clientViewData.dipVerified !== 'true' &&
clientViewData.dipVerified !== 1
"
[class.cv-badge--red]="isDipFlagged(clientViewData.dipLiveStatus)"
[class.cv-badge--green]="!isDipFlagged(clientViewData.dipLiveStatus) && isDipClear(clientViewData)"
[class.cv-badge--yellow]="!isDipFlagged(clientViewData.dipLiveStatus) && !isDipClear(clientViewData)"
>
{{
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
? 'Verified'
: 'Pending'
}}
{{ dipStatusLabel(clientViewData) }}
</span>
</div>
</div>
}
@if (clientViewData.nameVerified !== null) {
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<div class="cv-cell cv-cell--full">
<div class="dip-alert">
<fa-icon icon="exclamation-triangle" class="dip-alert__icon"></fa-icon>
<div>
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
<div class="dip-alert__detail">
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
obtain a new number. Account operations are not restricted.
</div>
</div>
</div>
</div>
}
<!--
A frozen customer must be obvious the moment anyone opens them, not something a teller
discovers when a withdrawal is refused at the counter.
-->
@if (clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH') {
<div class="cv-cell cv-cell--full">
<div class="dip-alert">
<fa-icon icon="ban" class="dip-alert__icon"></fa-icon>
<div>
<strong>Confirmed UN Security Council sanctions match — accounts frozen.</strong>
<div class="dip-alert__detail">
Debits are blocked on this customer's deposit accounts and lending is barred. Do not discuss the
reason with the customer. See Reports &rarr; Sanctions Screening &amp; Alerts.
</div>
</div>
</div>
</div>
}
<!--
Driven by sanctionsAlertStatus (the live alert) rather than nameVerified, which records
the outcome at onboarding and never changes. A client confirmed against the UNSC list and
frozen afterwards was still reading "Clear" here — the single worst place to be wrong,
since this header is what anyone opening the customer sees first.
-->
@if (clientViewData.nameVerified !== null || clientViewData.sanctionsAlertStatus) {
<div class="cv-cell">
<div class="cv-cell__label">Sanctions Screening</div>
<div class="cv-cell__value">
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
"
[class.cv-badge--red]="
clientViewData.nameVerified === false ||
clientViewData.nameVerified === 'false' ||
clientViewData.nameVerified === 0
[class.cv-badge--green]="!clientViewData.sanctionsAlertStatus && isNameCleared(clientViewData)"
[class.cv-badge--yellow]="
clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW' ||
(!clientViewData.sanctionsAlertStatus && !isNameCleared(clientViewData))
"
[class.cv-badge--red]="clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH'"
>
{{
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
? 'Clear'
: 'Pending'
}}
{{ sanctionsHeaderLabel(clientViewData) }}
</span>
</div>
</div>
@@ -410,9 +430,11 @@
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
@if (isActive()) {
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
}
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
@@ -286,6 +286,39 @@ $accent: #0ea5e9;
}
}
// A DIP hit on an existing customer is not an account restriction, so it is shown as a standing
// advisory on the client rather than as a blocking state.
.cv-cell--full {
grid-column: 1 / -1;
}
.dip-alert {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgb(239 68 68 / 15%);
border: 1px solid rgb(239 68 68 / 40%);
border-left: 3px solid #ef4444;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
color: #fecaca;
line-height: 1.5;
&__icon {
color: #fca5a5;
font-size: 15px;
margin-top: 1px;
flex-shrink: 0;
}
&__detail {
margin-top: 3px;
color: #fca5a5;
font-weight: 400;
}
}
.actions-menu {
color: #fff !important;
}
@@ -85,6 +85,54 @@ import { formatTabLabel } from 'app/shared/utils/format-tab-label.util';
})
export class ClientsViewComponent implements OnInit {
complianceHideClientData = environment.complianceHideClientData;
/**
* Whether the number's CURRENT DIP/MNRL status is a hit. Driven by the live lookup rather than
* the onboarding-time `dipVerified` flag, which never changes once the client is created.
*/
isDipFlagged(liveStatus: string | null | undefined): boolean {
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
}
/** True only when the number is affirmatively clean — not merely "not flagged". */
isDipClear(client: any): boolean {
if (client?.dipLiveStatus) {
return client.dipLiveStatus === 'CLEAN';
}
return client?.dipVerified === true || client?.dipVerified === 'true' || client?.dipVerified === 1;
}
/** True when onboarding screening cleared the name — the pre-alert baseline. */
isNameCleared(client: any): boolean {
return client?.nameVerified === true || client?.nameVerified === 'true' || client?.nameVerified === 1;
}
/**
* Header badge for sanctions. A live alert always wins over the onboarding flag: the flag says
* what was true the day the client joined, the alert says what is true now.
*/
sanctionsHeaderLabel(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return 'Frozen — Sanctions Match';
case 'PENDING_REVIEW':
return 'Under Review';
case 'FALSE_POSITIVE':
return 'Cleared — False Positive';
default:
return this.isNameCleared(client) ? 'Clear' : 'Pending';
}
}
dipStatusLabel(client: any): string {
const live = client?.dipLiveStatus;
if (live === 'SUSPECTED') return 'Flagged — Suspected';
if (live === 'DISCONNECTED') return 'Flagged — Disconnected';
if (live === 'SUSPECTED_AND_DISCONNECTED') return 'Flagged — Suspected & Disconnected';
if (live === 'SERVICE_UNAVAILABLE') return 'Not Verified';
if (live === 'CLEAN') return 'Verified';
return this.isDipClear(client) ? 'Verified' : 'Pending';
}
/**
* Mask a string, keeping first and last letter, masking the rest with *
*/
@@ -461,12 +509,24 @@ export class ClientsViewComponent implements OnInit {
}
/**
* Opens the Welcome Kit Dispatch dialog.
* Opens the Welcome Kit Dispatch dialog. The kit is only posted once the client is active, so the dialog is
* unreachable before then — the menu entry is hidden and this guard covers any other route into the action.
*/
private dispatchWelcomeKit() {
if (!this.isActive()) {
return;
}
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
data: { clientId: this.clientViewData.id },
width: '600px'
data: {
clientId: this.clientViewData.id,
clientName: this.clientViewData.displayName,
accountNo: this.clientViewData.accountNo,
cifNumber: this.clientViewData.cifNumber,
mobileNo: this.clientViewData.mobileNo,
emailAddress: this.clientViewData.emailAddress,
officeName: this.clientViewData.officeName
},
width: '680px'
});
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
if (response) {
@@ -5,90 +5,277 @@
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
<div class="wk-header" mat-dialog-title>
<div class="wk-header__icon">
<mat-icon>card_giftcard</mat-icon>
</div>
<div class="wk-header__text">
<h2 class="wk-header__title">Welcome Kit Dispatch</h2>
<p class="wk-header__subtitle">Posting the kit doubles as a physical address check for this client.</p>
</div>
</div>
<mat-dialog-content class="mat-typography">
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
<mat-dialog-content class="mat-typography wk-content">
<div class="wk-loading" *ngIf="isLoading">
<mat-spinner diameter="40"></mat-spinner>
</div>
<ng-container *ngIf="!isLoading">
<!-- Tracking view if dispatches already exist -->
<div
class="dispatch-tracking-panel"
*ngFor="let dispatch of activeDispatches; let i = index"
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
>
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
Download Details
</button>
<!-- Who the kit is going to -->
<section class="wk-client">
<div class="wk-client__avatar">{{ (clientName || '?').charAt(0) }}</div>
<div class="wk-client__details">
<div class="wk-client__name">{{ clientName || 'Client' }}</div>
<div class="wk-client__meta" *ngIf="data.accountNo">Account No. {{ data.accountNo }}</div>
</div>
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
<p style="margin: 4px 0">
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
</p>
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
<p style="margin: 4px 0">
<strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p>
</div>
</section>
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
<!-- Existing dispatches, newest first -->
<section
class="wk-dispatch"
*ngFor="let dispatch of activeDispatches; let i = index"
[class.wk-dispatch--past]="i > 0"
>
<header class="wk-dispatch__head">
<span class="wk-dispatch__label">{{ i === 0 ? 'Current Dispatch' : 'Previous Dispatch' }}</span>
<span class="wk-chip" [ngClass]="'wk-chip--' + (dispatch.status | lowercase)">
<mat-icon class="wk-chip__icon">
{{
dispatch.status === 'DELIVERED'
? 'verified'
: dispatch.status === 'RETURNED'
? 'gpp_bad'
: 'local_shipping'
}}
</mat-icon>
{{ verificationLabel(dispatch.status) }}
</span>
</header>
<!-- Create form if they can dispatch a new one -->
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3>Create New Dispatch</h3>
<div fxLayout="column" fxLayoutGap="16px">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Address</mat-label>
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
Delivery Address is required
<p class="wk-dispatch__hint">{{ verificationHint(dispatch) }}</p>
<dl class="wk-facts">
<div class="wk-facts__row wk-facts__row--wide">
<dt>Delivery Address</dt>
<dd>{{ getDisplayAddress(dispatch) }}</dd>
</div>
<div class="wk-facts__row">
<dt>Tracking ID</dt>
<dd class="wk-facts__mono">{{ dispatch.trackingId }}</dd>
</div>
<div class="wk-facts__row">
<dt>Delivery Mode</dt>
<dd>
{{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'"
>({{ dispatch.deliveryModeOtherText }})</ng-container
>
</dd>
</div>
<div class="wk-facts__row">
<dt>Dispatched On</dt>
<dd>{{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row" *ngIf="dispatch.deliveredOnDate">
<dt>Delivered On</dt>
<dd>{{ dispatch.deliveredOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row" *ngIf="dispatch.returnedOnDate">
<dt>Returned On</dt>
<dd>{{ dispatch.returnedOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row wk-facts__row--wide" *ngIf="dispatch.remarks">
<dt>Remarks</dt>
<dd>{{ dispatch.remarks }}</dd>
</div>
</dl>
<!-- Outcome form, shown in place of the buttons once an outcome is picked -->
<div class="wk-outcome" *ngIf="outcomeFor?.id === dispatch.id" [formGroup]="outcomeForm">
<p class="wk-outcome__title">
Mark this kit as
<strong>{{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }}</strong>
</p>
<div class="wk-outcome__fields">
<mat-form-field appearance="outline">
<mat-label>{{ outcomeStatus === 'DELIVERED' ? 'Delivered On' : 'Returned On' }}</mat-label>
<input matInput [matDatepicker]="outcomePicker" [max]="maxDate" formControlName="outcomeDate" required />
<mat-datepicker-toggle matSuffix [for]="outcomePicker"></mat-datepicker-toggle>
<mat-datepicker #outcomePicker></mat-datepicker>
<mat-error *ngIf="outcomeForm.get('outcomeDate')?.hasError('required')"> Date is required </mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Remarks (optional)</mat-label>
<input
matInput
formControlName="remarks"
maxlength="500"
placeholder="What the delivery partner reported"
/>
</mat-form-field>
</div>
<div class="wk-outcome__actions">
<button mat-button type="button" (click)="cancelOutcome()">Cancel</button>
<button
mat-flat-button
type="button"
[color]="outcomeStatus === 'DELIVERED' ? 'primary' : 'warn'"
[disabled]="outcomeForm.invalid"
(click)="submitOutcome()"
>
Confirm {{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }}
</button>
</div>
</div>
<footer class="wk-dispatch__actions" *ngIf="outcomeFor?.id !== dispatch.id">
<button mat-stroked-button type="button" (click)="downloadAddressLabel(dispatch)">
<mat-icon>print</mat-icon>
Label
</button>
<button mat-stroked-button type="button" (click)="downloadPdf(dispatch)">
<mat-icon>download</mat-icon>
Details
</button>
<ng-container *ngIf="dispatch.status === 'DISPATCHED'">
<button mat-stroked-button color="warn" type="button" (click)="startOutcome(dispatch, 'RETURNED')">
<mat-icon>gpp_bad</mat-icon>
Not Verified
</button>
<button mat-flat-button color="primary" type="button" (click)="startOutcome(dispatch, 'DELIVERED')">
<mat-icon>verified</mat-icon>
Verified
</button>
</ng-container>
</footer>
</section>
<!-- New dispatch -->
<section class="wk-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3 class="wk-form__title">
{{ activeDispatches.length > 0 ? 'Dispatch a Replacement Kit' : 'Create New Dispatch' }}
</h3>
<!-- The kit goes to one of the addresses captured at client creation; it is chosen, never typed. -->
<ng-container *ngIf="addressChoices.length > 0">
<mat-form-field appearance="outline" class="wk-full">
<mat-label>Deliver To</mat-label>
<mat-select formControlName="addressId" required>
<mat-option *ngFor="let address of addressChoices" [value]="address.id">
{{ address.type }} — {{ address.oneLine }}
</mat-option>
</mat-select>
<mat-hint>Addresses recorded for this client. Update them on the Address tab if one is wrong.</mat-hint>
<mat-error *ngIf="dispatchForm.get('addressId')?.hasError('required')">
Select the address to deliver to
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<div class="wk-address" *ngIf="selectedAddress as address">
<mat-icon class="wk-address__icon">place</mat-icon>
<div class="wk-address__body">
<span class="wk-address__type">{{ address.type }}</span>
<span class="wk-address__name">{{ clientName }}</span>
<span class="wk-address__line" *ngFor="let line of address.lines">{{ line }}</span>
</div>
</div>
</ng-container>
<div class="wk-noaddress" *ngIf="addressesLoaded && addressChoices.length === 0">
<mat-icon>error_outline</mat-icon>
<span>
No address is recorded for this client. Add one on the Address tab before dispatching a welcome kit.
</span>
</div>
<div class="wk-form__row">
<mat-form-field appearance="outline">
<mat-label>Delivery Mode</mat-label>
<mat-select formControlName="deliveryMode" required>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
{{ mode }}
</mat-option>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">{{ mode }}</mat-option>
</mat-select>
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
Delivery Mode is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width"
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
>
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
<mat-form-field appearance="outline">
<mat-label>Dispatched On</mat-label>
<input
matInput
[matDatepicker]="dispatchPicker"
[max]="maxDate"
formControlName="dispatchedOnDate"
required
/>
<mat-datepicker-toggle matSuffix [for]="dispatchPicker"></mat-datepicker-toggle>
<mat-datepicker #dispatchPicker></mat-datepicker>
<mat-error *ngIf="dispatchForm.get('dispatchedOnDate')?.hasError('required')">
Dispatch date is required
</mat-error>
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="wk-full" *ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'">
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
</mat-error>
</mat-form-field>
<!-- Printed before the kit is posted, so the tracking ID below is filled in afterwards. -->
<div class="wk-label-cta">
<div class="wk-label-cta__text">
<span class="wk-label-cta__title">Envelope address label</span>
<span class="wk-label-cta__hint">
Print this first — name, address, contact details and the welcome note, ready to fold into a window
envelope.
</span>
</div>
<button
mat-stroked-button
color="primary"
type="button"
[disabled]="!selectedAddress"
(click)="downloadAddressLabel()"
>
<mat-icon>print</mat-icon>
Download
</button>
</div>
<mat-form-field appearance="outline" class="wk-full">
<mat-label>Tracking ID</mat-label>
<input matInput formControlName="trackingId" maxlength="100" required placeholder="e.g. EX123456789IN" />
<mat-icon matSuffix>confirmation_number</mat-icon>
<mat-hint>Enter the consignment number printed on the delivery partner's receipt.</mat-hint>
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('required')"> Tracking ID is required </mat-error>
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('maxlength')">
Tracking ID cannot exceed 100 characters
</mat-error>
</mat-form-field>
</section>
<!-- Nothing to do: a kit is already out and not yet resolved -->
<p class="wk-empty" *ngIf="!welcomeKitsData?.canDispatchNew && activeDispatches.length > 0">
A welcome kit is already recorded for this client. Record its outcome above before dispatching another.
</p>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
<button mat-button type="button" (click)="close()">Close</button>
<button
mat-raised-button
mat-flat-button
color="primary"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
type="button"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading && !outcomeFor"
(click)="submit()"
[disabled]="dispatchForm.invalid || isLoading"
>
<mat-icon>send</mat-icon>
Dispatch
</button>
</mat-dialog-actions>
@@ -5,55 +5,375 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.dispatch-tracking-panel {
background-color: #f5f5f5;
border-radius: 8px;
padding: 16px;
$wk-primary: #1f497d;
$wk-border: #e3e7ee;
$wk-muted: #6b7280;
$wk-surface: #f7f9fc;
/* ---------- header ---------- */
.wk-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
margin: 0;
border-bottom: 1px solid $wk-border;
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex: 0 0 40px;
border-radius: 10px;
background: rgba($wk-primary, 0.1);
color: $wk-primary;
}
&__title {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
line-height: 1.3;
}
&__subtitle {
margin: 2px 0 0;
font-size: 0.8rem;
color: $wk-muted;
}
}
.wk-content {
padding-top: 16px;
}
.wk-loading {
display: flex;
justify-content: center;
padding: 2.5rem 0;
}
/* ---------- client identity ---------- */
.wk-client {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 10px;
background: linear-gradient(135deg, rgba($wk-primary, 0.08), rgba($wk-primary, 0.02));
border: 1px solid rgba($wk-primary, 0.15);
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
flex: 0 0 38px;
border-radius: 50%;
background: $wk-primary;
color: #fff;
font-weight: 600;
text-transform: uppercase;
}
p {
margin: 6px 0;
&__name {
font-weight: 600;
font-size: 1rem;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
color: white;
&__meta {
font-size: 0.8rem;
color: $wk-muted;
font-variant-numeric: tabular-nums;
}
}
&.status-dispatched {
background-color: #1976d2;
}
/* ---------- dispatch panel ---------- */
.wk-dispatch {
border: 1px solid $wk-border;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 16px;
background: $wk-surface;
&.status-in_transit {
background-color: #f57c00;
}
&--past {
opacity: 0.75;
}
&.status-delivered {
background-color: #388e3c;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
&.status-returned {
background-color: #d32f2f;
&__label {
font-weight: 600;
font-size: 0.95rem;
}
&__hint {
margin: 0 0 12px;
font-size: 0.8rem;
color: $wk-muted;
line-height: 1.45;
}
&__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.dispatch-create-form {
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
/* ---------- status chip ---------- */
.wk-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
&__icon {
font-size: 15px;
width: 15px;
height: 15px;
}
&--dispatched {
background: rgb(25 118 210 / 12%);
color: #1565c0;
}
&--delivered {
background: rgb(56 142 60 / 14%);
color: #2e7d32;
}
&--returned {
background: rgb(211 47 47 / 12%);
color: #c62828;
}
}
.full-width {
/* ---------- fact grid ---------- */
.wk-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
margin: 0;
&__row {
min-width: 0;
&--wide {
grid-column: 1 / -1;
}
dt {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $wk-muted;
margin-bottom: 2px;
}
dd {
margin: 0;
font-size: 0.875rem;
word-break: break-word;
}
}
&__mono {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-weight: 600;
letter-spacing: 0.02em;
}
}
/* ---------- outcome form ---------- */
.wk-outcome {
margin-top: 14px;
padding: 14px;
border-radius: 8px;
background: #fff;
border: 1px dashed rgba($wk-primary, 0.4);
&__title {
margin: 0 0 10px;
font-size: 0.875rem;
}
&__fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 14px;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}
/* ---------- create form ---------- */
.wk-form {
&__title {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 600;
}
&__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 14px;
}
}
.wk-full {
width: 100%;
}
.wk-address {
display: flex;
gap: 10px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 8px;
background: $wk-surface;
border: 1px solid $wk-border;
&__icon {
color: $wk-primary;
font-size: 20px;
width: 20px;
height: 20px;
flex: 0 0 20px;
}
&__body {
display: flex;
flex-direction: column;
font-size: 0.85rem;
line-height: 1.5;
}
&__type {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $wk-primary;
margin-bottom: 2px;
}
&__name {
font-weight: 600;
}
&__line {
color: $wk-muted;
}
}
.wk-noaddress {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 8px;
background: rgb(211 47 47 / 6%);
border: 1px solid rgb(211 47 47 / 25%);
font-size: 0.83rem;
line-height: 1.5;
color: #b3261e;
mat-icon {
font-size: 19px;
width: 19px;
height: 19px;
flex: 0 0 19px;
}
}
/* ---------- envelope label call-to-action ---------- */
.wk-label-cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 12px 14px;
margin-bottom: 18px;
border-radius: 8px;
background: rgba($wk-primary, 0.04);
border: 1px solid rgba($wk-primary, 0.18);
&__text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 240px;
}
&__title {
font-weight: 600;
font-size: 0.875rem;
}
&__hint {
font-size: 0.78rem;
color: $wk-muted;
line-height: 1.45;
}
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
.wk-empty {
margin: 4px 0 0;
padding: 12px 14px;
border-radius: 8px;
background: $wk-surface;
border: 1px solid $wk-border;
font-size: 0.85rem;
color: $wk-muted;
}
mat-dialog-actions {
padding: 12px 24px 20px;
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
@@ -12,12 +12,14 @@ import {
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
MatDialogActions
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatIcon } from '@angular/material/icon';
import { ClientsService } from '../../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DatePipe } from '@angular/common';
import { jsPDF } from 'jspdf';
@@ -32,32 +34,79 @@ import autoTable from 'jspdf-autotable';
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatProgressSpinnerModule
MatProgressSpinnerModule,
MatIcon
],
providers: [DatePipe]
})
export class WelcomeKitDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
data: { clientId: string } = inject(MAT_DIALOG_DATA);
data: {
clientId: string;
clientName?: string;
accountNo?: string;
cifNumber?: string;
mobileNo?: string;
emailAddress?: string;
officeName?: string;
} = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private datePipe = inject(DatePipe);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
isLoading = true;
/** Set once a dispatch or an outcome is saved, so the client view knows to reload on close. */
private hasChanges = false;
welcomeKitsData: any = null;
/** Addresses captured for the client, each formatted for display and selection. */
addressChoices: { id: any; type: string; isActive: boolean; lines: string[]; oneLine: string }[] = [];
/** True once the address lookup has run, so the empty state only shows when there genuinely are none. */
addressesLoaded = false;
/** Dispatch whose outcome is being recorded, or null while the tracking list is shown. */
outcomeFor: any = null;
outcomeStatus: 'DELIVERED' | 'RETURNED' = 'DELIVERED';
maxDate = new Date();
dispatchForm: FormGroup = this.formBuilder.group({
deliveryAddress: [
'',
// The kit goes to one of the addresses captured at client creation, never to a hand-typed one, so the
// dispatch record always traces back to an address the client actually gave.
addressId: [
null,
Validators.required
],
deliveryMode: [
'POSTMAN',
Validators.required
],
deliveryModeOtherText: ['']
deliveryModeOtherText: [''],
trackingId: [
'',
[
Validators.required,
Validators.maxLength(100)
]
],
dispatchedOnDate: [
new Date(),
Validators.required
]
});
outcomeForm: FormGroup = this.formBuilder.group({
outcomeDate: [
new Date(),
Validators.required
],
remarks: [
'',
Validators.maxLength(500)
]
});
deliveryModes = [
'POSTMAN',
'COURIER',
@@ -76,7 +125,37 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
get clientName(): string {
return this.welcomeKitsData?.clientName || this.data.clientName || '';
}
/** The kit is a physical address check, so the outcome is phrased as verification rather than delivery. */
verificationLabel(status: string): string {
switch (status) {
case 'DELIVERED':
return 'Verified';
case 'RETURNED':
return 'Not Verified';
default:
return 'Awaiting Verification';
}
}
verificationHint(dispatch: any): string {
switch (dispatch?.status) {
case 'DELIVERED':
return 'Kit delivered, so the address stands verified.';
case 'RETURNED':
return 'Kit came back undelivered, so the address could not be verified.';
default:
return 'Check this tracking ID on the delivery partner’s website or app, then record the outcome below.';
}
}
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.dispatchForm.patchValue({ dispatchedOnDate: this.maxDate });
this.outcomeForm.patchValue({ outcomeDate: this.maxDate });
this.fetchData();
}
@@ -98,33 +177,77 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
/** Order the address types are offered in: a kit is posted to the correspondence address when one exists. */
private static readonly ADDRESS_TYPE_ORDER = [
'Correspondence',
'Current / Residential',
'Permanent Address',
'Office / Work'
];
fetchPrimaryAddress() {
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
next: (addresses: any) => {
this.isLoading = false;
if (addresses && addresses.length > 0) {
// Attempt to build a readable address from the first address returned
const primary = addresses[0];
const parts = [];
if (primary.addressLine1) parts.push(primary.addressLine1);
if (primary.addressLine2) parts.push(primary.addressLine2);
if (primary.city) parts.push(primary.city);
if (primary.stateName) parts.push(primary.stateName);
if (primary.postalCode) parts.push(primary.postalCode);
if (primary.countryName) parts.push(primary.countryName);
this.addressesLoaded = true;
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
this.dispatchForm.patchValue({
deliveryAddress: clientNameStr + parts.join(', ')
const usable = (addresses || []).filter((a: any) => a && (a.addressLine1 || a.postalCode || a.city));
this.addressChoices = usable
.map((a: any) => {
const lines = [
a.addressLine1,
a.addressLine2,
a.addressLine3,
a.street,
a.townVillage,
a.city,
a.countyDistrict,
a.stateName || a.stateProvinceName,
a.postalCode,
a.countryName
]
.map((part: any) => (part == null ? '' : String(part).trim()))
.filter((part: string) => part.length > 0);
return {
id: a.addressId ?? a.id,
type: a.addressType || 'Address',
isActive: a.isActive !== false,
lines,
oneLine: lines.join(', ')
};
})
.sort((x: any, y: any) => {
// Active addresses first, then by how suitable the type is for posting.
if (x.isActive !== y.isActive) return x.isActive ? -1 : 1;
const order = WelcomeKitDialogComponent.ADDRESS_TYPE_ORDER;
const xi = order.indexOf(x.type);
const yi = order.indexOf(y.type);
return (xi === -1 ? order.length : xi) - (yi === -1 ? order.length : yi);
});
if (this.addressChoices.length > 0) {
this.dispatchForm.patchValue({ addressId: this.addressChoices[0].id });
}
},
error: () => {
this.isLoading = false;
this.addressesLoaded = true;
}
});
}
/** The address the operator picked, or null before one is chosen. */
get selectedAddress() {
const id = this.dispatchForm.get('addressId')?.value;
return this.addressChoices.find((a) => a.id === id) || null;
}
/** Lines of the selected address, for the on-screen preview and the printed label. */
get addressLines(): string[] {
return this.selectedAddress?.lines || [];
}
get activeDispatches() {
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
return this.welcomeKitsData.dispatches;
@@ -138,36 +261,104 @@ export class WelcomeKitDialogComponent implements OnInit {
return;
}
const chosen = this.selectedAddress;
if (!chosen) {
this.snackBar.open('Select the address the kit was posted to', 'Close', { duration: 3000 });
return;
}
this.isLoading = true;
const formValue = this.dispatchForm.value;
const today = new Date();
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload = {
deliveryAddress: formValue.deliveryAddress,
// Stored as text so the dispatch keeps the address exactly as it read on the day it went out, even if
// the client's address record is edited later.
deliveryAddress: `${chosen.type}: ${chosen.oneLine}`,
addressId: chosen.id,
deliveryMode: formValue.deliveryMode,
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
dispatchedOnDate: formattedDate,
locale: 'en',
dateFormat: 'dd MMMM yyyy'
trackingId: (formValue.trackingId || '').trim(),
dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat),
locale,
dateFormat
};
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
next: () => {
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
this.hasChanges = true;
this.snackBar.open('Welcome Kit dispatched successfully', 'Close', { duration: 3000 });
this.fetchData();
},
error: () => {
error: (err: any) => {
this.isLoading = false;
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
this.snackBar.open(this.errorMessage(err, 'Failed to dispatch welcome kit'), 'Close', { duration: 5000 });
}
});
}
/** Opens the outcome form for a dispatch, pre-set to the outcome the operator clicked. */
startOutcome(dispatch: any, status: 'DELIVERED' | 'RETURNED') {
this.outcomeFor = dispatch;
this.outcomeStatus = status;
this.outcomeForm.reset({ outcomeDate: this.maxDate, remarks: '' });
}
cancelOutcome() {
this.outcomeFor = null;
}
/** Records what the operator found on the delivery partner's site: delivered verifies the address, returned does not. */
submitOutcome() {
if (this.outcomeForm.invalid || !this.outcomeFor) {
this.outcomeForm.markAllAsTouched();
return;
}
this.isLoading = true;
const formValue = this.outcomeForm.value;
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload = {
status: this.outcomeStatus,
outcomeDate: this.dateUtils.formatDate(formValue.outcomeDate, dateFormat),
remarks: (formValue.remarks || '').trim() || null,
locale,
dateFormat
};
this.clientsService.updateWelcomeKitStatus(this.outcomeFor.id, payload).subscribe({
next: () => {
this.hasChanges = true;
this.outcomeFor = null;
this.snackBar.open(
this.outcomeStatus === 'DELIVERED' ? 'Address marked as verified' : 'Address marked as not verified',
'Close',
{ duration: 3000 }
);
this.fetchData();
},
error: (err: any) => {
this.isLoading = false;
this.snackBar.open(this.errorMessage(err, 'Failed to record the outcome'), 'Close', { duration: 5000 });
}
});
}
close() {
this.dialogRef.close(this.hasChanges);
}
/** Surfaces the backend's own message (duplicate tracking ID, inactive client) instead of a generic failure. */
private errorMessage(err: any, fallback: string): string {
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
}
getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || '';
const name = this.welcomeKitsData?.clientName || '';
const name = this.clientName;
if (name && !address.startsWith(name)) {
return `${name} — ${address}`;
}
@@ -185,6 +376,9 @@ export class WelcomeKitDialogComponent implements OnInit {
const trackingId = dispatch.trackingId || 'N/A';
const status = dispatch.status || 'N/A';
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
const verification = this.verificationLabel(dispatch.status);
const outcomeOn =
this.datePipe.transform(dispatch.deliveredOnDate || dispatch.returnedOnDate, 'mediumDate') || 'Awaited';
const primaryColor: [number, number, number] = [
31,
@@ -307,6 +501,14 @@ export class WelcomeKitDialogComponent implements OnInit {
[
'Dispatched On',
dispatchedOn
],
[
'Address Verification',
verification
],
[
'Outcome Recorded On',
outcomeOn
]
]
});
@@ -344,4 +546,192 @@ export class WelcomeKitDialogComponent implements OnInit {
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
}
/**
* Address label for the envelope. It is printed before the kit is posted — off the live form for a new
* dispatch, or off a saved record — so the operator can fold it into a window envelope and hand it over the
* counter. Layout is a single A4 sheet: the addressee block sits inside the window cut-out zone, and the
* welcome message sits below the fold where it stays hidden until the envelope is opened.
*/
downloadAddressLabel(dispatch?: any) {
const doc = new jsPDF();
const pageWidth = doc.internal.pageSize.getWidth();
const primaryColor: [number, number, number] = [
31,
73,
125
];
const grayColor: [number, number, number] = [
110,
110,
110
];
const lineColor: [number, number, number] = [
205,
212,
222
];
const name = this.clientName || 'Client';
// A saved dispatch keeps the address it actually went out with; a new one uses the address currently
// selected in the form. The saved value carries a "Type: " prefix that belongs on the record, not the envelope.
const addressLines = dispatch
? String(dispatch.deliveryAddress || '')
.replace(/^[^:]+:\s*/, '')
.split(',')
.map((part: string) => part.trim())
.filter((part: string) => part.length > 0)
: this.addressLines;
// --- Sender block, top-left, as the return address ---
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 26, 'F');
doc.setFontSize(17);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('CBSNexora', 18, 14);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('Core Banking Solution', 18, 20);
doc.setFontSize(7.5);
doc.setTextColor(...grayColor);
doc.setFont('helvetica', 'bold');
doc.text('FROM', 22, 37);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8.5);
doc.setTextColor(95, 100, 110);
let fromY = 42.5;
const branch = this.data.officeName ? `CBSNexora — ${this.data.officeName}` : 'CBSNexora';
[
branch,
'Customer Service Desk | support@cbsnexora.com | 1800-000-0000'
].forEach((line) => {
doc.text(line, 22, fromY);
fromY += 4.6;
});
// --- Addressee card: name, address and the client's reference details in one block, sized so the whole
// card sits behind a standard window cut-out ---
const cardX = 22;
const cardY = 60;
const cardW = pageWidth - 44;
const padX = cardX + 9;
const refs: string[] = [];
if (this.data.accountNo) refs.push(`Account No. ${this.data.accountNo}`);
if (this.data.cifNumber) refs.push(`CIF ${this.data.cifNumber}`);
if (this.data.mobileNo) refs.push(`Mobile ${this.data.mobileNo}`);
if (this.data.emailAddress) refs.push(`Email ${this.data.emailAddress}`);
if (dispatch?.trackingId) refs.push(`Tracking ID ${dispatch.trackingId}`);
// Height is measured off the same offsets the text below uses, so the card closes a fixed margin under the
// last line instead of leaving a gap that grows with a short address.
const addrStartY = 27;
const addrEndY = addrStartY + (addressLines.length - 1) * 6.4;
const cardH = refs.length > 0 ? addrEndY + 5 + refs.length * 5 + 4 : addrEndY + 9;
doc.setFillColor(252, 253, 255);
doc.setDrawColor(...lineColor);
doc.setLineWidth(0.3);
doc.roundedRect(cardX, cardY, cardW, cardH, 2, 2, 'FD');
// Accent spine marks the addressee edge without boxing the text in.
doc.setFillColor(...primaryColor);
doc.rect(cardX, cardY + 1, 1.6, cardH - 2, 'F');
doc.setFontSize(7);
doc.setTextColor(...grayColor);
doc.text('ALIGN THIS BLOCK WITH THE ENVELOPE WINDOW', cardX, cardY - 3.5);
doc.setFontSize(7.5);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('TO', padX, cardY + 10);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(20, 20, 20);
doc.text(name, padX, cardY + 19);
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.setTextColor(45, 45, 45);
let addrY = cardY + addrStartY;
addressLines.forEach((line: string) => {
doc.text(line, padX, addrY);
addrY += 6.4;
});
// Reference details close the same card, separated by a hairline rather than a second frame.
if (refs.length > 0) {
const dividerY = cardY + addrEndY + 5;
doc.setDrawColor(...lineColor);
doc.setLineWidth(0.2);
doc.line(padX, dividerY, cardX + cardW - 9, dividerY);
doc.setFontSize(8.5);
doc.setTextColor(...grayColor);
let refY = dividerY + 5;
refs.forEach((line) => {
doc.text(line, padX, refY);
refY += 5;
});
}
// --- Fold line: hairline broken around a centred label, so it guides the fold without competing with
// the address block above it ---
const foldY = cardY + cardH + 22;
const foldGap = 12;
doc.setDrawColor(226, 230, 236);
doc.setLineWidth(0.15);
doc.setLineDashPattern(
[
1,
2.5
],
0
);
doc.line(30, foldY, pageWidth / 2 - foldGap, foldY);
doc.line(pageWidth / 2 + foldGap, foldY, pageWidth - 30, foldY);
doc.setLineDashPattern([], 0);
doc.setFontSize(6.5);
doc.setTextColor(178, 184, 194);
doc.text('FOLD', pageWidth / 2, foldY + 1.6, { align: 'center' });
// --- Welcome message, below the fold ---
let msgY = foldY + 14;
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('Welcome to CBSNexora', 22, msgY);
msgY += 10;
doc.setFontSize(10.5);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
doc.text(`Dear ${name},`, 22, msgY);
msgY += 8;
const body = [
'Thank you for opening your account with us. Enclosed is your Welcome Kit containing your account documentation and the essential materials you need to start banking with us.',
'Please keep your account number and CIF safe, and do not share your banking credentials with anyone. Our staff will never ask you for your PIN, password or OTP.',
'If anything in this kit is missing or incorrect, contact your branch or call our customer service desk and we will put it right.'
];
body.forEach((paragraph) => {
const lines = doc.splitTextToSize(paragraph, pageWidth - 44);
doc.text(lines, 22, msgY);
msgY += lines.length * 5.6 + 5;
});
msgY += 4;
doc.text('Warm regards,', 22, msgY);
msgY += 6;
doc.setFont('helvetica', 'bold');
doc.text('CBSNexora Operations Team', 22, msgY);
const stamp = this.datePipe.transform(new Date(), 'yyyyMMdd-HHmmss');
doc.save(`WelcomeKit_Label_${this.data.accountNo || this.data.clientId}_${stamp}.pdf`);
}
}
@@ -283,26 +283,61 @@
</div>
<div class="pd-section__body">
<div class="pd-grid">
<!-- Primary Mobile DIP -->
<!--
Primary Mobile DIP. Driven by dipLiveStatus — the latest lookup — not by dipVerified,
which is frozen at onboarding and would keep reporting "Verified" long after the number
was reported for fraud or reassigned to someone else.
-->
<div class="pd-field">
<div class="pd-field__label">Mobile DIP Status</div>
<div class="pd-field__value">
@if (
clientViewData.mobileVerified === true ||
clientViewData.mobileVerified === 'true' ||
clientViewData.mobileVerified === 1 ||
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
) {
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<span class="pd-verify-badge pd-verify-badge--flagged">
<i class="fa fa-exclamation-triangle"></i> {{ dipStatusLabel(clientViewData) }}
</span>
} @else if (isDipClear(clientViewData)) {
<span class="pd-verify-badge pd-verify-badge--verified">
<i class="fa fa-check-circle"></i> Verified
</span>
} @else {
<span class="pd-verify-badge pd-verify-badge--pending"> <i class="fa fa-clock-o"></i> Pending </span>
<span class="pd-verify-badge pd-verify-badge--pending">
<i class="fa fa-clock-o"></i> {{ dipStatusLabel(clientViewData) }}
</span>
}
</div>
</div>
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<div class="pd-field pd-field--full">
<div class="pd-dip-alert">
<i class="fa fa-exclamation-triangle"></i>
<div>
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
<div class="pd-dip-alert__detail">
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
obtain a new number. Account operations are not restricted.
</div>
</div>
</div>
</div>
}
<!--
A sanctions alert raised after onboarding is far more serious than a DIP hit and must be
impossible to miss on the customer's own page, not only in the Principal Officer's queue.
-->
@if (clientViewData.sanctionsAlertStatus) {
<div class="pd-field pd-field--full">
<div
class="pd-dip-alert"
[class.pd-dip-alert--review]="clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW'"
>
<i class="fa fa-exclamation-triangle"></i>
<div>
<strong>{{ sanctionsAlertHeadline(clientViewData) }}</strong>
<div class="pd-dip-alert__detail">{{ sanctionsAlertDetail(clientViewData) }}</div>
</div>
</div>
</div>
}
@if (clientViewData.dipTimestamp) {
<div class="pd-field">
<div class="pd-field__label">DIP Verified On</div>
@@ -314,7 +349,12 @@
<div class="pd-field">
<div class="pd-field__label">Alternate Mobile DIP Status</div>
<div class="pd-field__value">
@if (
@if (isDipFlagged(clientViewData.alternateDipLiveStatus)) {
<span class="pd-verify-badge pd-verify-badge--flagged">
<i class="fa fa-exclamation-triangle"></i> {{ alternateDipStatusLabel(clientViewData) }}
</span>
} @else if (
clientViewData.alternateDipLiveStatus === 'CLEAN' ||
clientViewData.alternateDipVerified === true ||
clientViewData.alternateDipVerified === 'true' ||
clientViewData.alternateDipVerified === 1
@@ -248,9 +248,59 @@ $teal: #00897b;
border: 1px solid #fde68a;
}
&--danger {
&--danger,
&--flagged {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
}
// A DIP hit on an existing customer restricts their NUMBER, not their account, so it reads as a
// standing advisory across the full width rather than as a blocked-account state.
.pd-field--full {
grid-column: 1 / -1;
}
.pd-dip-alert {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 6px;
padding: 10px 14px;
font-size: 13px;
color: #7f1d1d;
line-height: 1.5;
i {
color: #dc2626;
font-size: 15px;
margin-top: 2px;
flex-shrink: 0;
}
&__detail {
margin-top: 3px;
font-weight: 400;
color: #991b1b;
}
// An unreviewed hit is not yet a finding, so it reads as amber rather than red.
&--review {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #92400e;
i {
color: #d97706;
}
.pd-dip-alert__detail {
color: #b45309;
}
}
}
@@ -32,6 +32,14 @@ interface ClientViewData {
displayName?: string;
mobileNo?: string;
emailAddress?: string;
/**
* Current DIP/MNRL status resolved from the latest lookup, as opposed to the `dipVerified`
* flag captured when the client was onboarded.
*/
dipLiveStatus?: string;
alternateDipLiveStatus?: string;
/** Disposition of the latest flagged sanctions alert, or absent when the client carries none. */
sanctionsAlertStatus?: string;
dateOfBirth?: number[];
gender?: { id: number; name: string; active?: boolean };
clientType?: { id: number; name: string; active?: boolean };
@@ -67,6 +75,91 @@ export class PersonalDataTabComponent {
/** Client View Data */
clientViewData!: ClientViewData;
/**
* Whether the number's CURRENT DIP/MNRL status is a hit.
*
* The onboarding-time `dipVerified` flag never changes, so a customer cleared when they joined
* kept reading "Verified" even after their number was reported for fraud or reassigned. These
* helpers read `dipLiveStatus`, which the API resolves from the most recent lookup.
*/
isDipFlagged(liveStatus: string | null | undefined): boolean {
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
}
/** True only when the number is affirmatively clean — not merely "not flagged". */
isDipClear(client: any): boolean {
if (client?.dipLiveStatus) {
return client.dipLiveStatus === 'CLEAN';
}
return (
client?.mobileVerified === true ||
client?.mobileVerified === 'true' ||
client?.mobileVerified === 1 ||
client?.dipVerified === true ||
client?.dipVerified === 'true' ||
client?.dipVerified === 1
);
}
dipStatusLabel(client: any): string {
return this.labelFor(client?.dipLiveStatus, this.isDipClear(client));
}
/** Headline for the sanctions banner, driven by how far the alert has progressed. */
sanctionsAlertHeadline(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return 'Confirmed UN Security Council sanctions match.';
case 'FALSE_POSITIVE':
return 'Sanctions hit reviewed as a false positive.';
default:
return 'Sanctions match awaiting Principal Officer review.';
}
}
sanctionsAlertDetail(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return (
'Debits are frozen on this client’s deposit accounts and the case has been escalated. ' +
'Report to FIU-IND and the nodal officer within 24 hours if not already done.'
);
case 'FALSE_POSITIVE':
return 'Recorded as a different person from the listed individual. See the Sanctions Logs for the reasoning.';
default:
return (
'This client matched the UN Security Council Consolidated List on a later screening. ' +
'Review the alert under Reports → Sanctions Screening & Alerts before transacting.'
);
}
}
alternateDipStatusLabel(client: any): string {
const clear =
client?.alternateDipLiveStatus === 'CLEAN' ||
client?.alternateDipVerified === true ||
client?.alternateDipVerified === 'true' ||
client?.alternateDipVerified === 1;
return this.labelFor(client?.alternateDipLiveStatus, clear);
}
private labelFor(liveStatus: string | null | undefined, clear: boolean): string {
switch (liveStatus) {
case 'SUSPECTED':
return 'Flagged — Suspected';
case 'DISCONNECTED':
return 'Flagged — Disconnected';
case 'SUSPECTED_AND_DISCONNECTED':
return 'Flagged — Suspected & Disconnected';
case 'SERVICE_UNAVAILABLE':
return 'Not Verified';
case 'CLEAN':
return 'Verified';
default:
return clear ? 'Verified' : 'Pending';
}
}
constructor() {
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
this.clientViewData = data.clientViewData;
+2 -2
View File
@@ -1015,8 +1015,8 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
updateWelcomeKitStatus(welcomeKitId: number, data: any): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, data);
}
getKycExceptions(resolved = false): Observable<any[]> {