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:
+11
@@ -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(
|
||||
[
|
||||
'../',
|
||||
|
||||
+2
@@ -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">
|
||||
|
||||
+2
@@ -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
-1
@@ -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
-1
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
+11
@@ -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(
|
||||
[
|
||||
'../',
|
||||
|
||||
+2
@@ -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">
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+11
@@ -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
-1
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
+55
@@ -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>
|
||||
}
|
||||
+108
@@ -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;
|
||||
}
|
||||
}
|
||||
+210
@@ -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])
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user