implemented a Maker-Checker workflow for Products, with approval-based updates and product locking to prevent concurrent edits
This commit is contained in:
+21
-2
@@ -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 {
|
||||
|
||||
+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/.
|
||||
-->
|
||||
|
||||
<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 }}" — 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…</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>
|
||||
+180
@@ -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;
|
||||
}
|
||||
}
|
||||
+217
@@ -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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user