From 468eaa96ab366733c8a6e6df0f8dcadf98543d48 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 29 Jul 2026 10:27:45 +0530 Subject: [PATCH] feat(clients): maker-checker for client edits (frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New "Pending Client Edits" screen under Checker Inbox & Tasks: server-driven pagination, date range + Today/Yesterday/All presets, status filter (Pending/Approved/Rejected), search — mirrors ChequebookRequestsComponent's architecture. - Compare dialog diffs the maker's proposed values against a point-in-time "before" snapshot (client-edit-diff.util.ts) captured when the edit page was opened, so only genuinely changed fields show — works correctly for Pending, Approved, and Rejected edits alike, since it no longer depends on comparing against the live (possibly already-updated) client record. - edit-client.component.ts: General tab save now submits for approval instead of applying immediately, shows a pending-approval banner, and blocks a second submission while one's outstanding. - error-handler.interceptor.ts: added the new /clients/{id}/edit/pending check to the existing "expected 403/404, don't alert" allowlist (same pattern already used for client images/KYC). Co-Authored-By: Claude Sonnet 5 --- src/app/clients/clients.service.ts | 37 ++ .../edit-client/edit-client.component.html | 15 +- .../edit-client/edit-client.component.scss | 20 + .../edit-client/edit-client.component.ts | 74 ++- .../clients/shared/client-edit-diff.util.ts | 149 ++++++ .../core/http/error-handler.interceptor.ts | 6 +- .../client-edit-compare-dialog.component.html | 56 +++ .../client-edit-compare-dialog.component.scss | 87 ++++ .../client-edit-compare-dialog.component.ts | 56 +++ .../pending-client-edits.component.html | 157 +++++++ .../pending-client-edits.component.scss | 348 ++++++++++++++ .../pending-client-edits.component.ts | 429 ++++++++++++++++++ .../checker-inbox-and-tasks.component.html | 12 + src/app/tasks/tasks-routing.module.ts | 6 + 14 files changed, 1412 insertions(+), 40 deletions(-) create mode 100644 src/app/clients/shared/client-edit-diff.util.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 4e1d04a2d..a768add5e 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -82,6 +82,43 @@ export class ClientsService { return this.http.delete(`/clients/${clientId}`); } + /** + * Maker-checker for Client edits — stages the change in m_client_pending_edit instead of + * applying it immediately; a different user must call approveClientEdit() before it takes + * effect. See ClientEditApiResource on the backend. + */ + submitClientEdit(clientId: string, client: any) { + return this.http.post(`/clients/${clientId}/edit`, client); + } + + getPendingClientEdit(clientId: string) { + return this.http.get(`/clients/${clientId}/edit/pending`); + } + + getAllPendingClientEdits( + status?: string, + clientName?: string, + fromDate?: string, + toDate?: string, + offset = 0, + limit = 20 + ): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (status) params = params.set('status', status); + if (clientName) params = params.set('clientName', clientName); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + return this.http.get(`/clients/edit/pending`, { params }); + } + + approveClientEdit(clientId: string) { + return this.http.post(`/clients/${clientId}/edit/approve`, {}); + } + + rejectClientEdit(clientId: string, rejectReason?: string) { + return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' }); + } + getClientDataAndTemplate(clientId: string) { const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true'); return this.http.get(`/clients/${clientId}`, { params: httpParams }); diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index d4aa4f13d..b25827c09 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -25,9 +25,22 @@ {{ 'labels.inputs.GENERAL' | translate }}
+ @if (hasPendingGeneralEdit) { +
+ hourglass_top + An edit is awaiting approval from another user. You can't submit another edit until it's resolved. +
+ }
@if (isTabReadOnly(0)) { - } @else { diff --git a/src/app/clients/edit-client/edit-client.component.scss b/src/app/clients/edit-client/edit-client.component.scss index 537b656ef..31b7f327b 100644 --- a/src/app/clients/edit-client/edit-client.component.scss +++ b/src/app/clients/edit-client/edit-client.component.scss @@ -217,6 +217,26 @@ } } +/* ─── Pending Edit Approval Banner ─── */ +.pending-edit-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + margin: 0 0 16px; + background: #eff6ff; + border: 1px solid #93c5fd; + border-radius: 8px; + color: #1e40af; + font-size: 13px; + font-weight: 500; + + mat-icon { + color: #2563eb; + flex-shrink: 0; + } +} + /* ─── Re-KYC Alert Banner ─── */ .rekyc-alert { display: flex; diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index f253ba7e9..16d73e575 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -38,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util'; /** * Edit Client Component — uses the same wizard/stepper layout as Create Client. @@ -116,6 +117,9 @@ export class EditClientComponent { clientDataAndTemplate: any; /** Client ID */ clientId: string; + /** True while a General-tab edit is staged awaiting checker approval — blocks submitting + * another one (the backend enforces this too; this just avoids a round-trip error toast). */ + hasPendingGeneralEdit = false; constructor() { this.route.data.subscribe((data: { clientDataAndTemplate: any }) => { @@ -124,6 +128,15 @@ export class EditClientComponent { if (this.clientDataAndTemplate.legalForm) { this.legalFormType = this.clientDataAndTemplate.legalForm.id; } + this.loadPendingGeneralEdit(); + }); + } + + private loadPendingGeneralEdit(): void { + if (!this.clientId) return; + this.clientsService.getPendingClientEdit(this.clientId).subscribe({ + next: (edit: any) => (this.hasPendingGeneralEdit = !!edit && edit.status === 'PENDING'), + error: () => (this.hasPendingGeneralEdit = false) }); } @@ -434,15 +447,27 @@ export class EditClientComponent { clientData.emailNewOtpId = this.newEmailOtpId; } + // Point-in-time "before" snapshot for the checker's diff view — captured now, from the + // client record loaded when this edit page opened, rather than relying on comparing against + // the LIVE client later (which would show nothing changed once a checker approves the edit, + // since by then current == proposed). + clientData[ORIGINAL_VALUES_PAYLOAD_KEY] = buildOriginalValuesSnapshot(originalData); + this.savingClient = true; - this.clientsService.updateClient(this.clientId, clientData).subscribe({ + // Maker-checker: stage the edit instead of applying it immediately. The customer-facing + // side effects that a direct update would have had immediately — SMS/Email contact-change + // notification, re-KYC flagging, PAN re-verification reset — are all deferred until a + // different user actually approves the edit (approval replays this exact payload through + // the same updateClient() the direct PUT used to call, so the backend-driven notification + // still fires correctly, just at approval time instead of now). + this.clientsService.submitClientEdit(this.clientId, clientData).subscribe({ next: () => { this.savingClient = false; this.tabEditMode[tabIndex] = false; + this.hasPendingGeneralEdit = true; - // Senior Citizen / PWD supporting documents — same pattern as create-client's - // post-creation upload, triggered here since these are plain file inputs on the - // General step, not part of the JSON update payload. + // Senior Citizen / PWD supporting documents — separate upload endpoints, not part of + // the staged JSON edit, so these still apply immediately as before. if (this.clientGeneralStep?.seniorCitizenProofFile) { const seniorCitizenProofForm = new FormData(); seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile); @@ -460,40 +485,13 @@ export class EditClientComponent { this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} }); } - // Log mobile/email changes as client notes for risk audit - this.logFieldChangeNotes(changedFields); - - // If name or DOB changed, flag re-KYC requirement and reset PAN verification - if (changedFields.nameChanged || changedFields.dobChanged) { - this.dobChanged = true; - this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification - this.flagReKycRequired(changedFields); - } - - // 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; - } - if (changedFields.nameChanged) { - const form = this.clientGeneralForm; - originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname; - originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename; - originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname; - originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname; - } - - // Notification is backend-driven (SMS + Email) — just reassure staff it happened. - if (changedFields.mobileChanged || changedFields.emailChanged) { - this.snackBar.open('Customer notified via SMS/Email of the contact change.', 'Close', { - duration: 4000 - }); - } + this.snackBar.open( + 'Edit submitted for approval — a different user must review it before it takes effect.', + 'Close', + { + duration: 5000 + } + ); this.resetContactVerificationState(); this.selectedTabIndex = this.selectedTabIndex + 1; diff --git a/src/app/clients/shared/client-edit-diff.util.ts b/src/app/clients/shared/client-edit-diff.util.ts new file mode 100644 index 000000000..e4c539672 --- /dev/null +++ b/src/app/clients/shared/client-edit-diff.util.ts @@ -0,0 +1,149 @@ +/** + * 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/. + */ + +/** + * Shared between the maker side (edit-client, which captures a point-in-time snapshot of the + * client's current values right before staging an edit) and the checker side (pending-client-edits, + * which diffs the staged payload against that snapshot). Keeping both in one place means the two + * sides can never drift out of sync on which fields are captured/compared/labelled. + * + * Extracts a payload key's CURRENT value from a client record (as returned by GET /clients/{id}) — + * CodeValue fields come back as nested {id, name} objects, so most extractors pull the nested .id + * to compare against the payload's raw id. + */ +export const PENDING_EDIT_FIELD_EXTRACTORS: Record any> = { + officeId: (c) => c.officeId, + legalFormId: (c) => c.legalForm?.id, + nationalityId: (c) => c.nationality, + isStaff: (c) => c.isStaff, + active: (c) => c.active, + accountNo: (c) => c.accountNo, + salutationId: (c) => c.salutation?.id, + firstname: (c) => c.firstname, + middlename: (c) => c.middlename, + lastname: (c) => c.lastname, + fullname: (c) => c.fullname, + genderId: (c) => c.gender?.id, + maritalStatusId: (c) => c.maritalStatus?.id, + educationLevelId: (c) => c.educationLevel?.id, + degreeQualificationId: (c) => c.degreeQualification?.id, + religionId: (c) => c.religion?.id, + socialCategoryId: (c) => c.socialCategory?.id, + mobileNo: (c) => c.mobileNo, + alternateMobileNo: (c) => c.alternateMobileNo, + emailAddress: (c) => c.emailAddress, + dateOfBirth: (c) => c.dateOfBirth, + occupationCategoryId: (c) => c.occupationCategory?.id, + occupationId: (c) => c.occupation?.id, + monthlyIncome: (c) => c.monthlyIncome, + annualIncome: (c) => c.annualIncome, + annualTurnover: (c) => c.annualTurnover, + monthlyTurnover: (c) => c.monthlyTurnover, + isPwd: (c) => c.isPwd, + pwdCategoryId: (c) => c.pwdCategory?.id, + isSeniorCitizen: (c) => c.isSeniorCitizen, + agricultureIncome: (c) => c.agricultureIncome, + nonAgricultureIncome: (c) => c.nonAgricultureIncome, + savingsProductId: (c) => c.savingsProductId, + staffId: (c) => c.staffId, + guardianClientId: (c) => c.guardianClientId, + guardianRelation: (c) => c.guardianRelation +}; + +/** Payload keys that are computed, internal, or otherwise never meaningfully "changed" from a + * checker's point of view — always sent by the form (age breakdown, OTP tracking ids, the + * business-date default on submittedOnDate) but with no comparable "current" value. */ +export const NON_COMPARABLE_KEYS = new Set([ + 'dateFormat', + 'locale', + 'ageYears', + 'ageMonths', + 'ageDays', + 'isMinor', + 'addSavings', + 'submittedOnDate', + 'activationDate', + // Flattened onto top-level guardianClientId/guardianRelation right after this in the payload — + // that flattened pair already has real comparators above, so the nested group is redundant. + 'guardianDetails', + // Mobile/email DIP verification + name-match + sanctions-screening results — carried along on + // every save to re-affirm the client's EXISTING verification state, not something the maker + // edited on this pass. dipTimestamp especially would almost never string-match exactly even + // when functionally unchanged, so these were never real "proposed changes" to review. + 'dipVerified', + 'dipLookupLogId', + 'dipTimestamp', + 'alternateDipVerified', + 'alternateDipLookupLogId', + 'alternateDipTimestamp', + 'nameVerified', + 'screeningLogId', + 'mobileNewOtpId', + 'mobileOldOtpId', + 'emailNewOtpId', + 'emailOldOtpId' +]); + +export const PENDING_EDIT_FIELD_LABELS: Record = { + officeId: 'Office', + legalFormId: 'Legal Form', + nationalityId: 'Nationality', + isStaff: 'Is Staff', + active: 'Active', + accountNo: 'Account Number', + salutationId: 'Salutation', + firstname: 'First Name', + middlename: 'Middle Name', + lastname: 'Last Name', + fullname: 'Full Name', + genderId: 'Gender', + maritalStatusId: 'Marital Status', + educationLevelId: 'Education Level', + degreeQualificationId: 'Degree / Qualification', + religionId: 'Religion', + socialCategoryId: 'Social Category', + mobileNo: 'Mobile Number', + alternateMobileNo: 'Alternate Mobile Number', + emailAddress: 'Email Address', + dateOfBirth: 'Date of Birth', + occupationCategoryId: 'Occupation Category', + occupationId: 'Occupation', + monthlyIncome: 'Monthly Income', + annualIncome: 'Annual Income', + annualTurnover: 'Yearly Turnover', + monthlyTurnover: 'Monthly Turnover', + isPwd: 'Person with Disability', + pwdCategoryId: 'Disability Type', + isSeniorCitizen: 'Senior Citizen', + agricultureIncome: 'Agriculture Income', + nonAgricultureIncome: 'Non-Agriculture Income', + submittedOnDate: 'Submitted On', + savingsProductId: 'Linked Savings Product', + staffId: 'Staff' +}; + +/** Key under which the maker's point-in-time "before" snapshot rides along in the submit payload — + * stripped out by the backend before replaying the rest through updateClient(), and stored + * separately as original_json for the checker's diff. Never a real Client field name, so it can't + * collide with anything ClientDataValidator actually expects. */ +export const ORIGINAL_VALUES_PAYLOAD_KEY = '_originalValues'; + +/** Builds the maker-side "before" snapshot from the client record loaded when the edit page + * opened — flat key names matching the payload's own, so the checker side can diff two plain + * objects directly without needing to know Client's nested response shape at all. */ +export function buildOriginalValuesSnapshot(clientData: any): Record { + const snapshot: Record = {}; + if (!clientData) return snapshot; + for (const key of Object.keys(PENDING_EDIT_FIELD_EXTRACTORS)) { + const value = PENDING_EDIT_FIELD_EXTRACTORS[key](clientData); + if (value !== undefined) { + snapshot[key] = value; + } + } + return snapshot; +} diff --git a/src/app/core/http/error-handler.interceptor.ts b/src/app/core/http/error-handler.interceptor.ts index e730f03b0..d83994a33 100644 --- a/src/app/core/http/error-handler.interceptor.ts +++ b/src/app/core/http/error-handler.interceptor.ts @@ -54,7 +54,11 @@ export class ErrorHandlerInterceptor implements HttpInterceptor { const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images'); const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/); const isDirectorRequest = request.url.includes('/election/directors'); - const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest; + // "No pending edit for this client" is the normal/expected state — checked on every edit-page + // load, not a real error the user needs to see. + const isClientPendingEditRequest = request.url.match(/\/clients\/\d+\/edit\/pending$/); + const isSilentRequest = + isClientImageRequest || isClientKycRequest || isDirectorRequest || isClientPendingEditRequest; if (!environment.production && !isSilentRequest) { log.error(`Request Error: ${errorMessage}`); diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.html new file mode 100644 index 000000000..ac71e3c8a --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.html @@ -0,0 +1,56 @@ + + +

