From 5c76aa6b272d080dee9e14e91788517795a2a262 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 21 Aug 2026 13:00:25 +0530 Subject: [PATCH] feat(tasks,deposits): review product edits in a dialog showing only what changed The pending-product-edit comparison listed every field the edit form posted -- around fifty, nearly all unchanged -- as an inline panel under the table, which buried the handful a checker actually needs to look at. It is now a dialog listing only fields whose value differs. Values are resolved to what the maker saw rather than what the payload carries: enum and GL-account ids become their labels, dates in either API shape read the same, and empty values no longer register as changes. Without that, option fields compared an expanded {id, value} object against a bare id and reported every one of them as changed. Fixed Deposit accounts created by a reinvestment now show the account they came from, and the matured account shows the one it went into, each linking to the other. Co-Authored-By: Claude Opus 5 (1M context) --- .../general-tab/general-tab.component.html | 16 ++ .../general-tab/general-tab.component.ts | 21 ++ .../pending-savings-products.component.html | 40 +-- .../pending-savings-products.component.scss | 58 ---- .../pending-savings-products.component.ts | 61 ++-- .../product-edit-diff-dialog.component.html | 53 ++++ .../product-edit-diff-dialog.component.scss | 115 ++++++++ .../product-edit-diff-dialog.component.ts | 261 ++++++++++++++++++ 8 files changed, 504 insertions(+), 121 deletions(-) create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html index 9ddcc0a99..e353a802e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html @@ -83,6 +83,22 @@ }} } + @if (fixedDepositsAccountData.reinvestedFromAccountNo) { +
+ {{ 'labels.inputs.Reinvested From' | translate }} + + {{ fixedDepositsAccountData.reinvestedFromAccountNo }} + +
+ } + @if (fixedDepositsAccountData.reinvestedIntoAccountNo) { +
+ {{ 'labels.inputs.Reinvested Into' | translate }} + + {{ fixedDepositsAccountData.reinvestedIntoAccountNo }} + +
+ } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts index a6175a18d..7da833264 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts @@ -48,4 +48,25 @@ export class GeneralTabComponent { this.entityType = 'Center'; } } + + /** + * A reinvested FD always keeps the same client/group/center as the account it came from, so the + * counterpart account's view page sits at the same route -- only the account-id segment differs. + */ + private siblingAccountLink(targetAccountId: number): string | null { + const segments = this.router.url.split('?')[0].split('/').filter(Boolean); + const idx = segments.indexOf('fixed-deposits-accounts'); + if (idx === -1) return null; + return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general'; + } + + get reinvestedFromLink(): string | null { + const id = this.fixedDepositsAccountData?.reinvestedFromAccountId; + return id ? this.siblingAccountLink(id) : null; + } + + get reinvestedIntoLink(): string | null { + const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId; + return id ? this.siblingAccountLink(id) : null; + } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html index 5016189b8..ffccfe7d7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html @@ -49,8 +49,13 @@ Actions - - - @if (diffRows.length === 0) { -

Loading comparison…

- } @else { - - - - - - - - - - @for (row of diffRows; track row.label) { - - - - - - } - -
FieldOriginalProposed
{{ row.label }}{{ row.original }}{{ row.proposed }}
- } - - } } @if (!showData) { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss index 892bb22f2..cfc9860dd 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss @@ -87,64 +87,6 @@ $text-muted: #64748b; } } -.psp-diff-panel { - margin-top: 20px; - border: 1px solid $border; - border-radius: 12px; - background: #fff; - padding: 16px; -} - -.psp-diff-header { - display: flex; - align-items: center; - justify-content: space-between; - - h3 { - margin: 0; - font-size: 14px; - font-weight: 700; - } -} - -.psp-diff-loading { - color: $text-muted; - font-size: 13px; -} - -.psp-diff-table { - width: 100%; - border-collapse: collapse; - margin-top: 12px; - font-size: 12.5px; - - th, - td { - text-align: left; - padding: 8px 10px; - border-bottom: 1px solid $border; - } - - th { - color: $text-muted; - font-weight: 600; - } -} - -.psp-diff-row--changed { - background: #fffbeb; - - .psp-diff-original { - color: #b91c1c; - text-decoration: line-through; - } - - .psp-diff-proposed { - color: #15803d; - font-weight: 600; - } -} - .empty-state { display: flex; flex-direction: column; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts index db9554f9d..cf4276918 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts @@ -23,10 +23,10 @@ import { } from '@angular/material/table'; import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; -import { MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ProductsService } from 'app/products/products.service'; +import { ProductEditDiffDialogComponent } from './product-edit-diff-dialog/product-edit-diff-dialog.component'; 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'; @@ -50,8 +50,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatHeaderRow, MatRowDef, MatRow, - MatIcon, - MatIconButton + MatIcon ] }) export class PendingSavingsProductsComponent { @@ -72,9 +71,8 @@ export class PendingSavingsProductsComponent { 'actions' ]; - selectedEdit: any = null; - originalProduct: any = null; - diffRows: { label: string; original: any; proposed: any; changed: boolean }[] = []; + /** Set only while the original product is being fetched, so the button can show progress. */ + loadingDiffFor: any = null; constructor() { this.route.data.subscribe((data: { pendingSavingsProductsData: any }) => { @@ -96,18 +94,23 @@ export class PendingSavingsProductsComponent { return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN'); } + /** + * Fetched with template=true so the response carries the option lists (accountingRuleOptions, + * interestCompoundingPeriodTypeOptions, ...) alongside the product. The submitted edit stores enum + * fields as bare ids, so the diff needs those lists to show a readable name instead of a raw number. + */ private getOriginalProduct$(edit: any) { switch (edit.productType) { case 'CURRENT': - return this.productsService.getCurrentProduct(edit.savingsProductId); + return this.productsService.getCurrentProduct(edit.savingsProductId, true); case 'SHARE': - return this.productsService.getShareProduct(edit.savingsProductId); + return this.productsService.getShareProduct(edit.savingsProductId, true); case 'FD': - return this.productsService.getFixedDepositProduct(edit.savingsProductId); + return this.productsService.getFixedDepositProductAndTemplate(edit.savingsProductId); case 'RD': - return this.productsService.getRecurringDepositProduct(edit.savingsProductId); + return this.productsService.getRecurringDepositProductAndTemplate(edit.savingsProductId); default: - return this.productsService.getSavingProduct(edit.savingsProductId); + return this.productsService.getSavingProduct(edit.savingsProductId, true); } } @@ -142,29 +145,27 @@ export class PendingSavingsProductsComponent { } viewDiff(edit: any): void { - this.selectedEdit = edit; - this.diffRows = []; - this.getOriginalProduct$(edit).subscribe((original: any) => { - this.originalProduct = original; - const proposed = edit.proposedChanges || {}; - const keys = Object.keys(proposed); - this.diffRows = keys.map((key) => { - const originalValue = original ? original[key] : undefined; - const proposedValue = proposed[key]; - return { - label: key, - original: originalValue, - proposed: proposedValue, - changed: JSON.stringify(originalValue) !== JSON.stringify(proposedValue) - }; - }); + this.loadingDiffFor = edit; + this.getOriginalProduct$(edit).subscribe({ + next: (original: any) => { + this.loadingDiffFor = null; + this.dialog.open(ProductEditDiffDialogComponent, { + width: '820px', + data: { + productName: edit.savingsProductName, + original: original ?? {}, + proposed: edit.proposedChanges ?? {} + } + }); + }, + error: () => { + this.loadingDiffFor = null; + } }); } closeDiff(): void { - this.selectedEdit = null; - this.originalProduct = null; - this.diffRows = []; + this.loadingDiffFor = null; } approve(edit: any): void { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html new file mode 100644 index 000000000..4e143f412 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html @@ -0,0 +1,53 @@ + +

+ difference + + Proposed changes — {{ productName }} + +

+ +
+ @if (rows.length === 0) { +
+ check_circle +

No changes to review

+

+ This edit was submitted without changing any product setting — every submitted value matches the current + product. +

+
+ } @else { +

+ {{ rows.length }} field{{ rows.length === 1 ? '' : 's' }} changed. Unchanged fields are hidden. +

+ + + + + + + + + + + @for (row of rows; track row.label) { + + + + + + } + +
FieldCurrentProposed
{{ row.label }}{{ row.original }}{{ row.proposed }}
+ } +
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss new file mode 100644 index 000000000..3a82f2792 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss @@ -0,0 +1,115 @@ +/** + * 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; +$muted: #64748b; + +.ped-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; + font-weight: 600; + color: #0f172a; + border-bottom: 1px solid $border; + padding-bottom: 12px; + margin-bottom: 0; + + &__icon { + color: #0ea5e9; + } +} + +.ped-content { + max-height: 70vh; + overflow-y: auto; + padding-top: 16px; +} + +.ped-summary { + margin: 0 0 14px; + font-size: 12.5px; + color: $muted; +} + +.ped-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th, + td { + text-align: left; + padding: 9px 12px; + border-bottom: 1px solid $border; + vertical-align: top; + } + + th { + color: $muted; + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.4px; + background: #f8fafc; + position: sticky; + top: 0; + } + + tbody tr:hover { + background: #fffbeb; + } + + &__label { + font-weight: 600; + color: #1e293b; + width: 34%; + } + + &__old { + color: #b91c1c; + text-decoration: line-through; + width: 33%; + } + + &__new { + color: #15803d; + font-weight: 600; + width: 33%; + } +} + +.ped-empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 32px 16px; + + &__icon { + font-size: 40px; + width: 40px; + height: 40px; + color: #16a34a; + margin-bottom: 10px; + } + + &__title { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + color: #1e293b; + } + + &__text { + margin: 0; + font-size: 12.5px; + color: $muted; + max-width: 420px; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts new file mode 100644 index 000000000..43e1e7dea --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -0,0 +1,261 @@ +/** + * 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 { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +interface DiffRow { + label: string; + original: string; + proposed: string; +} + +/** + * Shows what a pending product edit actually changes: only the fields whose value differs, with the + * original and proposed values side by side. The submitted payload carries every field the edit form + * posted, most of them unchanged, so listing all of them buries the handful a checker needs to look at. + */ +@Component({ + selector: 'mifosx-product-edit-diff-dialog', + templateUrl: './product-edit-diff-dialog.component.html', + styleUrls: ['./product-edit-diff-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + MatIcon + ] +}) +export class ProductEditDiffDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + productName = ''; + rows: DiffRow[] = []; + + /** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. */ + private static readonly IGNORED_KEYS = new Set([ + 'locale', + 'dateFormat', + 'monthDayFormat', + 'id', + 'productId', + 'savingsProductId' + ]); + + /** + * Payload fields the stored product exposes under a different path, so the "current" side has + * something to compare against instead of always reading as newly set. + */ + private static readonly FIELD_ALIASES: Record = { + currencyCode: 'currency.code', + digitsAfterDecimal: 'currency.decimalPlaces', + inMultiplesOf: 'currency.inMultiplesOf', + taxGroupId: 'taxGroup.id' + }; + + /** + * GL-account fields, keyed by their payload name, mapped to where the stored product keeps the + * expanded account under `accountingMappings`. Their option lists live pooled under + * `accountingMappingOptions` rather than a per-field `Options` key, so both sides of the + * diff need this table to turn an account id into the account's name. + */ + private static readonly GL_FIELD_TO_MAPPING: Record = { + savingsReferenceAccountId: 'savingsReferenceAccount', + savingsControlAccountId: 'savingsControlAccount', + transfersInSuspenseAccountId: 'transfersInSuspenseAccount', + interestOnSavingsAccountId: 'interestOnSavingsAccount', + incomeFromFeeAccountId: 'incomeFromFeeAccount', + incomeFromPenaltyAccountId: 'incomeFromPenaltyAccount', + incomeFromInterestId: 'incomeFromInterest', + overdraftPortfolioControlId: 'overdraftPortfolioControl', + writeOffAccountId: 'writeOffAccount', + feesReceivableAccountId: 'feeReceivableAccount', + penaltiesReceivableAccountId: 'penaltyReceivableAccount', + interestPayableAccountId: 'interestPayableAccount', + interestReceivableAccountId: 'interestReceivableAccount', + escheatLiabilityId: 'escheatLiabilityAccount' + }; + + private glAccountNamesById = new Map(); + + ngOnInit(): void { + this.productName = this.data.productName ?? ''; + const original = this.data.original ?? {}; + const proposed = this.data.proposed ?? {}; + this.buildGlAccountLookup(original); + + this.rows = Object.keys(proposed) + .filter((key) => !ProductEditDiffDialogComponent.IGNORED_KEYS.has(key)) + .map((key) => ({ key, originalValue: this.originalValueFor(key, original) })) + .filter(({ key, originalValue }) => !this.sameValue(originalValue, proposed[key])) + .map(({ key, originalValue }) => ({ + label: this.humanizeKey(key), + original: this.displayValue(originalValue, key, original), + proposed: this.displayValue(proposed[key], key, original) + })) + .sort((a, b) => a.label.localeCompare(b.label)); + } + + /** Pools every GL account the template offers so an account id can be shown as its name. */ + private buildGlAccountLookup(template: any): void { + const optionGroups = template?.accountingMappingOptions ?? {}; + for (const group of Object.values(optionGroups)) { + if (!Array.isArray(group)) { + continue; + } + for (const account of group) { + if (account?.id === undefined) { + continue; + } + const label = account.name ?? account.glCode ?? null; + if (label) { + this.glAccountNamesById.set(String(account.id), account.glCode ? `${label} (${account.glCode})` : label); + } + } + } + } + + /** Reads the current value for a payload field, following an alias or GL mapping when needed. */ + private originalValueFor(key: string, original: any): any { + const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key]; + if (glMappingKey) { + return original?.accountingMappings?.[glMappingKey] ?? null; + } + const alias = ProductEditDiffDialogComponent.FIELD_ALIASES[key]; + if (alias) { + return alias.split('.').reduce((acc: any, part: string) => (acc == null ? acc : acc[part]), original); + } + return original?.[key]; + } + + /** + * Enum/option fields arrive from the edit as a bare id, which reads as a meaningless number in the + * table. The product fetched with template=true carries the matching option list under a predictable + * `Options` key (accountingRule -> accountingRuleOptions), so the id is resolved back to the + * label the maker actually picked. Returns null when there is no such list to resolve against. + */ + private resolveOptionLabel(fieldKey: string, id: any, template: any): string | null { + if (ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[fieldKey]) { + return this.glAccountNamesById.get(String(id)) ?? null; + } + const options = template?.[`${fieldKey}Options`]; + if (!Array.isArray(options)) { + return null; + } + const match = options.find((option: any) => String(option?.id) === String(id)); + if (!match) { + return null; + } + return match.value ?? match.name ?? match.code ?? null; + } + + /** + * The stored product returns enum/option fields as objects ({id, value}) while the submitted payload + * carries just the id, so a raw comparison reports every such field as changed. Comparing on the id + * (and coercing numeric strings) keeps the list to fields whose value genuinely moved. + */ + private sameValue(a: any, b: any): boolean { + return JSON.stringify(this.normalize(a)) === JSON.stringify(this.normalize(b)); + } + + private normalize(value: any): any { + if (value === null || value === undefined || value === '') { + return null; + } + // Dates come back from the product as [yyyy, m, d] but are submitted as "yyyy-MM-dd"; without + // folding both to one form every date field reads as changed on every edit. + const asDate = this.toIsoDate(value); + if (asDate !== null) { + return asDate; + } + if (Array.isArray(value)) { + // An empty array and an absent value mean the same thing here. + return value.length === 0 ? null : value.map((item) => this.normalize(item)); + } + if (typeof value === 'object') { + if (value.id !== undefined) { + return this.normalize(value.id); + } + return Object.keys(value).length === 0 ? null : JSON.stringify(value); + } + if (typeof value === 'boolean') { + return value; + } + const asNumber = Number(value); + return Number.isNaN(asNumber) ? value : asNumber; + } + + /** Recognises both date shapes the API uses and returns "yyyy-MM-dd", or null if not a date. */ + private toIsoDate(value: any): string | null { + if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) { + const [ + year, + month, + day + ] = value; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + return null; + } + + private displayValue(value: any, fieldKey: string, template: any): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + const asDate = this.toIsoDate(value); + if (asDate !== null) { + return asDate; + } + if (Array.isArray(value)) { + return value.length === 0 ? '—' : `${value.length} item${value.length === 1 ? '' : 's'}`; + } + if (typeof value === 'object') { + if (Object.keys(value).length === 0) { + return '—'; + } + // An expanded GL account -- shown the same way as the resolved id on the other side. + if (value.name && value.glCode) { + return `${value.name} (${value.glCode})`; + } + // Already an expanded {id, value} option from the stored product. + return value.value ?? value.name ?? value.code ?? (value.id !== undefined ? String(value.id) : '—'); + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + // A bare id that names an option -- show the option's label, not the number. + return this.resolveOptionLabel(fieldKey, value, template) ?? String(value); + } + + /** camelCase / PascalCase field name to a spaced, capitalised label. */ + private humanizeKey(key: string): string { + const spaced = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); + } +}