Merge branch 'maker-checker' into dev: Product Approval workflow
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Brings in the Product Approval feature (anshul.rathore) — a unified Approvals
screen for Loan/Savings/Current/Share/FD/RD/Charge product requests:
- Merges two existing pending-request sources into one list: staged product
  edits (m_saving_product_temp, via /{producttype}/pending) and native
  maker-checker entries (/makercheckers) for product CREATE/DELETE and
  LOANPRODUCT/CHARGE of any action.
- Checker Inbox no longer lists product requests at all — they now live only
  in Product Approval, so a checker isn't checking two queues for the same
  thing. Clicking a non-product row now navigates straight to that entity's
  own view page when one exists, instead of always opening a detail dialog.
- View Checker Inbox dialog gains a caller-supplied heading (used when opened
  from Product Approval) and a notice steering staged product edits back to
  Product Approval instead of being actioned in place.
- Route renamed pending-savings-products -> product-approval, with the old
  path kept as a redirect for existing bookmarks/links.

8 files conflicted with dev's own recent Checker Inbox work (popup-based
detail view, e8097bc2/63134f17) and were resolved by hand, keeping both
sides' real functionality rather than picking one branch wholesale:
- product-edit-diff-dialog.component.{ts,html,scss}: kept dev's diff dialog
  (resolves GL account ids to names, aliases fields like currencyCode against
  the stored product, normalises the two date shapes the API uses) over
  maker-checker's simpler raw-value version.
- pending-savings-products.component.{ts,html}: kept maker-checker's full
  isPendingEdit()-based dispatch (dev's version was a strict subset of it).
- view-checker-inbox.component.{ts,html,scss}: kept dev's per-entry Delete
  action and maker-checker's recursive Command-payload tree renderer and
  custom heading side by side.

Also recovered two regressions the merge introduced silently (no conflict
markers, so `git merge` gave no warning):
- tasks.service.ts lost deleteMakerChecker() entirely — restored.
- Checker Inbox's bulk Delete action (button, deleteChecker()/
  bulkDeleteChecker(), and its .cv-btn-delete style) was dropped across three
  files — restored to match dev's pre-merge behaviour exactly.

And one duplicate the merge produced without a conflict marker: two
independent viewEntry() implementations landed in checker-inbox.component.ts
(TS2393, caught at build). Kept maker-checker's entity-view-page-routing
version — a superset of dev's dialog-only version — and dropped the duplicate,
along with a leftover diagnostic console.log.
This commit is contained in:
2026-09-11 11:47:51 +05:30
25 changed files with 735 additions and 119 deletions
@@ -9,7 +9,7 @@
<div class="vci" [class.vci--dialog]="isDialog">
<!-- The heading lives here in both modes, so a dialog does not end up showing it twice. -->
<div class="vci-head">
<span class="vci-head__title">{{ 'labels.heading.View Checker Inbox' | translate }}</span>
<span class="vci-head__title">{{ headingTitle }}</span>
@if (isDialog) {
<button type="button" class="vci-head__close" (click)="close()">
<fa-icon icon="times"></fa-icon>
@@ -112,11 +112,8 @@
<div class="vci-section">
<div class="vci-section__title">{{ 'labels.heading.Command' | translate }}</div>
<dl class="vci-grid">
@for (json of jsondata | keyvalue; track json.key) {
<div class="vci-kv">
<dt>{{ json.key }}</dt>
<dd>{{ json.value }}</dd>
</div>
@for (field of commandFields; track $index) {
<ng-container *ngTemplateOutlet="commandField; context: { $implicit: field }"></ng-container>
}
</dl>
</div>
@@ -124,3 +121,54 @@
</div>
}
</div>
<!-- Recursive renderer for one Command payload field. Scalars stay in the two-column grid; objects and
arrays span the full width so their nested content has room instead of overflowing the dialog. -->
<ng-template #commandField let-field>
@switch (field.kind) {
@case ('object') {
<div class="vci-kv vci-kv--wide">
<dt>{{ field.label }}</dt>
<dd>
<dl class="vci-subgrid">
@for (child of field.fields; track $index) {
<ng-container *ngTemplateOutlet="commandField; context: { $implicit: child }"></ng-container>
}
</dl>
</dd>
</div>
}
@case ('list') {
<div class="vci-kv vci-kv--wide">
<dt>
{{ field.label }} <span class="vci-count">{{ field.items.length }}</span>
</dt>
<dd>
@if (field.items.length === 0) {
<span class="vci-none">None</span>
}
@for (item of field.items; track $index) {
@if (item.kind === 'scalar') {
<span class="vci-chip">{{ item.text }}</span>
} @else {
<div class="vci-entry">
<div class="vci-entry__head">{{ item.label }}</div>
<dl class="vci-subgrid">
@for (child of item.kind === 'object' ? item.fields : item.items; track $index) {
<ng-container *ngTemplateOutlet="commandField; context: { $implicit: child }"></ng-container>
}
</dl>
</div>
}
}
</dd>
</div>
}
@default {
<div class="vci-kv">
<dt>{{ field.label }}</dt>
<dd>{{ field.text }}</dd>
</div>
}
}
</ng-template>
@@ -225,6 +225,83 @@ $bg-page: #f8fafc;
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 {
@@ -19,9 +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 { 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',
@@ -30,7 +83,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
KeyValuePipe,
NgTemplateOutlet,
DateFormatPipe
]
})
@@ -44,7 +97,9 @@ export class ViewCheckerInboxComponent {
* 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 });
private dialogData = inject<{ checkerId: number | string; title?: string } | null>(MAT_DIALOG_DATA, {
optional: true
});
loading = false;
@@ -52,6 +107,84 @@ export class ViewCheckerInboxComponent {
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 */
@@ -60,24 +193,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);
}
/**
@@ -88,19 +213,27 @@ 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;
}
// Route entry: detail is pre-fetched by the resolver and injected via route.data.
this.route.data.subscribe((data: { checkerInboxDetail: any }) => {
this.applyDetail(data.checkerInboxDetail);
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 {
@@ -113,6 +246,7 @@ export class ViewCheckerInboxComponent {
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