+ This mobile number is flagged on the DoT Digital Intelligence Platform.
+
+ OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
+ obtain a new number. Account operations are not restricted.
+
+ Confirmed UN Security Council sanctions match — accounts frozen.
+
+ Debits are blocked on this customer's deposit accounts and lending is barred. Do not discuss the
+ reason with the customer. See Reports → Sanctions Screening & Alerts.
+
+ {{ activeDispatches.length > 0 ? 'Dispatch a Replacement Kit' : 'Create New Dispatch' }}
+
+
+
+ 0">
+
+ Deliver To
+
+
+ {{ address.type }} — {{ address.oneLine }}
+
+
+ Addresses recorded for this client. Update them on the Address tab if one is wrong.
+
+ Select the address to deliver to
-
+
+ error_outline
+
+ No address is recorded for this client. Add one on the Address tab before dispatching a welcome kit.
+
+
+
+
+ Delivery Mode
-
- {{ mode }}
-
+ {{ mode }}
Delivery Mode is required
-
- Specify Other Mode
-
-
- Please specify the delivery mode
+
+ Dispatched On
+
+
+
+
+ Dispatch date is required
-
+
+
+ Specify Other Mode
+
+
+ Please specify the delivery mode
+
+
+
+
+
+
+ Envelope address label
+
+ Print this first — name, address, contact details and the welcome note, ready to fold into a window
+ envelope.
+
+
+
+
+
+
+ Tracking ID
+
+ confirmation_number
+ Enter the consignment number printed on the delivery partner's receipt.
+ Tracking ID is required
+
+ Tracking ID cannot exceed 100 characters
+
+
+
+
+
+
0">
+ A welcome kit is already recorded for this client. Record its outcome above before dispatching another.
+
-
+
diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.scss b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.scss
index 4eea9cdf5..22c3d61b0 100644
--- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.scss
+++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.scss
@@ -5,55 +5,375 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-.dispatch-tracking-panel {
- background-color: #f5f5f5;
- border-radius: 8px;
- padding: 16px;
+
+$wk-primary: #1f497d;
+$wk-border: #e3e7ee;
+$wk-muted: #6b7280;
+$wk-surface: #f7f9fc;
+
+/* ---------- header ---------- */
+.wk-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 20px 24px 16px;
+ margin: 0;
+ border-bottom: 1px solid $wk-border;
+
+ &__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ flex: 0 0 40px;
+ border-radius: 10px;
+ background: rgba($wk-primary, 0.1);
+ color: $wk-primary;
+ }
+
+ &__title {
+ margin: 0;
+ font-size: 1.15rem;
+ font-weight: 600;
+ line-height: 1.3;
+ }
+
+ &__subtitle {
+ margin: 2px 0 0;
+ font-size: 0.8rem;
+ color: $wk-muted;
+ }
+}
+
+.wk-content {
+ padding-top: 16px;
+}
+
+.wk-loading {
+ display: flex;
+ justify-content: center;
+ padding: 2.5rem 0;
+}
+
+/* ---------- client identity ---------- */
+.wk-client {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
margin-bottom: 16px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, rgba($wk-primary, 0.08), rgba($wk-primary, 0.02));
+ border: 1px solid rgba($wk-primary, 0.15);
- h3 {
- margin-top: 0;
- margin-bottom: 12px;
- font-size: 1.1rem;
+ &__avatar {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ flex: 0 0 38px;
+ border-radius: 50%;
+ background: $wk-primary;
+ color: #fff;
+ font-weight: 600;
+ text-transform: uppercase;
}
- p {
- margin: 6px 0;
+ &__name {
+ font-weight: 600;
+ font-size: 1rem;
}
- .status-badge {
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 0.85rem;
- font-weight: 500;
- color: white;
+ &__meta {
+ font-size: 0.8rem;
+ color: $wk-muted;
+ font-variant-numeric: tabular-nums;
+ }
+}
- &.status-dispatched {
- background-color: #1976d2;
- }
+/* ---------- dispatch panel ---------- */
+.wk-dispatch {
+ border: 1px solid $wk-border;
+ border-radius: 10px;
+ padding: 14px 16px;
+ margin-bottom: 16px;
+ background: $wk-surface;
- &.status-in_transit {
- background-color: #f57c00;
- }
+ &--past {
+ opacity: 0.75;
+ }
- &.status-delivered {
- background-color: #388e3c;
- }
+ &__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 6px;
+ }
- &.status-returned {
- background-color: #d32f2f;
+ &__label {
+ font-weight: 600;
+ font-size: 0.95rem;
+ }
+
+ &__hint {
+ margin: 0 0 12px;
+ font-size: 0.8rem;
+ color: $wk-muted;
+ line-height: 1.45;
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 14px;
+
+ button mat-icon {
+ margin-right: 4px;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
}
}
}
-.dispatch-create-form {
- h3 {
- margin-top: 0;
- margin-bottom: 12px;
- font-size: 1.1rem;
+/* ---------- status chip ---------- */
+.wk-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ white-space: nowrap;
+
+ &__icon {
+ font-size: 15px;
+ width: 15px;
+ height: 15px;
+ }
+
+ &--dispatched {
+ background: rgb(25 118 210 / 12%);
+ color: #1565c0;
+ }
+
+ &--delivered {
+ background: rgb(56 142 60 / 14%);
+ color: #2e7d32;
+ }
+
+ &--returned {
+ background: rgb(211 47 47 / 12%);
+ color: #c62828;
}
}
-.full-width {
+/* ---------- fact grid ---------- */
+.wk-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px 18px;
+ margin: 0;
+
+ &__row {
+ min-width: 0;
+
+ &--wide {
+ grid-column: 1 / -1;
+ }
+
+ dt {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: $wk-muted;
+ margin-bottom: 2px;
+ }
+
+ dd {
+ margin: 0;
+ font-size: 0.875rem;
+ word-break: break-word;
+ }
+ }
+
+ &__mono {
+ font-family: 'Roboto Mono', 'Courier New', monospace;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ }
+}
+
+/* ---------- outcome form ---------- */
+.wk-outcome {
+ margin-top: 14px;
+ padding: 14px;
+ border-radius: 8px;
+ background: #fff;
+ border: 1px dashed rgba($wk-primary, 0.4);
+
+ &__title {
+ margin: 0 0 10px;
+ font-size: 0.875rem;
+ }
+
+ &__fields {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 0 14px;
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ }
+}
+
+/* ---------- create form ---------- */
+.wk-form {
+ &__title {
+ margin: 0 0 12px;
+ font-size: 1rem;
+ font-weight: 600;
+ }
+
+ &__row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 0 14px;
+ }
+}
+
+.wk-full {
width: 100%;
}
+
+.wk-address {
+ display: flex;
+ gap: 10px;
+ padding: 12px 14px;
+ margin-bottom: 16px;
+ border-radius: 8px;
+ background: $wk-surface;
+ border: 1px solid $wk-border;
+
+ &__icon {
+ color: $wk-primary;
+ font-size: 20px;
+ width: 20px;
+ height: 20px;
+ flex: 0 0 20px;
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ }
+
+ &__type {
+ font-size: 0.68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: $wk-primary;
+ margin-bottom: 2px;
+ }
+
+ &__name {
+ font-weight: 600;
+ }
+
+ &__line {
+ color: $wk-muted;
+ }
+}
+
+.wk-noaddress {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px 14px;
+ margin-bottom: 16px;
+ border-radius: 8px;
+ background: rgb(211 47 47 / 6%);
+ border: 1px solid rgb(211 47 47 / 25%);
+ font-size: 0.83rem;
+ line-height: 1.5;
+ color: #b3261e;
+
+ mat-icon {
+ font-size: 19px;
+ width: 19px;
+ height: 19px;
+ flex: 0 0 19px;
+ }
+}
+
+/* ---------- envelope label call-to-action ---------- */
+.wk-label-cta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ flex-wrap: wrap;
+ padding: 12px 14px;
+ margin-bottom: 18px;
+ border-radius: 8px;
+ background: rgba($wk-primary, 0.04);
+ border: 1px solid rgba($wk-primary, 0.18);
+
+ &__text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1 1 240px;
+ }
+
+ &__title {
+ font-weight: 600;
+ font-size: 0.875rem;
+ }
+
+ &__hint {
+ font-size: 0.78rem;
+ color: $wk-muted;
+ line-height: 1.45;
+ }
+
+ button mat-icon {
+ margin-right: 4px;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+}
+
+.wk-empty {
+ margin: 4px 0 0;
+ padding: 12px 14px;
+ border-radius: 8px;
+ background: $wk-surface;
+ border: 1px solid $wk-border;
+ font-size: 0.85rem;
+ color: $wk-muted;
+}
+
+mat-dialog-actions {
+ padding: 12px 24px 20px;
+
+ button mat-icon {
+ margin-right: 4px;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+}
diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts
index b3d72602b..03dadc5c0 100644
--- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts
+++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts
@@ -12,12 +12,14 @@ import {
MatDialogRef,
MatDialogTitle,
MatDialogContent,
- MatDialogActions,
- MatDialogClose
+ MatDialogActions
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatIcon } from '@angular/material/icon';
import { ClientsService } from '../../../clients.service';
+import { SettingsService } from 'app/settings/settings.service';
+import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DatePipe } from '@angular/common';
import { jsPDF } from 'jspdf';
@@ -32,32 +34,79 @@ import autoTable from 'jspdf-autotable';
MatDialogTitle,
MatDialogContent,
MatDialogActions,
- MatDialogClose,
- MatProgressSpinnerModule
+ MatProgressSpinnerModule,
+ MatIcon
],
providers: [DatePipe]
})
export class WelcomeKitDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef);
- data: { clientId: string } = inject(MAT_DIALOG_DATA);
+ data: {
+ clientId: string;
+ clientName?: string;
+ accountNo?: string;
+ cifNumber?: string;
+ mobileNo?: string;
+ emailAddress?: string;
+ officeName?: string;
+ } = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private datePipe = inject(DatePipe);
+ private settingsService = inject(SettingsService);
+ private dateUtils = inject(Dates);
isLoading = true;
+ /** Set once a dispatch or an outcome is saved, so the client view knows to reload on close. */
+ private hasChanges = false;
welcomeKitsData: any = null;
+ /** Addresses captured for the client, each formatted for display and selection. */
+ addressChoices: { id: any; type: string; isActive: boolean; lines: string[]; oneLine: string }[] = [];
+ /** True once the address lookup has run, so the empty state only shows when there genuinely are none. */
+ addressesLoaded = false;
+ /** Dispatch whose outcome is being recorded, or null while the tracking list is shown. */
+ outcomeFor: any = null;
+ outcomeStatus: 'DELIVERED' | 'RETURNED' = 'DELIVERED';
+
+ maxDate = new Date();
+
dispatchForm: FormGroup = this.formBuilder.group({
- deliveryAddress: [
- '',
+ // The kit goes to one of the addresses captured at client creation, never to a hand-typed one, so the
+ // dispatch record always traces back to an address the client actually gave.
+ addressId: [
+ null,
Validators.required
],
deliveryMode: [
'POSTMAN',
Validators.required
],
- deliveryModeOtherText: ['']
+ deliveryModeOtherText: [''],
+ trackingId: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(100)
+ ]
+ ],
+ dispatchedOnDate: [
+ new Date(),
+ Validators.required
+ ]
});
+
+ outcomeForm: FormGroup = this.formBuilder.group({
+ outcomeDate: [
+ new Date(),
+ Validators.required
+ ],
+ remarks: [
+ '',
+ Validators.maxLength(500)
+ ]
+ });
+
deliveryModes = [
'POSTMAN',
'COURIER',
@@ -76,7 +125,37 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
+ get clientName(): string {
+ return this.welcomeKitsData?.clientName || this.data.clientName || '';
+ }
+
+ /** The kit is a physical address check, so the outcome is phrased as verification rather than delivery. */
+ verificationLabel(status: string): string {
+ switch (status) {
+ case 'DELIVERED':
+ return 'Verified';
+ case 'RETURNED':
+ return 'Not Verified';
+ default:
+ return 'Awaiting Verification';
+ }
+ }
+
+ verificationHint(dispatch: any): string {
+ switch (dispatch?.status) {
+ case 'DELIVERED':
+ return 'Kit delivered, so the address stands verified.';
+ case 'RETURNED':
+ return 'Kit came back undelivered, so the address could not be verified.';
+ default:
+ return 'Check this tracking ID on the delivery partner’s website or app, then record the outcome below.';
+ }
+ }
+
ngOnInit(): void {
+ this.maxDate = this.settingsService.businessDate;
+ this.dispatchForm.patchValue({ dispatchedOnDate: this.maxDate });
+ this.outcomeForm.patchValue({ outcomeDate: this.maxDate });
this.fetchData();
}
@@ -98,33 +177,77 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
+ /** Order the address types are offered in: a kit is posted to the correspondence address when one exists. */
+ private static readonly ADDRESS_TYPE_ORDER = [
+ 'Correspondence',
+ 'Current / Residential',
+ 'Permanent Address',
+ 'Office / Work'
+ ];
+
fetchPrimaryAddress() {
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
next: (addresses: any) => {
this.isLoading = false;
- if (addresses && addresses.length > 0) {
- // Attempt to build a readable address from the first address returned
- const primary = addresses[0];
- const parts = [];
- if (primary.addressLine1) parts.push(primary.addressLine1);
- if (primary.addressLine2) parts.push(primary.addressLine2);
- if (primary.city) parts.push(primary.city);
- if (primary.stateName) parts.push(primary.stateName);
- if (primary.postalCode) parts.push(primary.postalCode);
- if (primary.countryName) parts.push(primary.countryName);
+ this.addressesLoaded = true;
- const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
- this.dispatchForm.patchValue({
- deliveryAddress: clientNameStr + parts.join(', ')
+ const usable = (addresses || []).filter((a: any) => a && (a.addressLine1 || a.postalCode || a.city));
+ this.addressChoices = usable
+ .map((a: any) => {
+ const lines = [
+ a.addressLine1,
+ a.addressLine2,
+ a.addressLine3,
+ a.street,
+ a.townVillage,
+ a.city,
+ a.countyDistrict,
+ a.stateName || a.stateProvinceName,
+ a.postalCode,
+ a.countryName
+ ]
+ .map((part: any) => (part == null ? '' : String(part).trim()))
+ .filter((part: string) => part.length > 0);
+
+ return {
+ id: a.addressId ?? a.id,
+ type: a.addressType || 'Address',
+ isActive: a.isActive !== false,
+ lines,
+ oneLine: lines.join(', ')
+ };
+ })
+ .sort((x: any, y: any) => {
+ // Active addresses first, then by how suitable the type is for posting.
+ if (x.isActive !== y.isActive) return x.isActive ? -1 : 1;
+ const order = WelcomeKitDialogComponent.ADDRESS_TYPE_ORDER;
+ const xi = order.indexOf(x.type);
+ const yi = order.indexOf(y.type);
+ return (xi === -1 ? order.length : xi) - (yi === -1 ? order.length : yi);
});
+
+ if (this.addressChoices.length > 0) {
+ this.dispatchForm.patchValue({ addressId: this.addressChoices[0].id });
}
},
error: () => {
this.isLoading = false;
+ this.addressesLoaded = true;
}
});
}
+ /** The address the operator picked, or null before one is chosen. */
+ get selectedAddress() {
+ const id = this.dispatchForm.get('addressId')?.value;
+ return this.addressChoices.find((a) => a.id === id) || null;
+ }
+
+ /** Lines of the selected address, for the on-screen preview and the printed label. */
+ get addressLines(): string[] {
+ return this.selectedAddress?.lines || [];
+ }
+
get activeDispatches() {
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
return this.welcomeKitsData.dispatches;
@@ -138,36 +261,104 @@ export class WelcomeKitDialogComponent implements OnInit {
return;
}
+ const chosen = this.selectedAddress;
+ if (!chosen) {
+ this.snackBar.open('Select the address the kit was posted to', 'Close', { duration: 3000 });
+ return;
+ }
+
this.isLoading = true;
const formValue = this.dispatchForm.value;
-
- const today = new Date();
- const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
+ const dateFormat = this.settingsService.dateFormat;
+ const locale = this.settingsService.language.code;
const payload = {
- deliveryAddress: formValue.deliveryAddress,
+ // Stored as text so the dispatch keeps the address exactly as it read on the day it went out, even if
+ // the client's address record is edited later.
+ deliveryAddress: `${chosen.type}: ${chosen.oneLine}`,
+ addressId: chosen.id,
deliveryMode: formValue.deliveryMode,
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
- dispatchedOnDate: formattedDate,
- locale: 'en',
- dateFormat: 'dd MMMM yyyy'
+ trackingId: (formValue.trackingId || '').trim(),
+ dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat),
+ locale,
+ dateFormat
};
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
next: () => {
- this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
+ this.hasChanges = true;
+ this.snackBar.open('Welcome Kit dispatched successfully', 'Close', { duration: 3000 });
this.fetchData();
},
- error: () => {
+ error: (err: any) => {
this.isLoading = false;
- this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
+ this.snackBar.open(this.errorMessage(err, 'Failed to dispatch welcome kit'), 'Close', { duration: 5000 });
}
});
}
+ /** Opens the outcome form for a dispatch, pre-set to the outcome the operator clicked. */
+ startOutcome(dispatch: any, status: 'DELIVERED' | 'RETURNED') {
+ this.outcomeFor = dispatch;
+ this.outcomeStatus = status;
+ this.outcomeForm.reset({ outcomeDate: this.maxDate, remarks: '' });
+ }
+
+ cancelOutcome() {
+ this.outcomeFor = null;
+ }
+
+ /** Records what the operator found on the delivery partner's site: delivered verifies the address, returned does not. */
+ submitOutcome() {
+ if (this.outcomeForm.invalid || !this.outcomeFor) {
+ this.outcomeForm.markAllAsTouched();
+ return;
+ }
+
+ this.isLoading = true;
+ const formValue = this.outcomeForm.value;
+ const dateFormat = this.settingsService.dateFormat;
+ const locale = this.settingsService.language.code;
+
+ const payload = {
+ status: this.outcomeStatus,
+ outcomeDate: this.dateUtils.formatDate(formValue.outcomeDate, dateFormat),
+ remarks: (formValue.remarks || '').trim() || null,
+ locale,
+ dateFormat
+ };
+
+ this.clientsService.updateWelcomeKitStatus(this.outcomeFor.id, payload).subscribe({
+ next: () => {
+ this.hasChanges = true;
+ this.outcomeFor = null;
+ this.snackBar.open(
+ this.outcomeStatus === 'DELIVERED' ? 'Address marked as verified' : 'Address marked as not verified',
+ 'Close',
+ { duration: 3000 }
+ );
+ this.fetchData();
+ },
+ error: (err: any) => {
+ this.isLoading = false;
+ this.snackBar.open(this.errorMessage(err, 'Failed to record the outcome'), 'Close', { duration: 5000 });
+ }
+ });
+ }
+
+ close() {
+ this.dialogRef.close(this.hasChanges);
+ }
+
+ /** Surfaces the backend's own message (duplicate tracking ID, inactive client) instead of a generic failure. */
+ private errorMessage(err: any, fallback: string): string {
+ return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
+ }
+
getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || '';
- const name = this.welcomeKitsData?.clientName || '';
+ const name = this.clientName;
if (name && !address.startsWith(name)) {
return `${name} — ${address}`;
}
@@ -185,6 +376,9 @@ export class WelcomeKitDialogComponent implements OnInit {
const trackingId = dispatch.trackingId || 'N/A';
const status = dispatch.status || 'N/A';
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
+ const verification = this.verificationLabel(dispatch.status);
+ const outcomeOn =
+ this.datePipe.transform(dispatch.deliveredOnDate || dispatch.returnedOnDate, 'mediumDate') || 'Awaited';
const primaryColor: [number, number, number] = [
31,
@@ -307,6 +501,14 @@ export class WelcomeKitDialogComponent implements OnInit {
[
'Dispatched On',
dispatchedOn
+ ],
+ [
+ 'Address Verification',
+ verification
+ ],
+ [
+ 'Outcome Recorded On',
+ outcomeOn
]
]
});
@@ -344,4 +546,192 @@ export class WelcomeKitDialogComponent implements OnInit {
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
}
+
+ /**
+ * Address label for the envelope. It is printed before the kit is posted — off the live form for a new
+ * dispatch, or off a saved record — so the operator can fold it into a window envelope and hand it over the
+ * counter. Layout is a single A4 sheet: the addressee block sits inside the window cut-out zone, and the
+ * welcome message sits below the fold where it stays hidden until the envelope is opened.
+ */
+ downloadAddressLabel(dispatch?: any) {
+ const doc = new jsPDF();
+ const pageWidth = doc.internal.pageSize.getWidth();
+
+ const primaryColor: [number, number, number] = [
+ 31,
+ 73,
+ 125
+ ];
+ const grayColor: [number, number, number] = [
+ 110,
+ 110,
+ 110
+ ];
+ const lineColor: [number, number, number] = [
+ 205,
+ 212,
+ 222
+ ];
+
+ const name = this.clientName || 'Client';
+ // A saved dispatch keeps the address it actually went out with; a new one uses the address currently
+ // selected in the form. The saved value carries a "Type: " prefix that belongs on the record, not the envelope.
+ const addressLines = dispatch
+ ? String(dispatch.deliveryAddress || '')
+ .replace(/^[^:]+:\s*/, '')
+ .split(',')
+ .map((part: string) => part.trim())
+ .filter((part: string) => part.length > 0)
+ : this.addressLines;
+
+ // --- Sender block, top-left, as the return address ---
+ doc.setFillColor(...primaryColor);
+ doc.rect(0, 0, pageWidth, 26, 'F');
+ doc.setFontSize(17);
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor(255, 255, 255);
+ doc.text('CBSNexora', 18, 14);
+ doc.setFontSize(9);
+ doc.setFont('helvetica', 'normal');
+ doc.text('Core Banking Solution', 18, 20);
+
+ doc.setFontSize(7.5);
+ doc.setTextColor(...grayColor);
+ doc.setFont('helvetica', 'bold');
+ doc.text('FROM', 22, 37);
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(8.5);
+ doc.setTextColor(95, 100, 110);
+ let fromY = 42.5;
+ const branch = this.data.officeName ? `CBSNexora — ${this.data.officeName}` : 'CBSNexora';
+ [
+ branch,
+ 'Customer Service Desk | support@cbsnexora.com | 1800-000-0000'
+ ].forEach((line) => {
+ doc.text(line, 22, fromY);
+ fromY += 4.6;
+ });
+
+ // --- Addressee card: name, address and the client's reference details in one block, sized so the whole
+ // card sits behind a standard window cut-out ---
+ const cardX = 22;
+ const cardY = 60;
+ const cardW = pageWidth - 44;
+ const padX = cardX + 9;
+
+ const refs: string[] = [];
+ if (this.data.accountNo) refs.push(`Account No. ${this.data.accountNo}`);
+ if (this.data.cifNumber) refs.push(`CIF ${this.data.cifNumber}`);
+ if (this.data.mobileNo) refs.push(`Mobile ${this.data.mobileNo}`);
+ if (this.data.emailAddress) refs.push(`Email ${this.data.emailAddress}`);
+ if (dispatch?.trackingId) refs.push(`Tracking ID ${dispatch.trackingId}`);
+
+ // Height is measured off the same offsets the text below uses, so the card closes a fixed margin under the
+ // last line instead of leaving a gap that grows with a short address.
+ const addrStartY = 27;
+ const addrEndY = addrStartY + (addressLines.length - 1) * 6.4;
+ const cardH = refs.length > 0 ? addrEndY + 5 + refs.length * 5 + 4 : addrEndY + 9;
+
+ doc.setFillColor(252, 253, 255);
+ doc.setDrawColor(...lineColor);
+ doc.setLineWidth(0.3);
+ doc.roundedRect(cardX, cardY, cardW, cardH, 2, 2, 'FD');
+
+ // Accent spine marks the addressee edge without boxing the text in.
+ doc.setFillColor(...primaryColor);
+ doc.rect(cardX, cardY + 1, 1.6, cardH - 2, 'F');
+
+ doc.setFontSize(7);
+ doc.setTextColor(...grayColor);
+ doc.text('ALIGN THIS BLOCK WITH THE ENVELOPE WINDOW', cardX, cardY - 3.5);
+
+ doc.setFontSize(7.5);
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor(...primaryColor);
+ doc.text('TO', padX, cardY + 10);
+
+ doc.setFontSize(14);
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor(20, 20, 20);
+ doc.text(name, padX, cardY + 19);
+
+ doc.setFontSize(11);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(45, 45, 45);
+ let addrY = cardY + addrStartY;
+ addressLines.forEach((line: string) => {
+ doc.text(line, padX, addrY);
+ addrY += 6.4;
+ });
+
+ // Reference details close the same card, separated by a hairline rather than a second frame.
+ if (refs.length > 0) {
+ const dividerY = cardY + addrEndY + 5;
+ doc.setDrawColor(...lineColor);
+ doc.setLineWidth(0.2);
+ doc.line(padX, dividerY, cardX + cardW - 9, dividerY);
+
+ doc.setFontSize(8.5);
+ doc.setTextColor(...grayColor);
+ let refY = dividerY + 5;
+ refs.forEach((line) => {
+ doc.text(line, padX, refY);
+ refY += 5;
+ });
+ }
+
+ // --- Fold line: hairline broken around a centred label, so it guides the fold without competing with
+ // the address block above it ---
+ const foldY = cardY + cardH + 22;
+ const foldGap = 12;
+ doc.setDrawColor(226, 230, 236);
+ doc.setLineWidth(0.15);
+ doc.setLineDashPattern(
+ [
+ 1,
+ 2.5
+ ],
+ 0
+ );
+ doc.line(30, foldY, pageWidth / 2 - foldGap, foldY);
+ doc.line(pageWidth / 2 + foldGap, foldY, pageWidth - 30, foldY);
+ doc.setLineDashPattern([], 0);
+ doc.setFontSize(6.5);
+ doc.setTextColor(178, 184, 194);
+ doc.text('FOLD', pageWidth / 2, foldY + 1.6, { align: 'center' });
+
+ // --- Welcome message, below the fold ---
+ let msgY = foldY + 14;
+ doc.setFontSize(14);
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor(...primaryColor);
+ doc.text('Welcome to CBSNexora', 22, msgY);
+ msgY += 10;
+
+ doc.setFontSize(10.5);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(40, 40, 40);
+ doc.text(`Dear ${name},`, 22, msgY);
+ msgY += 8;
+
+ const body = [
+ 'Thank you for opening your account with us. Enclosed is your Welcome Kit containing your account documentation and the essential materials you need to start banking with us.',
+ 'Please keep your account number and CIF safe, and do not share your banking credentials with anyone. Our staff will never ask you for your PIN, password or OTP.',
+ 'If anything in this kit is missing or incorrect, contact your branch or call our customer service desk and we will put it right.'
+ ];
+ body.forEach((paragraph) => {
+ const lines = doc.splitTextToSize(paragraph, pageWidth - 44);
+ doc.text(lines, 22, msgY);
+ msgY += lines.length * 5.6 + 5;
+ });
+
+ msgY += 4;
+ doc.text('Warm regards,', 22, msgY);
+ msgY += 6;
+ doc.setFont('helvetica', 'bold');
+ doc.text('CBSNexora Operations Team', 22, msgY);
+
+ const stamp = this.datePipe.transform(new Date(), 'yyyyMMdd-HHmmss');
+ doc.save(`WelcomeKit_Label_${this.data.accountNo || this.data.clientId}_${stamp}.pdf`);
+ }
}
diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html
index a5643242e..287371407 100644
--- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html
+++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html
@@ -283,26 +283,61 @@
+ This mobile number is flagged on the DoT Digital Intelligence Platform.
+
+ OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
+ obtain a new number. Account operations are not restricted.
+
+ Confirmed matches against the UN Security Council Consolidated List. Accounts are frozen; the STR must
+ reach FIU-IND within 7 days of confirmation.
+
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts
index 4bb75d87d..c2dc8081b 100644
--- a/src/app/fiu/fiu.component.ts
+++ b/src/app/fiu/fiu.component.ts
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service';
+import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import {
BRANCHES,
@@ -130,6 +131,7 @@ export class FiuComponent implements OnInit {
private static readonly SEED_VERSION = '2026-01-cash-threshold';
private amlService = inject(AmlService);
+ private sanctionsLogsService = inject(SanctionsLogsService);
private authenticationService = inject(AuthenticationService);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -176,6 +178,13 @@ export class FiuComponent implements OnInit {
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
amlAlerts: any[] = [];
+
+ /**
+ * Confirmed UNSC sanctions matches carrying an STR obligation. Held separately from amlAlerts
+ * because they are identity-driven rather than transaction-driven — see the template comment.
+ */
+ sanctionsStrCases: any[] = [];
+ sanctionsStrFilter = '';
amlAlertStatusFilter = '';
amlReviewNotes: Record = {};
@@ -320,6 +329,37 @@ export class FiuComponent implements OnInit {
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
}
+ /** Loads confirmed sanctions matches that owe an STR. */
+ loadSanctionsStrCases() {
+ this.sanctionsLogsService.getStrCases(this.sanctionsStrFilter || undefined).subscribe({
+ next: (rows: any) => (this.sanctionsStrCases = rows || []),
+ // A failure here must not blank the AML side of the tab, which is unrelated.
+ error: () => (this.sanctionsStrCases = [])
+ });
+ }
+
+ strStatusLabel(str: any): string {
+ if (str?.strStatus === 'FILED') {
+ return 'FILED';
+ }
+ return this.isStrOverdue(str) ? 'OVERDUE' : 'PENDING FILING';
+ }
+
+ strStatusClass(str: any): string {
+ if (str?.strStatus === 'FILED') {
+ return 'fiu-pill fiu-pill--ok';
+ }
+ return this.isStrOverdue(str) ? 'fiu-pill fiu-pill--danger' : 'fiu-pill fiu-pill--warn';
+ }
+
+ /** Past its 7-day PMLA deadline and still unfiled — the state that actually needs chasing. */
+ isStrOverdue(str: any): boolean {
+ if (!str?.strDueDate || str.strStatus === 'FILED') {
+ return false;
+ }
+ return new Date(str.strDueDate) < new Date(new Date().toDateString());
+ }
+
get isPrincipalOfficer(): boolean {
return this.authenticationService.isPrincipalOfficerUser();
}
@@ -547,6 +587,7 @@ export class FiuComponent implements OnInit {
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
+ this.loadSanctionsStrCases();
}
loadAmlCtrReports(): void {
diff --git a/src/app/letter-mgmt/database-export/database-export.component.html b/src/app/letter-mgmt/database-export/database-export.component.html
new file mode 100644
index 000000000..ae897cf7a
--- /dev/null
+++ b/src/app/letter-mgmt/database-export/database-export.component.html
@@ -0,0 +1,219 @@
+
+
+
+
+
+
Database Export
+
+
+
+
+
+
+ This export contains the complete database
+ — every client's personal details, KYC documents, accounts and transactions. A request must be approved by an
+ administrator before the archive can be downloaded, and the approver cannot be the person who raised it. The
+ archive can only be downloaded by the user who raised the request, not by the approver, and an approval is valid
+ for a single download — taking another copy means raising a new request. Every request, approval and download is
+ recorded.
+
+
+
+
+
+
Request a Full Export
+
+ Reason for this export
+
+ Recorded against the request and written into the archive. Minimum 10 characters.
+ A reason is required
+
+ Please give at least 10 characters of context
+
+
+
+
Screened On
@@ -59,10 +66,42 @@ import { SanctionsLogsService } from './sanctions-logs.service';
Reviewing Officer's Assessment
@if (alert.disposition === 'PENDING_REVIEW') {
+
- Not yet reviewed. The branch officer must record an outcome before this can be approved.
+ Not yet reviewed. Compare the customer's identifiers against the listed particulars, then record the outcome
+ below.
+ @if (alert.matchedNames) {
+
+ Listed as
+ {{ alert.matchedNames }}
+
+ }
+ @if (showAssessForm) {
+
+
+
+
+ Minimum 10 characters — this text is the compliance record.
+
+
+ }
} @else {
{{ dispositionLabel }}
@@ -81,6 +120,18 @@ import { SanctionsLogsService } from './sanctions-logs.service';
· Debits frozen on account(s) {{ alert.frozenAccountIds }}
}