diff --git a/src/app/pipes/date-format.pipe.ts b/src/app/pipes/date-format.pipe.ts index 4582f2621..c12113dff 100644 --- a/src/app/pipes/date-format.pipe.ts +++ b/src/app/pipes/date-format.pipe.ts @@ -59,6 +59,10 @@ export class DateFormatPipe implements PipeTransform, OnDestroy { if (Array.isArray(value)) { // Backend sometimes returns [yyyy, MM, dd] dateVal = moment(value.join('-'), 'YYYY-MM-DD'); + } else if (typeof value === 'number' && value < 1e12) { + // Unix timestamp in seconds (e.g. /makercheckers madeOnDate). + // moment(seconds) would treat it as milliseconds → 1970; use moment.unix() instead. + dateVal = moment.unix(value); } else { dateVal = moment(value); } diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index c986955bc..de7aaaa7e 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -76,7 +76,7 @@ export class ProductsService { /** * 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. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingSavingsProductEdits(): Observable { return this.http.get('/savingsproducts/pending'); @@ -124,7 +124,7 @@ export class ProductsService { /** * 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. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingCurrentProductEdits(): Observable { return this.http.get('/currentproducts/pending'); @@ -168,7 +168,7 @@ export class ProductsService { /** * 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. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingShareProductEdits(): Observable { return this.http.get('/products/share/pending'); @@ -313,7 +313,7 @@ export class ProductsService { /** * 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. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingFixedDepositProductEdits(): Observable { return this.http.get('/fixeddepositproducts/pending'); @@ -608,7 +608,7 @@ export class ProductsService { /** * 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. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingRecurringDepositProductEdits(): Observable { return this.http.get('/recurringdepositproducts/pending'); diff --git a/src/app/savings/savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component.ts b/src/app/savings/savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component.ts index 99b8e30d5..2bc3398bc 100644 --- a/src/app/savings/savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component.ts +++ b/src/app/savings/savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component.ts @@ -27,7 +27,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, - NgClass, TransactionPaymentDetailComponent, CurrencyPipe, DateFormatPipe diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts index 01047441e..fa6a999a2 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts @@ -60,7 +60,6 @@ import { MatTooltipModule } from '@angular/material/tooltip'; DateFormatPipe, DatetimeFormatPipe, FormatNumberPipe, - YesnoPipe, MatCardModule, MatTooltipModule ] diff --git a/src/app/shared/file-upload/file-upload.component.ts b/src/app/shared/file-upload/file-upload.component.ts index 5ad7607ca..583ce5cb2 100644 --- a/src/app/shared/file-upload/file-upload.component.ts +++ b/src/app/shared/file-upload/file-upload.component.ts @@ -20,9 +20,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './file-upload.component.html', styleUrls: ['./file-upload.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS, - NgStyle, - FaIconComponent + ...STANDALONE_SHARED_IMPORTS ] }) export class FileUploadComponent implements OnInit { diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts index d4c0ca1b5..685d2777b 100644 --- a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts @@ -42,10 +42,8 @@ import { MatOption } from '@angular/material/core'; MatDialogContent, MatDialogActions, MatDialogClose, - MatDivider, MatButton, FormsModule, - NgClass, MatIcon, MatFormField, MatLabel, diff --git a/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts index 741a0c065..aa08800eb 100644 --- a/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts +++ b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts @@ -35,8 +35,7 @@ import { take } from 'rxjs/operators'; FormsModule, MatFormField, MatLabel, - MatInput, - MatError + MatInput ] }) export class InlineCreateShortcutDialogComponent { diff --git a/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts b/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts index 9bebd7216..a41050263 100644 --- a/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts +++ b/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts @@ -25,8 +25,7 @@ import { SponsorDDReconciliationService } from '../sponsor-dd-reconciliation.ser MatCardModule, MatTableModule, MatButtonModule, - MatSnackBarModule, - FaIconComponent + MatSnackBarModule ], templateUrl: './reco-batch-list.component.html', styles: [ diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts index edd2f06f9..9a728cd77 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts @@ -34,8 +34,7 @@ import { SponsorDDService } from '../sponsor-dd.service'; MatTableModule, MatButtonModule, MatDialogModule, - MatSnackBarModule, - FaIconComponent + MatSnackBarModule ], templateUrl: './dd-inventory-list.component.html', styles: [ diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index 01bf708a3..f7a020b33 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -39,8 +39,7 @@ import { SponsorDDService } from '../sponsor-dd.service'; MatDialogModule, MatSnackBarModule, MatFormFieldModule, - MatInputModule, - FaIconComponent + MatInputModule ], templateUrl: './dd-list.component.html', styles: [ diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.html index 2a1f02ef2..0f7c556a9 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.html @@ -7,7 +7,7 @@ -->
- @if (checkerData) { @@ -37,9 +37,6 @@ - diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.scss index 4dea1d459..252e4ffc0 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.scss @@ -112,15 +112,6 @@ $text-muted: #64748b; } } -.cv-btn-delete { - border-color: #dc2626; - color: #dc2626; - - &:hover { - background: #fef2f2; - } -} - .cv-btn-reject { border-color: #64748b; color: #64748b; 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 6800f4e61..f16c22be9 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 @@ -33,12 +33,14 @@ import { SettingsService } from 'app/settings/settings.service'; /** Dialog Components */ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { ViewCheckerInboxComponent } from '../../view-checker-inbox/view-checker-inbox.component'; import { Dates } from 'app/core/utils/dates'; import { TranslateService } from '@ngx-translate/core'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatCheckbox } from '@angular/material/checkbox'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { isProductEntity, isTempTableAccountEdit } from '../../product-entities'; @Component({ selector: 'mifosx-checker-inbox', @@ -104,27 +106,19 @@ export class CheckerInboxComponent implements OnInit { ]; /** - * Entities whose pending edits are gated by our own custom staging tables (m_savings_product_temp - * for products, the savings account edit-request table for Nominee/Digital-Feature edits) 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 staging table, leaving - * its lock/hold stuck forever. Action them from Approvals -> Pending Product Edits / Pending Account - * Edits instead, where that's handled correctly. + * The Checker Inbox holds non-product pending requests only. Two groups are routed elsewhere: + * + * - Product requests of every action (CREATE, UPDATE, DELETE) belong to Approvals -> Product + * Approval, which merges them with the product edits staged in m_saving_product_temp so a + * checker sees one queue per product rather than two. + * - Savings/current account edits are staged the same way as product edits and belong to + * Approvals -> Pending Account Edits. Approving them here would call + * /makercheckers/{id}?command=approve, which replays the original command handler and never + * touches the staging table, leaving its lock held forever. */ - private readonly productEditEntities = [ - 'SAVINGSPRODUCT', - 'CURRENTPRODUCT', - 'SHAREPRODUCT', - 'FIXEDDEPOSITPRODUCT', - 'RECURRINGDEPOSITPRODUCT', - 'SAVINGSACCOUNT', - 'CURRENTACCOUNT' - ]; - - private excludeProductEdits(items: any[]): any[] { + private excludeRoutedElsewhere(items: any[]): any[] { return (items || []).filter( - (item: any) => !(this.productEditEntities.includes(item?.entityName) && item?.actionName === 'UPDATE') + (item: any) => !isProductEntity(item?.entityName) && !isTempTableAccountEdit(item?.entityName, item?.actionName) ); } @@ -141,7 +135,7 @@ export class CheckerInboxComponent implements OnInit { constructor() { this.route.data.subscribe((data: { makerCheckerResource: any; makerCheckerTemplate: any }) => { const raw = data.makerCheckerResource; - this.searchData = this.excludeProductEdits(Array.isArray(raw) ? raw : (raw?.pageItems ?? [])); + this.searchData = this.excludeRoutedElsewhere(Array.isArray(raw) ? raw : (raw?.pageItems ?? [])); this.checkerData = this.searchData.length > 0; this.makerCheckerTemplate = data.makerCheckerTemplate ?? {}; this.dataSource = new MatTableDataSource(this.searchData); @@ -181,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 = this.excludeProductEdits(response); + this.searchData = this.excludeRoutedElsewhere(response); if (this.searchData.length === 0) { this.noSearchedData = true; } else { @@ -242,20 +236,6 @@ export class CheckerInboxComponent implements OnInit { }); } - deleteChecker() { - const deleteCheckerDialogRef = this.dialog.open(ConfirmationDialogComponent, { - data: { - heading: this.translateService.instant('labels.heading.Delete Checker'), - dialogContext: this.translateService.instant('labels.dialogContext.Are you sure you want to delete checker') - } - }); - deleteCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { - if (response.confirm) { - this.bulkDeleteChecker(); - } - }); - } - bulkCheckerApproveorReject(action: any) { const selectedAccounts = this.selection.selected.length; const listSelectedAccounts = this.selection.selected; @@ -270,22 +250,94 @@ export class CheckerInboxComponent implements OnInit { }); } - bulkDeleteChecker() { - const selectedAccounts = this.selection.selected.length; - const listSelectedAccounts = this.selection.selected; - let approvedAccounts = 0; - listSelectedAccounts.forEach((element: any) => { - this.tasksService.deleteMakerChecker(element.id).subscribe((response: any) => { - approvedAccounts++; - if (selectedAccounts === approvedAccounts) { + applyFilter(filterValue: string = '') { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** + * Maps a maker-checker entityName to the Angular route path for that entity's existing view page. + * Returns null for entity types that have no dedicated view page in this application. + */ + private entityRoute(entityName: string, resourceId: string | number): string | null { + const id = resourceId; + switch ((entityName ?? '').toUpperCase()) { + case 'LOANPRODUCT': + return `/products/loan-products/${id}`; + case 'SAVINGSPRODUCT': + return `/products/saving-products/${id}`; + case 'CURRENTPRODUCT': + return `/products/current-products/${id}`; + case 'SHAREPRODUCT': + return `/products/share-products/${id}`; + case 'FIXEDDEPOSITPRODUCT': + return `/products/fixed-deposit-products/${id}`; + case 'RECURRINGDEPOSITPRODUCT': + return `/products/recurring-deposit-products/${id}`; + case 'CHARGE': + return `/products/charges/${id}`; + case 'GLACCOUNT': + return `/accounting/chart-of-accounts/gl-accounts/view/${id}`; + case 'CLIENT': + return `/clients/${id}/general`; + case 'LOAN': + return `/loans/${id}`; + case 'SAVINGSACCOUNT': + return `/savings-accounts/${id}`; + case 'CURRENTACCOUNT': + return `/current-accounts/${id}`; + default: + return null; + } + } + + /** + * Handles a row click in the Checker Inbox. + * + * Priority: + * 1. If the row has a known entityName and a resourceId → navigate to the entity's existing + * view page (read-only; no maker-checker action buttons are present on those pages). + * 2. If no resourceId is available (e.g. a DELETE action where the entity no longer exists) + * → show a brief informational snackbar. + * 3. If the entityName is unrecognised → fall back to the existing ViewCheckerInboxComponent + * dialog so the checker can still view the command details and action the entry. + */ + viewEntry(row: any): void { + const entityName: string = row.entityName ?? ''; + const resourceId = row.resourceId; + const route = this.entityRoute(entityName, resourceId); + + // DIAGNOSTIC — open browser DevTools console to confirm the row payload. + // Remove after confirming root cause. + console.log('[CheckerInbox] viewEntry row =', { + id: row.id, + entityName, + actionName: row.actionName, + resourceId, + resolvedRoute: route + }); + + if (route && resourceId != null) { + // Navigate to the existing entity view page (no maker-checker buttons there). + this.router.navigate([route]); + return; + } + + // resourceId is absent (CREATE rows: entity does not exist yet until approved), + // or entityName is unmapped — fall back to the detail dialog so the checker + // can still view the command payload and action the entry. + this.dialog + .open(ViewCheckerInboxComponent, { + data: { checkerId: row.id }, + autoFocus: false + }) + .afterClosed() + .subscribe((result: any) => { + // Only reload when something was actually actioned — a plain close must not throw away the + // checker's current search, which reload() does by re-navigating the route. + if (result?.refreshed) { this.reload(); } }); - }); - } - - applyFilter(filterValue: string = '') { - this.dataSource.filter = filterValue.trim().toLowerCase(); } /** diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts index 40ba63a9d..03f420097 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts @@ -98,8 +98,7 @@ const NATIONALITY_LABELS: Record = { MatRowDef, MatRow, MatIcon, - MatPaginatorModule, - ClientEditEmailDialogComponent + MatPaginatorModule ] }) export class PendingClientEditsComponent implements OnInit { 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 index 5016189b8..cd3c453f3 100644 --- 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 @@ -11,7 +11,12 @@
search - +
@@ -20,7 +25,16 @@ Type - {{ edit.productType }} + {{ productTypeLabel(edit.productType) }} + + + + + Request + + + {{ edit.actionName }} + @@ -49,8 +63,18 @@ Actions - +
+ + + + + + + + + + + + + + + + + + + +
FieldCurrentProposed
{{ row.label }}{{ row.original }} + {{ row.proposed }} + {{ row.proposed }} +
+ +

No differences found.

+
+
+ + + + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss new file mode 100644 index 000000000..9e943b7fd --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss @@ -0,0 +1,194 @@ +/** + * 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; +$bg-page: #f8fafc; +$text-dark: #1e293b; +$text-mid: #475569; +$text-soft: #94a3b8; +$changed: #fef9c3; +$changed-border: #fde047; +$new-text: #15803d; + +.diff-dialog { + display: flex; + flex-direction: column; + min-width: 560px; + max-width: 820px; +} + +// ── Header ─────────────────────────────────────────────────────────────────── +.diff-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 16px 20px 12px; + border-bottom: 1px solid $border; + background: $bg-page; + + &__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: $text-dark; + } + + &__icon { + font-size: 18px; + color: #2563eb; + } + + &__name { + font-size: 13px; + font-weight: 500; + color: $text-mid; + border-left: 1px solid $border; + padding-left: 8px; + } + + &__meta { + display: flex; + align-items: center; + gap: 10px; + } +} + +.diff-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + + &--changed { + background: #fef9c3; + color: #854d0e; + border: 1px solid #fde047; + } + + &--none { + background: #f0fdf4; + color: #166534; + border: 1px solid #86efac; + } +} + +.diff-toggle { + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + border: 1px solid $border; + border-radius: 6px; + background: #fff; + color: $text-mid; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + + &:hover { + background: #f1f5f9; + color: $text-dark; + } +} + +// ── Body ───────────────────────────────────────────────────────────────────── +.diff-body { + max-height: 60vh; + overflow-y: auto; + padding: 0 !important; +} + +.diff-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 9px 14px; + text-align: left; + border-bottom: 1px solid $border; + vertical-align: top; + } + + th { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-soft; + background: $bg-page; + position: sticky; + top: 0; + z-index: 1; + } + + &__label { + width: 28%; + font-weight: 600; + color: $text-mid; + } + + &__val { + width: 36%; + color: $text-dark; + word-break: break-word; + + &--orig { + background: #fff; + } + + &--prop { + background: #fff; + } + } +} + +.diff-row--changed { + background: $changed !important; + + td { + border-bottom-color: $changed-border; + } + + .diff-table__val--orig { + background: #fef2f2; + color: #b91c1c; + text-decoration: line-through; + text-decoration-color: #fca5a5; + } + + .diff-table__val--prop { + background: #f0fdf4; + } +} + +.diff-new { + color: $new-text; + font-weight: 600; +} + +.diff-empty { + padding: 32px; + text-align: center; + color: $text-soft; + font-style: italic; + font-size: 14px; +} + +// ── Actions ─────────────────────────────────────────────────────────────────── +.diff-actions { + padding: 12px 20px; + border-top: 1px solid $border; + background: $bg-page; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts new file mode 100644 index 000000000..521a48bea --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -0,0 +1,95 @@ +/** + * 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { NgClass } from '@angular/common'; + +export interface ProductEditDiffDialogData { + productName: string; + original: Record; + proposed: Record; +} + +export interface DiffRow { + label: string; + original: string; + proposed: string; + changed: boolean; +} + +@Component({ + selector: 'mifosx-product-edit-diff-dialog', + templateUrl: './product-edit-diff-dialog.component.html', + styleUrls: ['./product-edit-diff-dialog.component.scss'], + imports: [ + MatDialogModule, + MatButtonModule, + NgClass + ] +}) +export class ProductEditDiffDialogComponent { + dialogRef = inject(MatDialogRef); + data: ProductEditDiffDialogData = inject(MAT_DIALOG_DATA); + + diffRows: DiffRow[] = []; + showChangedOnly = false; + + get visibleRows(): DiffRow[] { + return this.showChangedOnly ? this.diffRows.filter((r) => r.changed) : this.diffRows; + } + + get changedCount(): number { + return this.diffRows.filter((r) => r.changed).length; + } + + constructor() { + this.diffRows = this.buildDiff(this.data.original, this.data.proposed); + } + + private buildDiff(original: Record, proposed: Record): DiffRow[] { + const allKeys = new Set([ + ...Object.keys(original), + ...Object.keys(proposed) + ]); + const rows: DiffRow[] = []; + allKeys.forEach((key) => { + const orig = original[key]; + const prop = proposed[key]; + const origStr = this.format(orig); + const propStr = this.format(prop); + rows.push({ + label: this.labelFor(key), + original: origStr, + proposed: propStr, + changed: origStr !== propStr + }); + }); + return rows.sort((a, b) => (a.changed === b.changed ? 0 : a.changed ? -1 : 1)); + } + + private format(value: any): string { + if (value === null || value === undefined) return '—'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + } + + private labelFor(key: string): string { + return key + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[_\-.]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component.ts index 36b86278b..10aba685e 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component.ts @@ -31,7 +31,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; styleUrls: ['./rejected-account.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - FaIconComponent, MatTable, MatColumnDef, MatHeaderCellDef, diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component.ts index e74e61ec2..7e0d2d405 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component.ts @@ -31,7 +31,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; styleUrls: ['./terminated-account.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - FaIconComponent, MatTable, MatColumnDef, MatHeaderCellDef, 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 433e1d7da..c6144a89a 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 @@ -74,12 +74,12 @@ - Pending Product Edits + Product Approval Product Approval list from the two pending-request sources the platform + * already has. No new workflow or table is involved; both feeds are read as-is. + * + * 1. Pending product edits (m_saving_product_temp), via /{producttype}/pending. UPDATE only, for the + * five product types whose PUT handler stages the change instead of logging a command. + * 2. Native maker-checker entries (m_portfolio_command_source), via /makercheckers. This is where + * product CREATE and DELETE land - and LOANPRODUCT/CHARGE requests of every action, since those + * were never diverted to the staging table. + * + * Rows staged in (1) also have an audit-only command-source row that surfaces in (2), so the + * maker-checker feed drops them - see isTempTableProductEdit. Each source keeps its own approve/reject + * path in the component; the merge here is presentational only. */ @Injectable() export class GetPendingSavingsProducts implements Resolve { private productsService = inject(ProductsService); + private tasksService = inject(TasksService); resolve(): Observable { return forkJoin({ @@ -27,15 +39,73 @@ export class GetPendingSavingsProducts implements Resolve { 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([]))) + rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))), + // includeJson so a CREATE row - which has no resourceId, and therefore no product to look up - + // can still show the submitted product name. + makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).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' })) + ...this.asEditRows(results.savings, 'SAVINGS'), + ...this.asEditRows(results.current, 'CURRENT'), + ...this.asEditRows(results.share, 'SHARE'), + ...this.asEditRows(results.fd, 'FD'), + ...this.asEditRows(results.rd, 'RD'), + ...this.asMakerCheckerRows(results.makerChecker) ]) ); } + + /** Staged product edits, unchanged apart from the source/action tags the merged table needs. */ + private asEditRows(edits: any[], productType: string): any[] { + return (edits || []).map((edit: any) => ({ + ...edit, + productType, + source: 'PENDING_EDIT', + actionName: 'UPDATE' + })); + } + + /** + * Normalises maker-checker entries onto the same field names the pending-edit table already renders, + * so the existing template keeps working for both sources. + */ + private asMakerCheckerRows(entries: any): any[] { + const items = Array.isArray(entries) ? entries : (entries?.pageItems ?? []); + return items + .filter((entry: any) => isProductEntity(entry?.entityName)) + .filter((entry: any) => !isTempTableProductEdit(entry?.entityName, entry?.actionName)) + .map((entry: any) => ({ + id: entry.id, + source: 'MAKER_CHECKER', + entityName: entry.entityName, + actionName: (entry.actionName ?? '').toUpperCase(), + productType: PRODUCT_ENTITY_TYPE_LABEL[(entry.entityName ?? '').toUpperCase()] ?? entry.entityName, + savingsProductId: entry.resourceId ?? null, + savingsProductName: this.resolveName(entry), + makerUsername: entry.maker, + // /makercheckers returns madeOnDate as a Unix timestamp in seconds. + // new Date(seconds) misreads it as milliseconds → January 1970. + // Normalise to milliseconds here, matching DatetimeFormatPipe's unix() branch. + createdOn: + typeof entry.madeOnDate === 'number' && entry.madeOnDate < 1e12 ? entry.madeOnDate * 1000 : entry.madeOnDate, + status: 'PENDING' + })); + } + + /** + * A CREATE has no resourceId - the product does not exist until approval - so the name comes from + * the submitted command payload. Falls back to the entity/id when the payload is absent (a DELETE + * carries none) or unparseable. + */ + private resolveName(entry: any): string { + try { + const payload = entry?.commandAsJson ? JSON.parse(entry.commandAsJson) : null; + if (payload?.name) { + return payload.name; + } + } catch { + /* fall through to the identifier below */ + } + return entry?.resourceId != null ? `${entry.entityName} #${entry.resourceId}` : entry.entityName; + } } diff --git a/src/app/tasks/product-entities.ts b/src/app/tasks/product-entities.ts new file mode 100644 index 000000000..90a5457f6 --- /dev/null +++ b/src/app/tasks/product-entities.ts @@ -0,0 +1,121 @@ +/** + * 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/. + */ + +/** + * Single source of truth for which maker-checker entities belong to Approvals -> Product Approval + * rather than the generic Checker Inbox. + * + * These are the entity names the application already treats as products: the five that own a + * /products/... view route and a pending-edit staging row, plus LOANPRODUCT and CHARGE, which are + * product-module entities whose maker-checker requests run entirely through the native pipeline. + * Deliberately excluded are the product-module *configuration* entities (tax groups, floating rates, + * delinquency buckets, product mixes, collaterals) - they stay in the Checker Inbox. + */ +export const PRODUCT_ENTITIES: readonly string[] = [ + 'LOANPRODUCT', + 'SAVINGSPRODUCT', + 'CURRENTPRODUCT', + 'SHAREPRODUCT', + 'FIXEDDEPOSITPRODUCT', + 'RECURRINGDEPOSITPRODUCT', + 'CHARGE' +]; + +/** + * The subset whose UPDATEs bypass the native command-source pipeline: the PUT handler calls + * submitEditForApproval, which stages the change in m_saving_product_temp and writes an *audit-only* + * command-source row alongside it. That row shows up in /makercheckers like any pending command, but + * approving it there replays the original handler and never clears the staging lock - so it must be + * actioned through /{producttype}/{id}/approve instead. + * + * Two consequences, both relied on below: + * - Product Approval must drop these rows from its maker-checker feed, since the pending-edit feed + * already supplies them (otherwise every product edit would be listed twice). + * - View Checker Inbox must not offer approve/reject on them. + */ +export const TEMP_TABLE_PRODUCT_ENTITIES: readonly string[] = [ + 'SAVINGSPRODUCT', + 'CURRENTPRODUCT', + 'SHAREPRODUCT', + 'FIXEDDEPOSITPRODUCT', + 'RECURRINGDEPOSITPRODUCT' +]; + +/** + * Account entities staged the same way (savings/current account Nominee and Digital-Feature edits). + * Not products - they belong to Approvals -> Pending Account Edits - but their UPDATE rows have to + * stay out of the Checker Inbox for exactly the same lock reason. + */ +export const TEMP_TABLE_ACCOUNT_ENTITIES: readonly string[] = [ + 'SAVINGSACCOUNT', + 'CURRENTACCOUNT' +]; + +/** Short type badge shown in the Product Approval table, matching the pending-edit productType tags. */ +export const PRODUCT_ENTITY_TYPE_LABEL: Record = { + LOANPRODUCT: 'LOAN', + SAVINGSPRODUCT: 'SAVINGS', + CURRENTPRODUCT: 'CURRENT', + SHAREPRODUCT: 'SHARE', + FIXEDDEPOSITPRODUCT: 'FD', + RECURRINGDEPOSITPRODUCT: 'RD', + CHARGE: 'CHARGE' +}; + +/** Whether a maker-checker entry is a product request, and so belongs to Product Approval. */ +export function isProductEntity(entityName: string | undefined | null): boolean { + return PRODUCT_ENTITIES.includes((entityName ?? '').toUpperCase()); +} + +/** Whether a maker-checker entry is a product edit already staged in m_saving_product_temp. */ +export function isTempTableProductEdit( + entityName: string | undefined | null, + actionName: string | undefined | null +): boolean { + return ( + TEMP_TABLE_PRODUCT_ENTITIES.includes((entityName ?? '').toUpperCase()) && + (actionName ?? '').toUpperCase() === 'UPDATE' + ); +} + +/** Whether a maker-checker entry is an account edit already staged for Pending Account Edits. */ +export function isTempTableAccountEdit( + entityName: string | undefined | null, + actionName: string | undefined | null +): boolean { + return ( + TEMP_TABLE_ACCOUNT_ENTITIES.includes((entityName ?? '').toUpperCase()) && + (actionName ?? '').toUpperCase() === 'UPDATE' + ); +} + +/** + * Display-only expansion of the short type badge above, for the Product Approval table. + * + * Presentation layer only: `productType` is still the value the component switches on to pick the + * right fetch/approve/reject endpoint, so the underlying row data, filtering and routing are + * untouched. Both the short code ('FD') and the spelled-out form ('FIXED DEPOSIT') are accepted so + * either feed maps correctly. + */ +export const PRODUCT_TYPE_DISPLAY_LABEL: Record = { + CURRENT: 'CURRENT PRODUCT', + SAVINGS: 'SAVINGS PRODUCT', + SHARE: 'SHARE PRODUCT', + CHARGE: 'CHARGE', + LOAN: 'LOAN PRODUCT', + FD: 'FIXED DEPOSIT PRODUCT', + 'FIXED DEPOSIT': 'FIXED DEPOSIT PRODUCT', + RD: 'RECURRING DEPOSIT PRODUCT', + 'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT' +}; + +/** Label shown in the Product Approval Type column. Unmapped types render unchanged. */ +export function productTypeDisplayLabel(productType: string | undefined | null): string { + const raw = (productType ?? '').toString(); + return PRODUCT_TYPE_DISPLAY_LABEL[raw.trim().toUpperCase()] ?? raw; +} diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 4dac6444d..7e18a29f3 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -124,13 +124,19 @@ const routes: Routes = [ } }, { - path: 'pending-savings-products', + path: 'product-approval', component: PendingSavingsProductsComponent, - data: { title: 'Pending Savings Product Edits' }, + data: { title: 'Product Approval' }, resolve: { pendingSavingsProductsData: GetPendingSavingsProducts } }, + { + // The tab was called "Pending Product Edits" until it grew to cover product CREATE and + // DELETE too; keep its old path working for bookmarks and saved links. + path: 'pending-savings-products', + redirectTo: 'product-approval' + }, { path: 'pending-savings-account-edits', component: PendingSavingsAccountEditsComponent, diff --git a/src/app/tasks/tasks.service.ts b/src/app/tasks/tasks.service.ts index 5f17d6809..f9f0e57f3 100644 --- a/src/app/tasks/tasks.service.ts +++ b/src/app/tasks/tasks.service.ts @@ -249,14 +249,6 @@ export class TasksService { return this.http.post(`/makercheckers/${makerCheckerId}`, {}, { params: httpParams }); } - /** - * Execute Maker Checker Delete Action - * @param {makerCheckerId} MakerCheckerId - */ - deleteMakerChecker(makerCheckerId: any): Observable { - return this.http.delete(`/makercheckers/${makerCheckerId}`); - } - /** * Get Maker Checker Details. * @param {makerCheckerId} MakerCheckerId 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 3ed785f19..bf2420d48 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,108 +6,166 @@ 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. +
+ +
+ {{ headingTitle }} + @if (isDialog) { + + } +
+ + + @if (loading) { +
+ + {{ 'labels.heading.Loading' | translate }}…
-
-} @else { -
- - - -
-} + } -
- - -
-

{{ 'labels.heading.View Checker Inbox' | translate }}

- - - -

{{ 'labels.heading.Details' | translate }}

- - - -
- {{ 'labels.inputs.ID' | translate }}: - {{ checkerInboxDetail.id }} + @if (checkerInboxDetail) { +
+ @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.
- -
- {{ 'labels.inputs.Status' | translate }}: - {{ checkerInboxDetail.processingResult }} + } @else { +
+ +
+ } -
- {{ 'labels.inputs.User' | translate }}: - {{ checkerInboxDetail.maker }} -
- -
- {{ 'labels.inputs.Action' | translate }}: - {{ checkerInboxDetail.actionName }} -
- -
- {{ 'labels.inputs.Entity' | translate }}: - {{ checkerInboxDetail.entityName }} -
- - @if (checkerInboxDetail.resourceId) { -
- {{ 'labels.inputs.Resource ID' | translate }}: - {{ checkerInboxDetail.resourceId }} +
+
{{ 'labels.heading.Details' | translate }}
+ +
+
+
{{ 'labels.inputs.ID' | translate }}
+
{{ checkerInboxDetail.id }}
- } - -
- {{ 'labels.inputs.Date' | translate }}: - {{ checkerInboxDetail.madeOnDate | dateFormat }} -
- - @if (checkerInboxDetail.officeName) { -
- {{ 'labels.inputs.Office' | translate }}: - {{ checkerInboxDetail.officeName }} +
+
{{ 'labels.inputs.Status' | translate }}
+
{{ checkerInboxDetail.processingResult || '—' }}
- } - - @if (checkerInboxDetail.savingsAccountNo) { -
- {{ 'labels.inputs.Savings Account Number' | translate }}: - {{ checkerInboxDetail.savingsAccountNo }} +
+
{{ 'labels.inputs.User' | translate }}
+
{{ checkerInboxDetail.maker || '—' }}
- } - - @if (checkerInboxDetail.groupLevelName) { -
- {{ checkerInboxDetail.groupLevelName }} - {{ checkerInboxDetail.groupName }} +
+
{{ 'labels.inputs.Action' | translate }}
+
+ {{ + checkerInboxDetail.actionName + }} +
- } - - @if (displayJSONData) { -

{{ 'labels.heading.Command' | translate }}

- - @for (json of jsondata | keyvalue; track json) { -
- {{ json.key }}: - {{ json.value }} +
+
{{ 'labels.inputs.Entity' | translate }}
+
{{ checkerInboxDetail.entityName }}
+
+ @if (checkerInboxDetail.resourceId) { +
+
{{ 'labels.inputs.Resource ID' | translate }}
+
{{ checkerInboxDetail.resourceId }}
} - } +
+
{{ 'labels.inputs.Date' | translate }}
+
{{ checkerInboxDetail.madeOnDate | dateFormat }}
+
+ @if (checkerInboxDetail.officeName) { +
+
{{ 'labels.inputs.Office' | translate }}
+
{{ checkerInboxDetail.officeName }}
+
+ } + @if (checkerInboxDetail.savingsAccountNo) { +
+
{{ 'labels.inputs.Savings Account Number' | translate }}
+
{{ checkerInboxDetail.savingsAccountNo }}
+
+ } + @if (checkerInboxDetail.groupLevelName) { +
+
{{ checkerInboxDetail.groupLevelName }}
+
{{ checkerInboxDetail.groupName }}
+
+ } +
- - + + @if (displayJSONData) { +
+
{{ 'labels.heading.Command' | translate }}
+
+ @for (field of commandFields; track $index) { + + } +
+
+ } +
+ }
+ + + + @switch (field.kind) { + @case ('object') { +
+
{{ field.label }}
+
+
+ @for (child of field.fields; track $index) { + + } +
+
+
+ } + @case ('list') { +
+
+ {{ field.label }} {{ field.items.length }} +
+
+ @if (field.items.length === 0) { + None + } + @for (item of field.items; track $index) { + @if (item.kind === 'scalar') { + {{ item.text }} + } @else { +
+
{{ item.label }}
+
+ @for (child of item.kind === 'object' ? item.fields : item.items; track $index) { + + } +
+
+ } + } +
+
+ } + @default { +
+
{{ field.label }}
+
{{ field.text }}
+
+ } + } +
diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss index 3d1ba1746..557951741 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss @@ -6,19 +6,321 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// ── Design tokens ───────────────────────────────────────────────────────────── +$border: #e2e8f0; +$bg-page: #f8fafc; +$text-dark: #1e293b; +$text-mid: #475569; +$text-soft: #94a3b8; +$success: #16a34a; +$warning: #d97706; + .mat-elevation-z1 { margin: 1em 0 1.5em; } -h2, -h3, -h4 { +.vci { + background: #fff; + + /* Opened as a popup rather than a full page: sized to its own content, capped so a long command + payload scrolls inside the dialog instead of pushing it off screen. As a route it stays a + normal page-width card. */ + &--dialog { + width: min(680px, 92vw); + max-height: 85vh; + overflow-y: auto; + border-radius: 10px; + } + + &:not(&--dialog) { + max-width: 900px; + margin: 20px auto; + border: 1px solid $border; + border-radius: 12px; + overflow: hidden; + } +} + +// ── Header ─────────────────────────────────────────────────────────────────── +.vci-head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid $border; + background: #fff; + position: sticky; + top: 0; + z-index: 1; + + &__title { + font-size: 15px; + font-weight: 700; + color: $text-dark; + } + + &__close { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + background: none; + font-family: inherit; + font-size: 12.5px; + font-weight: 600; + color: $text-soft; + cursor: pointer; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.15s; + + &:hover { + background: #f1f5f9; + color: $text-mid; + } + } +} + +.vci-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 48px 24px; + font-size: 14px; + color: $text-soft; + + &__ic { + animation: vci-spin 1s linear infinite; + } +} + +@keyframes vci-spin { + to { + transform: rotate(360deg); + } +} + +.vci-body { + padding: 16px 20px 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +// ── Actions ────────────────────────────────────────────────────────────────── +.vci-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.vci-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 18px; + border-radius: 8px; + font-family: inherit; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + border: 1.5px solid transparent; + transition: all 0.15s; + + &--approve { + background: #f0fdf4; + color: $success; + border-color: #86efac; + + &:hover { + background: $success; + color: #fff; + border-color: $success; + } + } + + &--delete { + background: #fff7ed; + color: #c2410c; + border-color: #fdba74; + + &:hover { + background: #ea580c; + color: #fff; + border-color: #ea580c; + } + } + + &--reject { + background: #fff; + color: $text-mid; + border-color: $border; + + &:hover { + background: #f1f5f9; + color: $text-dark; + } + } +} + +.vci-notice { + padding: 12px 16px; + border-left: 4px solid $warning; + background: #fffbeb; + border-radius: 6px; + font-size: 13px; + line-height: 1.5; + color: #92400e; +} + +// ── Detail sections ────────────────────────────────────────────────────────── +.vci-section { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; + + &__title { + padding: 10px 16px; + background: $bg-page; + border-bottom: 1px solid $border; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.07em; + color: $text-mid; + } +} + +/* One label above its value, two pairs per row where there is room. This replaces the global + flex-40/flex-60 utilities, which are sized for a full-width page and ran every field together + onto a single line inside the dialog. */ +.vci-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px 24px; margin: 0; font-weight: 500; } -span { - margin: 0.5em 0; +.vci-kv { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + + dt { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-soft; + } + + dd { + margin: 0; + font-size: 14px; + font-weight: 500; + color: $text-dark; + line-height: 1.35; + overflow-wrap: anywhere; + } + + // A nested object/array needs the full grid width, not one of the two columns, or its own + // label/value pairs get squeezed into a sliver and wrap character by character. + &--wide { + grid-column: 1 / -1; + } +} + +/* Nested object/array rendering inside the Command section. Reuses .vci-kv for the leaves, so a + nested field looks like every other label/value pair, just indented under its parent. */ +.vci-subgrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px 20px; + margin: 6px 0 0; + padding: 10px 12px; + border-left: 2px solid $border; + background: $bg-page; + border-radius: 0 6px 6px 0; + min-width: 0; +} + +/* One element of an array of objects. */ +.vci-entry { + margin-top: 8px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + overflow: hidden; + + &__head { + padding: 6px 12px; + background: $bg-page; + border-bottom: 1px solid $border; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-mid; + } + + .vci-subgrid { + margin: 0; + border-left: 0; + background: transparent; + } +} + +/* Count badge next to an array's label. */ +.vci-count { + display: inline-block; + margin-left: 6px; + padding: 0 7px; + border-radius: 20px; + background: $border; + color: $text-mid; + font-size: 10.5px; + font-weight: 700; +} + +/* A scalar element of an array (e.g. a list of codes) - shown inline rather than as a card. */ +.vci-chip { + display: inline-block; + margin: 3px 6px 0 0; + padding: 3px 10px; + border-radius: 6px; + background: $bg-page; + border: 1px solid $border; + font-size: 12.5px; + color: $text-dark; + overflow-wrap: anywhere; +} + +.vci-none { + font-size: 13px; + font-style: italic; + color: $text-soft; +} + +.vci-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + background: $bg-page; + color: $text-mid; + border: 1px solid $border; + + &--danger { + background: #fff1f2; + color: #be123c; + border-color: #fda4af; + } } .margin-t { 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 5bef1b3e1..f96bdd6e8 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 @@ -10,7 +10,7 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import * as _ from 'lodash'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; /** Custom Services */ import { TasksService } from '../tasks.service'; @@ -19,10 +19,62 @@ import { TasksService } from '../tasks.service'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { TranslateService } from '@ngx-translate/core'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { MatDivider } from '@angular/material/divider'; -import { KeyValuePipe } from '@angular/common'; +import { NgTemplateOutlet } from '@angular/common'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { isTempTableProductEdit } from '../product-entities'; + +/** + * One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of + * these so the template can lay nested objects out as label/value pairs and arrays as repeated + * entries, rather than dumping raw JSON at the checker. Purely a view model — the command data + * itself (`jsondata`) is left exactly as the API returned it. + */ +export type CommandField = + | { label: string; kind: 'scalar'; text: string } + | { label: string; kind: 'object'; fields: CommandField[] } + | { label: string; kind: 'list'; items: CommandField[] }; + +/** + * Segments that read as initialisms in this domain, so `upiFacility` becomes "UPI Facility" rather + * than "Upi Facility". Anything not listed just gets title-cased, so an unknown key still renders + * sensibly - no per-product hard-coding. + */ +const LABEL_ACRONYMS = new Set([ + 'upi', + 'id', + 'ids', + 'url', + 'api', + 'ifsc', + 'micr', + 'kyc', + 'ckyc', + 'aml', + 'pan', + 'gst', + 'cgst', + 'sgst', + 'igst', + 'tds', + 'gl', + 'neft', + 'rtgs', + 'imps', + 'nach', + 'ecs', + 'atm', + 'nri', + 'cif', + 'dob', + 'otp', + 'sms', + 'emi', + 'fd', + 'rd', + 'mis', + 'nef' +]); @Component({ selector: 'mifosx-view-checker-inbox', @@ -31,8 +83,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, - MatDivider, - KeyValuePipe, + NgTemplateOutlet, DateFormatPipe ] }) @@ -43,6 +94,98 @@ export class ViewCheckerInboxComponent { private translateService = inject(TranslateService); private tasksService = inject(TasksService); + /** Present only when opened as a popup from the Checker Inbox list; absent when reached by direct + * route (a bookmarked or shared link). Both entry points stay supported — this only changes where + * the detail comes from and how "done" is signalled: closing the dialog vs. navigating back. */ + private dialogRef = inject(MatDialogRef, { optional: true }); + private dialogData = inject<{ checkerId: number | string; title?: string } | null>(MAT_DIALOG_DATA, { + optional: true + }); + + loading = false; + + get isDialog(): boolean { + return !!this.dialogRef; + } + + /** + * Heading text. Defaults to the Checker Inbox heading — which is what the Checker Inbox itself and + * the direct route both get — but a caller opening this dialog from another tab can supply its own, + * so the checker sees which queue the request came from. Nothing else about the dialog changes. + */ + get headingTitle(): string { + return this.dialogData?.title ?? this.translateService.instant('labels.heading.View Checker Inbox'); + } + + /** + * The Command payload as a render tree. Built once per detail load rather than exposed as a getter, + * so change detection is not re-walking the payload on every cycle. + */ + commandFields: CommandField[] = []; + + /** camelCase / snake_case key -> readable label, e.g. `upiFacility` -> "UPI Facility". */ + private labelFor(key: string): string { + const spaced = String(key ?? '') + .replace(/[_\-.]+/g, ' ') + // splitAt lower|digit -> Upper, and at the tail of a run of capitals (`UPIFacility` -> `UPI Facility`) + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .trim(); + if (!spaced) { + return ''; + } + return spaced + .split(/\s+/) + .map((word) => + LABEL_ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1) + ) + .join(' '); + } + + /** Leaf rendering. Booleans read as Yes/No; everything else keeps the text it always had. */ + private scalarText(value: any): string { + if (value === null || value === undefined) { + return '—'; + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + return String(value); + } + + /** + * Recursively turns one key/value pair into a render node. Nothing is dropped or summarised - every + * key in the payload produces a node, so the checker still sees the complete request. + */ + private toField(label: string, value: any): CommandField { + if (Array.isArray(value)) { + return { + label, + kind: 'list', + items: value.map((item, index) => + item !== null && typeof item === 'object' + ? this.toField(`${label} ${index + 1}`, item) + : { label: '', kind: 'scalar' as const, text: this.scalarText(item) } + ) + }; + } + if (value !== null && typeof value === 'object') { + return { + label, + kind: 'object', + fields: Object.keys(value).map((key) => this.toField(this.labelFor(key), value[key])) + }; + } + return { label, kind: 'scalar', text: this.scalarText(value) }; + } + + private buildCommandFields(payload: any): CommandField[] { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return []; + } + return Object.keys(payload).map((key) => this.toField(this.labelFor(key), payload[key])); + } + /** Checker Inbox Details Data */ checkerInboxDetail: any; /** JsonData */ @@ -51,24 +194,16 @@ export class ViewCheckerInboxComponent { 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. + * A product edit staged in m_saving_product_temp is not approvable here: /makercheckers/{id} replays + * the original command handler and never clears the staging lock. Product Approval actions those + * through the product's own approve/reject endpoint instead. + * + * Scoped to staged UPDATEs on purpose. Product CREATE and DELETE rows are ordinary command-source + * entries with no staging row, and Product Approval opens this dialog to action them - so they must + * keep their buttons. */ - private readonly productEditEntities = [ - 'SAVINGSPRODUCT', - 'CURRENTPRODUCT', - 'SHAREPRODUCT', - 'FIXEDDEPOSITPRODUCT', - 'RECURRINGDEPOSITPRODUCT' - ]; - get isProductEditEntry(): boolean { - return ( - this.productEditEntities.includes(this.checkerInboxDetail?.entityName) && - this.checkerInboxDetail?.actionName === 'UPDATE' - ); + return isTempTableProductEdit(this.checkerInboxDetail?.entityName, this.checkerInboxDetail?.actionName); } /** @@ -79,11 +214,58 @@ export class ViewCheckerInboxComponent { * @param {TasksService} tasksService Tasks Service. */ constructor() { + // Route entry: detail is pre-fetched by the resolver and injected via route.data. this.route.data.subscribe((data: { checkerInboxDetail: any }) => { - this.checkerInboxDetail = data.checkerInboxDetail; - this.jsondata = JSON.parse(this.checkerInboxDetail.commandAsJson); - this.displayJSONData = !_.isEmpty(this.jsondata); + if (data.checkerInboxDetail) { + this.applyDetail(data.checkerInboxDetail); + } }); + + // Dialog entry: no resolver fires, so fetch the detail imperatively using the + // checkerId the caller passed in MAT_DIALOG_DATA. + if (this.dialogData?.checkerId != null && !this.checkerInboxDetail) { + this.loading = true; + this.tasksService.getCheckerInboxDetail(this.dialogData.checkerId).subscribe({ + next: (detail: any) => { + this.loading = false; + this.applyDetail(detail); + }, + error: () => { + this.loading = false; + } + }); + } + } + + private applyDetail(detail: any): void { + this.checkerInboxDetail = detail; + // commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined) + // throws — which as a route only broke the page, but in a dialog would leave an empty shell open. + try { + this.jsondata = detail?.commandAsJson ? JSON.parse(detail.commandAsJson) : {}; + } catch { + this.jsondata = {}; + } + this.displayJSONData = !_.isEmpty(this.jsondata); + this.commandFields = this.buildCommandFields(this.jsondata); + } + + /** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a + * dialog, or returns to the inbox route otherwise. */ + private finish(): void { + if (this.dialogRef) { + this.dialogRef.close({ refreshed: true }); + } else { + this.router.navigate(['../../'], { relativeTo: this.route }); + } + } + + close(): void { + if (this.dialogRef) { + this.dialogRef.close(); + } else { + this.router.navigate(['../../'], { relativeTo: this.route }); + } } /** @@ -99,7 +281,7 @@ export class ViewCheckerInboxComponent { approveCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { if (response.confirm) { this.tasksService.executeMakerCheckerAction(this.checkerInboxDetail.id, 'approve').subscribe((res: any) => { - this.router.navigate(['../../'], { relativeTo: this.route }); + this.finish(); }); } }); @@ -118,26 +300,7 @@ export class ViewCheckerInboxComponent { rejectCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { if (response.confirm) { this.tasksService.executeMakerCheckerAction(this.checkerInboxDetail.id, 'reject').subscribe((res: any) => { - this.router.navigate(['../../'], { relativeTo: this.route }); - }); - } - }); - } - - /** - * Delete Checker - */ - deleteChecker() { - const deleteCheckerDialogRef = this.dialog.open(ConfirmationDialogComponent, { - data: { - heading: this.translateService.instant('labels.heading.Delete Checker'), - dialogContext: this.translateService.instant('labels.dialogContext.Are you sure you want to delete checker') - } - }); - deleteCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { - if (response.confirm) { - this.tasksService.deleteMakerChecker(this.checkerInboxDetail.id).subscribe((res: any) => { - this.router.navigate(['../../'], { relativeTo: this.route }); + this.finish(); }); } }); diff --git a/src/app/users/dsc/dsc-list/dsc-list.component.ts b/src/app/users/dsc/dsc-list/dsc-list.component.ts index 429c65c72..064a1c890 100644 --- a/src/app/users/dsc/dsc-list/dsc-list.component.ts +++ b/src/app/users/dsc/dsc-list/dsc-list.component.ts @@ -52,7 +52,6 @@ import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog MatRow, MatButton, MatIconButton, - MatIcon, MatMenu, MatMenuItem, MatMenuTrigger,