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:
@@ -343,10 +343,13 @@ export class CheckerInboxComponent implements OnInit {
|
||||
// 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.
|
||||
// A Loan Base Interest Rate request carries a 10-column rate chart, which needs a wide dialog.
|
||||
const wide = (row.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE';
|
||||
this.dialog
|
||||
.open(ViewCheckerInboxComponent, {
|
||||
data: { checkerId: row.id },
|
||||
autoFocus: false
|
||||
autoFocus: false,
|
||||
...(wide ? { width: '1200px', maxWidth: '95vw' } : {})
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: any) => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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 { ChangeKind, CommandChange } from './command-diff.util';
|
||||
|
||||
/** Entity name the maker-checker queue uses for Loan Base Interest Rate submissions. */
|
||||
export const LOAN_BASE_INTEREST_RATE_ENTITY = 'LOANBASEINTERESTRATE';
|
||||
|
||||
export function isLoanBaseInterestRateCreate(entityName?: string | null, actionName?: string | null): boolean {
|
||||
return (
|
||||
(entityName ?? '').toUpperCase() === LOAN_BASE_INTEREST_RATE_ENTITY && (actionName ?? '').toUpperCase() === 'CREATE'
|
||||
);
|
||||
}
|
||||
|
||||
const HEADER_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'effectiveFromDate', label: 'Effective From Date' },
|
||||
{ key: 'currencyCode', label: 'Currency Code' },
|
||||
{ key: 'fallbackNoCibilSlab', label: 'Fallback No CIBIL Slab' },
|
||||
{ key: 'fallbackBelowMin', label: 'Fallback Below Min' },
|
||||
{ key: 'note', label: 'Note' }
|
||||
];
|
||||
|
||||
const NA = 'N.A.';
|
||||
const EMPTY = '—';
|
||||
|
||||
/** One chart cell as the checker reads it: a % rate, a formula, or N.A. */
|
||||
function cellText(cell: any): string {
|
||||
if (!cell || cell.isNa) {
|
||||
return NA;
|
||||
}
|
||||
if (cell.isFormula && cell.rateFormula) {
|
||||
return String(cell.rateFormula);
|
||||
}
|
||||
return cell.rateValue === null || cell.rateValue === undefined ? NA : `${Number(cell.rateValue)}%`;
|
||||
}
|
||||
|
||||
function plain(value: any): string {
|
||||
return value === null || value === undefined || value === '' ? EMPTY : String(value);
|
||||
}
|
||||
|
||||
function kindOf(oldText: string, newText: string, emptyTexts: string[]): ChangeKind {
|
||||
const oldEmpty = emptyTexts.includes(oldText);
|
||||
const newEmpty = emptyTexts.includes(newText);
|
||||
if (oldEmpty && !newEmpty) {
|
||||
return 'ADDED';
|
||||
}
|
||||
if (!oldEmpty && newEmpty) {
|
||||
return 'REMOVED';
|
||||
}
|
||||
return 'MODIFIED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares a submitted Loan Base Interest Rate version against the version currently in force and
|
||||
* returns only the values that differ: header fields, individual rate cells (per loan type and
|
||||
* amount slab) and CIBIL adjustment bands.
|
||||
*/
|
||||
export function diffLoanRateRegister(submitted: any, active: any): CommandChange[] {
|
||||
const changes: CommandChange[] = [];
|
||||
|
||||
for (const { key, label } of HEADER_FIELDS) {
|
||||
if (submitted?.[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
const oldText = plain(active?.[key]);
|
||||
const newText = plain(submitted[key]);
|
||||
if (oldText !== newText) {
|
||||
changes.push({ label, kind: kindOf(oldText, newText, [EMPTY]), oldValue: oldText, newValue: newText });
|
||||
}
|
||||
}
|
||||
|
||||
// Rate cells, keyed by loan type + slab order.
|
||||
const cellKey = (c: any) => `${c.productSrNo}|${c.slabOrder}`;
|
||||
const oldCells = new Map<string, any>(
|
||||
(active?.slabs ?? []).map((c: any): [string, any] => [
|
||||
cellKey(c),
|
||||
c
|
||||
])
|
||||
);
|
||||
const newCells = new Map<string, any>(
|
||||
(submitted?.slabs ?? []).map((c: any): [string, any] => [
|
||||
cellKey(c),
|
||||
c
|
||||
])
|
||||
);
|
||||
const names = new Map<number, string>();
|
||||
for (const c of active?.slabs ?? []) {
|
||||
if (c.productName) {
|
||||
names.set(c.productSrNo, c.productName);
|
||||
}
|
||||
}
|
||||
for (const p of submitted?.products ?? []) {
|
||||
names.set(p.srNo, p.name);
|
||||
}
|
||||
|
||||
const allKeys = new Set<string>([
|
||||
...newCells.keys(),
|
||||
...oldCells.keys()
|
||||
]);
|
||||
const cellChanges = [...allKeys]
|
||||
.map((k) => {
|
||||
const before = oldCells.get(k);
|
||||
const after = newCells.get(k);
|
||||
const ref = after ?? before;
|
||||
return {
|
||||
srNo: Number(ref.productSrNo),
|
||||
slabOrder: Number(ref.slabOrder),
|
||||
label: `${names.get(ref.productSrNo) ?? `Loan type ${ref.productSrNo}`} — ${ref.slabLabel ?? `Slab ${ref.slabOrder + 1}`}`,
|
||||
oldText: before ? cellText(before) : EMPTY,
|
||||
newText: after ? cellText(after) : EMPTY
|
||||
};
|
||||
})
|
||||
// A cell that is N.A. in the submission and was never in the old version is not a change.
|
||||
.filter((c) => c.oldText !== c.newText && !(c.oldText === EMPTY && c.newText === NA))
|
||||
.sort((a, b) => a.srNo - b.srNo || a.slabOrder - b.slabOrder);
|
||||
for (const c of cellChanges) {
|
||||
changes.push({
|
||||
label: c.label,
|
||||
kind: kindOf(c.oldText, c.newText, [
|
||||
EMPTY,
|
||||
NA
|
||||
]),
|
||||
oldValue: c.oldText,
|
||||
newValue: c.newText
|
||||
});
|
||||
}
|
||||
|
||||
// CIBIL bands, matched by their score range, so a re-ordered list is not reported as a change.
|
||||
const bandKey = (b: any) => `${b.scoreFrom}-${b.scoreTo}`;
|
||||
const bandText = (b: any) => `${Number(b.adjustmentPct)}% (${b.slabType ?? 'Flat'})`;
|
||||
const oldBands = new Map<string, any>(
|
||||
(active?.cibilSlabs ?? []).map((b: any): [string, any] => [
|
||||
bandKey(b),
|
||||
b
|
||||
])
|
||||
);
|
||||
const newBands = new Map<string, any>(
|
||||
(submitted?.cibilSlabs ?? []).map((b: any): [string, any] => [
|
||||
bandKey(b),
|
||||
b
|
||||
])
|
||||
);
|
||||
for (const k of new Set<string>([
|
||||
...newBands.keys(),
|
||||
...oldBands.keys()
|
||||
])) {
|
||||
const before = oldBands.get(k);
|
||||
const after = newBands.get(k);
|
||||
const oldText = before ? bandText(before) : EMPTY;
|
||||
const newText = after ? bandText(after) : EMPTY;
|
||||
if (oldText !== newText) {
|
||||
const ref = after ?? before;
|
||||
changes.push({
|
||||
label: `CIBIL ${ref.scoreFrom}–${ref.scoreTo}${ref.tier ? ` (${ref.tier})` : ''} — Adjustment`,
|
||||
kind: kindOf(oldText, newText, [EMPTY]),
|
||||
oldValue: oldText,
|
||||
newValue: newText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user