Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
This commit is contained in:
+2
-2
@@ -69,8 +69,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "13mb",
|
||||
"maximumError": "14mb"
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "16mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
+90
@@ -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>
|
||||
+98
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+28
@@ -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>
|
||||
|
||||
+115
-2
@@ -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
|
||||
|
||||
+28
-1
@@ -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" />
|
||||
|
||||
|
||||
+24
@@ -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 || '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+84
@@ -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"><{{ data.emailFrom ?? 'noreply@cbsnexora.in' }}></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>
|
||||
+219
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
+25
@@ -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>
|
||||
|
||||
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user