feat: edit client — save per tab, DOB re-KYC alert, audit logs, KYC improvements

- Edit button per tab with Save & Next confirmation dialog
- DOB change triggers re-KYC alert, resets PAN verification, auto-fills remarks
- Mobile/email changes logged as audit notes via client notes API
- Redesigned confirmation dialog with icon + title, type-based theming
- KYC re-edit flow: remarks mandatory for VERIFIED records, re-edit API command
- Load existing photo & signature in edit KYC, immediate upload on change
- Populate all KYC fields (document, AML, form60/15) from API on edit
- Non-individual PAN verification uses same flow as individual
- Fix table overflow when sidebar panel is open
- Add fr unit to stylelint allowed-list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-07 15:08:07 +05:30
co-authored by Claude Opus 4.6
parent b890d24bb7
commit 79e455c4ed
14 changed files with 1699 additions and 398 deletions
@@ -12,7 +12,12 @@
<h2>{{ 'labels.heading.Edit Client' | translate }}</h2>
</div>
<mat-tab-group class="client-tabs" animationDuration="200ms" [preserveContent]="true" [(selectedIndex)]="selectedTabIndex">
<mat-tab-group
class="client-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- Tab 1: General Details -->
<mat-tab>
<ng-template mat-tab-label>
@@ -58,8 +63,18 @@
</button>
}
</div>
@if (dobChanged) {
<div class="rekyc-alert">
<mat-icon class="rekyc-alert__icon">warning</mat-icon>
<div class="rekyc-alert__text">
<strong>Re-KYC Required:</strong> Date of birth was changed. Please re-verify PAN and update the remarks
below before proceeding.
</div>
</div>
}
<div class="tab-section">
<mifosx-client-kyc-step #clientKyc
<mifosx-client-kyc-step
#clientKyc
[isNonIndividual]="legalFormType === 2"
[clientId]="clientId"
[readOnly]="isTabReadOnly(1)"
@@ -115,7 +130,8 @@
</button>
}
</div>
<mifosx-client-india-address-step #clientIndiaAddress
<mifosx-client-india-address-step
#clientIndiaAddress
[clientId]="clientId"
[readOnly]="isTabReadOnly(addressTabIdx)"
></mifosx-client-india-address-step>
@@ -141,7 +157,8 @@
</button>
}
</div>
<mifosx-client-family-members-step #clientFamilyMembers
<mifosx-client-family-members-step
#clientFamilyMembers
[clientTemplate]="clientDataAndTemplate"
[clientId]="clientId"
[readOnly]="isTabReadOnly(familyTabIdx)"
@@ -182,9 +199,18 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="onNext()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
Saving...
} @else if (isCurrentTabEdited()) {
<fa-icon icon="save" class="m-r-10"></fa-icon>
Save &amp; Next
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
} @else {
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
}
</button>
}
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
@@ -48,11 +48,11 @@
.client-tabs {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep {
.mat-mdc-tab-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
.mat-mdc-tab {
@@ -97,7 +97,7 @@
align-items: center;
margin-top: 20px;
padding: 16px 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-top: 1px solid rgb(0 0 0 / 8%);
}
.action-bar-right {
@@ -106,7 +106,7 @@
}
.cancel-btn {
color: rgba(0, 0, 0, 0.6);
color: rgb(0 0 0 / 60%);
}
/* ─── Read-only tab state ─── */
@@ -149,3 +149,34 @@
color: #ef4444 !important;
border: 1px solid #fca5a5 !important;
}
/* ─── Re-KYC Alert Banner ─── */
.rekyc-alert {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
margin: 0 0 16px;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
color: #92400e;
&__icon {
color: #f59e0b;
font-size: 22px;
width: 22px;
height: 22px;
margin-top: 1px;
flex-shrink: 0;
}
&__text {
font-size: 13.5px;
line-height: 1.5;
strong {
font-weight: 600;
}
}
}
@@ -9,10 +9,12 @@
/** Angular Imports */
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { ClientsService } from '../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
/** Custom Components */
import { ClientGeneralStepComponent } from '../client-stepper/client-general-step/client-general-step.component';
@@ -60,6 +62,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class EditClientComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
@@ -79,6 +82,9 @@ export class EditClientComponent {
/** Track which tabs are in edit mode (false = read-only, true = editable) */
tabEditMode: Record<number, boolean> = {};
/** Flag: DOB was changed — requires re-KYC (PAN re-verification) */
dobChanged = false;
/** Client data and template (merged object from resolver) */
clientDataAndTemplate: any;
/** Client ID */
@@ -139,8 +145,13 @@ export class EditClientComponent {
return areValids;
}
/** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true;
}
/**
* Next button handler — just navigates between tabs (client already exists in edit mode).
* Next button handler — saves changes via API if the current tab was edited, then moves to next tab.
*/
onNext() {
if (this.selectedTabIndex === 0) {
@@ -155,9 +166,271 @@ export class EditClientComponent {
return;
}
}
// If tab was edited, confirm and save before moving on
if (this.isCurrentTabEdited()) {
this.confirmAndSaveTab();
} else {
this.selectedTabIndex = this.selectedTabIndex + 1;
}
}
/**
* Show confirmation dialog and save the current tab's data via API.
* Shows a special warning if DOB was changed (re-KYC required).
*/
private confirmAndSaveTab() {
let heading = 'Confirm Edit';
let dialogContext = 'Are you sure you want to save the changes';
let dialogType = 'basic';
// Check if DOB changed on the General tab — warn about re-KYC
if (this.selectedTabIndex === 0) {
const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
if (changes.dobChanged) {
heading = 'Re-KYC Required';
dialogContext =
'Date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed';
dialogType = 'warn';
}
}
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: { heading, dialogContext, type: dialogType }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.saveCurrentTab();
}
});
}
/**
* Save the current tab's data via API, then advance to the next tab.
*/
private saveCurrentTab() {
const tabIndex = this.selectedTabIndex;
// General Details tab (always index 0)
if (tabIndex === 0) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const clientData = { ...this.client, dateFormat, locale };
delete clientData.officeId;
// Detect changes to sensitive fields before saving
const originalData = this.clientDataAndTemplate;
const changedFields = this.detectSensitiveFieldChanges(originalData);
this.savingClient = true;
this.clientsService.updateClient(this.clientId, clientData).subscribe({
next: () => {
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
// Log mobile/email changes as client notes for risk audit
this.logFieldChangeNotes(changedFields);
// If DOB changed, flag re-KYC requirement and reset PAN verification
if (changedFields.dobChanged) {
this.dobChanged = true;
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
this.flagReKycRequired(changedFields.oldDob, changedFields.newDob);
}
// Update original data snapshot so subsequent edits compare against latest
if (changedFields.mobileChanged) {
originalData.mobileNo = changedFields.newMobile;
}
if (changedFields.emailChanged) {
originalData.emailAddress = changedFields.newEmail;
}
if (changedFields.dobChanged) {
originalData.dateOfBirth = changedFields.newDob;
}
this.selectedTabIndex = this.selectedTabIndex + 1;
},
error: () => {
this.savingClient = false;
}
});
return;
}
// KYC tab (always index 1)
if (tabIndex === 1) {
this.savingClient = true;
// Upload photo if selected
if (this.clientKycStep?.photographFile) {
this.clientsService
.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile)
.subscribe({ error: () => {} });
}
// Upload signature if selected
if (this.clientKycStep?.signatureFile) {
this.clientsService
.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile)
.subscribe({ error: () => {} });
}
// Save KYC data — use correct API based on KYC state
if (this.clientKycStep?.hasKycData) {
const kycData = this.clientKycStep.kycData;
const kycId = this.clientKycStep.kycId;
const isVerified = this.clientKycStep.isKycVerified;
// Validate remarks are present for verified KYC
if (isVerified && !kycData.remarks?.trim()) {
this.savingClient = false;
this.clientKycStep.kycForm.get('remarks')?.markAsTouched();
return;
}
let saveObs;
if (kycId && isVerified) {
// VERIFIED → re-edit command
const { kycType, ...updatePayload } = kycData;
saveObs = this.clientsService.reEditClientKyc(this.clientId, kycId, updatePayload);
} else if (kycId) {
// Existing but not verified → normal PUT
const { kycType, ...updatePayload } = kycData;
saveObs = this.clientsService.updateClientKyc(this.clientId, kycId, updatePayload);
} else {
// New → POST
saveObs = this.clientsService.saveClientKyc(this.clientId, kycData);
}
saveObs.subscribe({
next: (res: any) => {
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
// If re-edit, update KYC component state
if (isVerified && this.clientKycStep) {
this.clientKycStep.kycStatus = 200;
this.clientKycStep.panVerifyStatus = 'NONE';
this.clientKycStep.panVerifyName = '';
this.clientKycStep.panVerifiedOn = '';
}
if (!kycId && res?.resourceId) {
this.clientKycStep.kycId = res.resourceId.toString();
}
this.selectedTabIndex = this.selectedTabIndex + 1;
},
error: () => {
this.savingClient = false;
}
});
} else {
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
this.selectedTabIndex = this.selectedTabIndex + 1;
}
return;
}
// Address tab
const addressTabIdx = this.legalFormType === 2 ? 3 : 2;
if (tabIndex === addressTabIdx) {
this.savingClient = true;
if (this.clientIndiaAddressStep?.hasAddressData) {
this.saveClientAddresses(this.clientId);
}
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
this.selectedTabIndex = this.selectedTabIndex + 1;
return;
}
// Family tab
const familyTabIdx = this.legalFormType === 2 ? 4 : 3;
if (tabIndex === familyTabIdx) {
this.savingClient = true;
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
this.saveClientFamilyMembers(this.clientId);
}
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
this.selectedTabIndex = this.selectedTabIndex + 1;
return;
}
// Default: just advance
this.tabEditMode[tabIndex] = false;
this.selectedTabIndex = this.selectedTabIndex + 1;
}
/**
* Compare current form values with original data to detect changes in DOB, mobile, email.
*/
private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm;
const newMobile = form?.get('mobileNo')?.value || '';
const newEmail = form?.get('emailAddress')?.value || '';
const newDobRaw = form?.get('dateOfBirth')?.value;
const newDob = newDobRaw instanceof Date ? newDobRaw.toISOString().split('T')[0] : newDobRaw || '';
const oldMobile = originalData.mobileNo || '';
const oldEmail = originalData.emailAddress || '';
const oldDobRaw = originalData.dateOfBirth;
const oldDob = Array.isArray(oldDobRaw)
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
: oldDobRaw instanceof Date
? oldDobRaw.toISOString().split('T')[0]
: oldDobRaw || '';
return {
mobileChanged: newMobile !== oldMobile,
emailChanged: newEmail !== oldEmail,
dobChanged: newDob !== oldDob,
oldMobile,
newMobile,
oldEmail,
newEmail,
oldDob,
newDob
};
}
/**
* Save audit notes for mobile/email changes via the client notes API.
*/
private logFieldChangeNotes(changes: any) {
const timestamp = new Date().toISOString();
if (changes.mobileChanged) {
const note = `[AUDIT] Mobile number changed from "${changes.oldMobile}" to "${changes.newMobile}" on ${timestamp}`;
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
}
if (changes.emailChanged) {
const note = `[AUDIT] Email address changed from "${changes.oldEmail}" to "${changes.newEmail}" on ${timestamp}`;
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
}
}
/**
* When DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC.
*/
private flagReKycRequired(oldDob: string, newDob: string) {
// Reset PAN verification status so user must re-verify
if (this.clientKycStep) {
this.clientKycStep.panVerifyStatus = 'NONE';
this.clientKycStep.panVerifyName = '';
this.clientKycStep.panVerifiedOn = '';
// Pre-fill remarks with re-KYC note
const remarkText = `[Re-KYC Required] Date of birth changed from ${oldDob} to ${newDob}. PAN re-verification is mandatory.`;
this.clientKycStep.kycForm.get('remarks')?.setValue(remarkText);
}
// Also log DOB change as an audit note
const timestamp = new Date().toISOString();
const note = `[AUDIT] Date of birth changed from "${oldDob}" to "${newDob}" on ${timestamp}. Re-KYC (PAN re-verification) required.`;
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
}
legalFormChange(eventData: { legalForm: number }) {
this.legalFormType = eventData.legalForm;
}
@@ -185,12 +458,16 @@ export class EditClientComponent {
// Upload photo if selected
if (this.clientKycStep?.photographFile) {
this.clientsService.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile).subscribe({ error: () => {} });
this.clientsService
.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile)
.subscribe({ error: () => {} });
}
// Upload signature if selected
if (this.clientKycStep?.signatureFile) {
this.clientsService.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile).subscribe({ error: () => {} });
this.clientsService
.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile)
.subscribe({ error: () => {} });
}
// Save addresses from India address step
@@ -205,7 +482,20 @@ export class EditClientComponent {
// Save/update KYC data if available
if (this.clientKycStep?.hasKycData) {
this.clientsService.saveClientKyc(this.clientId, this.clientKycStep.kycData).subscribe({
const kycData = this.clientKycStep.kycData;
const kycId = this.clientKycStep.kycId;
const isVerified = this.clientKycStep.isKycVerified;
let kycObs;
if (kycId && isVerified) {
const { kycType, ...updatePayload } = kycData;
kycObs = this.clientsService.reEditClientKyc(this.clientId, kycId, updatePayload);
} else if (kycId) {
const { kycType, ...updatePayload } = kycData;
kycObs = this.clientsService.updateClientKyc(this.clientId, kycId, updatePayload);
} else {
kycObs = this.clientsService.saveClientKyc(this.clientId, kycData);
}
kycObs.subscribe({
next: () => navigateBack(),
error: () => navigateBack()
});
@@ -225,16 +515,36 @@ export class EditClientComponent {
private saveClientAddresses(clientId: string) {
const addressData = this.clientIndiaAddressStep.addressData;
const typeNameMap: Record<string, string[]> = {
permanent: ['Permanent', 'Permanent Address'],
current: ['Residential', 'Current', 'Current / Residential', 'Communication'],
office: ['Office', 'Office / Work', 'Business', 'Work'],
correspondence: ['Correspondence', 'Mailing', 'Postal']
permanent: [
'Permanent',
'Permanent Address'
],
current: [
'Residential',
'Current',
'Current / Residential',
'Communication'
],
office: [
'Office',
'Office / Work',
'Business',
'Work'
],
correspondence: [
'Correspondence',
'Mailing',
'Postal'
]
};
this.clientsService.getClientAddressTemplate().subscribe({
next: (template: any) => {
const typeOptions = template?.addressTypeIdOptions || [];
for (const [tab, addr] of Object.entries(addressData)) {
for (const [
tab,
addr
] of Object.entries(addressData)) {
if (!addr || !(addr as any).addressLine1) continue;
const possibleNames = typeNameMap[tab] || [tab];
@@ -259,8 +569,8 @@ export class EditClientComponent {
};
if ((addr as any).state && template?.stateProvinceIdOptions?.length) {
const stateMatch = template.stateProvinceIdOptions.find((s: any) =>
s.name?.toLowerCase() === (addr as any).state?.toLowerCase()
const stateMatch = template.stateProvinceIdOptions.find(
(s: any) => s.name?.toLowerCase() === (addr as any).state?.toLowerCase()
);
if (stateMatch) payload.stateProvinceId = stateMatch.id;
}