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;
});
});
}