feat(loan-products): wire GL mapping, checker diff view, and approval flow for loan interest rate register

- Add GL account picker (gl-picker.component.ts) for mapping each product row to a GL account
- Extract shared diff logic into rate-register-diff.util.ts + loan-rate-register-diff.util.ts
- Add rate-register-diff component to render before/after changes for checker approval
- Rewire checker-inbox and view-checker-inbox to show loan interest rate register diffs
- Refactor rate-register.service.ts (mock-to-real HTTP wiring, GL-aware payload mapping)
- Simplify preview-register component, moving diff rendering into the shared diff component
- Add glAccountId field to RateChartRow model

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:22:59 +05:30
co-authored by Claude Sonnet 5
parent a5a64d4efe
commit d61584f291
24 changed files with 1510 additions and 909 deletions
@@ -6,7 +6,7 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="vci" [class.vci--dialog]="isDialog">
<div class="vci" [class.vci--dialog]="isDialog" [class.vci--wide]="!!rateDiff">
<!-- 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">{{ headingTitle }}</span>
@@ -114,10 +114,30 @@
</div>
}
<!-- Loan Base Interest Rate: the same chart view as the Compare screen, before the change list. -->
@if (rateDiff) {
<div class="vci-section">
<div class="vci-section__title">Rate Chart Comparison</div>
@if (changesBaseline) {
<p class="vci-nochange">{{ changesBaseline }}</p>
}
<mifosx-rate-register-diff
[slabs]="rateDiff.slabs"
[draftRows]="rateDiff.draftRows"
[baselineRows]="rateDiff.baselineRows"
[draftCibil]="rateDiff.draftCibil"
[baselineCibil]="rateDiff.baselineCibil"
></mifosx-rate-register-diff>
</div>
}
<!-- UPDATE: only the fields this command actually moves, as OLD -> NEW. -->
@if (showChanges) {
<div class="vci-section">
<div class="vci-section__title">Changes Requested ({{ changes.length }})</div>
@if (changesBaseline) {
<p class="vci-nochange">{{ changesBaseline }}</p>
}
@if (changes.length === 0) {
<p class="vci-nochange">This request does not change any value.</p>
} @else {
@@ -143,7 +163,7 @@
}
<!-- CREATE: submitted data grouped into readable sections. -->
@if (!showChanges && createSections.length > 0) {
@if (!showChanges && !rateDiff && !loanRateLoading && createSections.length > 0) {
@for (section of createSections; track section.title) {
<div class="vci-section">
<div class="vci-section__title">{{ section.title }}</div>
@@ -157,7 +177,7 @@
}
<!-- DELETE summary, role-permission changes, and the fallback payload all render here. -->
@if (!showChanges && createSections.length === 0 && displayJSONData) {
@if (!showChanges && !rateDiff && !loanRateLoading && createSections.length === 0 && displayJSONData) {
<div class="vci-section">
<div class="vci-section__title">
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }}
@@ -32,6 +32,11 @@ $bg-page: #f8fafc;
border-radius: 10px;
}
/* A wide payload (the Loan Base Interest Rate chart) fills the dialog the caller sized for it. */
&--dialog#{&}--wide {
width: 100%;
}
&:not(&--dialog) {
max-width: 900px;
margin: 20px auto;
@@ -31,6 +31,7 @@ import {
groupCreateFields,
humanizeKey
} from '../command-diff.util';
import { diffLoanRateRegister, isLoanBaseInterestRateCreate } from '../loan-rate-register-diff.util';
import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch';
/** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */
@@ -42,6 +43,22 @@ import { UsersService } from 'app/users/users.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { OrganizationService } from 'app/organization/organization.service';
import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service';
import { RateRegisterDiffComponent } from 'app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component';
import {
CibilSlab,
RateChartRow,
SlabColumn
} from 'app/products/loan-interest-rate-register/models/rate-register.model';
/** Inputs of the shared rate-register comparison view (see RateRegisterDiffComponent). */
interface RateDiffView {
slabs: SlabColumn[];
draftRows: RateChartRow[];
baselineRows: RateChartRow[];
draftCibil: CibilSlab[];
baselineCibil: CibilSlab[];
}
/**
* One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of
@@ -132,7 +149,8 @@ const ENTRY_LABEL_KEYS = [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
NgTemplateOutlet,
DateFormatPipe
DateFormatPipe,
RateRegisterDiffComponent
]
})
export class ViewCheckerInboxComponent {
@@ -150,6 +168,7 @@ export class ViewCheckerInboxComponent {
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private organizationService = inject(OrganizationService);
private rateRegisterService = inject(RateRegisterService);
/** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */
private get entityFetchServices(): EntityFetchServices {
@@ -196,10 +215,22 @@ export class ViewCheckerInboxComponent {
/** UPDATE review: the fields this command actually moves, as OLD -> NEW. */
changes: CommandChange[] = [];
/** Note naming the version a Loan Base Interest Rate request is compared against. */
changesBaseline = '';
/** Loan Base Interest Rate request: the chart-style comparison (same view as the Compare screen). */
rateDiff: RateDiffView | null = null;
/** True once the current entity was read and a real comparison is on screen. */
showChanges = false;
/** True while the current entity is being fetched for comparison. */
comparing = false;
/** A Loan Base Interest Rate request still waiting for its chart: hides the raw payload meanwhile. */
get loanRateLoading(): boolean {
return (
this.comparing &&
isLoanBaseInterestRateCreate(this.checkerInboxDetail?.entityName, this.checkerInboxDetail?.actionName)
);
}
/** True when the screen is describing a record that is about to be deleted. */
deletingEntity = false;
/** CREATE review: submitted data bucketed into readable sections. */
@@ -354,6 +385,8 @@ export class ViewCheckerInboxComponent {
this.comparing = false;
this.deletingEntity = false;
this.createSections = [];
this.changesBaseline = '';
this.rateDiff = null;
// 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 {
@@ -377,6 +410,9 @@ export class ViewCheckerInboxComponent {
} else if (action === 'DELETE') {
this.loadDeleteSummary(detail);
} else if (action === 'CREATE') {
if (isLoanBaseInterestRateCreate(detail?.entityName, detail?.actionName)) {
this.loadLoanRateRegisterChanges(detail);
}
this.createSections = this.buildCreateSections(this.jsondata);
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
this.resolveGlAccountCreateLabels(detail, this.jsondata);
@@ -415,6 +451,59 @@ export class ViewCheckerInboxComponent {
});
}
/**
* Loan Base Interest Rate: every submission is a CREATE of a whole new version (16 loan types x 10
* slabs plus CIBIL bands), so the raw payload hides the handful of rates the maker actually moved.
* Compares it with the version in force today and shows only the differences as OLD -> NEW. With no
* active version (first ever submission) the grouped CREATE view stays on screen.
*/
private loadLoanRateRegisterChanges(detail: any): void {
this.comparing = true;
this.productsService.getActiveLoanBaseInterestRate().subscribe({
next: (active: any) => {
if (!this.isCurrent(detail)) {
return;
}
this.comparing = false;
this.changes = diffLoanRateRegister(this.jsondata ?? {}, active ?? {});
this.rateDiff = this.buildRateDiff(detail, active);
if (active) {
const effective = active.effectiveFromDate ? `, effective ${active.effectiveFromDate}` : '';
this.changesBaseline = `Compared with active version: ${active.name ?? 'Version ' + active.id}${effective}`;
} else {
this.changesBaseline =
'No active version exists yet - this is the first version, so every filled value is new.';
}
// The chart comparison already shows every change, so the flat list is only the fallback for a
// payload the chart could not be built from.
this.showChanges = !this.rateDiff;
},
error: () => {
this.comparing = false;
}
});
}
/**
* The chart-style view of a Loan Base Interest Rate request: the submitted version against the active
* one (or against a blank baseline when there is no active version yet). Null if the payload cannot be
* read, in which case only the change list is shown.
*/
private buildRateDiff(detail: any, active: any): RateDiffView | null {
const submitted = this.rateRegisterService.registerFromPendingEntry(detail);
if (!submitted) {
return null;
}
const base = active ? this.rateRegisterService.registerFromServerDto(active) : null;
return {
slabs: submitted.slabs,
draftRows: submitted.rows,
baselineRows: base ? base.rows : submitted.baselineRows,
draftCibil: submitted.cibilSlabs,
baselineCibil: base ? base.cibilSlabs : submitted.baselineCibilSlabs
};
}
/**
* DELETE: the payload is empty for every entity, so the command alone tells the checker nothing.
* Reads the record instead and shows the few fields that identify it.