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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { ProductsService } from 'app/products/products.service';
|
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 { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-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';
|
||||||
@@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
export class CreateBucketComponent implements OnInit {
|
export class CreateBucketComponent implements OnInit {
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private productsService = inject(ProductsService);
|
private productsService = inject(ProductsService);
|
||||||
|
private alertService = inject(AlertService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
@@ -174,6 +176,15 @@ export class CreateBucketComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.productsService.createDelinquencyBucket(data).subscribe((response: any) => {
|
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(
|
this.router.navigate(
|
||||||
[
|
[
|
||||||
'../',
|
'../',
|
||||||
|
|||||||
+2
@@ -7,6 +7,8 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<mifosx-delinquency-pending-requests entityName="DELINQUENCY_BUCKET"></mifosx-delinquency-pending-requests>
|
||||||
|
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<div class="table-title-group">
|
<div class="table-title-group">
|
||||||
<h2 class="table-title">
|
<h2 class="table-title">
|
||||||
|
|||||||
+2
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '@angular/material/table';
|
} from '@angular/material/table';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
|
DelinquencyPendingRequestsComponent,
|
||||||
MatTable,
|
MatTable,
|
||||||
MatSort,
|
MatSort,
|
||||||
MatColumnDef,
|
MatColumnDef,
|
||||||
|
|||||||
+10
-1
@@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog';
|
|||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { ProductsService } from 'app/products/products.service';
|
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 { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-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';
|
||||||
@@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
export class EditBucketComponent implements OnInit {
|
export class EditBucketComponent implements OnInit {
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private productsService = inject(ProductsService);
|
private productsService = inject(ProductsService);
|
||||||
|
private alertService = inject(AlertService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
@@ -191,7 +193,14 @@ export class EditBucketComponent implements OnInit {
|
|||||||
ranges: ranges
|
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 });
|
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 { MatDialog } from '@angular/material/dialog';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { ProductsService } from 'app/products/products.service';
|
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 { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
@@ -28,6 +29,7 @@ export class ViewBucketComponent {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private productsService = inject(ProductsService);
|
private productsService = inject(ProductsService);
|
||||||
|
private alertService = inject(AlertService);
|
||||||
|
|
||||||
/** Delinquency Bucket Data. */
|
/** Delinquency Bucket Data. */
|
||||||
delinquencyBucketData: any;
|
delinquencyBucketData: any;
|
||||||
@@ -47,7 +49,14 @@ export class ViewBucketComponent {
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((response: any) => {
|
dialogRef.afterClosed().subscribe((response: any) => {
|
||||||
if (response.delete) {
|
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 });
|
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 { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { ProductsService } from 'app/products/products.service';
|
import { ProductsService } from 'app/products/products.service';
|
||||||
|
import { AlertService } from 'app/core/alert/alert.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
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 {
|
export class CreateRangeComponent implements OnInit {
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private productsService = inject(ProductsService);
|
private productsService = inject(ProductsService);
|
||||||
|
private alertService = inject(AlertService);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
@@ -71,6 +73,15 @@ export class CreateRangeComponent implements OnInit {
|
|||||||
locale
|
locale
|
||||||
};
|
};
|
||||||
this.productsService.createDelinquencyRange(data).subscribe((response: any) => {
|
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(
|
this.router.navigate(
|
||||||
[
|
[
|
||||||
'../',
|
'../',
|
||||||
|
|||||||
+2
@@ -7,6 +7,8 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<mifosx-delinquency-pending-requests entityName="DELINQUENCY_RANGE"></mifosx-delinquency-pending-requests>
|
||||||
|
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<div class="table-title-group">
|
<div class="table-title-group">
|
||||||
<h2 class="table-title">
|
<h2 class="table-title">
|
||||||
|
|||||||
+2
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '@angular/material/table';
|
} from '@angular/material/table';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
|
DelinquencyPendingRequestsComponent,
|
||||||
MatTable,
|
MatTable,
|
||||||
MatSort,
|
MatSort,
|
||||||
MatColumnDef,
|
MatColumnDef,
|
||||||
|
|||||||
+11
@@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
|
|||||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { ProductsService } from 'app/products/products.service';
|
import { ProductsService } from 'app/products/products.service';
|
||||||
|
import { AlertService } from 'app/core/alert/alert.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
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 {
|
export class EditRangeComponent implements OnInit {
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private productsService = inject(ProductsService);
|
private productsService = inject(ProductsService);
|
||||||
|
private alertService = inject(AlertService);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
@@ -78,6 +80,15 @@ export class EditRangeComponent implements OnInit {
|
|||||||
locale
|
locale
|
||||||
};
|
};
|
||||||
this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => {
|
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(
|
this.router.navigate(
|
||||||
[
|
[
|
||||||
'../../',
|
'../../',
|
||||||
|
|||||||
+10
-1
@@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core';
|
|||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { ProductsService } from 'app/products/products.service';
|
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 { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
@@ -28,6 +29,7 @@ export class ViewRangeComponent {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private productsService = inject(ProductsService);
|
private productsService = inject(ProductsService);
|
||||||
|
private alertService = inject(AlertService);
|
||||||
|
|
||||||
/** Delinquency Range Data. */
|
/** Delinquency Range Data. */
|
||||||
delinquencyRangeData: any;
|
delinquencyRangeData: any;
|
||||||
@@ -44,7 +46,14 @@ export class ViewRangeComponent {
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((response: any) => {
|
dialogRef.afterClosed().subscribe((response: any) => {
|
||||||
if (response.delete) {
|
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 });
|
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) });
|
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. */
|
/** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */
|
||||||
getLoanRateRegisterAuditDetail(id: number): Observable<any> {
|
getLoanRateRegisterAuditDetail(id: number): Observable<any> {
|
||||||
return this.http.get(`/audits/${id}`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
|
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),
|
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
|
||||||
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
|
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
|
||||||
CHARGE: (id, s) => s.products.getCharge(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 ----------------------------------------------------------------
|
// ---- Accounts ----------------------------------------------------------------
|
||||||
SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id),
|
SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id),
|
||||||
@@ -199,6 +202,12 @@ const DELETE_SUMMARY_KEYS: Record<string, string[]> = {
|
|||||||
'email',
|
'email',
|
||||||
'officeName'
|
'officeName'
|
||||||
],
|
],
|
||||||
|
DELINQUENCY_RANGE: [
|
||||||
|
'classification',
|
||||||
|
'minimumAgeDays',
|
||||||
|
'maximumAgeDays'
|
||||||
|
],
|
||||||
|
DELINQUENCY_BUCKET: ['name'],
|
||||||
GLACCOUNT: [
|
GLACCOUNT: [
|
||||||
'name',
|
'name',
|
||||||
'glCode',
|
'glCode',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -417,6 +418,9 @@ export class ViewCheckerInboxComponent {
|
|||||||
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
|
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
|
||||||
this.resolveGlAccountCreateLabels(detail, this.jsondata);
|
this.resolveGlAccountCreateLabels(detail, this.jsondata);
|
||||||
}
|
}
|
||||||
|
if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') {
|
||||||
|
this.resolveDelinquencyBucketCreateRanges(detail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +433,10 @@ export class ViewCheckerInboxComponent {
|
|||||||
* entity showed before.
|
* entity showed before.
|
||||||
*/
|
*/
|
||||||
private loadUpdateChanges(detail: any): void {
|
private loadUpdateChanges(detail: any): void {
|
||||||
|
if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') {
|
||||||
|
this.loadDelinquencyBucketChanges(detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
|
const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
|
||||||
if (!request$) {
|
if (!request$) {
|
||||||
return;
|
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
|
* 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.
|
* slabs plus CIBIL bands), so the raw payload hides the handful of rates the maker actually moved.
|
||||||
|
|||||||
Reference in New Issue
Block a user