feat(tasks): open a checker inbox entry in a popup instead of a page

Clicking a row navigated away to its own page, costing the checker the list and
whatever search they had set up. It now opens in a dialog over the list, the
same pattern the Disbursement and Closure Requests tabs already use.

ViewCheckerInboxComponent gained the optional MatDialogRef/MAT_DIALOG_DATA pair
rather than being duplicated, so the direct route still works for a bookmarked
link. Two differences follow from being a dialog:

 - the list rows carry only summary fields, so the detail (which is what has
   commandAsJson) is fetched here; by route it stays resolver-provided. The
   template reads checkerInboxDetail.* unguarded, so it is now behind an @if
   with a loading state -- otherwise a dialog would render against undefined.
 - commandAsJson is absent on some entries (a DELETE carries no payload) and
   JSON.parse(undefined) throws. By route that broke the page; in a dialog it
   would have left an empty shell open. Parsing is now guarded.

The list only reloads when something was actually actioned, since reload()
re-navigates the route and would otherwise discard the checker's search on a
plain close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 18:30:24 +05:30
co-authored by Claude Opus 5
parent d9db217b91
commit e8097bc2cb
5 changed files with 251 additions and 113 deletions
@@ -155,12 +155,7 @@
<td mat-cell *matCellDef="let makerChecker">{{ makerChecker.entityName }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="[row.id, 'view']"
class="table-row"
></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" (click)="viewEntry(row)" class="table-row"></tr>
</table>
</div>
}
@@ -33,6 +33,7 @@ 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';
@@ -288,6 +289,27 @@ export class CheckerInboxComponent implements OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/**
* Opens one inbox entry in a popup rather than navigating to its own page, so the checker keeps
* the list (and any search/filter they set up) behind the dialog. The direct route stays available
* for a bookmarked link — ViewCheckerInboxComponent supports both entry points.
*/
viewEntry(row: any): void {
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();
}
});
}
/**
* Refetches data for the component
* TODO: Replace by a custom reload component instead of hard-coded back-routing.
@@ -6,108 +6,125 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
@if (isProductEditEntry) {
<div class="container m-b-20">
<div class="mat-elevation-z1" style="padding: 12px 16px; border-left: 4px solid #f59e0b; background: #fffbeb">
This is a product edit awaiting approval. Approve or reject it from
<strong>Approvals &gt; Pending Product Edits</strong> instead — actioning it here would not release its lock.
<div [class.vci--dialog]="isDialog">
@if (isDialog) {
<div class="vci-dialog-bar">
<span class="vci-dialog-bar__title">{{ 'labels.heading.View Checker Inbox' | translate }}</span>
<span class="vci-dialog-bar__close" (click)="close()">✕ {{ 'labels.buttons.Close' | translate }}</span>
</div>
</div>
} @else {
<div class="layout-row align-end gap-1percent layout-lt-md-column container m-b-20">
<button mat-raised-button color="success" (click)="approveChecker()">
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
</button>
<button mat-raised-button color="warn" (click)="deleteChecker()">
<fa-icon icon="trash" class="m-r-10"></fa-icon>{{ 'labels.buttons.Delete' | translate }}
</button>
<button mat-raised-button color="reject" (click)="rejectChecker()">
<fa-icon icon="times" class="m-r-10"></fa-icon>{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
}
}
<div class="container">
<mat-card>
<mat-card-content>
<div class="layout-row-wrap responsive-column">
<h2 class="mat-h2 flex-fill">{{ 'labels.heading.View Checker Inbox' | translate }}</h2>
<!-- As a dialog the detail is fetched rather than resolved, so nothing below can be rendered
until it arrives — the template reads checkerInboxDetail.* unguarded. -->
@if (loading) {
<div class="vci-loading">{{ 'labels.heading.Loading' | translate }}…</div>
}
<mat-divider [inset]="true"></mat-divider>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.ID' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.id }}</span>
@if (checkerInboxDetail) {
@if (isProductEditEntry) {
<div class="container m-b-20">
<div class="mat-elevation-z1" style="padding: 12px 16px; border-left: 4px solid #f59e0b; background: #fffbeb">
This is a product edit awaiting approval. Approve or reject it from
<strong>Approvals &gt; Pending Product Edits</strong> instead — actioning it here would not release its lock.
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Status' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.processingResult }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.User' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.maker }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Action' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.actionName }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Entity' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.entityName }}</span>
</div>
@if (checkerInboxDetail.resourceId) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Resource ID' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.resourceId }}</span>
</div>
}
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Date' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.madeOnDate | dateFormat }}</span>
</div>
@if (checkerInboxDetail.officeName) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Office' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.officeName }}</span>
</div>
}
@if (checkerInboxDetail.savingsAccountNo) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Savings Account Number' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.savingsAccountNo }}</span>
</div>
}
@if (checkerInboxDetail.groupLevelName) {
<div class="flex-fill">
<span class="flex-40"> {{ checkerInboxDetail.groupLevelName }}</span>
<span class="flex-60">{{ checkerInboxDetail.groupName }}</span>
</div>
}
@if (displayJSONData) {
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Command' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
@for (json of jsondata | keyvalue; track json) {
<div class="flex-fill">
<span class="flex-40">{{ json.key }}:</span>
<span class="flex-60">{{ json.value }}</span>
</div>
}
}
</div>
</mat-card-content>
</mat-card>
} @else {
<div class="layout-row align-end gap-1percent layout-lt-md-column container m-b-20">
<button mat-raised-button color="success" (click)="approveChecker()">
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
</button>
<button mat-raised-button color="warn" (click)="deleteChecker()">
<fa-icon icon="trash" class="m-r-10"></fa-icon>{{ 'labels.buttons.Delete' | translate }}
</button>
<button mat-raised-button color="reject" (click)="rejectChecker()">
<fa-icon icon="times" class="m-r-10"></fa-icon>{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
}
<div class="container">
<mat-card>
<mat-card-content>
<div class="layout-row-wrap responsive-column">
<h2 class="mat-h2 flex-fill">{{ 'labels.heading.View Checker Inbox' | translate }}</h2>
<mat-divider [inset]="true"></mat-divider>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.ID' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.id }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Status' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.processingResult }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.User' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.maker }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Action' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.actionName }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Entity' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.entityName }}</span>
</div>
@if (checkerInboxDetail.resourceId) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Resource ID' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.resourceId }}</span>
</div>
}
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Date' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.madeOnDate | dateFormat }}</span>
</div>
@if (checkerInboxDetail.officeName) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Office' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.officeName }}</span>
</div>
}
@if (checkerInboxDetail.savingsAccountNo) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Savings Account Number' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.savingsAccountNo }}</span>
</div>
}
@if (checkerInboxDetail.groupLevelName) {
<div class="flex-fill">
<span class="flex-40"> {{ checkerInboxDetail.groupLevelName }}</span>
<span class="flex-60">{{ checkerInboxDetail.groupName }}</span>
</div>
}
@if (displayJSONData) {
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Command' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
@for (json of jsondata | keyvalue; track json) {
<div class="flex-fill">
<span class="flex-40">{{ json.key }}:</span>
<span class="flex-60">{{ json.value }}</span>
</div>
}
}
</div>
</mat-card-content>
</mat-card>
</div>
}
</div>
@@ -10,6 +10,60 @@
margin: 1em 0 1.5em;
}
/* Opened as a popup rather than a full page — sizes to its own content (capped so a long command
payload still scrolls inside the dialog) instead of stretching to the viewport. */
.vci--dialog {
width: min(720px, 92vw);
max-height: 85vh;
overflow-y: auto;
.container {
max-width: none;
padding: 0;
}
}
.vci-dialog-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
position: sticky;
top: 0;
z-index: 1;
&__title {
font-size: 14px;
font-weight: 700;
color: #475569;
}
&__close {
margin-left: auto;
font-size: 12px;
font-weight: 500;
color: #94a3b8;
cursor: pointer;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.15s;
&:hover {
background: #f1f5f9;
color: #475569;
}
}
}
.vci-loading {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: #94a3b8;
}
h2,
h3,
h4 {
@@ -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, MatDialogModule } from '@angular/material/dialog';
/** Custom Services */
import { TasksService } from '../tasks.service';
@@ -33,7 +33,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent,
MatDivider,
KeyValuePipe,
DateFormatPipe
DateFormatPipe,
MatDialogModule
]
})
export class ViewCheckerInboxComponent {
@@ -42,6 +43,17 @@ export class ViewCheckerInboxComponent {
private router = inject(Router);
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<ViewCheckerInboxComponent>, { optional: true });
private dialogData = inject<{ checkerId: number | string } | null>(MAT_DIALOG_DATA, { optional: true });
loading = false;
get isDialog(): boolean {
return !!this.dialogRef;
}
/** Checker Inbox Details Data */
checkerInboxDetail: any;
@@ -79,13 +91,51 @@ export class ViewCheckerInboxComponent {
* @param {TasksService} tasksService Tasks Service.
*/
constructor() {
// As a popup the list only has summary rows, so the detail (which carries commandAsJson) is
// fetched here; as a route it is already resolved by GetCheckerInboxDetailResolver.
if (this.dialogData?.checkerId != null) {
this.loading = true;
this.tasksService.getCheckerInboxDetail(this.dialogData.checkerId).subscribe((detail: any) => {
this.loading = false;
this.applyDetail(detail);
});
return;
}
this.route.data.subscribe((data: { checkerInboxDetail: any }) => {
this.checkerInboxDetail = data.checkerInboxDetail;
this.jsondata = JSON.parse(this.checkerInboxDetail.commandAsJson);
this.displayJSONData = !_.isEmpty(this.jsondata);
this.applyDetail(data.checkerInboxDetail);
});
}
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);
}
/** "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 });
}
}
/**
* Approve Checker
*/
@@ -99,7 +149,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,7 +168,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 });
this.finish();
});
}
});
@@ -137,7 +187,7 @@ export class ViewCheckerInboxComponent {
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();
});
}
});