Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
This commit is contained in:
@@ -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 → Sanctions Screening & Alerts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!--
|
||||
Driven by sanctionsAlertStatus (the live alert) rather than nameVerified, which records
|
||||
the outcome at onboarding and never changes. A client confirmed against the UNSC list and
|
||||
frozen afterwards was still reading "Clear" here — the single worst place to be wrong,
|
||||
since this header is what anyone opening the customer sees first.
|
||||
-->
|
||||
@if (clientViewData.nameVerified !== null || clientViewData.sanctionsAlertStatus) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__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) {
|
||||
|
||||
+240
-53
@@ -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>
|
||||
|
||||
+353
-33
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+422
-32
@@ -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;
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
Reference in New Issue
Block a user