+ Proposed Changes — Client + {{ + data.clientDisplayName + }} +

+ +
+ + + + + + + + + + @for (row of data.rows; track row.key) { + + + + + + } @empty { + + + + } + +
FieldCurrent ValueProposed Value
{{ row.label }}{{ row.current }}{{ row.value }}
No changed fields detected.
+ + @if (data.status && data.status !== 'PENDING' && data.checkerName) { +

+ {{ data.status === 'APPROVED' ? 'Approved' : 'Rejected' }} by {{ data.checkerName }} + @if (data.checkedDate) { + on {{ data.checkedDate }} + } +

+ } + + @if (data.rejectReason) { +

Reject Reason: {{ data.rejectReason }}

+ } +
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.scss new file mode 100644 index 000000000..7b7212f64 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.scss @@ -0,0 +1,87 @@ +/** + * 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/. + */ + +$border: #e2e8f0; +$text-muted: #64748b; + +.cecd-client-link { + color: #1e40af; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.cecd-content { + min-width: 420px; + max-width: 70vw; + // Explicit scroll behavior rather than relying only on Material's default dialog-content + // sizing — with many changed fields this keeps the dialog itself a fixed height (Close button + // always visible) and scrolls just the table. + max-height: 60vh; + overflow-y: auto; +} + +.cecd-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th, + td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid $border; + } + + th { + color: $text-muted; + font-weight: 600; + // Stays visible while scrolling through a long list of changed fields. + position: sticky; + top: 0; + background: #fff; + z-index: 1; + } +} + +.cecd-label { + font-weight: 600; + color: #0d1b2a; +} + +.cecd-current { + color: #b91c1c; + text-decoration: line-through; + text-decoration-color: #fca5a5; +} + +.cecd-proposed { + color: #15803d; + font-weight: 600; +} + +.cecd-empty { + color: $text-muted; + font-size: 12.5px; + text-align: center; + padding: 16px 10px; +} + +.cecd-resolution-note { + margin-top: 14px; + font-size: 12.5px; + color: #166534; +} + +.cecd-reject-reason { + margin-top: 14px; + font-size: 12.5px; + color: #b91c1c; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.ts new file mode 100644 index 000000000..11dff4a3e --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-compare-dialog/client-edit-compare-dialog.component.ts @@ -0,0 +1,56 @@ +/** + * 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 { + MatDialogRef, + 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'; + +export interface ClientEditCompareRow { + key: string; + label: string; + current: any; + value: any; +} + +export interface ClientEditCompareDialogData { + clientId: string; + clientDisplayName: string; + rows: ClientEditCompareRow[]; + status?: string; + checkerName?: string; + checkedDate?: string | null; + rejectReason?: string; +} + +/** Popup version of the Pending Client Edits "Compare" panel — shows the Field/Current/Proposed + * diff in an in-app modal instead of an inline expansion below the table. */ +@Component({ + selector: 'mifosx-client-edit-compare-dialog', + templateUrl: './client-edit-compare-dialog.component.html', + styleUrls: ['./client-edit-compare-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose + ] +}) +export class ClientEditCompareDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); +} 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 new file mode 100644 index 000000000..98f557aed --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html @@ -0,0 +1,157 @@ + + +
+
+ + + + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + + +
+
+ From + + + +
+ — +
+ To + + + +
+
+ + +
+ + @if (loading) { +
+

Loading…

+
+ } @else if (loaded && edits.length === 0) { +
+ +

No {{ filterStatus | lowercase }} client edits

+

+ No Client details edits found for this filter. Try + All dates, or a different status above. +

+
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Client + {{ + edit.clientDisplayName + }} + Submitted By{{ edit.makerName }}Submitted On{{ formatDate(edit.submittedDate) }}Status + {{ edit.status }} + Actions + + @if (edit.status === 'PENDING') { + + + } +
+
+ + + } +
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 new file mode 100644 index 000000000..61ffa8119 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss @@ -0,0 +1,348 @@ +/** + * 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/. + */ + +$border: #e2e8f0; +$text-muted: #64748b; + +.pce-tab-content { + padding: 16px 0; +} + +.pce-client-link { + color: #1e40af; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.pce-action-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #f8fafc; + border: 1px solid $border; + border-radius: 10px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.pce-search-box { + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 10px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + min-width: 200px; + max-width: 280px; + transition: border-color 0.15s; + + &:focus-within { + border-color: #1353a0; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + color: #94a3b8; + flex-shrink: 0; + } + + input { + border: none; + outline: none; + font-size: 13px; + width: 100%; + color: #0d1b2a; + font-family: inherit; + background: transparent; + + &::placeholder { + color: #94a3b8; + } + } +} + +.pce-filter-select { + height: 34px; + min-width: 120px; + padding: 0 10px; + border: 1px solid $border; + border-radius: 8px; + font-size: 13px; + color: #0d1b2a; + font-family: inherit; + background: #fff; + cursor: pointer; + + &:focus { + outline: none; + border-color: #1353a0; + } +} + +.pce-fb-sep { + width: 1px; + height: 22px; + background: $border; + flex-shrink: 0; + margin: 0 4px; +} + +.pce-presets { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.pce-preset-chip { + height: 28px; + padding: 0 12px; + border-radius: 14px; + border: 1px solid $border; + background: #fff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + color: #475569; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + border-color: #1353a0; + color: #1353a0; + background: #eff6ff; + } + + &--active { + background: #1353a0; + border-color: #1353a0; + color: #fff; + + &:hover { + background: #0f3f7a; + } + } +} + +.pce-date-range { + display: inline-flex; + align-items: center; + gap: 4px; + + &__sep { + color: #94a3b8; + font-size: 16px; + font-weight: 300; + padding: 0 2px; + user-select: none; + } +} + +.pce-date-box { + display: inline-flex; + align-items: center; + gap: 4px; + height: 34px; + padding: 0 6px 0 10px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + transition: border-color 0.15s; + + &:hover { + border-color: #cbd5e1; + } + + &:focus-within { + border-color: #1353a0; + } + + &__lbl { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + white-space: nowrap; + user-select: none; + letter-spacing: 0.03em; + } + + &__input { + border: none; + outline: none; + font-size: 13px; + width: 76px; + color: #0d1b2a; + font-family: inherit; + background: transparent; + cursor: pointer; + } + + &__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + background: none; + cursor: pointer; + color: #94a3b8; + padding: 0; + border-radius: 4px; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + } + + &:hover { + color: #1353a0; + background: #eff6ff; + } + } +} + +.pce-btn { + font-size: 11.5px; + font-weight: 600; + border-radius: 8px !important; + height: 32px; + padding: 0 10px; + margin-right: 6px; + + fa-icon { + margin-right: 4px; + font-size: 11px; + } +} + +.pce-btn-view { + border-color: #2563eb; + color: #2563eb; + + &:hover { + background: #eff6ff; + } +} + +.pce-btn-refresh { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + cursor: pointer; + color: #475569; + margin-left: auto; + + fa-icon { + font-size: 15px; + } + + &:hover:not(:disabled) { + border-color: #1353a0; + color: #1353a0; + background: #eff6ff; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +.pce-btn-approve { + border-color: #16a34a; + color: #16a34a; + + &:hover { + background: #f0fdf4; + } +} + +.pce-btn-reject { + border-color: #dc2626; + color: #dc2626; + + &:hover { + background: #fef2f2; + } +} + +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + + &--pending { + background: #fef9c3; + color: #854d0e; + } + + &--approved { + background: #dcfce7; + color: #166534; + } + + &--rejected { + background: #fee2e2; + color: #991b1b; + } +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px; + background: #fff; + border: 1px dashed $border; + border-radius: 14px; + text-align: center; + margin: 16px 0; + + .empty-icon { + font-size: 42px; + color: #cbd5e1; + margin-bottom: 14px; + } + + .empty-title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + + .empty-text { + margin: 0; + font-size: 12.5px; + color: $text-muted; + max-width: 380px; + line-height: 1.5; + } +} 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 new file mode 100644 index 000000000..c3fdc1c3b --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts @@ -0,0 +1,429 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; +import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; +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'; + +/** 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 + * General step itself uses, so a checker sees "Male" / "Graduate" instead of a bare id. */ +const ID_FIELD_OPTIONS_KEY: Record = { + officeId: 'officeOptions', + staffId: 'staffOptions', + legalFormId: 'clientLegalFormOptions', + salutationId: 'salutationOptions', + genderId: 'genderOptions', + maritalStatusId: 'maritalStatusOptions', + educationLevelId: 'educationLevelOptions', + degreeQualificationId: 'degreeQualificationOptions', + religionId: 'religionOptions', + socialCategoryId: 'socialCategoryOptions', + occupationCategoryId: 'occupationCategoryOptions', + occupationId: 'occupationOptions', + pwdCategoryId: 'pwdCategoryOptions', + savingsProductId: 'savingProductOptions' +}; + +/** Code-value lists the General step fetches individually rather than via /clients/template. */ +const OPTIONS_KEY_TO_CODE_NAME: Record = { + maritalStatusOptions: 'MaritalStatus', + educationLevelOptions: 'EducationLevel', + degreeQualificationOptions: 'DegreeQualification', + religionOptions: 'Religion', + socialCategoryOptions: 'SocialCategory' +}; + +const NATIONALITY_LABELS: Record = { + INDIAN: 'Indian', + NRI: 'NRI', + FOREIGN_NATIONAL: 'Foreign National', + PIO: 'PIO', + OCI: 'OCI' +}; + +/** + * Checker-facing list of Client details edits awaiting approval. + * Mirrors PendingShareAccountEditsComponent's list + compare + approve/reject pattern — the + * "compare" panel here generically diffs whatever keys are in the staged payload_json, since + * Client has far more editable fields than Share Account's fixed handful. + */ +@Component({ + selector: 'mifosx-pending-client-edits', + templateUrl: './pending-client-edits.component.html', + styleUrls: ['./pending-client-edits.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatIcon, + MatPaginatorModule + ] +}) +export class PendingClientEditsComponent implements OnInit { + private dialog = inject(MatDialog); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + + /** Current page of pending edits, as returned by the server — pagination/date/search filtering + * all happen server-side now (GET /clients/edit/pending?status&clientName&fromDate&toDate& + * offset&limit), mirroring ChequebookRequestsComponent. */ + edits: any[] = []; + totalRecords = 0; + pageSize = 20; + pageIndex = 0; + pageSizeOptions = [ + 10, + 20, + 50 + ]; + loading = false; + loaded = false; + + /** Populated from /clients/template + per-code-value lookups, used to resolve raw ids in the + * compare panel into human-readable labels. Empty object until it loads — entries() just falls + * back to showing the raw id/value until then. */ + private optionsByKey: Record = {}; + + displayedColumns: string[] = [ + 'clientDisplayName', + 'makerName', + 'submittedDate', + 'status', + 'actions' + ]; + + searchText = ''; + fromDate: Date | null = null; + toDate: Date | null = null; + maxDate!: Date; + activePreset = 'today'; + readonly datePresets = [ + { key: 'today', label: 'Today' }, + { key: 'yesterday', label: 'Yesterday' }, + { key: 'all', label: 'All' } + ]; + + filterStatus = 'PENDING'; + readonly statusOptions = [ + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + + ngOnInit(): void { + const today = new Date(this.settingsService.businessDate); + today.setHours(0, 0, 0, 0); + this.maxDate = today; + this.loadOptions(); + this.applyPreset('today'); + } + + private loadOptions(): void { + this.clientsService.getClientTemplate().subscribe((template: any) => { + this.optionsByKey = { ...this.optionsByKey, ...(template || {}) }; + }); + Object.entries(OPTIONS_KEY_TO_CODE_NAME).forEach( + ([ + optionsKey, + codeName + ]) => { + this.clientsService.getCodeValuesByName(codeName).subscribe((options: any[]) => { + this.optionsByKey[optionsKey] = options || []; + }); + } + ); + } + + onFilterChange(): void { + this.pageIndex = 0; + this.load(); + } + + load(): void { + this.loading = true; + this.clientsService + .getAllPendingClientEdits( + this.filterStatus, + this.searchText.trim() || undefined, + this.isoDate(this.fromDate), + this.isoDate(this.toDate), + this.pageIndex * this.pageSize, + this.pageSize + ) + .subscribe({ + next: (res: any) => { + this.edits = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + this.loading = false; + this.loaded = true; + }, + error: () => { + this.edits = []; + this.totalRecords = 0; + this.loading = false; + this.loaded = true; + } + }); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.load(); + } + + applyFilter(filterValue: string = ''): void { + this.searchText = filterValue; + this.pageIndex = 0; + this.load(); + } + + applyPreset(key: string): void { + this.activePreset = key; + const today = new Date(this.settingsService.businessDate); + today.setHours(0, 0, 0, 0); + if (key === 'today') { + this.fromDate = new Date(today); + this.toDate = new Date(today); + } else if (key === 'yesterday') { + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + this.fromDate = yesterday; + this.toDate = yesterday; + } else { + this.fromDate = null; + this.toDate = null; + } + this.pageIndex = 0; + this.load(); + } + + onFromDateChange(): void { + this.activePreset = ''; + if (this.fromDate && this.toDate && this.fromDate > this.toDate) { + this.toDate = new Date(this.fromDate); + } + this.pageIndex = 0; + this.load(); + } + + onToDateChange(): void { + this.activePreset = ''; + this.pageIndex = 0; + this.load(); + } + + private isoDate(d: Date | null): string | undefined { + if (!d) return undefined; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + formatDate(date: any): string { + if (!date) return ''; + const d = new Date(date); + return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN'); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'status-badge--pending', + APPROVED: 'status-badge--approved', + REJECTED: 'status-badge--rejected' + }; + return map[status] ?? ''; + } + + /** "Field / Current / Proposed" rows for whatever in the staged payload actually differs from + * the point-in-time snapshot captured by the maker's browser when the edit page was opened + * (stored as original_json — see buildOriginalValuesSnapshot() in client-edit-diff.util.ts). + * The payload itself carries every General-step field (changed or not), so this filters it down + * to only what the maker actually proposed to change — a TRUE historical diff, unlike comparing + * against the LIVE client, which would show nothing once an edit is approved (by then current == + * proposed). Falls back to showing every field unfiltered when there's no original snapshot at + * all (edits submitted before this column existed) rather than risk silently hiding a change. */ + entries(edit: any): { key: string; label: string; current: any; value: any }[] { + if (!edit?.payloadJson) return []; + let parsed: Record; + try { + parsed = JSON.parse(edit.payloadJson); + } catch { + return []; + } + let original: Record | null = null; + if (edit.originalJson) { + try { + original = JSON.parse(edit.originalJson); + } catch { + original = null; + } + } + + const keys = Object.keys(parsed).filter( + (key) => !NON_COMPARABLE_KEYS.has(key) && parsed[key] !== null && parsed[key] !== undefined && parsed[key] !== '' + ); + const rows: { key: string; label: string; current: any; value: any }[] = []; + for (const key of keys) { + const proposedRaw = parsed[key]; + const hasOriginal = !!original && Object.prototype.hasOwnProperty.call(original, key); + const originalRaw = hasOriginal ? original![key] : undefined; + + // Nested objects (e.g. clientNonPersonDetails for non-individual clients) aren't captured + // in the flat original snapshot at all — always show them rather than guess at a diff. + if (typeof proposedRaw === 'object' && !Array.isArray(proposedRaw)) { + rows.push({ key, label: PENDING_EDIT_FIELD_LABELS[key] || key, current: null, value: proposedRaw }); + continue; + } + + const changed = !original || !hasOriginal || this.normalize(originalRaw) !== this.normalize(proposedRaw); + if (!changed) continue; + rows.push({ + key, + label: PENDING_EDIT_FIELD_LABELS[key] || key, + current: hasOriginal ? this.resolveValue(key, originalRaw) : null, + value: this.resolveValue(key, proposedRaw) + }); + } + return rows; + } + + private resolveValue(key: string, value: any): any { + if (key === 'nationalityId') { + return NATIONALITY_LABELS[value] || value; + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + const optionsKey = ID_FIELD_OPTIONS_KEY[key]; + const options = optionsKey ? this.optionsByKey[optionsKey] : null; + if (options?.length) { + const match = options.find((o: any) => String(o.id) === String(value)); + if (match) return match.name || match.value || value; + } + return value; + } + + /** Normalizes a value (Fineract date array, formatted date string, boolean, id, plain value) + * into a comparable string so a genuinely-unchanged field isn't shown as a false "change" just + * because one side is an array-date and the other a formatted string. */ + private normalize(value: any): string { + if (value === null || value === undefined) return ''; + if (Array.isArray(value) && value.length >= 3 && value.every((v) => typeof v === 'number')) { + return `${value[0]}-${value[1]}-${value[2]}`; + } + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'string') { + const asDate = new Date(value); + if (!isNaN(asDate.getTime()) && /[a-zA-Z]/.test(value)) { + return `${asDate.getFullYear()}-${asDate.getMonth() + 1}-${asDate.getDate()}`; + } + } + return String(value).trim(); + } + + viewCompare(edit: any): void { + this.openCompareDialog(edit); + } + + private openCompareDialog(edit: any): void { + this.dialog.open(ClientEditCompareDialogComponent, { + width: '640px', + data: { + clientId: edit.clientId, + clientDisplayName: edit.clientDisplayName, + rows: this.entries(edit), + status: edit.status, + checkerName: edit.checkerName, + checkedDate: edit.checkedDate ? this.formatDate(edit.checkedDate) : null, + rejectReason: edit.rejectReason + } + }); + } + + approve(edit: any): void { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: 'Approve Client Edit', + dialogContext: `Apply the pending details edit for client "${edit.clientDisplayName}"? This cannot be undone.`, + type: 'basic' + } + }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response?.confirm) { + this.clientsService.approveClientEdit(edit.clientId).subscribe(() => this.reload()); + } + }); + } + + reject(edit: any): void { + const formfields: FormfieldBase[] = [ + new InputBase({ + controlName: 'rejectReason', + label: 'Reason for rejection', + value: '', + type: 'text', + required: false + }) + ]; + const dialogRef = this.dialog.open(FormDialogComponent, { + data: { + title: `Reject edit for client "${edit.clientDisplayName}"`, + layout: { addButtonText: 'Reject' }, + formfields + } + }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response?.data) { + const rejectReason = response.data.value.rejectReason || ''; + this.clientsService.rejectClientEdit(edit.clientId, rejectReason).subscribe(() => this.reload()); + } + }); + } + + reload(): void { + this.load(); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 9bc4863d7..c5a2bfd23 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -130,6 +130,18 @@ Pending Share Account Edits + + + Pending Client Edits +