diff --git a/angular.json b/angular.json index 1fe5293cf..fd43e003d 100644 --- a/angular.json +++ b/angular.json @@ -69,8 +69,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "13mb", - "maximumError": "14mb" + "maximumWarning": "15mb", + "maximumError": "16mb" }, { "type": "anyComponentStyle", diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html new file mode 100644 index 000000000..a681b04e5 --- /dev/null +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html @@ -0,0 +1,90 @@ +
+
+ people + CHILDREN +
+ + +
+
+ @for (child of children.controls; track $index) { +
+
+ + Child Full Name* + + + + Gender* + + @for (g of genderIdOptions; track g.id) { + {{ g.name }} + } + + + + Date of Birth + + + + + + Nationality + + Indian + NRI + Foreign National + Other + + + + Aadhaar Number (Optional) + + + + PAN Number (Optional) + + @if ( + $any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched + ) { + Invalid PAN format + } + +
+
+ @if ($index < children.controls.length - 1) {
+
+ } + } +
+ +
+
+
+ + + + + +
\ No newline at end of file diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts new file mode 100644 index 000000000..6e457afbb --- /dev/null +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts @@ -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, + @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); + } + } + +} diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html index d5b1e8492..6b3c5ea75 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html @@ -139,6 +139,21 @@ {{ familyMemberForm.get('remarks')?.value }} } + @if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) { +
+ Children +
+ @for (child of familyMemberForm.get('children')?.value; track $index) { +
+ {{ child.fullName }} + @if (child.dateOfBirth) { + · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }} + } +
+ } +
+
+ } } @@ -447,6 +462,19 @@ Remarks + + + @if (isSpouse) { +
Children
+
+ Does this family member have a child/children? + @if (familyMemberForm.get('hasChildren')?.value) { + + } +
+ } } diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts index 552c39daf..49930b556 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts @@ -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; diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 4d040cc5c..faa5e7b1a 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -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 diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html index d7f979973..2ad771dcb 100644 --- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html +++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html @@ -36,7 +36,34 @@ Status: {{ dispatch.status }}

- + + + +
+ + +
+
+
diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts index b3d72602b..0ef5ca2d5 100644 --- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts @@ -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 || ''; diff --git a/src/app/clients/clients.module.ts b/src/app/clients/clients.module.ts index 5171c0131..bd13c7eca 100644 --- a/src/app/clients/clients.module.ts +++ b/src/app/clients/clients.module.ts @@ -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, diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index ebed2c167..6f28fd67b 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -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 { + return this.http.put(`/welcomekit/${welcomeKitId}`, { status }); + } + getKycExceptions(resolved = false): Observable { return this.http.get(`/kyc/exceptions?resolved=${resolved}`); } diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index 92740d87c..748eba1fa 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -84,14 +84,46 @@ phone_iphone Mobile Number +
+ + Old mobile number not accessible (lost / deactivated SIM) + +
- + @if (!oldMobileNotAccessible) { + + } @else { +
+
+ Upload Police Complaint / FIR Copy for Lost Mobile +
+
+ + + @if (uploadingFir) { + + } + @if (firDocumentId && !uploadingFir) { + + check_circle FIR Uploaded Successfully + + } +
+
+ } { + 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, diff --git a/src/app/shares/share-holders/share-holders.component.html b/src/app/shares/share-holders/share-holders.component.html index daae3fe22..15adb5d11 100644 --- a/src/app/shares/share-holders/share-holders.component.html +++ b/src/app/shares/share-holders/share-holders.component.html @@ -20,11 +20,11 @@
- + - + All Branches @@ -33,7 +33,7 @@ - + All Products @@ -42,7 +42,7 @@ - + All Status Active @@ -53,7 +53,7 @@ - + diff --git a/src/app/shares/share-holders/share-holders.component.scss b/src/app/shares/share-holders/share-holders.component.scss index 6d41b20a0..27c01a151 100644 --- a/src/app/shares/share-holders/share-holders.component.scss +++ b/src/app/shares/share-holders/share-holders.component.scss @@ -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; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html new file mode 100644 index 000000000..a4b311eee --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html @@ -0,0 +1,84 @@ + + +

+ ✉ + Approval Email + +
+ @if (data.emailSent) { + + Sent + + } @else { + + Not sent + + } + + @if (data.emailOpenedAt) { + + Seen + + } @else { + + Not yet seen + + } +
+

+ +
+ + +
+

{{ data.emailSubject ?? '(No Subject)' }}

+ +
+
+
C
+
+ +
+
+ CBSNexora + <{{ data.emailFrom ?? 'noreply@cbsnexora.in' }}> + + {{ data.emailSentAt ? formatDate(data.emailSentAt) : '—' }} +
+
+ + to + @if (data.emailTo) { + {{ data.emailTo }} + } @else { + No email on file + } + + +
+
+
+
+ + + @if (data.emailBody) { +
+ } @else { +
+

No email content available.

+ @if (!data.emailSent) { +

The email has not been sent yet for this edit.

+ } +
+ } +
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.scss new file mode 100644 index 000000000..f50f1cd82 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.scss @@ -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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts new file mode 100644 index 000000000..e2a18cb2d --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts @@ -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(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 + }); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html index 98f557aed..19470635b 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html @@ -126,6 +126,25 @@ Actions + + + + {{ edit.emailSent ? 'Sent' : 'Not Sent' }} + + @if (edit.emailSent) { + + + {{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }} + + } + + @@ -137,6 +156,12 @@ Reject } + @if (edit.emailSent) { + + } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss index 61ffa8119..22992daa7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss @@ -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; + } +} + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts index c3fdc1c3b..a9dbab2fa 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts @@ -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 = { 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(); } diff --git a/src/app/users/dsc/dsc-list/dsc-list.component.ts b/src/app/users/dsc/dsc-list/dsc-list.component.ts index 429c65c72..aa2fb82e3 100644 --- a/src/app/users/dsc/dsc-list/dsc-list.component.ts +++ b/src/app/users/dsc/dsc-list/dsc-list.component.ts @@ -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,