feat: update changes
This commit is contained in:
+2
-2
@@ -69,8 +69,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "13mb",
|
"maximumWarning": "15mb",
|
||||||
"maximumError": "14mb"
|
"maximumError": "16mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"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>
|
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||||
</div>
|
</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 ═══ -->
|
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||||
@@ -447,6 +462,19 @@
|
|||||||
<mat-label>Remarks</mat-label>
|
<mat-label>Remarks</mat-label>
|
||||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||||
</mat-form-field>
|
</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>
|
</form>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|||||||
+115
-2
@@ -17,7 +17,7 @@ import {
|
|||||||
MatDialogClose,
|
MatDialogClose,
|
||||||
MatDialogContent
|
MatDialogContent
|
||||||
} from '@angular/material/dialog';
|
} 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 { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
maxDate = new Date();
|
maxDate = new Date();
|
||||||
|
|
||||||
|
hasChildrenChecked = false;
|
||||||
familyMemberForm: UntypedFormGroup;
|
familyMemberForm: UntypedFormGroup;
|
||||||
|
|
||||||
/** Client search */
|
/** Client search */
|
||||||
@@ -197,6 +199,23 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
// Re-fetch linked client details (profile, signature, KYC status)
|
// Re-fetch linked client details (profile, signature, KYC status)
|
||||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
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) {
|
if (this.isViewMode) {
|
||||||
this.familyMemberForm.disable();
|
this.familyMemberForm.disable();
|
||||||
}
|
}
|
||||||
@@ -225,8 +244,83 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
isAlive: [true],
|
isAlive: [true],
|
||||||
marriageDate: [''],
|
marriageDate: [''],
|
||||||
marriageAuthority: [''],
|
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 */
|
/** Handle marriage certificate file selection */
|
||||||
@@ -481,6 +575,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
member._linkedClient = this.selectedClient;
|
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
|
// Attach marriage certificate file if present
|
||||||
if (this.marriageCertFile) {
|
if (this.marriageCertFile) {
|
||||||
member._marriageCertFile = this.marriageCertFile;
|
member._marriageCertFile = this.marriageCertFile;
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocumentName: 'KYC Document Type',
|
kycDocumentName: 'KYC Document Type',
|
||||||
kycDocumentNumber: 'KYC Document Number',
|
kycDocumentNumber: 'KYC Document Number',
|
||||||
amlCheckStatus: 'AML Check Status',
|
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
|
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
||||||
@@ -415,8 +416,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
aadhaarNumber: [
|
aadhaarNumber: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
Validators.pattern(/^\d{12}$/),
|
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/),
|
||||||
Validators.maxLength(12)
|
Validators.maxLength(14)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
panAadhaarSeedingStatus: [''],
|
panAadhaarSeedingStatus: [''],
|
||||||
@@ -890,6 +891,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form15Type: data.form15Type || '15G',
|
form15Type: data.form15Type || '15G',
|
||||||
form15FY: data.form15FY || ''
|
form15FY: data.form15FY || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// Sync chip-picker selection with the prefilled document name
|
// Sync chip-picker selection with the prefilled document name
|
||||||
if (data.kycDocumentName) {
|
if (data.kycDocumentName) {
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
|
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
|
||||||
|
|||||||
+28
-1
@@ -36,7 +36,34 @@
|
|||||||
<strong>Status:</strong>
|
<strong>Status:</strong>
|
||||||
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
||||||
</p>
|
</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" />
|
<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 {
|
getDisplayAddress(dispatch: any): string {
|
||||||
const address = dispatch.deliveryAddress || '';
|
const address = dispatch.deliveryAddress || '';
|
||||||
const name = this.welcomeKitsData?.clientName || '';
|
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 { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
||||||
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-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 { 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 { 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 { 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';
|
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,
|
ClientPreviewStepComponent,
|
||||||
ClientAddressStepComponent,
|
ClientAddressStepComponent,
|
||||||
ClientFamilyMemberDialogComponent,
|
ClientFamilyMemberDialogComponent,
|
||||||
|
ClientFamilyMemberChildDialogComponent,
|
||||||
CaptureImageDialogComponent,
|
CaptureImageDialogComponent,
|
||||||
AddClientCollateralComponent,
|
AddClientCollateralComponent,
|
||||||
ClientDatatableStepComponent,
|
ClientDatatableStepComponent,
|
||||||
|
|||||||
@@ -123,6 +123,20 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
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) {
|
getClientDataAndTemplate(clientId: string) {
|
||||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||||
@@ -958,4 +972,8 @@ export class ClientsService {
|
|||||||
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
||||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
|
||||||
|
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,14 +84,46 @@
|
|||||||
<mat-icon>phone_iphone</mat-icon>
|
<mat-icon>phone_iphone</mat-icon>
|
||||||
<span>Mobile Number</span>
|
<span>Mobile Number</span>
|
||||||
</div>
|
</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">
|
<div class="contact-verify-panel__grid">
|
||||||
<mifosx-client-contact-otp-block
|
@if (!oldMobileNotAccessible) {
|
||||||
[clientId]="clientId"
|
<mifosx-client-contact-otp-block
|
||||||
channel="MOBILE"
|
[clientId]="clientId"
|
||||||
[destination]="contactChanges.oldMobile"
|
channel="MOBILE"
|
||||||
label="Old Mobile Number"
|
[destination]="contactChanges.oldMobile"
|
||||||
(stateChange)="onOldMobileOtpState($event)"
|
label="Old Mobile Number"
|
||||||
></mifosx-client-contact-otp-block>
|
(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
|
<mifosx-client-contact-otp-block
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
channel="MOBILE"
|
channel="MOBILE"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
|
|||||||
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatDivider } from '@angular/material/divider';
|
import { MatDivider } from '@angular/material/divider';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
|
|||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatCheckboxModule,
|
||||||
MatDivider,
|
MatDivider,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatProgressSpinner,
|
MatProgressSpinner,
|
||||||
@@ -108,6 +110,13 @@ export class EditClientComponent {
|
|||||||
newMobileOtpId: number | null = null;
|
newMobileOtpId: number | null = null;
|
||||||
oldMobileVerified = false;
|
oldMobileVerified = false;
|
||||||
newMobileVerified = 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;
|
oldEmailOtpId: number | null = null;
|
||||||
newEmailOtpId: number | null = null;
|
newEmailOtpId: number | null = null;
|
||||||
oldEmailVerified = false;
|
oldEmailVerified = false;
|
||||||
@@ -215,11 +224,16 @@ export class EditClientComponent {
|
|||||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
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 {
|
get contactVerificationComplete(): boolean {
|
||||||
const changes = this.contactChanges;
|
const changes = this.contactChanges;
|
||||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
if (changes.mobileChanged) {
|
||||||
return false;
|
const isOldSatisfied = this.oldMobileNotAccessible
|
||||||
|
? (this.firDocumentId != null)
|
||||||
|
: (this.oldMobileVerified);
|
||||||
|
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -227,6 +241,41 @@ export class EditClientComponent {
|
|||||||
return true;
|
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 {
|
onOldMobileOtpState(state: ContactOtpState): void {
|
||||||
this.oldMobileOtpId = state.otpId;
|
this.oldMobileOtpId = state.otpId;
|
||||||
this.oldMobileVerified = state.verified;
|
this.oldMobileVerified = state.verified;
|
||||||
@@ -254,6 +303,10 @@ export class EditClientComponent {
|
|||||||
this.newMobileOtpId = null;
|
this.newMobileOtpId = null;
|
||||||
this.oldMobileVerified = false;
|
this.oldMobileVerified = false;
|
||||||
this.newMobileVerified = false;
|
this.newMobileVerified = false;
|
||||||
|
this.oldMobileNotAccessible = false;
|
||||||
|
this.firFileName = '';
|
||||||
|
this.firDocumentId = null;
|
||||||
|
this.uploadingFir = false;
|
||||||
this.oldEmailOtpId = null;
|
this.oldEmailOtpId = null;
|
||||||
this.newEmailOtpId = null;
|
this.newEmailOtpId = null;
|
||||||
this.oldEmailVerified = false;
|
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
|
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
||||||
// state fields for the full naming rationale.
|
// state fields for the full naming rationale.
|
||||||
if (changedFields.mobileChanged) {
|
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;
|
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||||
}
|
}
|
||||||
if (changedFields.emailChanged) {
|
if (changedFields.emailChanged) {
|
||||||
@@ -630,25 +687,34 @@ export class EditClientComponent {
|
|||||||
|
|
||||||
private detectSensitiveFieldChanges(originalData: any) {
|
private detectSensitiveFieldChanges(originalData: any) {
|
||||||
const form = this.clientGeneralForm;
|
const form = this.clientGeneralForm;
|
||||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
if (!form || !originalData) {
|
||||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
return {
|
||||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
nameChanged: false,
|
||||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
mobileChanged: false,
|
||||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
emailChanged: false,
|
||||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
dobChanged: false,
|
||||||
// customer's email casing into the saved record or the OTP destination.
|
oldName: '',
|
||||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
newName: '',
|
||||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
oldMobile: '',
|
||||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
newMobile: '',
|
||||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
oldEmail: '',
|
||||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
newEmail: '',
|
||||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
oldDob: '',
|
||||||
// report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
|
newDob: ''
|
||||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||||
|
|
||||||
const oldMobile = (originalData.mobileNo || '').trim();
|
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||||
const oldEmail = (originalData.emailAddress || '').trim();
|
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
||||||
const oldDobRaw = originalData.dateOfBirth;
|
const oldDobRaw = originalData.dateOfBirth;
|
||||||
const oldDob = Array.isArray(oldDobRaw)
|
const oldDob = Array.isArray(oldDobRaw)
|
||||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||||
@@ -669,10 +735,10 @@ export class EditClientComponent {
|
|||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const newName = buildName({
|
const newName = buildName({
|
||||||
firstname: form?.get('firstname')?.value || '',
|
firstname: form.get('firstname')?.value || '',
|
||||||
middlename: form?.get('middlename')?.value || '',
|
middlename: form.get('middlename')?.value || '',
|
||||||
lastname: form?.get('lastname')?.value || '',
|
lastname: form.get('lastname')?.value || '',
|
||||||
fullname: form?.get('fullname')?.value || ''
|
fullname: form.get('fullname')?.value || ''
|
||||||
});
|
});
|
||||||
const oldName = buildName({
|
const oldName = buildName({
|
||||||
firstname: originalData.firstname || '',
|
firstname: originalData.firstname || '',
|
||||||
@@ -681,12 +747,19 @@ export class EditClientComponent {
|
|||||||
fullname: originalData.fullname || ''
|
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 {
|
return {
|
||||||
nameChanged: newName !== oldName,
|
nameChanged: newName !== oldName,
|
||||||
mobileChanged: newMobile !== oldMobile,
|
mobileChanged,
|
||||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
emailChanged,
|
||||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
|
||||||
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
|
||||||
dobChanged: newDob !== oldDob,
|
dobChanged: newDob !== oldDob,
|
||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
|
|||||||
@@ -20,11 +20,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-filters">
|
<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" />
|
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
|
||||||
</mat-form-field>
|
</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-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
|
||||||
<mat-option value="">All Branches</mat-option>
|
<mat-option value="">All Branches</mat-option>
|
||||||
<mat-option *ngFor="let office of offices" [value]="office.id">
|
<mat-option *ngFor="let office of offices" [value]="office.id">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</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-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
|
||||||
<mat-option value="">All Products</mat-option>
|
<mat-option value="">All Products</mat-option>
|
||||||
<mat-option *ngFor="let product of products" [value]="product.id">
|
<mat-option *ngFor="let product of products" [value]="product.id">
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</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-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
|
||||||
<mat-option value="">All Status</mat-option>
|
<mat-option value="">All Status</mat-option>
|
||||||
<mat-option value="active">Active</mat-option>
|
<mat-option value="active">Active</mat-option>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</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">
|
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
|
||||||
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
|
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
|
||||||
<input matEndDate formControlName="end" placeholder="End 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
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
margin: 1.5rem;
|
margin: 1.5rem auto;
|
||||||
padding: 0;
|
max-width: 1400px;
|
||||||
border-radius: var(--radius-xl);
|
padding: 20px 24px;
|
||||||
background: var(--bg-card);
|
border-radius: 16px;
|
||||||
box-shadow: var(--shadow-sm);
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header {
|
.table-header {
|
||||||
padding: 1.5rem;
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
|
|
||||||
.table-header-row {
|
.table-header-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 16px;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-title-group {
|
.table-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 12px;
|
||||||
|
|
||||||
.table-title {
|
.table-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-main);
|
color: #0f172a;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -45,83 +51,175 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
background: var(--bg-hover);
|
background: #f1f5f9;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--primary-main);
|
background: #1353a0;
|
||||||
opacity: 0.8;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) { opacity: 0.7; }
|
||||||
opacity: 0.6;
|
&:nth-child(3) { opacity: 0.4; }
|
||||||
}
|
&:nth-child(4) { opacity: 0.2; }
|
||||||
|
|
||||||
&:nth-child(3) {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(4) {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-count {
|
.table-count {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.2rem 0.65rem;
|
||||||
background: var(--bg-hover);
|
background: #f1f5f9;
|
||||||
color: var(--text-muted);
|
color: #64748b;
|
||||||
border-radius: var(--radius-full);
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.02em;
|
font-family: 'Roboto Mono', monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pill-styled filter bar */
|
||||||
.table-filters {
|
.table-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
mat-form-field {
|
.table-search {
|
||||||
width: 240px;
|
flex: 1 1 200px;
|
||||||
margin-bottom: -1.25em;
|
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 {
|
.table-container {
|
||||||
position: relative;
|
border: 1px solid #e2e8f0;
|
||||||
overflow: auto;
|
border-radius: 12px;
|
||||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
.table-loader {
|
.table-loader {
|
||||||
position: absolute;
|
height: 2px;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modern-table {
|
.modern-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: transparent;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
|
||||||
tr.mat-row {
|
::ng-deep {
|
||||||
cursor: pointer;
|
.mat-mdc-header-row {
|
||||||
transition: background-color 0.2s ease;
|
background: #f8fafc;
|
||||||
|
min-height: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.mat-mdc-header-cell {
|
||||||
background-color: var(--bg-hover);
|
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">
|
<ng-container matColumnDef="actions">
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
<td mat-cell *matCellDef="let edit">
|
<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)">
|
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
||||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
||||||
</button>
|
</button>
|
||||||
@@ -137,6 +156,12 @@
|
|||||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||||
</button>
|
</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>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
+38
@@ -346,3 +346,41 @@ $text-muted: #64748b;
|
|||||||
line-height: 1.5;
|
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 { 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 { 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 { 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
|
/** 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
|
* 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,
|
MatRowDef,
|
||||||
MatRow,
|
MatRow,
|
||||||
MatIcon,
|
MatIcon,
|
||||||
MatPaginatorModule
|
MatPaginatorModule,
|
||||||
|
ClientEditEmailDialogComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PendingClientEditsComponent implements OnInit {
|
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 {
|
reload(): void {
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
MatRow
|
MatRow
|
||||||
} from '@angular/material/table';
|
} from '@angular/material/table';
|
||||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
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 { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||||
@@ -52,7 +52,7 @@ import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog
|
|||||||
MatRow,
|
MatRow,
|
||||||
MatButton,
|
MatButton,
|
||||||
MatIconButton,
|
MatIconButton,
|
||||||
MatIcon,
|
MatIconModule,
|
||||||
MatMenu,
|
MatMenu,
|
||||||
MatMenuItem,
|
MatMenuItem,
|
||||||
MatMenuTrigger,
|
MatMenuTrigger,
|
||||||
|
|||||||
Reference in New Issue
Block a user