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) <noreply@anthropic.com>
This commit is contained in:
+16
@@ -83,6 +83,22 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (fixedDepositsAccountData.reinvestedFromAccountNo) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Reinvested From' | translate }}</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
<a [routerLink]="reinvestedFromLink">{{ fixedDepositsAccountData.reinvestedFromAccountNo }}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (fixedDepositsAccountData.reinvestedIntoAccountNo) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Reinvested Into' | translate }}</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
<a [routerLink]="reinvestedIntoLink">{{ fixedDepositsAccountData.reinvestedIntoAccountNo }}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+21
@@ -48,4 +48,25 @@ export class GeneralTabComponent {
|
|||||||
this.entityType = 'Center';
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-33
@@ -49,8 +49,13 @@
|
|||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
<td mat-cell *matCellDef="let edit">
|
<td mat-cell *matCellDef="let edit">
|
||||||
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="viewDiff(edit)">
|
<button
|
||||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
mat-stroked-button
|
||||||
|
class="psp-btn psp-btn-view"
|
||||||
|
[disabled]="loadingDiffFor === edit"
|
||||||
|
(click)="viewDiff(edit)"
|
||||||
|
>
|
||||||
|
<fa-icon icon="eye" class="m-r-10"></fa-icon>{{ loadingDiffFor === edit ? 'Loading…' : 'Compare' }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-stroked-button class="psp-btn psp-btn-approve" (click)="approve(edit)">
|
<button mat-stroked-button class="psp-btn psp-btn-approve" (click)="approve(edit)">
|
||||||
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
|
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
|
||||||
@@ -65,37 +70,6 @@
|
|||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (selectedEdit) {
|
|
||||||
<div class="psp-diff-panel">
|
|
||||||
<div class="psp-diff-header">
|
|
||||||
<h3>Comparing "{{ selectedEdit.savingsProductName }}" — Original vs Proposed</h3>
|
|
||||||
<button mat-icon-button (click)="closeDiff()"><fa-icon icon="times"></fa-icon></button>
|
|
||||||
</div>
|
|
||||||
@if (diffRows.length === 0) {
|
|
||||||
<p class="psp-diff-loading">Loading comparison…</p>
|
|
||||||
} @else {
|
|
||||||
<table class="psp-diff-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Field</th>
|
|
||||||
<th>Original</th>
|
|
||||||
<th>Proposed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of diffRows; track row.label) {
|
|
||||||
<tr [class.psp-diff-row--changed]="row.changed">
|
|
||||||
<td class="psp-diff-label">{{ row.label }}</td>
|
|
||||||
<td class="psp-diff-original">{{ row.original }}</td>
|
|
||||||
<td class="psp-diff-proposed">{{ row.proposed }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!showData) {
|
@if (!showData) {
|
||||||
|
|||||||
-58
@@ -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 {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+31
-30
@@ -23,10 +23,10 @@ import {
|
|||||||
} from '@angular/material/table';
|
} from '@angular/material/table';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatIconButton } from '@angular/material/button';
|
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
import { ProductsService } from 'app/products/products.service';
|
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 { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||||
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
|
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,
|
MatHeaderRow,
|
||||||
MatRowDef,
|
MatRowDef,
|
||||||
MatRow,
|
MatRow,
|
||||||
MatIcon,
|
MatIcon
|
||||||
MatIconButton
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PendingSavingsProductsComponent {
|
export class PendingSavingsProductsComponent {
|
||||||
@@ -72,9 +71,8 @@ export class PendingSavingsProductsComponent {
|
|||||||
'actions'
|
'actions'
|
||||||
];
|
];
|
||||||
|
|
||||||
selectedEdit: any = null;
|
/** Set only while the original product is being fetched, so the button can show progress. */
|
||||||
originalProduct: any = null;
|
loadingDiffFor: any = null;
|
||||||
diffRows: { label: string; original: any; proposed: any; changed: boolean }[] = [];
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.route.data.subscribe((data: { pendingSavingsProductsData: any }) => {
|
this.route.data.subscribe((data: { pendingSavingsProductsData: any }) => {
|
||||||
@@ -96,18 +94,23 @@ export class PendingSavingsProductsComponent {
|
|||||||
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
|
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) {
|
private getOriginalProduct$(edit: any) {
|
||||||
switch (edit.productType) {
|
switch (edit.productType) {
|
||||||
case 'CURRENT':
|
case 'CURRENT':
|
||||||
return this.productsService.getCurrentProduct(edit.savingsProductId);
|
return this.productsService.getCurrentProduct(edit.savingsProductId, true);
|
||||||
case 'SHARE':
|
case 'SHARE':
|
||||||
return this.productsService.getShareProduct(edit.savingsProductId);
|
return this.productsService.getShareProduct(edit.savingsProductId, true);
|
||||||
case 'FD':
|
case 'FD':
|
||||||
return this.productsService.getFixedDepositProduct(edit.savingsProductId);
|
return this.productsService.getFixedDepositProductAndTemplate(edit.savingsProductId);
|
||||||
case 'RD':
|
case 'RD':
|
||||||
return this.productsService.getRecurringDepositProduct(edit.savingsProductId);
|
return this.productsService.getRecurringDepositProductAndTemplate(edit.savingsProductId);
|
||||||
default:
|
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 {
|
viewDiff(edit: any): void {
|
||||||
this.selectedEdit = edit;
|
this.loadingDiffFor = edit;
|
||||||
this.diffRows = [];
|
this.getOriginalProduct$(edit).subscribe({
|
||||||
this.getOriginalProduct$(edit).subscribe((original: any) => {
|
next: (original: any) => {
|
||||||
this.originalProduct = original;
|
this.loadingDiffFor = null;
|
||||||
const proposed = edit.proposedChanges || {};
|
this.dialog.open(ProductEditDiffDialogComponent, {
|
||||||
const keys = Object.keys(proposed);
|
width: '820px',
|
||||||
this.diffRows = keys.map((key) => {
|
data: {
|
||||||
const originalValue = original ? original[key] : undefined;
|
productName: edit.savingsProductName,
|
||||||
const proposedValue = proposed[key];
|
original: original ?? {},
|
||||||
return {
|
proposed: edit.proposedChanges ?? {}
|
||||||
label: key,
|
}
|
||||||
original: originalValue,
|
});
|
||||||
proposed: proposedValue,
|
},
|
||||||
changed: JSON.stringify(originalValue) !== JSON.stringify(proposedValue)
|
error: () => {
|
||||||
};
|
this.loadingDiffFor = null;
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
closeDiff(): void {
|
closeDiff(): void {
|
||||||
this.selectedEdit = null;
|
this.loadingDiffFor = null;
|
||||||
this.originalProduct = null;
|
|
||||||
this.diffRows = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
approve(edit: any): void {
|
approve(edit: any): void {
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
<h2 mat-dialog-title class="ped-title">
|
||||||
|
<mat-icon class="ped-title__icon">difference</mat-icon>
|
||||||
|
<span>
|
||||||
|
Proposed changes — <strong>{{ productName }}</strong>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div mat-dialog-content class="ped-content">
|
||||||
|
@if (rows.length === 0) {
|
||||||
|
<div class="ped-empty">
|
||||||
|
<mat-icon class="ped-empty__icon">check_circle</mat-icon>
|
||||||
|
<p class="ped-empty__title">No changes to review</p>
|
||||||
|
<p class="ped-empty__text">
|
||||||
|
This edit was submitted without changing any product setting — every submitted value matches the current
|
||||||
|
product.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="ped-summary">
|
||||||
|
<strong>{{ rows.length }}</strong> field{{ rows.length === 1 ? '' : 's' }} changed. Unchanged fields are hidden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="ped-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Current</th>
|
||||||
|
<th>Proposed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of rows; track row.label) {
|
||||||
|
<tr>
|
||||||
|
<td class="ped-table__label">{{ row.label }}</td>
|
||||||
|
<td class="ped-table__old">{{ row.original }}</td>
|
||||||
|
<td class="ped-table__new">{{ row.proposed }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+115
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+261
@@ -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<ProductEditDiffDialogComponent>>(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<string, string> = {
|
||||||
|
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 `<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<string, string> = {
|
||||||
|
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<string, string>();
|
||||||
|
|
||||||
|
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<any>(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
|
||||||
|
* `<field>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user