feat(delinquency): maker-checker UI for Delinquency Ranges and Buckets

Checker inbox now resolves DELINQUENCY_RANGE/DELINQUENCY_BUCKET entries
to readable diffs instead of raw JSON, and each bucket/range screen
shows its own pending-approval state via a new pending-requests panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:52:11 +05:30
co-authored by Claude Sonnet 5
parent d61584f291
commit afcd3af918
16 changed files with 539 additions and 3 deletions
@@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
@@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateBucketComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router);
private route = inject(ActivatedRoute);
dialog = inject(MatDialog);
@@ -174,6 +176,15 @@ export class CreateBucketComponent implements OnInit {
};
this.productsService.createDelinquencyBucket(data).subscribe((response: any) => {
// Maker-checker: the request waits in the Checker Inbox, so there is no bucket to open yet.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency bucket request submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
this.router.navigate(
[
'../',
@@ -7,6 +7,8 @@
-->
<div class="container">
<mifosx-delinquency-pending-requests entityName="DELINQUENCY_BUCKET"></mifosx-delinquency-pending-requests>
<div class="table-header">
<div class="table-title-group">
<h2 class="table-title">
@@ -24,6 +24,7 @@ import {
} from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
DelinquencyPendingRequestsComponent,
MatTable,
MatSort,
MatColumnDef,
@@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
@@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class EditBucketComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router);
private route = inject(ActivatedRoute);
dialog = inject(MatDialog);
@@ -191,7 +193,14 @@ export class EditBucketComponent implements OnInit {
ranges: ranges
};
this.productsService.updateDelinquencyBucket(this.delinquencyBucketId, data).subscribe(() => {
this.productsService.updateDelinquencyBucket(this.delinquencyBucketId, data).subscribe((response: any) => {
// Maker-checker: the change is applied only after a checker approves it.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency bucket change submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -28,6 +29,7 @@ export class ViewBucketComponent {
private router = inject(Router);
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
/** Delinquency Bucket Data. */
delinquencyBucketData: any;
@@ -47,7 +49,14 @@ export class ViewBucketComponent {
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.productsService.deleteDelinquencyBucket(this.delinquencyBucketData.id).subscribe(() => {
this.productsService.deleteDelinquencyBucket(this.delinquencyBucketData.id).subscribe((result: any) => {
// Maker-checker: the bucket is deleted only after a checker approves.
if (result?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency bucket delete request submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -24,6 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateRangeComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
@@ -71,6 +73,15 @@ export class CreateRangeComponent implements OnInit {
locale
};
this.productsService.createDelinquencyRange(data).subscribe((response: any) => {
// Maker-checker: the request waits in the Checker Inbox, so there is no range to open yet.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency range request submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
this.router.navigate(
[
'../',
@@ -7,6 +7,8 @@
-->
<div class="container">
<mifosx-delinquency-pending-requests entityName="DELINQUENCY_RANGE"></mifosx-delinquency-pending-requests>
<div class="table-header">
<div class="table-title-group">
<h2 class="table-title">
@@ -24,6 +24,7 @@ import {
} from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
DelinquencyPendingRequestsComponent,
MatTable,
MatSort,
MatColumnDef,
@@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -24,6 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class EditRangeComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
@@ -78,6 +80,15 @@ export class EditRangeComponent implements OnInit {
locale
};
this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => {
// Maker-checker: the change is applied only after a checker approves it.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency range change submitted for approval.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
return;
}
this.router.navigate(
[
'../../',
@@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -28,6 +29,7 @@ export class ViewRangeComponent {
private router = inject(Router);
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
/** Delinquency Range Data. */
delinquencyRangeData: any;
@@ -44,7 +46,14 @@ export class ViewRangeComponent {
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.productsService.deleteDelinquencyRange(this.delinquencyRangeData.id).subscribe(() => {
this.productsService.deleteDelinquencyRange(this.delinquencyRangeData.id).subscribe((result: any) => {
// Maker-checker: the range is deleted only after a checker approves.
if (result?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency range delete request submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -0,0 +1,55 @@
<!--
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/.
-->
@if (rows.length > 0) {
<div class="dpr">
<div class="dpr__head">
<i class="fas fa-hourglass-half"></i>
<strong>{{ rows.length }} request{{ rows.length > 1 ? 's' : '' }} awaiting approval</strong>
<span class="dpr__sub">These changes take effect only after a checker approves them.</span>
<a class="dpr__link" routerLink="/checker-inbox-and-tasks/checker-inbox">Open Approvals →</a>
</div>
<table class="dpr__table">
<thead>
<tr>
<th>Action</th>
<th>Record</th>
<th>What changes</th>
<th>Requested by</th>
<th>Requested on</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.id) {
<tr>
<td>
<span class="dpr__chip">{{ row.action }}</span>
</td>
<td>{{ row.subject }}</td>
<td>
@if (row.changes.length > 0) {
@for (change of row.changes; track change.label) {
<div class="dpr__change">
<span class="dpr__field">{{ change.label }}</span>
<span class="dpr__old">{{ change.oldValue }}</span>
<span class="dpr__arrow">→</span>
<span class="dpr__new">{{ change.newValue }}</span>
</div>
}
} @else {
{{ row.detail }}
}
</td>
<td>{{ row.maker }}</td>
<td>{{ row.madeOn | date: 'dd MMM yyyy, hh:mm a' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@@ -0,0 +1,108 @@
/**
* 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/.
*/
.dpr {
margin-bottom: 16px;
border: 1px solid #fcd34d;
border-radius: 10px;
background: #fffbeb;
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 13px;
color: #78350f;
i {
color: #d97706;
}
}
&__sub {
color: #92400e;
}
&__link {
margin-left: auto;
font-weight: 600;
color: #92400e;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
padding: 8px 16px;
font-size: 11px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #92400e;
border-top: 1px solid #fde68a;
}
td {
padding: 8px 16px;
border-top: 1px solid #fde68a;
color: #1e293b;
}
}
&__change {
display: flex;
align-items: baseline;
gap: 8px;
padding: 2px 0;
}
&__field {
min-width: 90px;
font-size: 12px;
color: #92400e;
}
&__old {
padding: 0 6px;
border-radius: 4px;
background: #fee2e2;
color: #991b1b;
text-decoration: line-through;
}
&__arrow {
color: #92400e;
}
&__new {
padding: 0 6px;
border-radius: 4px;
background: #dcfce7;
color: #166534;
font-weight: 600;
}
&__chip {
display: inline-block;
padding: 1px 10px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 600;
}
}
@@ -0,0 +1,210 @@
/**
* 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, Input, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
/** One field that a pending request would change: what it is now, and what it would become. */
interface PendingChange {
label: string;
oldValue: string;
newValue: string;
}
interface PendingRow {
id: number;
action: string;
/** What the request is about, e.g. the range or bucket name. */
subject: string;
/** UPDATE: the fields that move, old -> new. */
changes: PendingChange[];
/** CREATE / DELETE: a plain description of what is being added or removed. */
detail: string;
maker: string;
madeOn: Date | null;
}
/**
* Lists the Delinquency Range / Bucket requests that a maker has submitted and a checker has not yet
* approved. Without this the list pages look unchanged after a submit, so the maker cannot tell the
* request exists, what it would change, or where to approve it.
*/
@Component({
selector: 'mifosx-delinquency-pending-requests',
templateUrl: './delinquency-pending-requests.component.html',
styleUrls: ['./delinquency-pending-requests.component.scss'],
imports: [
DatePipe,
RouterLink
]
})
export class DelinquencyPendingRequestsComponent implements OnInit {
private productsService = inject(ProductsService);
/** DELINQUENCY_RANGE or DELINQUENCY_BUCKET. */
@Input({ required: true }) entityName: string;
rows: PendingRow[] = [];
ngOnInit(): void {
this.productsService
.getPendingMakerCheckerByEntity(this.entityName)
.pipe(
catchError(() => of([])),
switchMap((entries: any[]) => {
if (!entries || entries.length === 0) {
return of([] as PendingRow[]);
}
// The list call has no payload and no current values. The payload comes from the audit entry,
// and the current range or bucket from the normal read calls, so an UPDATE can show old -> new.
return forkJoin(entries.map((entry) => this.buildRow(entry)));
})
)
.subscribe((rows) => (this.rows = rows));
}
private buildRow(entry: any): Observable<PendingRow> {
const isBucket = this.entityName === 'DELINQUENCY_BUCKET';
const action = (entry.actionName ?? '').toUpperCase();
const hasTarget = action !== 'CREATE' && entry.resourceId !== undefined && entry.resourceId !== null;
const audit$ = this.productsService.getLoanRateRegisterAuditDetail(entry.id).pipe(catchError(() => of(null)));
const current$ = hasTarget
? (isBucket
? this.productsService.getDelinquencyBucket(entry.resourceId)
: this.productsService.getDelinquencyRange(entry.resourceId)
).pipe(catchError(() => of(null)))
: of(null);
// Bucket ranges are stored and submitted as ids; the names come from the range list.
const ranges$ = isBucket ? this.productsService.getDelinquencyRanges().pipe(catchError(() => of([]))) : of([]);
return forkJoin({ audit: audit$, current: current$, ranges: ranges$ }).pipe(
map(({ audit, current, ranges }) => {
let payload: any = null;
try {
payload = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : null;
} catch {
payload = null;
}
const rangesById = new Map<number, any>(
(ranges ?? []).map((range: any) => [
Number(range.id),
range
])
);
const row: PendingRow = {
id: entry.id,
action,
subject: '',
changes: [],
detail: '',
maker: entry.maker,
madeOn: entry.madeOnDate ? new Date(Number(entry.madeOnDate) * 1000) : null
};
if (isBucket) {
this.fillBucket(row, action, payload, current, rangesById);
} else {
this.fillRange(row, action, payload, current);
}
if (!row.subject) {
row.subject = entry.resourceId ? `#${entry.resourceId}` : '—';
}
return row;
})
);
}
private fillRange(row: PendingRow, action: string, payload: any, current: any): void {
const describe = (range: any) =>
`${range.classification} (${range.minimumAgeDays} - ${range.maximumAgeDays ?? 'no limit'} days)`;
if (action === 'CREATE' && payload) {
row.subject = payload.classification ?? '';
row.detail = `New range: ${describe(payload)}`;
} else if (action === 'DELETE' && current) {
row.subject = current.classification ?? '';
row.detail = `Range to be deleted: ${describe(current)}`;
} else if (action === 'UPDATE' && current) {
row.subject = current.classification ?? '';
const fields: [string, string][] = [
[
'classification',
'Classification'
],
[
'minimumAgeDays',
'Days from'
],
[
'maximumAgeDays',
'Days till'
]
];
row.changes = this.diff(fields, payload, current);
if (row.changes.length === 0) {
row.detail = 'No field value changes';
}
}
}
private fillBucket(row: PendingRow, action: string, payload: any, current: any, rangesById: Map<number, any>): void {
const names = (ids: any[]) =>
(ids ?? [])
.map((entry) => rangesById.get(Number(entry?.id ?? entry)) ?? { id: Number(entry?.id ?? entry) })
.sort((a, b) => (a.minimumAgeDays ?? 0) - (b.minimumAgeDays ?? 0))
.map((range) => range.classification ?? `Range #${range.id}`)
.join(', ');
if (action === 'CREATE' && payload) {
row.subject = payload.name ?? '';
row.detail = `New bucket with ranges: ${names(payload.ranges) || '—'}`;
} else if (action === 'DELETE' && current) {
row.subject = current.name ?? '';
row.detail = `Bucket to be deleted (ranges: ${names(current.ranges) || '—'})`;
} else if (action === 'UPDATE' && current && payload) {
row.subject = current.name ?? '';
const before = { name: current.name, ranges: names(current.ranges) };
const after = { name: payload.name ?? current.name, ranges: names(payload.ranges ?? current.ranges) };
row.changes = this.diff(
[
[
'name',
'Name'
],
[
'ranges',
'Ranges'
]
],
after,
before
);
if (row.changes.length === 0) {
row.detail = 'No field value changes';
}
}
}
/** The fields whose submitted value differs from today's. */
private diff(fields: [string, string][], submitted: any, current: any): PendingChange[] {
return fields
.filter(([key]) => submitted && submitted[key] !== undefined && String(submitted[key]) !== String(current?.[key]))
.map(
([
key,
label
]) => ({
label,
oldValue: String(current?.[key] ?? '—'),
newValue: String(submitted[key])
})
);
}
}
+6
View File
@@ -830,6 +830,12 @@ export class ProductsService {
return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
}
/** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */
getPendingMakerCheckerByEntity(entityName: string): Observable<any> {
const params = new HttpParams().set('entityName', entityName);
return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
}
/** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */
getLoanRateRegisterAuditDetail(id: number): Observable<any> {
return this.http.get(`/audits/${id}`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
@@ -63,6 +63,9 @@ const ENTITY_FETCHERS: Record<string, (id: string, s: EntityFetchServices) => Ob
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(id, true),
// SMA / NPA classification master. The bucket is read as-is; the dialog resolves its range ids to names.
DELINQUENCY_RANGE: (id, s) => s.products.getDelinquencyRange(id),
DELINQUENCY_BUCKET: (id, s) => s.products.getDelinquencyBucket(id),
// ---- Accounts ----------------------------------------------------------------
SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id),
@@ -199,6 +202,12 @@ const DELETE_SUMMARY_KEYS: Record<string, string[]> = {
'email',
'officeName'
],
DELINQUENCY_RANGE: [
'classification',
'minimumAgeDays',
'maximumAgeDays'
],
DELINQUENCY_BUCKET: ['name'],
GLACCOUNT: [
'name',
'glCode',
@@ -10,6 +10,7 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import * as _ from 'lodash';
import { forkJoin } from 'rxjs';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
/** Custom Services */
@@ -417,6 +418,9 @@ export class ViewCheckerInboxComponent {
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
this.resolveGlAccountCreateLabels(detail, this.jsondata);
}
if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') {
this.resolveDelinquencyBucketCreateRanges(detail);
}
}
}
@@ -429,6 +433,10 @@ export class ViewCheckerInboxComponent {
* entity showed before.
*/
private loadUpdateChanges(detail: any): void {
if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') {
this.loadDelinquencyBucketChanges(detail);
return;
}
const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
if (!request$) {
return;
@@ -451,6 +459,78 @@ export class ViewCheckerInboxComponent {
});
}
/** One line per range for a delinquency bucket, e.g. 'SMA-1 (31 - 60 days)', oldest range first. */
private delinquencyRangeNames(ids: any[], rangesById: Map<number, any>): string {
return (ids ?? [])
.map((entry) => rangesById.get(Number(entry?.id ?? entry)) ?? { id: Number(entry?.id ?? entry) })
.sort((a, b) => (a.minimumAgeDays ?? 0) - (b.minimumAgeDays ?? 0))
.map((range) =>
range.classification
? `${range.classification} (${range.minimumAgeDays} - ${range.maximumAgeDays ?? 'no limit'} days)`
: `Range #${range.id}`
)
.join(', ');
}
/**
* Delinquency bucket UPDATE: the payload lists range ids, which mean nothing to a checker, and the stored
* bucket lists range objects, so a plain field diff would show two unreadable lists. Both sides are turned
* into the same readable range names first, so only a real difference is reported as OLD -> NEW.
*/
private loadDelinquencyBucketChanges(detail: any): void {
this.comparing = true;
forkJoin({
current: this.productsService.getDelinquencyBucket(detail?.resourceId),
ranges: this.productsService.getDelinquencyRanges()
}).subscribe({
next: ({ current, ranges }: { current: any; ranges: any }) => {
if (!this.isCurrent(detail)) {
return;
}
this.comparing = false;
const rangesById = new Map<number, any>(
(ranges ?? []).map((range: any) => [
Number(range.id),
range
])
);
const submitted = {
...(this.jsondata ?? {}),
ranges: this.delinquencyRangeNames(this.jsondata?.ranges, rangesById)
};
const existing = { ...(current ?? {}), ranges: this.delinquencyRangeNames(current?.ranges, rangesById) };
this.changes = diffCommandAgainstEntity(submitted, existing);
this.showChanges = true;
},
error: () => {
this.comparing = false;
}
});
}
/** Delinquency bucket CREATE: show the chosen ranges by name instead of their ids. */
private resolveDelinquencyBucketCreateRanges(detail: any): void {
this.productsService.getDelinquencyRanges().subscribe({
next: (ranges: any) => {
if (!this.isCurrent(detail)) {
return;
}
const rangesById = new Map<number, any>(
(ranges ?? []).map((range: any) => [
Number(range.id),
range
])
);
const resolved = {
...(this.jsondata ?? {}),
ranges: this.delinquencyRangeNames(this.jsondata?.ranges, rangesById)
};
this.createSections = this.buildCreateSections(resolved);
this.commandFields = this.buildCommandFields(resolved);
}
});
}
/**
* Loan Base Interest Rate: every submission is a CREATE of a whole new version (16 loan types x 10
* slabs plus CIBIL bands), so the raw payload hides the handful of rates the maker actually moved.