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

This commit is contained in:
2026-08-06 11:45:46 +05:30
21 changed files with 1187 additions and 105 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "13mb",
"maximumError": "14mb"
"maximumWarning": "15mb",
"maximumError": "16mb"
},
{
"type": "anyComponentStyle",
@@ -0,0 +1,90 @@
<div class="fm-dialog">
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px;">
<mat-icon class="fm-dialog__icon">people</mat-icon>
<span style="font-weight: 500; font-size: 18px;">CHILDREN</span>
</div>
<mat-dialog-content>
<form [formGroup]="childrenForm">
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px;">
@for (child of children.controls; track $index) {
<div [formGroupName]="$index"
style="position: relative; padding: 24px 16px 16px; margin-top: 16px; margin-bottom: 24px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff;">
<div class="fm-grid">
<mat-form-field appearance="outline">
<mat-label>Child Full Name*</mat-label>
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Gender*</mat-label>
<mat-select formControlName="genderId" required>
@for (g of genderIdOptions; track g.id) {
<mat-option [value]="g.id">{{ g.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
<mat-label>Date of Birth</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
<mat-datepicker #childDobPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Nationality</mat-label>
<mat-select formControlName="nationality">
<mat-option value="INDIAN">Indian</mat-option>
<mat-option value="NRI">NRI</mat-option>
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
<mat-option value="OTHER">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Aadhaar Number (Optional)</mat-label>
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
placeholder="12-digit Aadhaar"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>PAN Number (Optional)</mat-label>
<input
matInput
formControlName="panNumber"
maxlength="10"
placeholder="ABCDE1234F"
style="text-transform: uppercase"
(input)="
$any(children.at($index)).get('panNumber')
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (
$any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched
) {
<mat-error>Invalid PAN format</mat-error>
}
</mat-form-field>
</div>
</div>
@if ($index < children.controls.length - 1) { <div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;">
</div>
}
}
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;"></div>
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
<mat-icon>add</mat-icon> Add Another Child
</button>
</div>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
Add Member
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,98 @@
import { Component, Inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'cbs-client-family-member-child-dialog',
templateUrl: './client-family-member-child-dialog.component.html',
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatIcon,
MatButtonModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatTooltipModule
]
})
export class ClientFamilyMemberChildDialogComponent implements OnInit {
childrenForm: UntypedFormGroup;
genderIdOptions: any[];
maxDate: Date = new Date();
constructor(
public dialogRef: MatDialogRef<ClientFamilyMemberChildDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private formBuilder: UntypedFormBuilder
) {
this.genderIdOptions = data.genderIdOptions;
this.childrenForm = this.formBuilder.group({
children: this.formBuilder.array([])
});
}
ngOnInit() {
if (this.data.children && this.data.children.length > 0) {
this.data.children.forEach((child: any) => {
this.children.push(this.formBuilder.group({
id: [child.id],
fullName: [child.fullName, Validators.required],
genderId: [child.genderId, Validators.required],
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
}));
});
} else {
this.addChild();
}
}
get children(): UntypedFormArray {
return this.childrenForm.get('children') as UntypedFormArray;
}
createChildFormGroup(): UntypedFormGroup {
return this.formBuilder.group({
id: [''],
fullName: ['', Validators.required],
genderId: ['', Validators.required],
dateOfBirth: [''],
nationality: ['INDIAN'],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
});
}
addChild(): void {
this.children.push(this.createChildFormGroup());
}
removeChild(index: number): void {
this.children.removeAt(index);
}
submit(): void {
if (this.childrenForm.valid) {
this.dialogRef.close(this.children.value);
}
}
}
@@ -139,6 +139,21 @@
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
</div>
}
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
<div class="fm-readonly-remarks">
<span class="fm-readonly-label">Children</span>
<div class="fm-children-list">
@for (child of familyMemberForm.get('children')?.value; track $index) {
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px;">
<span>{{ child.fullName }}</span>
@if (child.dateOfBirth) {
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
}
</div>
}
</div>
</div>
}
}
<!-- ═══ ADD / EDIT MODE ═══ -->
@@ -447,6 +462,19 @@
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
</mat-form-field>
<!-- Children Section -->
@if (isSpouse) {
<div class="fm-section-label" style="margin-top: 16px;">Children</div>
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between;">
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
@if (familyMemberForm.get('hasChildren')?.value) {
<button mat-stroked-button type="button" color="primary" (click)="openChildrenDialog()" style="margin-left: 16px;">
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
</button>
}
</div>
}
}
</form>
</mat-dialog-content>
@@ -17,7 +17,7 @@ import {
MatDialogClose,
MatDialogContent
} from '@angular/material/dialog';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ClientsService } from 'app/clients/clients.service';
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
private dialog = inject(MatDialog);
maxDate = new Date();
hasChildrenChecked = false;
familyMemberForm: UntypedFormGroup;
/** Client search */
@@ -197,6 +199,23 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
// Re-fetch linked client details (profile, signature, KYC status)
this.fetchLinkedClientData(m.linkedClientId.toString());
}
if (m.children && m.children.length > 0) {
this.hasChildrenChecked = true;
this.familyMemberForm.patchValue({ hasChildren: true });
m.children.forEach((child: any) => {
this.children.push(
this.formBuilder.group({
id: [child.id],
fullName: [child.fullName, Validators.required],
genderId: [child.genderId, Validators.required],
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
})
);
});
}
if (this.isViewMode) {
this.familyMemberForm.disable();
}
@@ -225,8 +244,83 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
isAlive: [true],
marriageDate: [''],
marriageAuthority: [''],
linkedClientId: ['']
linkedClientId: [''],
hasChildren: [false],
children: this.formBuilder.array([])
});
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe(value => {
if (value && !this.hasChildrenChecked) {
this.openChildrenDialog();
} else if (!value) {
this.hasChildrenChecked = false;
this.children.clear();
}
});
}
get children(): UntypedFormArray {
return this.familyMemberForm.get('children') as UntypedFormArray;
}
async openChildrenDialog() {
// dynamically import the dialog to avoid circular dependency issues if any
const { ClientFamilyMemberChildDialogComponent } = await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
data: {
children: this.children.value,
genderIdOptions: this.data.options.genderIdOptions
},
width: '500px'
});
dialogRef.afterClosed().subscribe((result: any[]) => {
if (result) {
this.children.clear();
result.forEach(child => {
this.children.push(this.formBuilder.group({
id: [child.id],
fullName: [child.fullName, Validators.required],
genderId: [child.genderId, Validators.required],
dateOfBirth: [child.dateOfBirth],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
}));
});
this.hasChildrenChecked = true;
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
} else {
if (this.children.length === 0) {
this.hasChildrenChecked = false;
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
}
}
});
}
createChildFormGroup(): UntypedFormGroup {
return this.formBuilder.group({
id: [''],
fullName: ['', Validators.required],
genderId: ['', Validators.required],
dateOfBirth: [''],
nationality: ['INDIAN'],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
});
}
addChild(): void {
this.children.push(this.createChildFormGroup());
}
removeChild(index: number): void {
this.children.removeAt(index);
if (this.children.length === 0) {
this.familyMemberForm.patchValue({ hasChildren: false });
}
}
/** Handle marriage certificate file selection */
@@ -481,6 +575,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
member._linkedClient = this.selectedClient;
}
// Format children dates
if (this.isSpouse && member.children && member.children.length > 0) {
member.children = member.children.map((child: any) => {
const c = { ...child };
if (c.dateOfBirth instanceof Date) {
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
} else if (!c.dateOfBirth) {
delete c.dateOfBirth;
}
if (c.panNumber) {
c.panNumber = c.panNumber.toUpperCase();
}
return c;
});
} else {
delete member.children;
}
delete member.hasChildren;
// Attach marriage certificate file if present
if (this.marriageCertFile) {
member._marriageCertFile = this.marriageCertFile;
@@ -153,7 +153,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocumentName: 'KYC Document Type',
kycDocumentNumber: 'KYC Document Number',
amlCheckStatus: 'AML Check Status',
bannedEntityMatch: 'Banned Entity List Match'
bannedEntityMatch: 'Banned Entity List Match',
remarks: 'Reason for Re-edit'
};
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
@@ -432,8 +433,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumber: [
'',
[
Validators.pattern(/^\d{12}$/),
Validators.maxLength(12)
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/),
Validators.maxLength(14)
]
],
panAadhaarSeedingStatus: [''],
@@ -961,6 +962,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
// Set remarks as required if KYC is verified
if (this.isKycVerified) {
this.kycForm.get('remarks')?.setValidators(Validators.required);
this.kycForm.get('remarks')?.updateValueAndValidity();
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
}
this.persistedIsException = data.isException === true;
this.persistedExceptionReason = data.exceptionReason || null;
// Sync chip-picker selection with the prefilled document name
@@ -36,7 +36,34 @@
<strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p>
</div>
<!-- Action buttons: only shown for DISPATCHED / IN_TRANSIT, only for users with UPDATE_WELCOMEKIT permission -->
<ng-container *mifosxHasPermission="'UPDATE_WELCOMEKIT'">
<div
*ngIf="canUpdateStatus(dispatch.status)"
style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;"
>
<button
mat-stroked-button
color="primary"
type="button"
[disabled]="isLoading"
(click)="updateStatus(dispatch, 'DELIVERED')"
>
✅ Mark as Delivered
</button>
<button
mat-stroked-button
color="warn"
type="button"
[disabled]="isLoading"
(click)="updateStatus(dispatch, 'RETURNED')"
>
↩ Mark as Returned
</button>
</div>
</ng-container>
</div><!-- end dispatch-tracking-panel -->
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
@@ -165,6 +165,30 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
canUpdateStatus(status: string): boolean {
return status === 'DISPATCHED' || status === 'IN_TRANSIT';
}
updateStatus(dispatch: any, newStatus: string) {
const label = newStatus === 'DELIVERED' ? 'Delivered' : 'Returned';
const confirmed = confirm(`Are you sure you want to mark this as ${label}? This action cannot be undone from the UI.`);
if (!confirmed) {
return;
}
this.isLoading = true;
this.clientsService.updateWelcomeKitStatus(dispatch.id, newStatus).subscribe({
next: () => {
this.snackBar.open(`Welcome Kit marked as ${label} successfully`, 'Close', { duration: 3000 });
this.fetchData();
},
error: () => {
this.isLoading = false;
this.snackBar.open(`Failed to mark Welcome Kit as ${label}`, 'Close', { duration: 3000 });
}
});
}
getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || '';
const name = this.welcomeKitsData?.clientName || '';
+2
View File
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
ClientPreviewStepComponent,
ClientAddressStepComponent,
ClientFamilyMemberDialogComponent,
ClientFamilyMemberChildDialogComponent,
CaptureImageDialogComponent,
AddClientCollateralComponent,
ClientDatatableStepComponent,
+18
View File
@@ -123,6 +123,20 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
}
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
* Only populated after the Checker has approved and the send succeeded. */
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
return this.http.get<{
emailSubject: string | null;
emailBody: string | null;
emailSent: boolean;
emailSentAt: string | null;
emailOpenedAt: string | null;
emailFrom: string | null;
emailTo: string | null;
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
}
getClientDataAndTemplate(clientId: string) {
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
return this.http.get(`/clients/${clientId}`, { params: httpParams });
@@ -959,6 +973,10 @@ 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 });
}
getKycExceptions(resolved = false): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
}
@@ -84,14 +84,46 @@
<mat-icon>phone_iphone</mat-icon>
<span>Mobile Number</span>
</div>
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px;">
<mat-checkbox
[checked]="oldMobileNotAccessible"
(change)="onOldMobileNotAccessibleToggle($event.checked)"
color="primary"
>
Old mobile number not accessible (lost / deactivated SIM)
</mat-checkbox>
</div>
<div class="contact-verify-panel__grid">
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
[destination]="contactChanges.oldMobile"
label="Old Mobile Number"
(stateChange)="onOldMobileOtpState($event)"
></mifosx-client-contact-otp-block>
@if (!oldMobileNotAccessible) {
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
[destination]="contactChanges.oldMobile"
label="Old Mobile Number"
(stateChange)="onOldMobileOtpState($event)"
></mifosx-client-contact-otp-block>
} @else {
<div class="fir-upload-box" style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc;">
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569;">
Upload Police Complaint / FIR Copy for Lost Mobile
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<input type="file" (change)="onFirFileSelected($event)" accept=".pdf,.jpg,.jpeg,.png" #firFileInput hidden />
<button mat-stroked-button type="button" (click)="firFileInput.click()" [disabled]="uploadingFir">
<mat-icon>upload_file</mat-icon>
{{ firFileName ? firFileName : 'Select FIR Document' }}
</button>
@if (uploadingFir) {
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
}
@if (firDocumentId && !uploadingFir) {
<span style="color: #16a34a; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 4px;">
<mat-icon style="font-size: 16px; width: 16px; height: 16px;">check_circle</mat-icon> FIR Uploaded Successfully
</span>
}
</div>
</div>
}
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
import { MatTabsModule } from '@angular/material/tabs';
import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDivider } from '@angular/material/divider';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
...STANDALONE_SHARED_IMPORTS,
MatTabsModule,
MatIconModule,
MatCheckboxModule,
MatDivider,
FaIconComponent,
MatProgressSpinner,
@@ -108,6 +110,13 @@ export class EditClientComponent {
newMobileOtpId: number | null = null;
oldMobileVerified = false;
newMobileVerified = false;
// FIR exception flow properties for inaccessible old mobile number
oldMobileNotAccessible = false;
firFileName = '';
firDocumentId: number | null = null;
uploadingFir = false;
oldEmailOtpId: number | null = null;
newEmailOtpId: number | null = null;
oldEmailVerified = false;
@@ -215,11 +224,16 @@ export class EditClientComponent {
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
}
/** Whether every OTP block required by the fields that actually changed has been verified. */
/** Whether every OTP block required by the fields that actually changed has been verified (or FIR uploaded for lost mobile). */
get contactVerificationComplete(): boolean {
const changes = this.contactChanges;
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
return false;
if (changes.mobileChanged) {
const isOldSatisfied = this.oldMobileNotAccessible
? (this.firDocumentId != null)
: (this.oldMobileVerified);
if (!isOldSatisfied || !this.newMobileVerified) {
return false;
}
}
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
return false;
@@ -227,6 +241,41 @@ export class EditClientComponent {
return true;
}
onOldMobileNotAccessibleToggle(checked?: boolean): void {
if (checked !== undefined) {
this.oldMobileNotAccessible = checked;
}
if (this.oldMobileNotAccessible) {
this.oldMobileOtpId = null;
this.oldMobileVerified = false;
}
}
onFirFileSelected(event: any): void {
const file: File = event.target?.files?.[0];
if (!file) return;
this.firFileName = file.name;
this.uploadingFir = true;
const formData = new FormData();
formData.append('file', file);
formData.append('name', 'FIR - Lost Mobile Number');
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
next: (res: any) => {
this.uploadingFir = false;
this.firDocumentId = res?.resourceId || res?.id || null;
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
},
error: () => {
this.uploadingFir = false;
this.firDocumentId = null;
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
}
});
}
onOldMobileOtpState(state: ContactOtpState): void {
this.oldMobileOtpId = state.otpId;
this.oldMobileVerified = state.verified;
@@ -254,6 +303,10 @@ export class EditClientComponent {
this.newMobileOtpId = null;
this.oldMobileVerified = false;
this.newMobileVerified = false;
this.oldMobileNotAccessible = false;
this.firFileName = '';
this.firDocumentId = null;
this.uploadingFir = false;
this.oldEmailOtpId = null;
this.newEmailOtpId = null;
this.oldEmailVerified = false;
@@ -439,7 +492,11 @@ export class EditClientComponent {
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
// state fields for the full naming rationale.
if (changedFields.mobileChanged) {
clientData.mobileOldOtpId = this.oldMobileOtpId;
if (this.oldMobileNotAccessible && this.firDocumentId) {
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
} else {
clientData.mobileOldOtpId = this.oldMobileOtpId;
}
clientData.mobileNewOtpId = this.newMobileOtpId;
}
if (changedFields.emailChanged) {
@@ -630,25 +687,34 @@ export class EditClientComponent {
private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm;
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
// values are what get returned/sent as the actual OTP destination and saved value — only the
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
// customer's email casing into the saved record or the OTP destination.
const newMobile = (form?.get('mobileNo')?.value || '').trim();
const newEmail = (form?.get('emailAddress')?.value || '').trim();
const newDobRaw = form?.get('dateOfBirth')?.value;
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
// report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
// touched the date field, purely because the Date object round-tripped through the picker.
if (!form || !originalData) {
return {
nameChanged: false,
mobileChanged: false,
emailChanged: false,
dobChanged: false,
oldName: '',
newName: '',
oldMobile: '',
newMobile: '',
oldEmail: '',
newEmail: '',
oldDob: '',
newDob: ''
};
}
const mobileControl = form.get('mobileNo');
const emailControl = form.get('emailAddress');
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as a change
const newMobile = (mobileControl?.value || '').toString().trim();
const newEmail = (emailControl?.value || '').toString().trim();
const newDobRaw = form.get('dateOfBirth')?.value;
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
const oldMobile = (originalData.mobileNo || '').trim();
const oldEmail = (originalData.emailAddress || '').trim();
const oldMobile = (originalData.mobileNo || '').toString().trim();
const oldEmail = (originalData.emailAddress || '').toString().trim();
const oldDobRaw = originalData.dateOfBirth;
const oldDob = Array.isArray(oldDobRaw)
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
@@ -669,10 +735,10 @@ export class EditClientComponent {
).trim();
const newName = buildName({
firstname: form?.get('firstname')?.value || '',
middlename: form?.get('middlename')?.value || '',
lastname: form?.get('lastname')?.value || '',
fullname: form?.get('fullname')?.value || ''
firstname: form.get('firstname')?.value || '',
middlename: form.get('middlename')?.value || '',
lastname: form.get('lastname')?.value || '',
fullname: form.get('fullname')?.value || ''
});
const oldName = buildName({
firstname: originalData.firstname || '',
@@ -681,12 +747,19 @@ export class EditClientComponent {
fullname: originalData.fullname || ''
});
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
const normPhone = (val: string) => val.replace(/\D/g, '');
const newMobileNorm = normPhone(newMobile);
const oldMobileNorm = normPhone(oldMobile);
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND normalized values differ
const mobileChanged = !!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && (newMobileNorm !== oldMobileNorm);
const emailChanged = !!oldEmail && !!newEmail && !!emailControl?.dirty && (newEmail.toLowerCase() !== oldEmail.toLowerCase());
return {
nameChanged: newName !== oldName,
mobileChanged: newMobile !== oldMobile,
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
mobileChanged,
emailChanged,
dobChanged: newDob !== oldDob,
oldName,
newName,
@@ -20,11 +20,11 @@
</div>
<div class="table-filters">
<mat-form-field class="table-search" appearance="outline">
<mat-form-field class="table-search" appearance="outline" subscriptSizing="dynamic">
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
<mat-option value="">All Branches</mat-option>
<mat-option *ngFor="let office of offices" [value]="office.id">
@@ -33,7 +33,7 @@
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
<mat-option value="">All Products</mat-option>
<mat-option *ngFor="let product of products" [value]="product.id">
@@ -42,7 +42,7 @@
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
<mat-option value="">All Status</mat-option>
<mat-option value="active">Active</mat-option>
@@ -53,7 +53,7 @@
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic" (click)="picker.open()">
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
@@ -5,37 +5,43 @@
* 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/.
*/
:host {
display: block;
width: 100%;
}
.container {
margin: 1.5rem;
padding: 0;
border-radius: var(--radius-xl);
background: var(--bg-card);
box-shadow: var(--shadow-sm);
margin: 1.5rem auto;
max-width: 1400px;
padding: 20px 24px;
border-radius: 16px;
background: #ffffff;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
}
.table-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-light);
margin-bottom: 20px;
.table-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 16px;
}
.table-title-group {
display: flex;
align-items: center;
gap: 1rem;
gap: 12px;
.table-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
color: #0f172a;
display: flex;
align-items: center;
gap: 0.75rem;
@@ -45,83 +51,175 @@
width: 24px;
height: 24px;
position: relative;
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 2px;
background: var(--bg-hover);
background: #f1f5f9;
border-radius: 6px;
span {
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--primary-main);
opacity: 0.8;
background: #1353a0;
&:nth-child(2) {
opacity: 0.6;
}
&:nth-child(3) {
opacity: 0.4;
}
&:nth-child(4) {
opacity: 0.2;
}
&:nth-child(2) { opacity: 0.7; }
&:nth-child(3) { opacity: 0.4; }
&:nth-child(4) { opacity: 0.2; }
}
}
}
.table-count {
padding: 0.25rem 0.75rem;
background: var(--bg-hover);
color: var(--text-muted);
border-radius: var(--radius-full);
padding: 0.2rem 0.65rem;
background: #f1f5f9;
color: #64748b;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
font-family: 'Roboto Mono', monospace;
}
}
/* Pill-styled filter bar */
.table-filters {
display: flex;
gap: 1rem;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
mat-form-field {
width: 240px;
margin-bottom: -1.25em;
.table-search {
flex: 1 1 200px;
min-width: 180px;
max-width: 280px;
margin: 0;
}
.table-filter {
flex: 0 1 150px;
min-width: 130px;
max-width: 170px;
margin: 0;
}
/* Override Material Form Field Heights & Subscript Spacing */
::ng-deep {
.mat-mdc-form-field {
margin: 0;
}
.mat-mdc-form-field-subscript-wrapper {
display: none !important;
height: 0 !important;
}
.mat-mdc-form-field-flex {
height: 38px !important;
background: #ffffff !important;
align-items: center;
}
.mat-mdc-form-field-infix {
padding: 6px 0 !important;
min-height: 38px !important;
}
.mdc-notched-outline__leading {
border-radius: 8px 0 0 8px !important;
}
.mdc-notched-outline__trailing {
border-radius: 0 8px 8px 0 !important;
}
.mat-mdc-select-value,
.mat-mdc-select-placeholder,
input {
font-size: 13px !important;
}
}
button[mat-icon-button] {
color: #64748b;
width: 36px;
height: 36px;
&:hover {
color: #0f172a;
background: #e2e8f0;
}
}
}
}
.table-container {
position: relative;
overflow: auto;
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
background: #ffffff;
.table-loader {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
height: 2px;
}
}
.modern-table {
width: 100%;
background: transparent;
border-collapse: separate;
border-spacing: 0;
tr.mat-row {
cursor: pointer;
transition: background-color 0.2s ease;
::ng-deep {
.mat-mdc-header-row {
background: #f8fafc;
min-height: 38px;
height: 38px;
}
&:hover {
background-color: var(--bg-hover);
.mat-mdc-header-cell {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
padding: 0 16px;
white-space: nowrap;
}
.mat-mdc-row {
min-height: 42px;
height: 42px;
border-bottom: 1px solid #f1f5f9;
transition: background-color 0.15s ease;
cursor: pointer;
&:last-child {
border-bottom: none;
}
&:nth-child(even) {
background: #fafbfc;
}
&:hover {
background-color: #f1f5f9 !important;
}
}
.mat-mdc-cell {
font-size: 13px;
font-weight: 400;
color: #0f172a;
padding: 0 16px;
white-space: nowrap;
}
}
}
mat-paginator {
border-top: 1px solid #e2e8f0;
background: #ffffff;
}
@@ -0,0 +1,84 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h1 mat-dialog-title class="ced-title">
<span class="ced-title__icon">✉</span>
Approval Email
<div class="ced-status-row">
@if (data.emailSent) {
<span class="ced-chip ced-chip--sent">
<span class="ced-chip__dot"></span>Sent
</span>
} @else {
<span class="ced-chip ced-chip--notsent">
<span class="ced-chip__dot"></span>Not sent
</span>
}
@if (data.emailOpenedAt) {
<span class="ced-chip ced-chip--seen">
<fa-icon icon="eye" class="ced-chip__icon"></fa-icon>Seen
</span>
} @else {
<span class="ced-chip ced-chip--notseen">
<fa-icon icon="eye-slash" class="ced-chip__icon"></fa-icon>Not yet seen
</span>
}
</div>
</h1>
<div mat-dialog-content class="ced-content">
<!-- Classic Email Header (Gmail Style) -->
<div class="ced-gmail-header">
<h2 class="ced-gmail-subject">{{ data.emailSubject ?? '(No Subject)' }}</h2>
<div class="ced-gmail-meta">
<div class="ced-gmail-avatar">
<div class="ced-gmail-avatar-letter">C</div>
</div>
<div class="ced-gmail-meta-content">
<div class="ced-gmail-meta-top">
<span class="ced-gmail-sender-name">CBSNexora</span>
<span class="ced-gmail-sender-email">&lt;{{ data.emailFrom ?? 'noreply@cbsnexora.in' }}&gt;</span>
<span class="ced-gmail-spacer"></span>
<span class="ced-gmail-date">{{ data.emailSentAt ? formatDate(data.emailSentAt) : '—' }}</span>
</div>
<div class="ced-gmail-meta-bottom">
<span class="ced-gmail-to">
to
@if (data.emailTo) {
{{ data.emailTo }}
} @else {
<span style="font-style: italic; color: #d32f2f">No email on file</span>
}
</span>
<fa-icon icon="caret-down" class="ced-gmail-caret"></fa-icon>
</div>
</div>
</div>
</div>
<!-- HTML email body -->
@if (data.emailBody) {
<div class="ced-email-body" [innerHTML]="safeBody"></div>
} @else {
<div class="ced-empty">
<p>No email content available.</p>
@if (!data.emailSent) {
<p class="ced-empty__hint">The email has not been sent yet for this edit.</p>
}
</div>
}
</div>
<mat-dialog-actions align="end">
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -0,0 +1,219 @@
/**
* Copyright since 2025 Mifos Initiative
* MPL-2.0
*/
:host {
display: block;
}
.ced-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.15rem;
font-weight: 600;
color: #1a237e;
position: relative; /* For aligning badges to the right */
&__icon {
font-size: 1.3rem;
}
}
.ced-content {
min-width: 560px;
max-width: 760px;
max-height: 72vh;
overflow-y: auto;
padding: 0 4px;
}
/* ── Gmail-Style Email Header ─────────────────────────────────────────────────── */
.ced-gmail-header {
display: flex;
flex-direction: column;
margin-bottom: 30px;
padding: 0 4px;
font-family: 'Roboto', 'Inter', sans-serif; /* Gmail-like typography */
}
.ced-gmail-subject {
font-size: 1.35rem;
font-weight: 400;
color: #202124;
margin: 0 0 24px 0;
line-height: 1.4;
}
.ced-gmail-meta {
display: flex;
align-items: center; /* Avatar vertically centered with text */
gap: 12px;
}
.ced-gmail-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #f06292;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ced-gmail-avatar-letter {
color: white;
font-size: 1.25rem;
font-weight: 500;
}
.ced-gmail-meta-content {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
gap: 2px;
}
.ced-gmail-meta-top {
display: flex;
align-items: baseline;
width: 100%;
}
.ced-gmail-spacer {
flex-grow: 1;
}
.ced-gmail-sender-name {
font-size: 0.95rem;
font-weight: 600;
color: #202124;
margin-right: 6px;
}
.ced-gmail-sender-email {
color: #5f6368;
font-size: 0.8rem;
}
.ced-gmail-date {
color: #5f6368;
font-size: 0.75rem;
white-space: nowrap;
}
.ced-gmail-meta-bottom {
display: flex;
align-items: center;
gap: 4px;
}
.ced-gmail-to {
font-size: 0.75rem;
color: #5f6368;
}
.ced-gmail-caret {
font-size: 0.75rem;
color: #5f6368;
cursor: pointer;
}
.ced-status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: auto; /* Pushes badges to the right in the flex container */
}
/* ── Status chips ───────────────────────────────────────────────────────── */
.ced-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px 4px 10px;
border-radius: 20px;
font-size: 0.82rem;
font-weight: 500;
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&__icon {
font-size: 0.82rem;
}
&--sent {
background: #e8f5e9;
color: #1b5e20;
.ced-chip__dot { background: #43a047; }
}
&--notsent {
background: #f5f5f5;
color: #757575;
.ced-chip__dot { background: #bdbdbd; }
}
&--seen {
background: #e3f2fd;
color: #0d47a1;
}
&--notseen {
background: #f5f5f5;
color: #9e9e9e;
}
}
/* ── Divider ────────────────────────────────────────────────────────────── */
.ced-divider {
border: none;
border-top: 1px solid #eeeeee;
margin: 12px 0;
}
/* ── Email body iframe-like container ───────────────────────────────────── */
.ced-email-body {
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px 24px;
font-family: Arial, sans-serif;
font-size: 0.93rem;
line-height: 1.6;
color: #333;
// Reset any Angular Material overrides inside the rendered HTML
table {
border-collapse: collapse;
width: 100%;
}
td, th {
padding: 6px 12px;
border: 1px solid #e0e0e0;
}
p { margin: 0 0 10px; }
ul { padding-left: 20px; }
}
/* ── Empty state ────────────────────────────────────────────────────────── */
.ced-empty {
text-align: center;
padding: 32px 0;
color: #9e9e9e;
&__hint {
font-size: 0.85rem;
margin-top: 6px;
}
}
@@ -0,0 +1,65 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export interface ClientEditEmailDialogData {
emailSubject: string | null;
emailBody: string | null;
emailSent: boolean;
emailSentAt: string | null;
emailOpenedAt: string | null;
emailFrom: string | null;
emailTo: string | null;
}
/** Displays the exact confirmation email sent to the client when their pending edit was approved.
* Shows subject, HTML body (rendered), sent timestamp, and read/seen status. */
@Component({
selector: 'mifosx-client-edit-email-dialog',
templateUrl: './client-edit-email-dialog.component.html',
styleUrls: ['./client-edit-email-dialog.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
FaIconComponent
]
})
export class ClientEditEmailDialogComponent {
data = inject<ClientEditEmailDialogData>(MAT_DIALOG_DATA);
private sanitizer = inject(DomSanitizer);
get safeBody(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(this.data.emailBody ?? '');
}
formatDate(isoDate: string | null | undefined): string {
if (!isoDate) return '—';
const d = new Date(isoDate);
return d.toLocaleString('en-IN', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour12: true
});
}
}
@@ -126,6 +126,25 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<!-- ── Email status badges (compact, always visible) ── -->
<span class="pce-email-badge"
[class.pce-email-badge--sent]="edit.emailSent"
[class.pce-email-badge--nosent]="!edit.emailSent"
[title]="edit.emailSent ? 'Email sent on ' + formatDate(edit.emailSentAt) : 'No confirmation email sent'">
<fa-icon icon="envelope"></fa-icon>
{{ edit.emailSent ? 'Sent' : 'Not Sent' }}
</span>
@if (edit.emailSent) {
<span class="pce-email-badge"
[class.pce-email-badge--seen]="edit.emailOpenedAt"
[class.pce-email-badge--notseen]="!edit.emailOpenedAt"
[title]="edit.emailOpenedAt ? 'Opened on ' + formatDate(edit.emailOpenedAt) : 'Not yet opened'">
<fa-icon [icon]="edit.emailOpenedAt ? 'eye' : 'eye-slash'"></fa-icon>
{{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }}
</span>
}
<!-- ── Action buttons ── -->
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
</button>
@@ -137,6 +156,12 @@
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
}
@if (edit.emailSent) {
<button mat-stroked-button class="pce-btn pce-btn-email" (click)="viewEmail(edit)"
title="View the exact confirmation email sent to the client">
<fa-icon icon="envelope" class="m-r-10"></fa-icon>View Email
</button>
}
</td>
</ng-container>
@@ -346,3 +346,41 @@ $text-muted: #64748b;
line-height: 1.5;
}
}
/* ── Email badges ───────────────────────────────────────────────────────── */
.pce-email-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 600;
margin-right: 6px;
white-space: nowrap;
vertical-align: middle;
fa-icon { font-size: 0.7rem; }
&--sent {
background: #e8f5e9;
color: #1b5e20;
border: 1px solid #c8e6c9;
}
&--nosent {
background: #f5f5f5;
color: #9e9e9e;
border: 1px solid #e0e0e0;
}
&--seen {
background: #e3f2fd;
color: #0d47a1;
border: 1px solid #bbdefb;
}
&--notseen {
background: #fff3e0;
color: #e65100;
border: 1px solid #ffe0b2;
}
}
@@ -34,6 +34,7 @@ import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
import { ClientEditEmailDialogComponent } from './client-edit-email-dialog/client-edit-email-dialog.component';
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
* resolved against — populated from /clients/template plus the same per-code-value lookups the
@@ -97,7 +98,8 @@ const NATIONALITY_LABELS: Record<string, string> = {
MatRowDef,
MatRow,
MatIcon,
MatPaginatorModule
MatPaginatorModule,
ClientEditEmailDialogComponent
]
})
export class PendingClientEditsComponent implements OnInit {
@@ -423,6 +425,45 @@ export class PendingClientEditsComponent implements OnInit {
});
}
/** Opens the email preview dialog for an edit row. Fetches the email content first (subject,
* body, sent/opened timestamps) via the email-preview endpoint, then opens the dialog. */
viewEmail(edit: any): void {
this.clientsService.getClientEditEmailPreview(edit.clientId, edit.id).subscribe({
next: (preview) => {
try {
this.dialog.open(ClientEditEmailDialogComponent, {
width: '800px',
maxWidth: '96vw',
data: {
emailSubject: preview?.emailSubject || null,
emailBody: preview?.emailBody || null,
emailSent: preview?.emailSent || false,
emailSentAt: preview?.emailSentAt || null,
emailOpenedAt: preview?.emailOpenedAt || null,
emailFrom: preview?.emailFrom || null,
emailTo: preview?.emailTo || null
}
});
} catch (e: any) {
console.error('Dialog open failed (next): ', e);
}
},
error: () => {
try {
// Fallback: open dialog with no-content state
this.dialog.open(ClientEditEmailDialogComponent, {
width: '800px',
maxWidth: '96vw',
data: { emailSubject: null, emailBody: null, emailSent: false,
emailSentAt: null, emailOpenedAt: null, emailFrom: null, emailTo: null }
});
} catch (e: any) {
console.error('Dialog open failed (error): ', e);
}
}
});
}
reload(): void {
this.load();
}
@@ -21,7 +21,7 @@ import {
MatRow
} from '@angular/material/table';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatIconModule } from '@angular/material/icon';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { MatProgressBar } from '@angular/material/progress-bar';
@@ -52,7 +52,7 @@ import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog
MatRow,
MatButton,
MatIconButton,
MatIcon,
MatIconModule,
MatMenu,
MatMenuItem,
MatMenuTrigger,