From 1ea4cfe8d81ac2611cf0a18e310e8abcf1f3bedf Mon Sep 17 00:00:00 2001 From: keshav898966 Date: Fri, 3 Jul 2026 15:08:47 +0530 Subject: [PATCH] implemented a Maker-Checker workflow for Products, with approval-based updates and product locking to prevent concurrent edits --- .../current-products.component.html | 13 +- .../fixed-deposit-products.component.html | 13 +- src/app/products/products.service.ts | 100 ++++++++ ...dit-recurring-deposit-product.component.ts | 7 +- ...posit-product-accounting-step.component.ts | 6 +- .../recurring-deposit-products.component.html | 13 +- .../saving-products.component.html | 14 +- .../saving-products.component.scss | 8 + .../share-products.component.html | 13 +- .../share-products.component.ts | 14 ++ .../create-savings-account.component.html | 1 + .../create-savings-account.component.ts | 7 + ...avings-account-preview-step.component.html | 8 +- .../savings-account-preview-step.component.ts | 2 + .../checker-inbox/checker-inbox.component.ts | 23 +- .../pending-savings-products.component.html | 108 +++++++++ .../pending-savings-products.component.scss | 180 +++++++++++++++ .../pending-savings-products.component.ts | 217 ++++++++++++++++++ .../checker-inbox-and-tasks.component.html | 12 + .../getPendingSavingsProducts.resolver.ts | 41 ++++ src/app/tasks/tasks-routing.module.ts | 13 +- .../view-checker-inbox.component.html | 31 ++- .../view-checker-inbox.component.ts | 18 ++ src/assets/styles/_product-list.scss | 8 + src/environments/.env.ts | 4 +- 25 files changed, 838 insertions(+), 36 deletions(-) create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts create mode 100644 src/app/tasks/common-resolvers/getPendingSavingsProducts.resolver.ts diff --git a/src/app/products/current-products/current-products.component.html b/src/app/products/current-products/current-products.component.html index f9f46dcb6..cecad78bb 100644 --- a/src/app/products/current-products/current-products.component.html +++ b/src/app/products/current-products/current-products.component.html @@ -135,12 +135,19 @@ diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html index 6659f570b..77146aba1 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html @@ -137,12 +137,19 @@ diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index d9a8e4745..c986955bc 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -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 { + return this.http.get('/savingsproducts/pending'); + } + + getPendingSavingsProductEdit(savingsProductId: string): Observable { + return this.http.get(`/savingsproducts/${savingsProductId}/pending`); + } + + approveSavingsProductEdit(savingsProductId: string): Observable { + return this.http.post(`/savingsproducts/${savingsProductId}/approve`, {}); + } + + rejectSavingsProductEdit(savingsProductId: string, rejectReason: string): Observable { + return this.http.post(`/savingsproducts/${savingsProductId}/reject`, { rejectReason }); + } + /** * @returns {Observable} 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 { + return this.http.get('/currentproducts/pending'); + } + + getPendingCurrentProductEdit(currentProductId: string): Observable { + return this.http.get(`/currentproducts/${currentProductId}/pending`); + } + + approveCurrentProductEdit(currentProductId: string): Observable { + return this.http.post(`/currentproducts/${currentProductId}/approve`, {}); + } + + rejectCurrentProductEdit(currentProductId: string, rejectReason: string): Observable { + return this.http.post(`/currentproducts/${currentProductId}/reject`, { rejectReason }); + } + /** * @returns {Observable} 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 { + return this.http.get('/products/share/pending'); + } + + getPendingShareProductEdit(shareProductId: string): Observable { + return this.http.get(`/products/share/${shareProductId}/pending`); + } + + approveShareProductEdit(shareProductId: string): Observable { + return this.http.post(`/products/share/${shareProductId}/approve`, {}); + } + + rejectShareProductEdit(shareProductId: string, rejectReason: string): Observable { + return this.http.post(`/products/share/${shareProductId}/reject`, { rejectReason }); + } + getDividends(shareProductId: string): Observable { 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 { + return this.http.get('/fixeddepositproducts/pending'); + } + + getPendingFixedDepositProductEdit(fixedDepositProductId: string): Observable { + return this.http.get(`/fixeddepositproducts/${fixedDepositProductId}/pending`); + } + + approveFixedDepositProductEdit(fixedDepositProductId: string): Observable { + return this.http.post(`/fixeddepositproducts/${fixedDepositProductId}/approve`, {}); + } + + rejectFixedDepositProductEdit(fixedDepositProductId: string, rejectReason: string): Observable { + return this.http.post(`/fixeddepositproducts/${fixedDepositProductId}/reject`, { rejectReason }); + } + /** * @returns {Observable} 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 { + return this.http.get('/recurringdepositproducts/pending'); + } + + getPendingRecurringDepositProductEdit(recurringDepositProductId: string): Observable { + return this.http.get(`/recurringdepositproducts/${recurringDepositProductId}/pending`); + } + + approveRecurringDepositProductEdit(recurringDepositProductId: string): Observable { + return this.http.post(`/recurringdepositproducts/${recurringDepositProductId}/approve`, {}); + } + + rejectRecurringDepositProductEdit(recurringDepositProductId: string, rejectReason: string): Observable { + return this.http.post(`/recurringdepositproducts/${recurringDepositProductId}/reject`, { rejectReason }); + } + /** Returns the active CBS base interest-rate version(s). */ getAllRdInterestRates(): Observable { return this.getRdBaseInterestRates(); diff --git a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts index 595b959f3..9e2ffa221 100644 --- a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts +++ b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts @@ -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 }); diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts index 41d2d93cc..3ceaa248a 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts @@ -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 || []; diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html index 7616d87e5..62b3fc3d7 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html @@ -147,12 +147,19 @@ diff --git a/src/app/products/saving-products/saving-products.component.html b/src/app/products/saving-products/saving-products.component.html index 2ec3107a5..356b8d2c1 100644 --- a/src/app/products/saving-products/saving-products.component.html +++ b/src/app/products/saving-products/saving-products.component.html @@ -145,13 +145,19 @@ diff --git a/src/app/products/saving-products/saving-products.component.scss b/src/app/products/saving-products/saving-products.component.scss index a379b31c9..c54aa32d3 100644 --- a/src/app/products/saving-products/saving-products.component.scss +++ b/src/app/products/saving-products/saving-products.component.scss @@ -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 */ diff --git a/src/app/products/share-products/share-products.component.html b/src/app/products/share-products/share-products.component.html index c44b89651..db28741c4 100644 --- a/src/app/products/share-products/share-products.component.html +++ b/src/app/products/share-products/share-products.component.html @@ -135,12 +135,19 @@ diff --git a/src/app/products/share-products/share-products.component.ts b/src/app/products/share-products/share-products.component.ts index b01a42ab1..f98f40874 100644 --- a/src/app/products/share-products/share-products.component.ts +++ b/src/app/products/share-products/share-products.component.ts @@ -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; + }); }); } diff --git a/src/app/savings/create-savings-account/create-savings-account.component.html b/src/app/savings/create-savings-account/create-savings-account.component.html index 56d77ee1c..7bdf5abed 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.html +++ b/src/app/savings/create-savings-account/create-savings-account.component.html @@ -117,6 +117,7 @@ [savingsAccount]="savingsAccount" [savingsAccountDigital]="savingsAccountDigital" [savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []" + [submitting]="submitting" (submitEvent)="submit()" > diff --git a/src/app/savings/create-savings-account/create-savings-account.component.ts b/src/app/savings/create-savings-account/create-savings-account.component.ts index e7657c1ca..e5d7c2989 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.ts +++ b/src/app/savings/create-savings-account/create-savings-account.component.ts @@ -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') { diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html index dbb978978..44831e2d5 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html @@ -595,7 +595,13 @@ - diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts index 8f2cea945..f8837abfc 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts @@ -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; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts index 237dbb2c0..54da1de31 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts @@ -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 { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html new file mode 100644 index 000000000..5016189b8 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html @@ -0,0 +1,108 @@ + + +
+ @if (showData) { +
+ + search + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Type + {{ edit.productType }} + Product{{ edit.savingsProductName }}Submitted By{{ edit.makerUsername }}Submitted On{{ formatDate(edit.createdOn) }}Status + {{ edit.status }} + Actions + + + +
+
+ + @if (selectedEdit) { +
+
+

Comparing "{{ selectedEdit.savingsProductName }}" — Original vs Proposed

+ +
+ @if (diffRows.length === 0) { +

Loading comparison…

+ } @else { + + + + + + + + + + @for (row of diffRows; track row.label) { + + + + + + } + +
FieldOriginalProposed
{{ row.label }}{{ row.original }}{{ row.proposed }}
+ } +
+ } + } + + @if (!showData) { +
+ +

No pending savings product edits

+

There are no savings product edits awaiting approval at this time.

+
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss new file mode 100644 index 000000000..892bb22f2 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss @@ -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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts new file mode 100644 index 000000000..db9554f9d --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts @@ -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; + 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])); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 34e31fe3d..1b6c575b1 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -94,6 +94,18 @@ Pending Account + + + Pending Product Edits + { + private productsService = inject(ProductsService); + + resolve(): Observable { + 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' })) + ]) + ); + } +} diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 39f0940a1..273019314 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -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 {} diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html index 0b38001f8..3ed785f19 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html @@ -6,17 +6,26 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- - - -
+@if (isProductEditEntry) { +
+
+ This is a product edit awaiting approval. Approve or reject it from + Approvals > Pending Product Edits instead — actioning it here would not release its lock. +
+
+} @else { +
+ + + +
+}
diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts index 8332c1d60..44ebee780 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts @@ -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. diff --git a/src/assets/styles/_product-list.scss b/src/assets/styles/_product-list.scss index 4706b574b..d1cfc0934 100644 --- a/src/assets/styles/_product-list.scss +++ b/src/assets/styles/_product-list.scss @@ -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 */ diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 9d9b344ad..194839b55 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260629', - 'hash': 'bc980d20' + 'version': '260703', + 'hash': 'd00eac0a' }, 'allow_switching_backend_instance': true };