implemented a Maker-Checker workflow for Products, with approval-based updates and product locking to prevent concurrent edits

This commit is contained in:
keshav898966
2026-07-03 15:08:47 +05:30
parent d00eac0aa1
commit 1ea4cfe8d8
25 changed files with 838 additions and 36 deletions
@@ -135,12 +135,19 @@
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_CURRENTPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
@@ -137,12 +137,19 @@
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_FIXEDDEPOSITPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
+100
View File
@@ -74,6 +74,26 @@ export class ProductsService {
return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct);
}
/**
* Submitting a savings product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Savings Product Edits screen.
*/
getPendingSavingsProductEdits(): Observable<any> {
return this.http.get('/savingsproducts/pending');
}
getPendingSavingsProductEdit(savingsProductId: string): Observable<any> {
return this.http.get(`/savingsproducts/${savingsProductId}/pending`);
}
approveSavingsProductEdit(savingsProductId: string): Observable<any> {
return this.http.post(`/savingsproducts/${savingsProductId}/approve`, {});
}
rejectSavingsProductEdit(savingsProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/savingsproducts/${savingsProductId}/reject`, { rejectReason });
}
/**
* @returns {Observable<any>} Current products data
*/
@@ -102,6 +122,26 @@ export class ProductsService {
return this.http.delete(`/currentproducts/${currentProductId}`);
}
/**
* Submitting a current product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingCurrentProductEdits(): Observable<any> {
return this.http.get('/currentproducts/pending');
}
getPendingCurrentProductEdit(currentProductId: string): Observable<any> {
return this.http.get(`/currentproducts/${currentProductId}/pending`);
}
approveCurrentProductEdit(currentProductId: string): Observable<any> {
return this.http.post(`/currentproducts/${currentProductId}/approve`, {});
}
rejectCurrentProductEdit(currentProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/currentproducts/${currentProductId}/reject`, { rejectReason });
}
/**
* @returns {Observable<any>} Share products data
*/
@@ -126,6 +166,26 @@ export class ProductsService {
return this.http.put(`/products/share/${shareProductId}`, shareProduct);
}
/**
* Submitting a share product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingShareProductEdits(): Observable<any> {
return this.http.get('/products/share/pending');
}
getPendingShareProductEdit(shareProductId: string): Observable<any> {
return this.http.get(`/products/share/${shareProductId}/pending`);
}
approveShareProductEdit(shareProductId: string): Observable<any> {
return this.http.post(`/products/share/${shareProductId}/approve`, {});
}
rejectShareProductEdit(shareProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/products/share/${shareProductId}/reject`, { rejectReason });
}
getDividends(shareProductId: string): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend`);
}
@@ -251,6 +311,26 @@ export class ProductsService {
return this.http.put(`/fixeddepositproducts/${fixedDepositProductId}`, fixedDepositProduct);
}
/**
* Submitting a fixed deposit product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingFixedDepositProductEdits(): Observable<any> {
return this.http.get('/fixeddepositproducts/pending');
}
getPendingFixedDepositProductEdit(fixedDepositProductId: string): Observable<any> {
return this.http.get(`/fixeddepositproducts/${fixedDepositProductId}/pending`);
}
approveFixedDepositProductEdit(fixedDepositProductId: string): Observable<any> {
return this.http.post(`/fixeddepositproducts/${fixedDepositProductId}/approve`, {});
}
rejectFixedDepositProductEdit(fixedDepositProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/fixeddepositproducts/${fixedDepositProductId}/reject`, { rejectReason });
}
/**
* @returns {Observable<any>} Tax Components data
*/
@@ -526,6 +606,26 @@ export class ProductsService {
return this.http.put(`/recurringdepositproducts/${recurringDepositProductId}`, recurringDepositProduct);
}
/**
* Submitting a recurring deposit product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingRecurringDepositProductEdits(): Observable<any> {
return this.http.get('/recurringdepositproducts/pending');
}
getPendingRecurringDepositProductEdit(recurringDepositProductId: string): Observable<any> {
return this.http.get(`/recurringdepositproducts/${recurringDepositProductId}/pending`);
}
approveRecurringDepositProductEdit(recurringDepositProductId: string): Observable<any> {
return this.http.post(`/recurringdepositproducts/${recurringDepositProductId}/approve`, {});
}
rejectRecurringDepositProductEdit(recurringDepositProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/recurringdepositproducts/${recurringDepositProductId}/reject`, { rejectReason });
}
/** Returns the active CBS base interest-rate version(s). */
getAllRdInterestRates(): Observable<any> {
return this.getRdBaseInterestRates();
@@ -176,7 +176,11 @@ export class EditRecurringDepositProductComponent {
...this.recurringDepositProductSettingsStep.recurringDepositProductSettings,
...this.recurringDepositProductChargesStep.recurringDepositProductCharges,
...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting,
charts: this.recurringDepositProductCharts,
// charts intentionally omitted here - the backend no longer requires a charts payload for RD
// updates once rdSlabs is present, and this getter's chart data comes from the shared CBS
// base-rate template (not this product's own chart), which caused spurious
// "chart.overlapping.from.and.end.dates" errors when a product's real chart had different
// dates. Still included in recurringDepositProductPreview below for the read-only display tab.
rdSlabs: this.activeBaseSlabs
};
}
@@ -240,7 +244,6 @@ export class EditRecurringDepositProductComponent {
submit() {
const recurringDepositProduct: any = this.pruneEmptyValues({
...this.recurringDepositProduct,
charts: this.recurringDepositProductCharts,
charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code
});
@@ -91,7 +91,11 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap
};
const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {};
const accountingRuleId = 2; // Always Cash
// Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves
// the product's own current accounting rule when editing - previously this was hardcoded to 2
// unconditionally, which silently forced every edited product onto Cash-based accounting (and
// its required GL mappings) even for products that were and should remain "None".
const accountingRuleId = this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2;
this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
@@ -147,12 +147,19 @@
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_RECURRINGDEPOSITPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
@@ -145,13 +145,19 @@
<button
type="button"
class="sp-action-btn sp-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.sp-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_SAVINGSPRODUCT'"
>
<fa-icon icon="edit"></fa-icon>
<span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
@@ -481,6 +481,14 @@
border-color: var(--sp-amber);
}
}
&--disabled,
&--disabled:hover {
background: #f1f5f9;
color: #94a3b8;
border-color: #e2e8f0;
cursor: not-allowed;
}
}
/* No match strip */
@@ -135,12 +135,19 @@
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_SHAREPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
@@ -28,6 +28,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { ProductsService } from 'app/products/products.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -60,6 +61,7 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
private router = inject(Router);
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private productsService = inject(ProductsService);
shareProductsData: any[] = [];
displayedColumns: string[] = [
@@ -101,6 +103,18 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
constructor() {
this.route.data.subscribe((data: { shareProducts: any }) => {
this.shareProductsData = data.shareProducts.pageItems;
// ShareProductData is immutable on the backend, so isPendingApproval isn't embedded per-row like the
// other product lists - cross-reference against the pending-edits list instead.
this.productsService.getPendingShareProductEdits().subscribe((pendingEdits: any[]) => {
const pendingProductIds = new Set((pendingEdits || []).map((e) => e.savingsProductId));
this.shareProductsData = this.shareProductsData.map((p) => ({
...p,
isPendingApproval: pendingProductIds.has(p.id)
}));
this.dataSource = new MatTableDataSource(this.shareProductsData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
});
});
}
@@ -117,6 +117,7 @@
[savingsAccount]="savingsAccount"
[savingsAccountDigital]="savingsAccountDigital"
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
[submitting]="submitting"
(submitEvent)="submit()"
>
</mifosx-savings-account-preview-step>
@@ -64,6 +64,8 @@ export class CreateSavingsAccountComponent {
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4;
/** Guards against duplicate submissions from a double-click while the create request is in flight. */
submitting = false;
@ViewChild(SavingsAccountDetailsStepComponent, { static: true })
savingsAccountDetailsStep: SavingsAccountDetailsStepComponent;
@@ -116,6 +118,9 @@ export class CreateSavingsAccountComponent {
}
submit() {
if (this.submitting) {
return;
}
const detailsStep = this.savingsAccountDetailsStep;
if (detailsStep.selectedAccountStatus === 'JOINT' && detailsStep.totalJointSharePercentage !== 100) {
this.alertService.alert({
@@ -177,6 +182,7 @@ export class CreateSavingsAccountComponent {
}));
}
this.submitting = true;
this.savingsService.createSavingsAccount(savingsAccount).subscribe({
next: (response: any) => {
this.router.navigate(
@@ -188,6 +194,7 @@ export class CreateSavingsAccountComponent {
);
},
error: (error: any) => {
this.submitting = false;
const errorCode =
error?.error?.errors?.[0]?.userMessageGlobalisationCode ?? error?.error?.userMessageGlobalisationCode;
if (errorCode === 'error.msg.savings.account.duplicate.product.for.client') {
@@ -595,7 +595,13 @@
<button mat-stroked-button [routerLink]="['../..']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" class="preview-submit-row__primary" (click)="submitEvent.emit()">
<button
mat-raised-button
color="primary"
class="preview-submit-row__primary"
[disabled]="submitting"
(click)="submitEvent.emit()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Submit' | translate }}
</button>
@@ -94,6 +94,8 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
@Input() activeClientMembers?: any;
/** Hide internal Cancel/Submit buttons when parent provides its own action bar */
@Input() hideButtons = false;
/** Disables the Submit button while a submission is in flight, preventing duplicate double-click requests */
@Input() submitting = false;
/** Table Data Source */
dataSource: any;
@@ -103,6 +103,25 @@ export class CheckerInboxComponent implements OnInit {
'entity'
];
/**
* Entities whose pending edits are gated by our own m_savings_product_temp lock rather than the
* native command-source pipeline. They're excluded from Checker Inbox entirely - approving/rejecting
* them here would call the native /makercheckers/{id}?command=approve endpoint, which replays the
* original command handler directly and never touches our temp table, leaving its lock stuck forever.
* Action them from Approvals -> Pending Product Edits instead, where that's handled correctly.
*/
private readonly productEditEntities = [
'SAVINGSPRODUCT',
'CURRENTPRODUCT',
'SHAREPRODUCT',
'FIXEDDEPOSITPRODUCT',
'RECURRINGDEPOSITPRODUCT'
];
private excludeProductEdits(items: any[]): any[] {
return (items || []).filter((item: any) => !this.productEditEntities.includes(item?.entityName));
}
/**
* Retrieves the maker checker data from `resolve`.
* @param {ActivatedRoute} route Activated Route.
@@ -116,7 +135,7 @@ export class CheckerInboxComponent implements OnInit {
constructor() {
this.route.data.subscribe((data: { makerCheckerResource: any; makerCheckerTemplate: any }) => {
const raw = data.makerCheckerResource;
this.searchData = Array.isArray(raw) ? raw : (raw?.pageItems ?? []);
this.searchData = this.excludeProductEdits(Array.isArray(raw) ? raw : (raw?.pageItems ?? []));
this.checkerData = this.searchData.length > 0;
this.makerCheckerTemplate = data.makerCheckerTemplate ?? {};
this.dataSource = new MatTableDataSource(this.searchData);
@@ -156,7 +175,7 @@ export class CheckerInboxComponent implements OnInit {
makerDateTimeto: this.dateUtils.formatDate(this.makerCheckerSearchForm.value.makerDateTimeto, dateFormat)
};
this.tasksService.getMakerCheckerData(makerCheckerSearchParams).subscribe((response: any) => {
this.searchData = response;
this.searchData = this.excludeProductEdits(response);
if (this.searchData.length === 0) {
this.noSearchedData = true;
} else {
@@ -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/.
-->
<div class="psp-tab-content">
@if (showData) {
<div class="psp-action-bar">
<mat-form-field appearance="outline" class="psp-search-field">
<mat-icon matPrefix>search</mat-icon>
<input #filterInput matInput placeholder="Search by product name" (keyup)="applyFilter(filterInput.value)" />
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="productType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--type">{{ edit.productType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="savingsProductName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let edit">{{ edit.savingsProductName }}</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let edit">{{ edit.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let edit">{{ formatDate(edit.createdOn) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--pending">{{ edit.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="viewDiff(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
</button>
<button mat-stroked-button class="psp-btn psp-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psp-btn psp-btn-reject" (click)="reject(edit)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
@if (selectedEdit) {
<div class="psp-diff-panel">
<div class="psp-diff-header">
<h3>Comparing "{{ selectedEdit.savingsProductName }}" &mdash; Original vs Proposed</h3>
<button mat-icon-button (click)="closeDiff()"><fa-icon icon="times"></fa-icon></button>
</div>
@if (diffRows.length === 0) {
<p class="psp-diff-loading">Loading comparison&hellip;</p>
} @else {
<table class="psp-diff-table">
<thead>
<tr>
<th>Field</th>
<th>Original</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
@for (row of diffRows; track row.label) {
<tr [class.psp-diff-row--changed]="row.changed">
<td class="psp-diff-label">{{ row.label }}</td>
<td class="psp-diff-original">{{ row.original }}</td>
<td class="psp-diff-proposed">{{ row.proposed }}</td>
</tr>
}
</tbody>
</table>
}
</div>
}
}
@if (!showData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending savings product edits</p>
<p class="empty-text">There are no savings product edits awaiting approval at this time.</p>
</div>
}
</div>
@@ -0,0 +1,180 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$border: #e2e8f0;
$text-muted: #64748b;
.psp-tab-content {
padding: 16px 0;
}
.psp-action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.psp-search-field {
flex: 1;
min-width: 200px;
max-width: 320px;
}
.psp-btn {
font-size: 11.5px;
font-weight: 600;
border-radius: 8px !important;
height: 32px;
padding: 0 10px;
margin-right: 6px;
fa-icon {
margin-right: 4px;
font-size: 11px;
}
}
.psp-btn-view {
border-color: #2563eb;
color: #2563eb;
&:hover {
background: #eff6ff;
}
}
.psp-btn-approve {
border-color: #16a34a;
color: #16a34a;
&:hover {
background: #f0fdf4;
}
}
.psp-btn-reject {
border-color: #dc2626;
color: #dc2626;
&:hover {
background: #fef2f2;
}
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
&--pending {
background: #fef9c3;
color: #854d0e;
}
&--type {
background: #e0e7ff;
color: #3730a3;
}
}
.psp-diff-panel {
margin-top: 20px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
padding: 16px;
}
.psp-diff-header {
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
}
.psp-diff-loading {
color: $text-muted;
font-size: 13px;
}
.psp-diff-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid $border;
}
th {
color: $text-muted;
font-weight: 600;
}
}
.psp-diff-row--changed {
background: #fffbeb;
.psp-diff-original {
color: #b91c1c;
text-decoration: line-through;
}
.psp-diff-proposed {
color: #15803d;
font-weight: 600;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
background: #fff;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
.empty-icon {
font-size: 42px;
color: #cbd5e1;
margin-bottom: 14px;
}
.empty-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.empty-text {
margin: 0;
font-size: 12.5px;
color: $text-muted;
max-width: 380px;
line-height: 1.5;
}
}
@@ -0,0 +1,217 @@
/**
* 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, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ProductsService } from 'app/products/products.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-pending-savings-products',
templateUrl: './pending-savings-products.component.html',
styleUrls: ['./pending-savings-products.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
MatIconButton
]
})
export class PendingSavingsProductsComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
dataSource: MatTableDataSource<any>;
showData = false;
displayedColumns: string[] = [
'productType',
'savingsProductName',
'makerUsername',
'createdOn',
'status',
'actions'
];
selectedEdit: any = null;
originalProduct: any = null;
diffRows: { label: string; original: any; proposed: any; changed: boolean }[] = [];
constructor() {
this.route.data.subscribe((data: { pendingSavingsProductsData: any }) => {
const items = data.pendingSavingsProductsData || [];
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
});
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
formatDate(date: any): string {
if (!date) return '';
const d = Array.isArray(date)
? new Date(date[0], date[1] - 1, date[2], date[3] || 0, date[4] || 0)
: new Date(date);
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
}
private getOriginalProduct$(edit: any) {
switch (edit.productType) {
case 'CURRENT':
return this.productsService.getCurrentProduct(edit.savingsProductId);
case 'SHARE':
return this.productsService.getShareProduct(edit.savingsProductId);
case 'FD':
return this.productsService.getFixedDepositProduct(edit.savingsProductId);
case 'RD':
return this.productsService.getRecurringDepositProduct(edit.savingsProductId);
default:
return this.productsService.getSavingProduct(edit.savingsProductId);
}
}
private approveEdit$(edit: any) {
switch (edit.productType) {
case 'CURRENT':
return this.productsService.approveCurrentProductEdit(edit.savingsProductId);
case 'SHARE':
return this.productsService.approveShareProductEdit(edit.savingsProductId);
case 'FD':
return this.productsService.approveFixedDepositProductEdit(edit.savingsProductId);
case 'RD':
return this.productsService.approveRecurringDepositProductEdit(edit.savingsProductId);
default:
return this.productsService.approveSavingsProductEdit(edit.savingsProductId);
}
}
private rejectEdit$(edit: any, rejectReason: string) {
switch (edit.productType) {
case 'CURRENT':
return this.productsService.rejectCurrentProductEdit(edit.savingsProductId, rejectReason);
case 'SHARE':
return this.productsService.rejectShareProductEdit(edit.savingsProductId, rejectReason);
case 'FD':
return this.productsService.rejectFixedDepositProductEdit(edit.savingsProductId, rejectReason);
case 'RD':
return this.productsService.rejectRecurringDepositProductEdit(edit.savingsProductId, rejectReason);
default:
return this.productsService.rejectSavingsProductEdit(edit.savingsProductId, rejectReason);
}
}
viewDiff(edit: any): void {
this.selectedEdit = edit;
this.diffRows = [];
this.getOriginalProduct$(edit).subscribe((original: any) => {
this.originalProduct = original;
const proposed = edit.proposedChanges || {};
const keys = Object.keys(proposed);
this.diffRows = keys.map((key) => {
const originalValue = original ? original[key] : undefined;
const proposedValue = proposed[key];
return {
label: key,
original: originalValue,
proposed: proposedValue,
changed: JSON.stringify(originalValue) !== JSON.stringify(proposedValue)
};
});
});
}
closeDiff(): void {
this.selectedEdit = null;
this.originalProduct = null;
this.diffRows = [];
}
approve(edit: any): void {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Savings Product Edit',
dialogContext: `Apply the pending edit to "${edit.savingsProductName}"? This cannot be undone.`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.approveEdit$(edit).subscribe(() => this.reload());
}
});
}
reject(edit: any): void {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
label: 'Reason for rejection',
value: '',
type: 'text',
required: false
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: `Reject edit for "${edit.savingsProductName}"`,
layout: { addButtonText: 'Reject' },
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
const rejectReason = response.data.value.rejectReason || '';
this.rejectEdit$(edit, rejectReason).subscribe(() => this.reload());
}
});
}
reload(): void {
this.closeDiff();
const url = this.router.url;
this.router
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
.then(() => this.router.navigate([url]));
}
}
@@ -94,6 +94,18 @@
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span>
</a>
<a
mat-tab-link
class="ac-tab"
[routerLink]="['./pending-savings-products']"
routerLinkActive
#pendingSavingsProducts="routerLinkActive"
[active]="pendingSavingsProducts.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
<span>Pending Product Edits</span>
</a>
<a
mat-tab-link
class="ac-tab"
@@ -0,0 +1,41 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
/**
* Merges pending edits across all product types sharing the m_savings_product_temp backing table -
* Savings, Current Account, Share, Fixed Deposit, and Recurring Deposit products - into one list for
* the Pending Product Edits screen.
*/
@Injectable()
export class GetPendingSavingsProducts implements Resolve<any> {
private productsService = inject(ProductsService);
resolve(): Observable<any> {
return forkJoin({
savings: this.productsService.getPendingSavingsProductEdits().pipe(catchError(() => of([]))),
current: this.productsService.getPendingCurrentProductEdits().pipe(catchError(() => of([]))),
share: this.productsService.getPendingShareProductEdits().pipe(catchError(() => of([]))),
fd: this.productsService.getPendingFixedDepositProductEdits().pipe(catchError(() => of([]))),
rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([])))
}).pipe(
map((results) => [
...(results.savings || []).map((e: any) => ({ ...e, productType: 'SAVINGS' })),
...(results.current || []).map((e: any) => ({ ...e, productType: 'CURRENT' })),
...(results.share || []).map((e: any) => ({ ...e, productType: 'SHARE' })),
...(results.fd || []).map((e: any) => ({ ...e, productType: 'FD' })),
...(results.rd || []).map((e: any) => ({ ...e, productType: 'RD' }))
])
);
}
}
+12 -1
View File
@@ -26,6 +26,7 @@ import { RescheduleLoanComponent } from './checker-inbox-and-tasks-tabs/reschedu
import { PendingAccountComponent } from './checker-inbox-and-tasks-tabs/pending-account/pending-account.component';
import { RejectedAccountComponent } from './checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component';
import { TerminatedAccountComponent } from './checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component';
import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component';
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
/** Custom Resolvers */
@@ -40,6 +41,7 @@ import { GetCheckerInboxDetailResolver } from './common-resolvers/getCheckerInbo
import { GetPendingAccounts } from './common-resolvers/getPendingAccounts.resolver';
import { GetRejectedAccounts } from './common-resolvers/getRejectedAccounts.resolver';
import { GetTerminatedAccounts } from './common-resolvers/getTerminatedAccounts.resolver';
import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsProducts.resolver';
/** Tasks Routes */
const routes: Routes = [
@@ -115,6 +117,14 @@ const routes: Routes = [
terminatedAccountsData: GetTerminatedAccounts
}
},
{
path: 'pending-savings-products',
component: PendingSavingsProductsComponent,
data: { title: 'Pending Savings Product Edits' },
resolve: {
pendingSavingsProductsData: GetPendingSavingsProducts
}
},
{
path: 'withdrawal-requests',
component: WithdrawalRequestsComponent,
@@ -186,7 +196,8 @@ const routes: Routes = [
GetCheckerInboxDetailResolver,
GetPendingAccounts,
GetRejectedAccounts,
GetTerminatedAccounts
GetTerminatedAccounts,
GetPendingSavingsProducts
]
})
export class TasksRoutingModule {}
@@ -6,17 +6,26 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="layout-row align-end gap-1percent layout-lt-md-column container m-b-20">
<button mat-raised-button color="success" (click)="approveChecker()">
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
</button>
<button mat-raised-button color="warn" (click)="deleteChecker()">
<fa-icon icon="trash" class="m-r-10"></fa-icon>{{ 'labels.buttons.Delete' | translate }}
</button>
<button mat-raised-button color="reject" (click)="rejectChecker()">
<fa-icon icon="times" class="m-r-10"></fa-icon>{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
@if (isProductEditEntry) {
<div class="container m-b-20">
<div class="mat-elevation-z1" style="padding: 12px 16px; border-left: 4px solid #f59e0b; background: #fffbeb">
This is a product edit awaiting approval. Approve or reject it from
<strong>Approvals &gt; Pending Product Edits</strong> instead — actioning it here would not release its lock.
</div>
</div>
} @else {
<div class="layout-row align-end gap-1percent layout-lt-md-column container m-b-20">
<button mat-raised-button color="success" (click)="approveChecker()">
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
</button>
<button mat-raised-button color="warn" (click)="deleteChecker()">
<fa-icon icon="trash" class="m-r-10"></fa-icon>{{ 'labels.buttons.Delete' | translate }}
</button>
<button mat-raised-button color="reject" (click)="rejectChecker()">
<fa-icon icon="times" class="m-r-10"></fa-icon>{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
}
<div class="container">
<mat-card>
@@ -50,6 +50,24 @@ export class ViewCheckerInboxComponent {
/** Checks if there is any object or not in jsondata */
displayJSONData = false;
/**
* These entities' pending edits are gated by our own m_savings_product_temp lock, not the native
* command-source pipeline. Approving/rejecting them here would replay the original command handler
* directly via /makercheckers/{id}?command=approve and never clear our temp table's lock. Action them
* from Approvals -> Pending Product Edits instead.
*/
private readonly productEditEntities = [
'SAVINGSPRODUCT',
'CURRENTPRODUCT',
'SHAREPRODUCT',
'FIXEDDEPOSITPRODUCT',
'RECURRINGDEPOSITPRODUCT'
];
get isProductEditEntry(): boolean {
return this.productEditEntities.includes(this.checkerInboxDetail?.entityName);
}
/**
* Retrieves the maker checker id data from `resolve`.
* @param {ActivatedRoute} route Activated Route.
+8
View File
@@ -540,6 +540,14 @@
border-color: var(--pl-amber);
}
}
&--disabled,
&--disabled:hover {
background: #f1f5f9;
color: #94a3b8;
border-color: #e2e8f0;
cursor: not-allowed;
}
}
/* No-match strip */
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260629',
'hash': 'bc980d20'
'version': '260703',
'hash': 'd00eac0a'
},
'allow_switching_backend_instance': true
};