diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts index 9e3e84583..fb5046977 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts @@ -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( [ '../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html index 1088a50ab..7a26b5f79 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html @@ -7,6 +7,8 @@ -->
+ +

diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts index 00e11022e..24059b71f 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts @@ -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, diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts index 7272967f4..716396d55 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts @@ -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 }); }); } diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts index ae086e225..08abc1ad2 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts @@ -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 }); }); } diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts index 8c0e5ce92..a58a558da 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts @@ -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( [ '../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html index 4e56b3d84..3e2cdf8a5 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html @@ -7,6 +7,8 @@ -->
+ +

diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts index 41d218989..706f2c846 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts @@ -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, diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts index a83a58af3..2aacfa487 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts @@ -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( [ '../../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts index 85a6a1e6c..810b80da9 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts @@ -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 }); }); } diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html new file mode 100644 index 000000000..537a2e6a2 --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html @@ -0,0 +1,55 @@ + + +@if (rows.length > 0) { +
+
+ + {{ rows.length }} request{{ rows.length > 1 ? 's' : '' }} awaiting approval + These changes take effect only after a checker approves them. + Open Approvals → +
+ + + + + + + + + + + + @for (row of rows; track row.id) { + + + + + + + + } + +
ActionRecordWhat changesRequested byRequested on
+ {{ row.action }} + {{ row.subject }} + @if (row.changes.length > 0) { + @for (change of row.changes; track change.label) { +
+ {{ change.label }} + {{ change.oldValue }} + → + {{ change.newValue }} +
+ } + } @else { + {{ row.detail }} + } +
{{ row.maker }}{{ row.madeOn | date: 'dd MMM yyyy, hh:mm a' }}
+
+} diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss new file mode 100644 index 000000000..e913e9bad --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss @@ -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; + } +} diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts new file mode 100644 index 000000000..34d471f4e --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts @@ -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 { + 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( + (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): 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]) + }) + ); + } +} diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index 8c5759a99..779a0d773 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -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 { + 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 { return this.http.get(`/audits/${id}`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); diff --git a/src/app/tasks/maker-checker-entity-fetch.ts b/src/app/tasks/maker-checker-entity-fetch.ts index bc0814747..a9d7845e2 100644 --- a/src/app/tasks/maker-checker-entity-fetch.ts +++ b/src/app/tasks/maker-checker-entity-fetch.ts @@ -63,6 +63,9 @@ const ENTITY_FETCHERS: Record 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 = { 'email', 'officeName' ], + DELINQUENCY_RANGE: [ + 'classification', + 'minimumAgeDays', + 'maximumAgeDays' + ], + DELINQUENCY_BUCKET: ['name'], GLACCOUNT: [ 'name', 'glCode', diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts index e2b7b1aef..22a4a07b5 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts @@ -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): 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( + (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( + (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.