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:
+6
-1
@@ -41,7 +41,12 @@ export class CibilAdjustmentComponent implements OnInit {
|
||||
const idParam = this.route.snapshot.queryParamMap.get('id');
|
||||
const id = idParam ? Number(idParam) : null;
|
||||
this.service.list().subscribe((all) => {
|
||||
this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
|
||||
const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
|
||||
if (!found) {
|
||||
this.router.navigate(['/products/loan-interest-rate-register']);
|
||||
return;
|
||||
}
|
||||
this.register = found;
|
||||
this.active = this.service.getActive();
|
||||
});
|
||||
}
|
||||
|
||||
+46
-2
@@ -55,6 +55,9 @@
|
||||
{{ rows.length }} products × {{ slabs.length }} amount slabs
|
||||
<span *ngIf="canEdit">· click any cell to edit</span>
|
||||
<span *ngIf="!canEdit">· read-only view</span>
|
||||
<span class="er__gl-warn" *ngIf="unmappedCount() > 0"
|
||||
>· {{ unmappedCount() }} without GL — map every product's GL before submitting</span
|
||||
>
|
||||
</div>
|
||||
<div class="er__grid-actions" *ngIf="canEdit">
|
||||
<button mat-stroked-button class="er__dash-btn" (click)="addProduct()">
|
||||
@@ -65,6 +68,22 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="er__add-panel" *ngIf="addingProduct && canEdit">
|
||||
<div class="er__add-title">Add product</div>
|
||||
<div class="er__add-fields">
|
||||
<div class="er__field">
|
||||
<label>PRODUCT NAME</label>
|
||||
<input type="text" [(ngModel)]="newProductName" placeholder="e.g. Education Loan" />
|
||||
</div>
|
||||
<div class="er__field er__field--gl">
|
||||
<label>GL ACCOUNT (required)</label>
|
||||
<mifosx-gl-picker [options]="glOptions" [(value)]="newProductGl"></mifosx-gl-picker>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="confirmAddProduct()">Add</button>
|
||||
<button mat-stroked-button (click)="cancelAddProduct()">Cancel</button>
|
||||
</div>
|
||||
<div class="er__add-error" *ngIf="addProductError">{{ addProductError }}</div>
|
||||
</div>
|
||||
<div class="er__grid-scroll">
|
||||
<table class="er__grid">
|
||||
<thead>
|
||||
@@ -82,6 +101,25 @@
|
||||
<td class="er__col-particulars">
|
||||
<span class="er__srno">{{ row.srNo }}</span
|
||||
>{{ row.particulars }}
|
||||
<div class="er__gl" *ngIf="glEditRow !== i">
|
||||
<span
|
||||
class="er__gl-tag"
|
||||
[class.er__gl-tag--unmapped]="row.glAccountId == null"
|
||||
[class.er__gl-tag--editable]="canEdit"
|
||||
(click)="editGl(i)"
|
||||
>{{ row.glAccountId == null ? 'GL not mapped' : glLabel(row) }}</span
|
||||
>
|
||||
</div>
|
||||
<mifosx-gl-picker
|
||||
*ngIf="glEditRow === i"
|
||||
class="er__gl-select"
|
||||
[options]="glOptions"
|
||||
[value]="row.glAccountId ?? null"
|
||||
[allowClear]="true"
|
||||
[openOnInit]="true"
|
||||
(valueChange)="row.glAccountId = $event"
|
||||
(closed)="glEditRow = null"
|
||||
></mifosx-gl-picker>
|
||||
</td>
|
||||
<td
|
||||
*ngFor="let cell of row.rates; let j = index"
|
||||
@@ -125,8 +163,14 @@
|
||||
<button mat-stroked-button (click)="backToList()">{{ canEdit ? 'Cancel' : 'Back to list' }}</button>
|
||||
<div class="er__footer-note">Rates in % p.a. · N.A. = slab not offered · formulas resolved at sanction</div>
|
||||
<div class="er__footer-actions" *ngIf="canEdit">
|
||||
<button mat-stroked-button>Save draft</button>
|
||||
<button mat-flat-button class="er__submit" (click)="submitForApproval()">
|
||||
<button mat-stroked-button (click)="saveDraft()">Save draft</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
class="er__submit"
|
||||
(click)="submitForApproval()"
|
||||
[disabled]="!canSubmit"
|
||||
[title]="canSubmit ? '' : 'Map a GL account for every product first'"
|
||||
>
|
||||
<i class="fas fa-check"></i> Submit for approval
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+73
@@ -70,6 +70,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__gl {
|
||||
margin: 3px 0 0 30px;
|
||||
}
|
||||
|
||||
&__gl-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--unmapped {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
&--editable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
outline: 1px solid #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__gl-select {
|
||||
display: block;
|
||||
margin: 4px 0 0 30px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
&__gl-warn {
|
||||
color: #b45309;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__add-panel {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&__add-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__add-fields {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.er__field {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.er__field--gl {
|
||||
min-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-error {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&__readonly-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+88
-8
@@ -6,7 +6,8 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -18,7 +19,8 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { RateRegisterService } from '../rate-register.service';
|
||||
import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model';
|
||||
import { GlOption, RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model';
|
||||
import { GlPickerComponent } from '../gl-picker/gl-picker.component';
|
||||
|
||||
/**
|
||||
* Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10
|
||||
@@ -39,10 +41,13 @@ import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate
|
||||
MatSelectModule,
|
||||
MatIconModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule
|
||||
MatNativeDateModule,
|
||||
GlPickerComponent
|
||||
]
|
||||
})
|
||||
export class EditRegisterComponent implements OnInit {
|
||||
export class EditRegisterComponent implements OnInit, OnDestroy {
|
||||
private readonly snack = inject(MatSnackBar);
|
||||
|
||||
private readonly service = inject(RateRegisterService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
@@ -58,11 +63,42 @@ export class EditRegisterComponent implements OnInit {
|
||||
|
||||
@ViewChild('cellInput') cellInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
/** Leaving the page keeps the draft: edits are saved so a reload or a trip to Compare does not lose them. */
|
||||
ngOnDestroy(): void {
|
||||
if (this.canEdit) {
|
||||
this.service.saveDrafts();
|
||||
}
|
||||
}
|
||||
|
||||
saveDraft(): void {
|
||||
this.service.saveDrafts();
|
||||
this.snack.open('Draft saved', 'OK', { duration: 2000 });
|
||||
}
|
||||
|
||||
/** Posting GL accounts for the particular → GL mapping. */
|
||||
glOptions: GlOption[] = [];
|
||||
|
||||
/** "Add product" panel state. A new product needs a name and a GL before it can be added. */
|
||||
addingProduct = false;
|
||||
newProductName = '';
|
||||
newProductGl: number | null = null;
|
||||
addProductError = '';
|
||||
|
||||
/** Index of the row whose GL is being changed inline, or null. */
|
||||
glEditRow: number | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.service.getGlOptions().subscribe((options) => (this.glOptions = options));
|
||||
const idParam = this.route.snapshot.queryParamMap.get('id');
|
||||
const id = idParam ? Number(idParam) : null;
|
||||
this.service.list().subscribe((all) => {
|
||||
this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
|
||||
const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
|
||||
if (!found) {
|
||||
// e.g. a reload on an unsaved local draft: nothing to edit, go back to the list.
|
||||
this.router.navigate(['/products/loan-interest-rate-register']);
|
||||
return;
|
||||
}
|
||||
this.register = found;
|
||||
this.active = this.service.getActive();
|
||||
this.slabs = this.register.slabs;
|
||||
this.rows = this.register.rows;
|
||||
@@ -173,22 +209,66 @@ export class EditRegisterComponent implements OnInit {
|
||||
this.editing = null;
|
||||
}
|
||||
|
||||
/** Append a new empty product row at the bottom of the grid. */
|
||||
glLabel(row: RateChartRow): string | null {
|
||||
return this.service.glLabel(row.glAccountId);
|
||||
}
|
||||
|
||||
unmappedCount(): number {
|
||||
return this.rows.filter((r) => r.glAccountId == null).length;
|
||||
}
|
||||
|
||||
/** Submit is blocked until every product has a GL. */
|
||||
get canSubmit(): boolean {
|
||||
return this.unmappedCount() === 0;
|
||||
}
|
||||
|
||||
/** Opens the Add product panel. */
|
||||
addProduct(): void {
|
||||
if (!this.canEdit) {
|
||||
return;
|
||||
}
|
||||
const name = window.prompt('New product name', 'New product');
|
||||
this.addingProduct = true;
|
||||
this.newProductName = '';
|
||||
this.newProductGl = null;
|
||||
this.addProductError = '';
|
||||
}
|
||||
|
||||
cancelAddProduct(): void {
|
||||
this.addingProduct = false;
|
||||
this.addProductError = '';
|
||||
}
|
||||
|
||||
/** Append the new product row at the bottom of the grid. Name and GL are both mandatory. */
|
||||
confirmAddProduct(): void {
|
||||
const name = this.newProductName.trim();
|
||||
if (!name) {
|
||||
this.addProductError = 'Enter the product name.';
|
||||
return;
|
||||
}
|
||||
if (this.rows.some((r) => r.particulars.trim().toLowerCase() === name.toLowerCase())) {
|
||||
this.addProductError = 'A product with this name already exists.';
|
||||
return;
|
||||
}
|
||||
if (this.newProductGl == null) {
|
||||
this.addProductError = 'Select the GL account for this product.';
|
||||
return;
|
||||
}
|
||||
const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1;
|
||||
this.rows.push({
|
||||
srNo: next,
|
||||
particulars: name.trim(),
|
||||
particulars: name,
|
||||
glAccountId: this.newProductGl,
|
||||
rates: Array(this.slabs.length).fill(null),
|
||||
isFormula: false
|
||||
});
|
||||
this.cancelAddProduct();
|
||||
}
|
||||
|
||||
/** Toggle the inline GL selector on an existing row (Draft only). */
|
||||
editGl(rowIdx: number): void {
|
||||
if (this.canEdit) {
|
||||
this.glEditRow = this.glEditRow === rowIdx ? null : rowIdx;
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a new amount slab column; every existing row grows by one blank cell. */
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelect, MatSelectModule } from '@angular/material/select';
|
||||
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { GlOption } from '../models/rate-register.model';
|
||||
|
||||
/**
|
||||
* Compact searchable GL account dropdown. Typing in the search box filters by GL code or name.
|
||||
* Emits the chosen GL id; with allowClear a "GL not mapped" entry emits null.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-gl-picker',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
NgxMatSelectSearchModule
|
||||
],
|
||||
template: `
|
||||
<mat-form-field class="gl-picker" subscriptSizing="dynamic">
|
||||
<mat-select
|
||||
[value]="value"
|
||||
[placeholder]="placeholder"
|
||||
(selectionChange)="valueChange.emit($event.value)"
|
||||
(openedChange)="onOpenedChange($event)"
|
||||
>
|
||||
<mat-option>
|
||||
<ngx-mat-select-search
|
||||
[formControl]="filterCtrl"
|
||||
placeholderLabel="Search GL code or name"
|
||||
noEntriesFoundLabel="No GL account found"
|
||||
></ngx-mat-select-search>
|
||||
</mat-option>
|
||||
<mat-option *ngIf="allowClear" [value]="null">GL not mapped</mat-option>
|
||||
<mat-option *ngFor="let o of filtered" [value]="o.id">{{ o.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.gl-picker {
|
||||
width: 100%;
|
||||
min-width: 260px;
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class GlPickerComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {
|
||||
@Input() options: GlOption[] = [];
|
||||
@Input() value: number | null = null;
|
||||
@Input() placeholder = 'Select GL account';
|
||||
@Input() allowClear = false;
|
||||
/** Open the dropdown as soon as it is rendered (used for inline row editing). */
|
||||
@Input() openOnInit = false;
|
||||
|
||||
@Output() valueChange = new EventEmitter<number | null>();
|
||||
/** Fired when the dropdown panel closes, whether or not a value was picked. */
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
|
||||
@ViewChild(MatSelect) private select?: MatSelect;
|
||||
|
||||
filterCtrl = new UntypedFormControl('');
|
||||
filtered: GlOption[] = [];
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.filterCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => this.applyFilter());
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['options'] || changes['value']) {
|
||||
this.applyFilter();
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.openOnInit) {
|
||||
setTimeout(() => this.select?.open());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
onOpenedChange(opened: boolean): void {
|
||||
if (opened) {
|
||||
this.filterCtrl.setValue('');
|
||||
} else {
|
||||
this.closed.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Filter by code or name; the currently selected option always stays in the list so its label shows. */
|
||||
private applyFilter(): void {
|
||||
const q = ((this.filterCtrl.value as string) ?? '').trim().toLowerCase();
|
||||
const all = this.options ?? [];
|
||||
if (!q) {
|
||||
this.filtered = all.slice();
|
||||
return;
|
||||
}
|
||||
const matches = all.filter((o) => o.label.toLowerCase().includes(q));
|
||||
const selected = all.find((o) => o.id === this.value);
|
||||
this.filtered = selected && !matches.includes(selected) ? [
|
||||
selected,
|
||||
...matches
|
||||
] : matches;
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -54,9 +54,8 @@
|
||||
<div class="lirr__empty-icon"><i class="fas fa-folder-open"></i></div>
|
||||
<div class="lirr__empty-title">No rate register versions yet</div>
|
||||
<div class="lirr__empty-sub">
|
||||
Create the first version to publish rates for all loan products. The template comes pre-filled from the bank's
|
||||
approved Excel chart (34 products × 10 amount slabs + 5 CIBIL bands) — you can edit everything before
|
||||
submitting for approval.
|
||||
Create the first version to publish rates for all loan products. It starts with the standard loan types (rates
|
||||
blank) and 5 CIBIL bands — fill in the rates, add any bank-specific products, and submit for approval.
|
||||
</div>
|
||||
<button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()">
|
||||
<i class="fas fa-plus"></i> Create first version
|
||||
@@ -141,6 +140,9 @@
|
||||
<td class="lirr__panel-grid-first">
|
||||
<span class="lirr__panel-grid-srno">{{ row.srNo }}</span
|
||||
>{{ row.particulars }}
|
||||
<span class="lirr__gl-tag" [class.lirr__gl-tag--unmapped]="row.glAccountId == null">{{
|
||||
row.glAccountId == null ? 'GL not mapped' : glLabel(row.glAccountId)
|
||||
}}</span>
|
||||
</td>
|
||||
<td *ngFor="let cell of row.rates" class="lirr__panel-grid-num">{{ display(cell) }}</td>
|
||||
</tr>
|
||||
|
||||
+16
@@ -424,6 +424,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__gl-tag {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
|
||||
&--unmapped {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
&__history-foot {
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
+11
-4
@@ -38,9 +38,15 @@ export class LoanInterestRateRegisterComponent implements OnInit {
|
||||
active?: RateRegister;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load the GL list once so mapped particulars show a readable label instead of "GL #id".
|
||||
this.service.getGlOptions().subscribe();
|
||||
this.reload();
|
||||
}
|
||||
|
||||
glLabel(id: number | null | undefined): string | null {
|
||||
return this.service.glLabel(id);
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.service.list().subscribe((rows) => {
|
||||
this.registers = rows;
|
||||
@@ -168,10 +174,11 @@ export class LoanInterestRateRegisterComponent implements OnInit {
|
||||
|
||||
/** Scaffold the very first version — used only when the register has no versions at all. */
|
||||
createFirstVersion(): void {
|
||||
const draft = this.service.createFirstVersion();
|
||||
this.snack.open('Blank draft created — fill in rates and submit for approval', 'OK', { duration: 3000 });
|
||||
this.reload();
|
||||
this.edit(draft);
|
||||
this.service.createFirstVersion().subscribe((draft) => {
|
||||
this.snack.open('Draft created — fill in rates and submit for approval', 'OK', { duration: 3000 });
|
||||
this.reload();
|
||||
this.edit(draft);
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the Compare screen for a pending register (checker view). */
|
||||
|
||||
@@ -21,9 +21,17 @@ export interface RateChartRow {
|
||||
srNo: number;
|
||||
particulars: string;
|
||||
isFormula?: boolean;
|
||||
/** GL account this particular is mapped to. null/undefined = "GL not mapped". */
|
||||
glAccountId?: number | null;
|
||||
rates: RateCell[];
|
||||
}
|
||||
|
||||
/** One selectable GL account for the particular-to-GL mapping. */
|
||||
export interface GlOption {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent';
|
||||
|
||||
export interface CibilSlab {
|
||||
|
||||
+12
-110
@@ -48,121 +48,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Consolidated inline-diff rate chart -->
|
||||
<div class="pr__card">
|
||||
<div class="pr__card-head">
|
||||
<div>
|
||||
<div class="pr__card-title">Rate chart — inline diff</div>
|
||||
<div class="pr__card-sub">Changed cells highlighted · unchanged cells show single value</div>
|
||||
</div>
|
||||
<mat-slide-toggle *ngIf="changedCellCount() > 0" [(ngModel)]="showOnlyChanged" class="pr__toggle">
|
||||
Show only changed rows
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<div class="pr__empty" *ngIf="changedCellCount() === 0 && changedCibilCount() === 0">
|
||||
<i class="fas fa-equals"></i>
|
||||
<div class="pr__empty-title">No changes detected</div>
|
||||
<div class="pr__empty-sub">
|
||||
Draft is identical to the current Active register. Edit the Draft first to see diffs here.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pr__scroll" *ngIf="changedCellCount() > 0">
|
||||
<table class="pr__grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="pr__col-particulars">Particulars</th>
|
||||
<th *ngFor="let s of slabs" class="pr__col-rate">{{ s.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let row of visibleRows" [class.pr__row--changed]="rowHasChanges(row.srNo)">
|
||||
<td class="pr__col-particulars">
|
||||
<span class="pr__srno">{{ row.srNo }}</span
|
||||
>{{ row.particulars }}
|
||||
</td>
|
||||
<td
|
||||
*ngFor="let _ of slabs; let j = index"
|
||||
class="pr__col-rate"
|
||||
[class.pr__cell-changed]="cellChanged(row.srNo, j)"
|
||||
>
|
||||
<ng-container *ngIf="cellChanged(row.srNo, j); else sameCell">
|
||||
<span class="pr__old">{{ display(baselineCell(row.srNo, j)) }}</span>
|
||||
<span class="pr__arrow">→</span>
|
||||
<span class="pr__new">{{ display(draftCell(row.srNo, j)) }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameCell>{{ display(draftCell(row.srNo, j)) }}</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CIBIL slabs inline-diff -->
|
||||
<div class="pr__card" *ngIf="changedCibilCount() > 0">
|
||||
<div class="pr__card-head">
|
||||
<div>
|
||||
<div class="pr__card-title">CIBIL slabs — inline diff</div>
|
||||
<div class="pr__card-sub">Changed fields shown as OLD → NEW · unchanged fields render once</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="pr__cibil">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Score from</th>
|
||||
<th>Score to</th>
|
||||
<th>Tier</th>
|
||||
<th>Adjustment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let slab of draftCibil; let i = index" [class.pr__row--changed]="cibilChanged(i)">
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'from')">
|
||||
<ng-container *ngIf="cibilFieldChanged(i, 'from'); else sameFrom">
|
||||
<span class="pr__old">{{ baselineCibil[i]?.from ?? '—' }}</span>
|
||||
<span class="pr__arrow">→</span>
|
||||
<span class="pr__new">{{ slab.from }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameFrom>{{ slab.from }}</ng-template>
|
||||
</td>
|
||||
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'to')">
|
||||
<ng-container *ngIf="cibilFieldChanged(i, 'to'); else sameTo">
|
||||
<span class="pr__old">{{ baselineCibil[i]?.to ?? '—' }}</span>
|
||||
<span class="pr__arrow">→</span>
|
||||
<span class="pr__new">{{ slab.to }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameTo>{{ slab.to }}</ng-template>
|
||||
</td>
|
||||
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'tier')">
|
||||
<ng-container *ngIf="cibilFieldChanged(i, 'tier'); else sameTier">
|
||||
<span class="pr__old">{{ baselineCibil[i]?.tier ?? '—' }}</span>
|
||||
<span class="pr__arrow">→</span>
|
||||
<span class="pr__new">{{ slab.tier }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameTier>{{ slab.tier }}</ng-template>
|
||||
</td>
|
||||
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'adjustmentPct')">
|
||||
<ng-container *ngIf="cibilFieldChanged(i, 'adjustmentPct'); else sameAdj">
|
||||
<span class="pr__old">{{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }}</span>
|
||||
<span class="pr__arrow">→</span>
|
||||
<span class="pr__new">{{ formatPct(slab.adjustmentPct) }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameAdj>{{ formatPct(slab.adjustmentPct) }}</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<mifosx-rate-register-diff
|
||||
[slabs]="slabs"
|
||||
[draftRows]="draftRows"
|
||||
[baselineRows]="baselineRows"
|
||||
[draftCibil]="draftCibil"
|
||||
[baselineCibil]="baselineCibil"
|
||||
></mifosx-rate-register-diff>
|
||||
|
||||
<div class="pr__footer">
|
||||
<button mat-stroked-button (click)="backToEdit()">{{ canSubmit ? 'Cancel' : 'Back' }}</button>
|
||||
<div class="pr__footer-note" *ngIf="canSubmit">
|
||||
Submission will create a maker-checker request · the register becomes effective only after a checker approves it.
|
||||
</div>
|
||||
<div class="pr__footer-note" *ngIf="canSubmit && unmappedCount() > 0">
|
||||
{{ unmappedCount() }} product(s) have no GL account. Go back to edit and map a GL for every product before
|
||||
submitting.
|
||||
</div>
|
||||
<div class="pr__footer-note" *ngIf="!canSubmit">
|
||||
Review-only — this version has already been submitted ({{ draft.status }}).
|
||||
</div>
|
||||
@@ -172,7 +74,7 @@
|
||||
color="primary"
|
||||
class="pr__submit"
|
||||
(click)="submit()"
|
||||
[disabled]="submitting || changedCellCount() + changedCibilCount() === 0"
|
||||
[disabled]="submitting || unmappedCount() > 0 || changedCellCount() + changedCibilCount() === 0"
|
||||
>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
|
||||
|
||||
-177
@@ -159,183 +159,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// ---- Card wrapper -----------------------------------------------------
|
||||
|
||||
&__card {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
&__card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__card-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// ---- Empty state (no changes) -----------------------------------------
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 48px 20px;
|
||||
color: #64748b;
|
||||
|
||||
i {
|
||||
font-size: 28px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__empty-sub {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
// ---- Scrollable rate chart --------------------------------------------
|
||||
|
||||
&__scroll {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 10px;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
thead th.pr__col-particulars {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody td.pr__col-particulars {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__srno {
|
||||
display: inline-block;
|
||||
min-width: 24px;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
// ---- Row & cell highlights --------------------------------------------
|
||||
|
||||
&__row--changed {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
&__cell-changed {
|
||||
background: #fef3c7 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__old {
|
||||
color: #991b1b;
|
||||
font-weight: 500;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #dc2626;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
color: #94a3b8;
|
||||
margin: 0 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__new {
|
||||
color: #065f46;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// ---- CIBIL inline-diff table ------------------------------------------
|
||||
|
||||
&__cibil {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Footer -----------------------------------------------------------
|
||||
|
||||
&__footer {
|
||||
|
||||
+25
-108
@@ -8,13 +8,13 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { RateRegisterService } from '../rate-register.service';
|
||||
import { CibilSlab, RateCell, RateRegister, SlabColumn } from '../models/rate-register.model';
|
||||
import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model';
|
||||
import { RateRegisterDiffComponent } from '../rate-register-diff/rate-register-diff.component';
|
||||
import { countChangedCells, countChangedCibil } from '../rate-register-diff.util';
|
||||
|
||||
/**
|
||||
* Inline-diff Compare screen for a Loan Interest Rate Register draft.
|
||||
@@ -32,9 +32,8 @@ import { CibilSlab, RateCell, RateRegister, SlabColumn } from '../models/rate-re
|
||||
styleUrls: ['./preview-register.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatSlideToggleModule
|
||||
RateRegisterDiffComponent
|
||||
]
|
||||
})
|
||||
export class PreviewRegisterComponent implements OnInit {
|
||||
@@ -47,25 +46,33 @@ export class PreviewRegisterComponent implements OnInit {
|
||||
active?: RateRegister;
|
||||
|
||||
slabs: SlabColumn[] = [];
|
||||
draftRows: { srNo: number; particulars: string; rates: RateCell[] }[] = [];
|
||||
baselineRows: { srNo: number; particulars: string; rates: RateCell[] }[] = [];
|
||||
draftRows: RateChartRow[] = [];
|
||||
baselineRows: RateChartRow[] = [];
|
||||
draftCibil: CibilSlab[] = [];
|
||||
baselineCibil: CibilSlab[] = [];
|
||||
|
||||
submitting = false;
|
||||
/** User toggle to collapse the chart to only the rows that changed. */
|
||||
showOnlyChanged = true;
|
||||
|
||||
/** Submit button is live only for a Draft. A Pending Approval or Active register is review-only here. */
|
||||
get canSubmit(): boolean {
|
||||
return this.draft?.status === 'Draft';
|
||||
}
|
||||
|
||||
/** Products still without a GL; the backend rejects the save while any remain. */
|
||||
unmappedCount(): number {
|
||||
return this.draftRows.filter((r) => r.glAccountId == null).length;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const idParam = this.route.snapshot.queryParamMap.get('id');
|
||||
const id = idParam ? Number(idParam) : null;
|
||||
this.service.list().subscribe((all) => {
|
||||
this.draft = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
|
||||
const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
|
||||
if (!found) {
|
||||
this.router.navigate(['/products/loan-interest-rate-register']);
|
||||
return;
|
||||
}
|
||||
this.draft = found;
|
||||
this.active = this.service.getActive();
|
||||
this.slabs = this.draft.slabs;
|
||||
this.draftRows = this.draft.rows;
|
||||
@@ -78,97 +85,12 @@ export class PreviewRegisterComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
display(cell: RateCell): string {
|
||||
if (cell === null) {
|
||||
return 'N.A.';
|
||||
}
|
||||
if (typeof cell === 'string') {
|
||||
return cell;
|
||||
}
|
||||
return cell.toFixed(2);
|
||||
}
|
||||
|
||||
/** The baseline (OLD) value of a cell — null if the row did not exist in the baseline. */
|
||||
baselineCell(srNo: number, colIdx: number): RateCell {
|
||||
return this.baselineRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
|
||||
}
|
||||
|
||||
/** The draft (NEW) value of a cell. */
|
||||
draftCell(srNo: number, colIdx: number): RateCell {
|
||||
return this.draftRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
|
||||
}
|
||||
|
||||
/** True when the same {product, amount-slab} cell differs between baseline and draft. */
|
||||
cellChanged(srNo: number, colIdx: number): boolean {
|
||||
return !this.sameCell(this.baselineCell(srNo, colIdx), this.draftCell(srNo, colIdx));
|
||||
}
|
||||
|
||||
/** True when a product row has at least one changed cell — used by the "show only changed" filter. */
|
||||
rowHasChanges(srNo: number): boolean {
|
||||
for (let j = 0; j < this.slabs.length; j++) {
|
||||
if (this.cellChanged(srNo, j)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Rows the chart should actually render — all rows, or only the ones with any diff. */
|
||||
get visibleRows(): { srNo: number; particulars: string; rates: RateCell[] }[] {
|
||||
if (!this.showOnlyChanged || this.changedCellCount() === 0) {
|
||||
return this.draftRows;
|
||||
}
|
||||
return this.draftRows.filter((r) => this.rowHasChanges(r.srNo));
|
||||
}
|
||||
|
||||
cibilChanged(index: number): boolean {
|
||||
const a = this.baselineCibil[index];
|
||||
const d = this.draftCibil[index];
|
||||
if (!a || !d) {
|
||||
return true;
|
||||
}
|
||||
return a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct;
|
||||
}
|
||||
|
||||
/** Which CIBIL field(s) differ between baseline and draft — drives per-field highlight. */
|
||||
cibilFieldChanged(index: number, field: 'from' | 'to' | 'tier' | 'adjustmentPct'): boolean {
|
||||
const a = this.baselineCibil[index];
|
||||
const d = this.draftCibil[index];
|
||||
if (!a || !d) {
|
||||
return true;
|
||||
}
|
||||
return a[field] !== d[field];
|
||||
}
|
||||
|
||||
changedCellCount(): number {
|
||||
let n = 0;
|
||||
for (const row of this.draftRows) {
|
||||
for (let j = 0; j < this.slabs.length; j++) {
|
||||
if (this.cellChanged(row.srNo, j)) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length);
|
||||
}
|
||||
|
||||
changedCibilCount(): number {
|
||||
let n = 0;
|
||||
const max = Math.max(this.draftCibil.length, this.baselineCibil.length);
|
||||
for (let i = 0; i < max; i++) {
|
||||
if (this.cibilChanged(i)) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
formatPct(pct: number): string {
|
||||
if (pct === 0) {
|
||||
return '0.00%';
|
||||
}
|
||||
const sign = pct > 0 ? '+' : '−';
|
||||
return `${sign}${Math.abs(pct).toFixed(2)}%`;
|
||||
return countChangedCibil(this.draftCibil, this.baselineCibil);
|
||||
}
|
||||
|
||||
/** Opens the mock backend call. `rollbackTransaction:true` → row sits in checker inbox. */
|
||||
@@ -189,7 +111,12 @@ export class PreviewRegisterComponent implements OnInit {
|
||||
// we still unlock the UI so the user can retry instead of staring at a spinner forever.
|
||||
this.submitting = false;
|
||||
console.error('[PreviewRegister] submit failed', err);
|
||||
this.snack.open('Submit failed — check backend status and try again', 'Dismiss', { duration: 4500 });
|
||||
const reason =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.message ||
|
||||
'check backend status and try again';
|
||||
this.snack.open(`Submit failed — ${reason}`, 'Dismiss', { duration: 8000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -197,14 +124,4 @@ export class PreviewRegisterComponent implements OnInit {
|
||||
backToEdit(): void {
|
||||
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.draft.id } });
|
||||
}
|
||||
|
||||
private sameCell(a: RateCell, b: RateCell): boolean {
|
||||
if (a === null && b === null) {
|
||||
return true;
|
||||
}
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return Math.abs(a - b) < 1e-9;
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 { CibilSlab, RateCell, RateChartRow } from './models/rate-register.model';
|
||||
|
||||
/** Two chart cells are the same when both are N.A., both the same formula, or numerically equal. */
|
||||
export function sameCell(a: RateCell, b: RateCell): boolean {
|
||||
if (a === null && b === null) {
|
||||
return true;
|
||||
}
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return Math.abs(a - b) < 1e-9;
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
/** Number of {product, amount-slab} cells whose value differs between the draft and the baseline. */
|
||||
export function countChangedCells(draft: RateChartRow[], baseline: RateChartRow[], slabCount: number): number {
|
||||
let n = 0;
|
||||
for (const row of draft) {
|
||||
const base = baseline.find((r) => r.srNo === row.srNo);
|
||||
for (let j = 0; j < slabCount; j++) {
|
||||
if (!sameCell(base?.rates[j] ?? null, row.rates[j] ?? null)) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Number of CIBIL bands that differ (by position) between the draft and the baseline. */
|
||||
export function countChangedCibil(draft: CibilSlab[], baseline: CibilSlab[]): number {
|
||||
let n = 0;
|
||||
const max = Math.max(draft.length, baseline.length);
|
||||
for (let i = 0; i < max; i++) {
|
||||
const a = baseline[i];
|
||||
const d = draft[i];
|
||||
if (!a || !d || a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<!-- Consolidated inline-diff rate chart -->
|
||||
<div class="rd__card">
|
||||
<div class="rd__card-head">
|
||||
<div>
|
||||
<div class="rd__card-title">Rate chart — inline diff</div>
|
||||
<div class="rd__card-sub">Changed cells highlighted · unchanged cells show single value</div>
|
||||
</div>
|
||||
<mat-slide-toggle *ngIf="changedCellCount() > 0" [(ngModel)]="showOnlyChanged" class="rd__toggle">
|
||||
Show only changed rows
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<div class="rd__empty" *ngIf="changedCellCount() === 0 && changedCibilCount() === 0">
|
||||
<i class="fas fa-equals"></i>
|
||||
<div class="rd__empty-title">No changes detected</div>
|
||||
<div class="rd__empty-sub">The proposed version is identical to the current Active register.</div>
|
||||
</div>
|
||||
|
||||
<div class="rd__scroll" *ngIf="changedCellCount() > 0">
|
||||
<table class="rd__grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rd__col-particulars">Particulars</th>
|
||||
<th *ngFor="let s of slabs" class="rd__col-rate">{{ s.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let row of visibleRows" [class.rd__row--changed]="rowHasChanges(row.srNo)">
|
||||
<td class="rd__col-particulars">
|
||||
<span class="rd__srno">{{ row.srNo }}</span
|
||||
>{{ row.particulars }}
|
||||
<div>
|
||||
<span class="rd__gl-tag" [class.rd__gl-tag--unmapped]="row.glAccountId == null">{{ glText(row) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*ngFor="let _ of slabs; let j = index"
|
||||
class="rd__col-rate"
|
||||
[class.rd__cell-changed]="cellChanged(row.srNo, j)"
|
||||
>
|
||||
<ng-container *ngIf="cellChanged(row.srNo, j); else sameCell">
|
||||
<span class="rd__old">{{ display(baselineCell(row.srNo, j)) }}</span>
|
||||
<span class="rd__arrow">→</span>
|
||||
<span class="rd__new">{{ display(draftCell(row.srNo, j)) }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameCell>{{ display(draftCell(row.srNo, j)) }}</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CIBIL slabs inline-diff -->
|
||||
<div class="rd__card" *ngIf="changedCibilCount() > 0">
|
||||
<div class="rd__card-head">
|
||||
<div>
|
||||
<div class="rd__card-title">CIBIL slabs — inline diff</div>
|
||||
<div class="rd__card-sub">Changed fields shown as OLD → NEW · unchanged fields render once</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="rd__cibil">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Score from</th>
|
||||
<th>Score to</th>
|
||||
<th>Tier</th>
|
||||
<th>Adjustment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let slab of draftCibil; let i = index" [class.rd__row--changed]="cibilChanged(i)">
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'from')">
|
||||
<ng-container *ngIf="cibilFieldChanged(i, 'from'); else sameFrom">
|
||||
<span class="rd__old">{{ baselineCibil[i]?.from ?? '—' }}</span>
|
||||
<span class="rd__arrow">→</span>
|
||||
<span class="rd__new">{{ slab.from }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameFrom>{{ slab.from }}</ng-template>
|
||||
</td>
|
||||
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'to')">
|
||||
<ng-container *ngIf="cibilFieldChanged(i, 'to'); else sameTo">
|
||||
<span class="rd__old">{{ baselineCibil[i]?.to ?? '—' }}</span>
|
||||
<span class="rd__arrow">→</span>
|
||||
<span class="rd__new">{{ slab.to }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameTo>{{ slab.to }}</ng-template>
|
||||
</td>
|
||||
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'tier')">
|
||||
<ng-container *ngIf="cibilFieldChanged(i, 'tier'); else sameTier">
|
||||
<span class="rd__old">{{ baselineCibil[i]?.tier ?? '—' }}</span>
|
||||
<span class="rd__arrow">→</span>
|
||||
<span class="rd__new">{{ slab.tier }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameTier>{{ slab.tier }}</ng-template>
|
||||
</td>
|
||||
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'adjustmentPct')">
|
||||
<ng-container *ngIf="cibilFieldChanged(i, 'adjustmentPct'); else sameAdj">
|
||||
<span class="rd__old">{{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }}</span>
|
||||
<span class="rd__arrow">→</span>
|
||||
<span class="rd__new">{{ formatPct(slab.adjustmentPct) }}</span>
|
||||
</ng-container>
|
||||
<ng-template #sameAdj>{{ formatPct(slab.adjustmentPct) }}</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rd {
|
||||
&__card {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
&__card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__card-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// ---- Empty state (no changes) -----------------------------------------
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 48px 20px;
|
||||
color: #64748b;
|
||||
|
||||
i {
|
||||
font-size: 28px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__empty-sub {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
// ---- Scrollable rate chart --------------------------------------------
|
||||
|
||||
&__scroll {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 10px;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
thead th.rd__col-particulars {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody td.rd__col-particulars {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__srno {
|
||||
display: inline-block;
|
||||
min-width: 24px;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&__gl-tag {
|
||||
display: inline-block;
|
||||
margin-left: 30px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
|
||||
&--unmapped {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Row & cell highlights --------------------------------------------
|
||||
|
||||
&__row--changed {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
&__cell-changed {
|
||||
background: #fef3c7 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__old {
|
||||
color: #991b1b;
|
||||
font-weight: 500;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #dc2626;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
color: #94a3b8;
|
||||
margin: 0 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__new {
|
||||
color: #065f46;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// ---- CIBIL inline-diff table ------------------------------------------
|
||||
|
||||
&__cibil {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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/.
|
||||
*/
|
||||
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { RateRegisterService } from '../rate-register.service';
|
||||
import { CibilSlab, RateCell, RateChartRow, SlabColumn } from '../models/rate-register.model';
|
||||
import { countChangedCells, countChangedCibil, sameCell } from '../rate-register-diff.util';
|
||||
|
||||
/**
|
||||
* Inline-diff view of a proposed Loan Interest Rate Register version against a baseline: one rate
|
||||
* chart where every changed cell reads OLD -> NEW, plus the CIBIL bands. Shared by the Compare
|
||||
* screen (before submitting) and the Approvals dialog (before approving), so both show the same thing.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-rate-register-diff',
|
||||
templateUrl: './rate-register-diff.component.html',
|
||||
styleUrls: ['./rate-register-diff.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatSlideToggleModule
|
||||
]
|
||||
})
|
||||
export class RateRegisterDiffComponent implements OnInit {
|
||||
private readonly service = inject(RateRegisterService);
|
||||
|
||||
@Input() slabs: SlabColumn[] = [];
|
||||
@Input() draftRows: RateChartRow[] = [];
|
||||
@Input() baselineRows: RateChartRow[] = [];
|
||||
@Input() draftCibil: CibilSlab[] = [];
|
||||
@Input() baselineCibil: CibilSlab[] = [];
|
||||
|
||||
/** User toggle to collapse the chart to only the rows that changed. */
|
||||
showOnlyChanged = true;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Loads the GL list so mapped particulars show a readable label; the cache is shared.
|
||||
this.service.getGlOptions().subscribe();
|
||||
}
|
||||
|
||||
glText(row: RateChartRow): string {
|
||||
return row.glAccountId == null ? 'GL not mapped' : (this.service.glLabel(row.glAccountId) ?? 'GL not mapped');
|
||||
}
|
||||
|
||||
display(cell: RateCell): string {
|
||||
if (cell === null) {
|
||||
return 'N.A.';
|
||||
}
|
||||
if (typeof cell === 'string') {
|
||||
return cell;
|
||||
}
|
||||
return cell.toFixed(2);
|
||||
}
|
||||
|
||||
/** The baseline (OLD) value of a cell; null when the row did not exist in the baseline. */
|
||||
baselineCell(srNo: number, colIdx: number): RateCell {
|
||||
return this.baselineRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
|
||||
}
|
||||
|
||||
draftCell(srNo: number, colIdx: number): RateCell {
|
||||
return this.draftRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
|
||||
}
|
||||
|
||||
cellChanged(srNo: number, colIdx: number): boolean {
|
||||
return !sameCell(this.baselineCell(srNo, colIdx), this.draftCell(srNo, colIdx));
|
||||
}
|
||||
|
||||
rowHasChanges(srNo: number): boolean {
|
||||
for (let j = 0; j < this.slabs.length; j++) {
|
||||
if (this.cellChanged(srNo, j)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Rows to render: all of them, or only those with a change when the toggle is on. */
|
||||
get visibleRows(): RateChartRow[] {
|
||||
if (!this.showOnlyChanged || this.changedCellCount() === 0) {
|
||||
return this.draftRows;
|
||||
}
|
||||
return this.draftRows.filter((r) => this.rowHasChanges(r.srNo));
|
||||
}
|
||||
|
||||
changedCellCount(): number {
|
||||
return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length);
|
||||
}
|
||||
|
||||
changedCibilCount(): number {
|
||||
return countChangedCibil(this.draftCibil, this.baselineCibil);
|
||||
}
|
||||
|
||||
cibilChanged(index: number): boolean {
|
||||
const a = this.baselineCibil[index];
|
||||
const d = this.draftCibil[index];
|
||||
return !a || !d || a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct;
|
||||
}
|
||||
|
||||
/** Per-field highlight inside a CIBIL row. */
|
||||
cibilFieldChanged(index: number, field: 'from' | 'to' | 'tier' | 'adjustmentPct'): boolean {
|
||||
const a = this.baselineCibil[index];
|
||||
const d = this.draftCibil[index];
|
||||
return !a || !d || a[field] !== d[field];
|
||||
}
|
||||
|
||||
formatPct(pct: number): string {
|
||||
if (pct === 0) {
|
||||
return '0.00%';
|
||||
}
|
||||
const sign = pct > 0 ? '+' : '−';
|
||||
return `${sign}${Math.abs(pct).toFixed(2)}%`;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@
|
||||
*/
|
||||
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable, catchError, map, of, tap } from 'rxjs';
|
||||
import { Observable, catchError, forkJoin, map, of, switchMap, tap } from 'rxjs';
|
||||
import { ProductsService } from '../products.service';
|
||||
import {
|
||||
CibilSlab,
|
||||
GlOption,
|
||||
RateCell,
|
||||
RateChartRow,
|
||||
RateRegister,
|
||||
@@ -21,10 +22,9 @@ import {
|
||||
/**
|
||||
* Central Loan Interest Rate Register service.
|
||||
*
|
||||
* Primary data path is HTTP (/loanproducts/baseinterestrates). On HTTP failure (backend not yet
|
||||
* deployed, network error) the service transparently falls back to in-memory seeded data so the UI
|
||||
* never breaks during development. Local Drafts (not-yet-submitted new versions) live only in
|
||||
* memory and are merged in front of server data on every list().
|
||||
* Data comes from HTTP (/loanproducts/baseinterestrates); there is no built-in sample data. If the
|
||||
* backend cannot be reached the list is empty apart from local Drafts (not-yet-submitted new
|
||||
* versions), which live only in memory and are merged in front of server data on every list().
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RateRegisterService {
|
||||
@@ -37,7 +37,100 @@ export class RateRegisterService {
|
||||
private cache: RateRegister[] = [];
|
||||
|
||||
/** In-memory drafts the maker has created but not yet submitted. Preserved across list() refreshes. */
|
||||
private localDrafts: RateRegister[] = [];
|
||||
private localDrafts: RateRegister[] = this.loadDrafts();
|
||||
|
||||
private static readonly DRAFTS_KEY = 'cbsLoanRateRegisterDrafts';
|
||||
|
||||
/** Drafts are kept in this browser's localStorage so a reload does not lose the maker's work. */
|
||||
private loadDrafts(): RateRegister[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RateRegisterService.DRAFTS_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as RateRegister[]) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((d) => d?.status === 'Draft') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the current drafts (call after any edit the user should not lose). */
|
||||
saveDrafts(): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
RateRegisterService.DRAFTS_KEY,
|
||||
JSON.stringify(this.localDrafts.filter((d) => d.status === 'Draft'))
|
||||
);
|
||||
} catch {
|
||||
/* storage unavailable (private mode / quota): the draft just stays in memory */
|
||||
}
|
||||
}
|
||||
|
||||
/** Base for the synthetic ids of pending server entries: -(base + commandId). Local drafts count down from -1. */
|
||||
private static readonly PENDING_ID_BASE = 1_000_000;
|
||||
|
||||
/** One maker-checker entry (with its commandAsJson) as a read-only register, e.g. for the Approvals dialog. */
|
||||
registerFromPendingEntry(entry: any): RateRegister | null {
|
||||
return this.pendingToRegisters([entry])[0] ?? null;
|
||||
}
|
||||
|
||||
/** A version as the backend returns it, as a UI register; used as the "old" side of a comparison. */
|
||||
registerFromServerDto(dto: any): RateRegister {
|
||||
return this.fromBackend(dto, new Map());
|
||||
}
|
||||
|
||||
/** Turn the maker-checker entries awaiting a checker into read-only "Pending Approval" registers. */
|
||||
private pendingToRegisters(entries: any): RateRegister[] {
|
||||
const items: any[] = Array.isArray(entries) ? entries : (entries?.pageItems ?? []);
|
||||
const out: RateRegister[] = [];
|
||||
for (const e of items) {
|
||||
if ((e?.entityName ?? '').toUpperCase() !== 'LOANBASEINTERESTRATE' || !e?.commandAsJson) {
|
||||
continue;
|
||||
}
|
||||
let payload: any;
|
||||
try {
|
||||
payload = JSON.parse(e.commandAsJson);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const glBySrNo = new Map<number, number | null>();
|
||||
for (const p of payload.products ?? []) {
|
||||
glBySrNo.set(p.srNo, p.glAccountId ?? null);
|
||||
}
|
||||
const madeOn =
|
||||
typeof e.madeOnDate === 'number'
|
||||
? new Date(e.madeOnDate < 1e12 ? e.madeOnDate * 1000 : e.madeOnDate).toISOString().slice(0, 10)
|
||||
: String(e.madeOnDate ?? '').slice(0, 10);
|
||||
const reg = this.fromBackend(
|
||||
{
|
||||
id: -(RateRegisterService.PENDING_ID_BASE + e.id),
|
||||
name: payload.name,
|
||||
effectiveFromDate: payload.effectiveFromDate ?? madeOn,
|
||||
effectiveToDate: null,
|
||||
currencyCode: payload.currencyCode,
|
||||
isActive: false,
|
||||
fallbackNoCibilSlab: payload.fallbackNoCibilSlab,
|
||||
fallbackBelowMin: payload.fallbackBelowMin,
|
||||
note: payload.note,
|
||||
createdDate: madeOn,
|
||||
slabs: payload.slabs,
|
||||
cibilSlabs: payload.cibilSlabs
|
||||
},
|
||||
glBySrNo
|
||||
);
|
||||
reg.status = 'Pending Approval';
|
||||
reg.createdBy = e.maker ?? 'Maker';
|
||||
reg.lastModified = madeOn;
|
||||
// With no Active register to diff against, the Compare screen falls back to this blank baseline.
|
||||
reg.baselineRows = reg.rows.map((r) => ({
|
||||
srNo: r.srNo,
|
||||
particulars: r.particulars,
|
||||
glAccountId: r.glAccountId ?? null,
|
||||
rates: new Array(r.rates.length).fill(null)
|
||||
}));
|
||||
reg.baselineCibilSlabs = [];
|
||||
out.push(reg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private readonly slabs: SlabColumn[] = [
|
||||
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
|
||||
@@ -52,304 +145,6 @@ export class RateRegisterService {
|
||||
{ key: 's10', label: '> 25,00,000', amountUpto: null }
|
||||
];
|
||||
|
||||
// Values taken from the bank's approved Interest Rate of Loans register.
|
||||
private readonly rows: RateChartRow[] = [
|
||||
this.row(1, 'Consumer Loan', [
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
13,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]),
|
||||
this.row(
|
||||
2,
|
||||
'Self Employment Loan',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
13,
|
||||
12.5,
|
||||
12,
|
||||
11.5,
|
||||
11,
|
||||
10.5,
|
||||
10.5
|
||||
]
|
||||
),
|
||||
this.row(
|
||||
3,
|
||||
'Business Term Loan for running business',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
13,
|
||||
12.5,
|
||||
11,
|
||||
10.5,
|
||||
10,
|
||||
9.5,
|
||||
9.5
|
||||
]
|
||||
),
|
||||
this.row(
|
||||
4,
|
||||
'Cash Credit',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
13,
|
||||
13,
|
||||
11,
|
||||
10.5,
|
||||
10,
|
||||
9.5,
|
||||
9.5
|
||||
]
|
||||
),
|
||||
this.row(
|
||||
5,
|
||||
'Limit (OD) on property — MSME',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
13,
|
||||
13,
|
||||
11,
|
||||
10.5,
|
||||
10,
|
||||
9.5,
|
||||
9.5
|
||||
]
|
||||
),
|
||||
this.row(
|
||||
6,
|
||||
'Limit (OD) on property — Personal',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
14,
|
||||
13.5,
|
||||
12,
|
||||
11.5,
|
||||
11,
|
||||
10.5,
|
||||
10.5
|
||||
]
|
||||
),
|
||||
this.row(7, 'House Purchase / Construction', [
|
||||
null,
|
||||
null,
|
||||
15,
|
||||
12,
|
||||
12,
|
||||
9.25,
|
||||
8.75,
|
||||
8.25,
|
||||
8,
|
||||
8
|
||||
]),
|
||||
this.row(8, 'House Repairing, Renovation & Expansion Loan', [
|
||||
null,
|
||||
null,
|
||||
15,
|
||||
14,
|
||||
14,
|
||||
13,
|
||||
12,
|
||||
11,
|
||||
10.5,
|
||||
10
|
||||
]),
|
||||
this.row(
|
||||
9,
|
||||
'Commercial Real Estate Loan',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
13,
|
||||
12.5,
|
||||
12,
|
||||
11,
|
||||
10.5,
|
||||
10.5,
|
||||
10
|
||||
]
|
||||
),
|
||||
this.row(10, 'Loan against Jewellery', this.flat(10.8)),
|
||||
this.row(11, 'Loan against Warehouse Receipt (Farmers)', this.flat(13)),
|
||||
this.row(
|
||||
12,
|
||||
'Loan against Warehouse Receipt (Traders)',
|
||||
[
|
||||
10.5,
|
||||
10.5,
|
||||
10.5,
|
||||
10.5,
|
||||
10.5,
|
||||
10.5,
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
9
|
||||
]
|
||||
),
|
||||
this.row(13, 'Agriculture Loan', [
|
||||
null,
|
||||
null,
|
||||
15,
|
||||
12,
|
||||
12,
|
||||
12,
|
||||
11.75,
|
||||
11.5,
|
||||
11.25,
|
||||
11
|
||||
]),
|
||||
this.row(14, 'Personal Loan (Unsecured)', [
|
||||
18,
|
||||
18,
|
||||
16,
|
||||
15,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]),
|
||||
this.row(
|
||||
15,
|
||||
'Personal Loan (Secured / Property)',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
16,
|
||||
15,
|
||||
15,
|
||||
13,
|
||||
12,
|
||||
11.5,
|
||||
11,
|
||||
11
|
||||
]
|
||||
),
|
||||
this.row(16, 'Two Wheeler Loan', this.flat(13)),
|
||||
this.row(17, 'Car Loan (4-Wheel Domestic)', [
|
||||
null,
|
||||
null,
|
||||
10,
|
||||
9.5,
|
||||
9.5,
|
||||
8.75,
|
||||
8.5,
|
||||
8.35,
|
||||
8.25,
|
||||
8.25
|
||||
]),
|
||||
this.row(18, 'Old Car Loan (4-Wheel Domestic) & Refinance', [
|
||||
null,
|
||||
null,
|
||||
13,
|
||||
12.5,
|
||||
12.5,
|
||||
12,
|
||||
11.5,
|
||||
11,
|
||||
10.5,
|
||||
10.5
|
||||
]),
|
||||
this.row(19, 'Vehicle Loan (Commercial & Three-wheeler)', [
|
||||
null,
|
||||
null,
|
||||
13,
|
||||
12,
|
||||
12,
|
||||
10.5,
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
10
|
||||
]),
|
||||
this.row(20, 'Old Vehicle Loan (Commercial & Three-wheeler)', [
|
||||
null,
|
||||
null,
|
||||
15,
|
||||
14,
|
||||
14,
|
||||
13.5,
|
||||
13.5,
|
||||
13,
|
||||
12.5,
|
||||
12.5
|
||||
]),
|
||||
this.row(
|
||||
21,
|
||||
'Machinery & Plant Loan',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
13,
|
||||
12.5,
|
||||
12,
|
||||
11,
|
||||
10.5,
|
||||
10.5,
|
||||
10
|
||||
]
|
||||
),
|
||||
this.row(
|
||||
22,
|
||||
'Office Chamber / Equipment Loan',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
15,
|
||||
13,
|
||||
12.5,
|
||||
12,
|
||||
11,
|
||||
10.5,
|
||||
10.5,
|
||||
10
|
||||
]
|
||||
),
|
||||
this.formulaRow(23, 'Loan against F.D.R.', [
|
||||
'1% + FD',
|
||||
'1% + FD',
|
||||
'1% + FD',
|
||||
'1% + FD',
|
||||
'1% + FD',
|
||||
'1% + FD',
|
||||
'0.75% + FD',
|
||||
'0.50% + FD',
|
||||
'0.50% + FD',
|
||||
'0.50% + FD'
|
||||
]),
|
||||
this.row(24, 'Loan against Daily Deposit & Gullak Scheme', this.flat(10)),
|
||||
this.formulaRow(25, 'Loan against Recurring Deposit', Array(10).fill('1% + RD')),
|
||||
this.row(26, 'Loan against Staff Service Fund', this.flat(7)),
|
||||
this.row(27, 'Over Draft A/c', this.flat(null)),
|
||||
this.row(28, 'Loan on LIC Policy', this.flat(null)),
|
||||
this.row(29, 'Loan on KVP / NSC', this.flat(null)),
|
||||
this.row(30, 'Bill Discount', this.flat(null)),
|
||||
this.row(31, 'Temporary Limit', this.flat(15)),
|
||||
this.row(32, 'Bank Guarantee (commission)', this.flat(0.25)),
|
||||
this.row(33, 'Sugam Vyapar Loan', this.flat(null)),
|
||||
this.row(34, 'OD to Staff (upto 2 lakh)', this.flat(null))
|
||||
];
|
||||
|
||||
// Credit-score bands per the bank's approved register:
|
||||
// above 700 = base rate (no change), below that stepped up by 0.5% per band.
|
||||
// No discount band — the register never gives a negative adjustment.
|
||||
@@ -361,86 +156,81 @@ export class RateRegisterService {
|
||||
{ from: 701, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: 0 }
|
||||
];
|
||||
|
||||
// Seed data used ONLY when the HTTP backend is unreachable (dev fallback). Each entry has its own
|
||||
// deep-cloned rows & CIBIL slabs so edits to one register do not mutate another.
|
||||
private readonly seedRegisters: RateRegister[] = [
|
||||
this.seedRegister({
|
||||
id: 1001,
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2027',
|
||||
effectiveFrom: '2027-04-01',
|
||||
effectiveTo: '2028-03-31',
|
||||
status: 'Draft',
|
||||
createdBy: 'Sonali',
|
||||
lastModified: '2026-10-01',
|
||||
note: 'Scheduled · future effective'
|
||||
// No pre-seeded deltas — Draft starts identical to the Active register.
|
||||
// Only edits made in the UI will show as diffs on the Compare screen.
|
||||
}),
|
||||
this.seedRegister({
|
||||
id: 1002,
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2026',
|
||||
effectiveFrom: '2026-04-01',
|
||||
effectiveTo: '2027-03-31',
|
||||
status: 'Active',
|
||||
createdBy: 'Admin',
|
||||
lastModified: '2026-03-25',
|
||||
note: 'Approved by Board · Nagrik Sahakari Bank Maryadit Vidisha'
|
||||
}),
|
||||
this.seedRegister({
|
||||
id: 1003,
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2025',
|
||||
effectiveFrom: '2025-04-01',
|
||||
effectiveTo: '2026-03-31',
|
||||
status: 'Expired',
|
||||
createdBy: 'Admin',
|
||||
lastModified: '2025-03-20'
|
||||
}),
|
||||
this.seedRegister({
|
||||
id: 1004,
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2024',
|
||||
effectiveFrom: '2024-04-01',
|
||||
effectiveTo: '2025-03-31',
|
||||
status: 'Expired',
|
||||
createdBy: 'Admin',
|
||||
lastModified: '2024-03-18'
|
||||
}),
|
||||
this.seedRegister({
|
||||
id: 1005,
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2023',
|
||||
effectiveFrom: '2023-04-01',
|
||||
effectiveTo: '2024-03-31',
|
||||
status: 'Expired',
|
||||
createdBy: 'Admin',
|
||||
lastModified: '2023-03-22'
|
||||
})
|
||||
/**
|
||||
* The standard loan types every bank offers. Same list the backend seeds into
|
||||
* m_loan_rate_register_product; used only if that endpoint cannot be reached when the first
|
||||
* version is created. Any other particular is added by the bank itself.
|
||||
*/
|
||||
private readonly defaultParticulars: { srNo: number; name: string }[] = [
|
||||
{ srNo: 1, name: 'Housing Loan (Purchase/Construction)' },
|
||||
{ srNo: 2, name: 'Home Repair/Renovation' },
|
||||
{ srNo: 3, name: 'Personal Loan (Unsecured)' },
|
||||
{ srNo: 4, name: 'Personal Loan (Secured/Property)' },
|
||||
{ srNo: 5, name: 'Two Wheeler Loan' },
|
||||
{ srNo: 6, name: 'Car Loan (New)' },
|
||||
{ srNo: 7, name: 'Used Car/Vehicle Loan' },
|
||||
{ srNo: 8, name: 'Commercial Vehicle Loan' },
|
||||
{ srNo: 9, name: 'Gold Loan' },
|
||||
{ srNo: 10, name: 'Business Term Loan' },
|
||||
{ srNo: 11, name: 'Machinery & Equipment Loan' },
|
||||
{ srNo: 12, name: 'Cash Credit' },
|
||||
{ srNo: 13, name: 'Overdraft' },
|
||||
{ srNo: 14, name: 'Agriculture Loan' },
|
||||
{ srNo: 15, name: 'Loan against FDR' },
|
||||
{ srNo: 16, name: 'Loan against RD' }
|
||||
];
|
||||
|
||||
/**
|
||||
* List all registers (local drafts + server versions). Tries HTTP first; on failure OR when
|
||||
* the server returns an empty collection (fresh DB, nothing seeded yet) falls back to the
|
||||
* built-in seed registers so the UI always has something to show in development.
|
||||
* List all registers (local drafts + server versions). If the backend cannot be reached only the
|
||||
* local drafts are returned: there is no built-in sample data. A successful but empty response is a
|
||||
* genuinely empty register and shows the "Create first version" state.
|
||||
*/
|
||||
list(): Observable<RateRegister[]> {
|
||||
if (!this.useHttp) {
|
||||
this.cache = [
|
||||
...this.localDrafts,
|
||||
...this.seedRegisters
|
||||
];
|
||||
this.cache = [...this.localDrafts];
|
||||
return of(this.cache);
|
||||
}
|
||||
return this.productsService.getLoanBaseInterestRates().pipe(
|
||||
map((dtos: any[]) => (dtos ?? []).map((d) => this.fromBackend(d))),
|
||||
catchError((err) => {
|
||||
console.warn('[RateRegisterService] HTTP unavailable, using seeded data —', err?.message ?? err);
|
||||
this.useHttp = false;
|
||||
return of([...this.seedRegisters]);
|
||||
}),
|
||||
map((serverVersions) => {
|
||||
if (serverVersions.length === 0) {
|
||||
console.info('[RateRegisterService] Backend returned no versions — using seeded demo data.');
|
||||
return [...this.seedRegisters];
|
||||
// Approved versions, the particulars master (each row's GL) and the versions still awaiting a
|
||||
// checker. A failure of the master or the pending lookup must not hide the approved versions.
|
||||
return forkJoin({
|
||||
versions: this.productsService.getLoanBaseInterestRates(),
|
||||
products: this.productsService.getLoanRateRegisterProducts().pipe(catchError(() => of([]))),
|
||||
pending: this.productsService.getPendingLoanRateRegisters().pipe(
|
||||
// The list omits each entry's payload, so fetch the detail of every pending entry.
|
||||
switchMap((entries: any) => {
|
||||
const items: any[] = (Array.isArray(entries) ? entries : (entries?.pageItems ?? [])).filter(
|
||||
(e: any) => (e?.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE'
|
||||
);
|
||||
if (items.length === 0) {
|
||||
return of([] as any[]);
|
||||
}
|
||||
return forkJoin(
|
||||
items.map((e) =>
|
||||
this.productsService.getLoanRateRegisterAuditDetail(e.id).pipe(
|
||||
map((detail: any) => ({ ...detail, ...e })),
|
||||
catchError(() => of(null))
|
||||
)
|
||||
)
|
||||
).pipe(map((all) => all.filter((x) => x !== null)));
|
||||
}),
|
||||
catchError(() => of([] as any[]))
|
||||
)
|
||||
}).pipe(
|
||||
map(({ versions, products, pending }) => {
|
||||
const glBySrNo = new Map<number, number | null>();
|
||||
for (const p of (products as any[]) ?? []) {
|
||||
glBySrNo.set(p.srNo, p.glAccountId ?? null);
|
||||
}
|
||||
return serverVersions;
|
||||
const approved = ((versions as any[]) ?? []).map((d) => this.fromBackend(d, glBySrNo));
|
||||
return [
|
||||
...this.pendingToRegisters(pending),
|
||||
...approved
|
||||
];
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.warn('[RateRegisterService] Backend unavailable —', err?.message ?? err);
|
||||
this.useHttp = false;
|
||||
return of([] as RateRegister[]);
|
||||
}),
|
||||
tap((effectiveVersions) => {
|
||||
this.cache = [
|
||||
@@ -462,10 +252,10 @@ export class RateRegisterService {
|
||||
return of(this.localDrafts.find((r) => r.id === id));
|
||||
}
|
||||
if (!this.useHttp) {
|
||||
return of(this.seedRegisters.find((r) => r.id === id));
|
||||
return of(undefined);
|
||||
}
|
||||
return this.productsService.getLoanBaseInterestRateById(id).pipe(
|
||||
map((d: any) => (d ? this.fromBackend(d) : undefined)),
|
||||
map((d: any) => (d ? this.fromBackend(d, new Map()) : undefined)),
|
||||
catchError(() => of(undefined))
|
||||
);
|
||||
}
|
||||
@@ -476,12 +266,9 @@ export class RateRegisterService {
|
||||
}
|
||||
|
||||
/** The one shown by default on list → edit navigation. */
|
||||
getActiveOrFirstDraft(): RateRegister {
|
||||
getActiveOrFirstDraft(): RateRegister | undefined {
|
||||
return (
|
||||
this.cache.find((r) => r.status === 'Draft') ??
|
||||
this.cache.find((r) => r.status === 'Active') ??
|
||||
this.cache[0] ??
|
||||
this.seedRegisters[0]
|
||||
this.cache.find((r) => r.status === 'Draft') ?? this.cache.find((r) => r.status === 'Active') ?? this.cache[0]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -507,37 +294,20 @@ export class RateRegisterService {
|
||||
return of({ rollbackTransaction: false });
|
||||
}
|
||||
|
||||
if (!this.useHttp) {
|
||||
// Dev fallback — flip status locally so the UI can still demo the flow.
|
||||
draft.status = 'Pending Approval';
|
||||
draft.lastModified = new Date().toISOString().slice(0, 10);
|
||||
return of({ rollbackTransaction: true, commandId: Math.floor(Math.random() * 100000) });
|
||||
}
|
||||
|
||||
const payload = this.toBackend(draft);
|
||||
return this.productsService.saveLoanBaseInterestRate(payload).pipe(
|
||||
map((res: any) => {
|
||||
const rollback = res?.rollbackTransaction === true;
|
||||
const commandId = res?.commandId;
|
||||
if (rollback) {
|
||||
// Keep the local Draft visible as "Pending Approval" until the checker acts.
|
||||
draft.status = 'Pending Approval';
|
||||
draft.lastModified = new Date().toISOString().slice(0, 10);
|
||||
} else {
|
||||
// Direct commit — drop the local draft; the server now owns the new Active version.
|
||||
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
|
||||
this.cache = this.cache.filter((c) => c.id !== id);
|
||||
}
|
||||
// Either way the server now owns this version (a pending maker-checker entry, or the new
|
||||
// Active one), so the local draft goes. list() brings the server copy back on its next call.
|
||||
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
|
||||
this.cache = this.cache.filter((c) => c.id !== id);
|
||||
this.saveDrafts();
|
||||
return { rollbackTransaction: rollback, commandId };
|
||||
}),
|
||||
catchError((err) => {
|
||||
// HTTP failed (backend down, 4xx/5xx). Mark the draft as locally Pending so the UI isn't
|
||||
// stuck on "Submitting…", and surface the failure to the caller for toast handling.
|
||||
console.warn('[RateRegisterService] POST failed, keeping draft in local Pending state —', err?.message ?? err);
|
||||
draft.status = 'Pending Approval';
|
||||
draft.lastModified = new Date().toISOString().slice(0, 10);
|
||||
return of({ rollbackTransaction: true, commandId: undefined });
|
||||
})
|
||||
// No catchError: a rejected submit (e.g. a product without GL, backend down) must reach the
|
||||
// caller so it can show the reason instead of pretending the request was sent.
|
||||
);
|
||||
}
|
||||
|
||||
@@ -569,16 +339,51 @@ export class RateRegisterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffold a brand-new local Draft when the backend has no versions yet. Uses the Excel-sourced
|
||||
* template (34 products × 10 slabs + 5 CIBIL bands) so the user has a sensible starting point to
|
||||
* tweak and submit as the first-ever Active version. Carries a negative id like other local drafts.
|
||||
* Scaffold the first-ever Draft. Rows come from the backend's master list of particulars
|
||||
* (/loanproducts/baseinterestrates/products) with every rate blank, so the bank fills in its own
|
||||
* rates. If the backend is unreachable, the same standard loan types come from a built-in list.
|
||||
* Carries a negative id like other local drafts.
|
||||
*/
|
||||
createFirstVersion(): RateRegister {
|
||||
const template = this.seedRegisters.find((r) => r.status === 'Active') ?? this.seedRegisters[0];
|
||||
const newId = this.nextLocalDraftId();
|
||||
createFirstVersion(): Observable<RateRegister> {
|
||||
const fromTemplate = (): RateRegister =>
|
||||
this.scaffoldFirstDraft(
|
||||
this.defaultParticulars.map(
|
||||
(p): RateChartRow => ({
|
||||
srNo: p.srNo,
|
||||
particulars: p.name,
|
||||
glAccountId: null,
|
||||
rates: new Array(this.slabs.length).fill(null)
|
||||
})
|
||||
),
|
||||
'First version — standard loan types, rates to be filled'
|
||||
);
|
||||
|
||||
if (!this.useHttp) {
|
||||
return of(fromTemplate());
|
||||
}
|
||||
return this.productsService.getLoanRateRegisterProducts().pipe(
|
||||
map((products: any[]) => {
|
||||
const rows: RateChartRow[] = (products ?? []).map(
|
||||
(p): RateChartRow => ({
|
||||
srNo: p.srNo,
|
||||
particulars: p.name,
|
||||
glAccountId: p.glAccountId ?? null,
|
||||
rates: new Array(this.slabs.length).fill(null)
|
||||
})
|
||||
);
|
||||
return rows.length > 0
|
||||
? this.scaffoldFirstDraft(rows, 'First version — standard loan types, rates to be filled')
|
||||
: fromTemplate();
|
||||
}),
|
||||
catchError(() => of(fromTemplate()))
|
||||
);
|
||||
}
|
||||
|
||||
private scaffoldFirstDraft(rows: RateChartRow[], note: string): RateRegister {
|
||||
const cibilTemplate = this.cibilSlabs;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const draft: RateRegister = {
|
||||
id: newId,
|
||||
id: this.nextLocalDraftId(),
|
||||
name: 'Interest Rate of Loans — new version',
|
||||
effectiveFrom: today,
|
||||
effectiveTo: null,
|
||||
@@ -586,22 +391,23 @@ export class RateRegisterService {
|
||||
status: 'Draft',
|
||||
createdBy: 'Current User',
|
||||
lastModified: today,
|
||||
note: 'First Loan Interest Rate Register — seeded from Excel template',
|
||||
slabs: template.slabs,
|
||||
rows: this.cloneRows(template.rows),
|
||||
cibilSlabs: this.cloneCibilSlabs(template.cibilSlabs),
|
||||
// Baseline is empty rows for the first version — compare screen will show every cell as "new".
|
||||
baselineRows: template.rows.map((r) => ({
|
||||
note,
|
||||
slabs: this.slabs,
|
||||
rows,
|
||||
cibilSlabs: this.cloneCibilSlabs(cibilTemplate),
|
||||
// Baseline is blank for a first version, so Compare shows every filled cell as new.
|
||||
baselineRows: rows.map((r) => ({
|
||||
srNo: r.srNo,
|
||||
particulars: r.particulars,
|
||||
rates: new Array(r.rates.length).fill(null)
|
||||
})),
|
||||
baselineCibilSlabs: [],
|
||||
fallbackNoCibilSlab: template.fallbackNoCibilSlab,
|
||||
fallbackBelowMin: template.fallbackBelowMin
|
||||
fallbackNoCibilSlab: 2,
|
||||
fallbackBelowMin: 'block'
|
||||
};
|
||||
this.localDrafts.unshift(draft);
|
||||
this.cache.unshift(draft);
|
||||
this.saveDrafts();
|
||||
return draft;
|
||||
}
|
||||
|
||||
@@ -611,7 +417,7 @@ export class RateRegisterService {
|
||||
* with server-assigned ids.
|
||||
*/
|
||||
duplicateAsDraft(sourceId: number): RateRegister | undefined {
|
||||
const source = this.cache.find((r) => r.id === sourceId) ?? this.seedRegisters.find((r) => r.id === sourceId);
|
||||
const source = this.cache.find((r) => r.id === sourceId);
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -637,6 +443,7 @@ export class RateRegisterService {
|
||||
};
|
||||
this.localDrafts.unshift(copy);
|
||||
this.cache.unshift(copy);
|
||||
this.saveDrafts();
|
||||
return copy;
|
||||
}
|
||||
|
||||
@@ -656,76 +463,12 @@ export class RateRegisterService {
|
||||
return (minId <= 0 ? minId : 0) - 1;
|
||||
}
|
||||
|
||||
private row(srNo: number, particulars: string, rates: (number | null)[]): RateChartRow {
|
||||
return { srNo, particulars, rates };
|
||||
}
|
||||
|
||||
private formulaRow(srNo: number, particulars: string, rates: string[]): RateChartRow {
|
||||
return { srNo, particulars, isFormula: true, rates };
|
||||
}
|
||||
|
||||
private flat(value: number | null): (number | null)[] {
|
||||
return Array(10).fill(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a register with its OWN deep-cloned rows and CIBIL slabs — editing one register must
|
||||
* not mutate another. Optional overrides let a Draft start with real diffs vs the Active one.
|
||||
*/
|
||||
private seedRegister(spec: {
|
||||
id: number;
|
||||
name: string;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string | null;
|
||||
status: RateRegister['status'];
|
||||
createdBy: string;
|
||||
lastModified: string;
|
||||
note?: string;
|
||||
rateOverrides?: { srNo: number; colIdx: number; value: number | string | null }[];
|
||||
cibilOverrides?: { index: number; adjustmentPct: number }[];
|
||||
}): RateRegister {
|
||||
const rows = this.cloneRows(this.rows);
|
||||
const cibilSlabs = this.cloneCibilSlabs(this.cibilSlabs);
|
||||
for (const o of spec.rateOverrides ?? []) {
|
||||
const r = rows.find((x) => x.srNo === o.srNo);
|
||||
if (r) {
|
||||
r.rates[o.colIdx] = o.value;
|
||||
}
|
||||
}
|
||||
for (const o of spec.cibilOverrides ?? []) {
|
||||
const s = cibilSlabs[o.index];
|
||||
if (s) {
|
||||
s.adjustmentPct = o.adjustmentPct;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: spec.id,
|
||||
name: spec.name,
|
||||
effectiveFrom: spec.effectiveFrom,
|
||||
effectiveTo: spec.effectiveTo,
|
||||
currency: 'INR',
|
||||
status: spec.status,
|
||||
createdBy: spec.createdBy,
|
||||
lastModified: spec.lastModified,
|
||||
note: spec.note,
|
||||
slabs: this.slabs, // slab columns are structural; safe to share
|
||||
rows,
|
||||
cibilSlabs,
|
||||
// Frozen baseline (last-approved state). Lives separately from `rows`/`cibilSlabs` so edits
|
||||
// to the register are visible as diffs on the Compare screen, even when the register being
|
||||
// edited happens to be the one that is currently Active.
|
||||
baselineRows: this.cloneRows(rows),
|
||||
baselineCibilSlabs: this.cloneCibilSlabs(cibilSlabs),
|
||||
fallbackNoCibilSlab: 2,
|
||||
fallbackBelowMin: 'block'
|
||||
};
|
||||
}
|
||||
|
||||
private cloneRows(src: RateChartRow[]): RateChartRow[] {
|
||||
return src.map((r) => ({
|
||||
srNo: r.srNo,
|
||||
particulars: r.particulars,
|
||||
isFormula: r.isFormula,
|
||||
glAccountId: r.glAccountId ?? null,
|
||||
rates: [...r.rates]
|
||||
}));
|
||||
}
|
||||
@@ -740,7 +483,7 @@ export class RateRegisterService {
|
||||
// that gap.
|
||||
|
||||
/** Map a backend {@code LoanRateRegisterData} payload into a UI RateRegister. */
|
||||
private fromBackend(d: any): RateRegister {
|
||||
private fromBackend(d: any, glBySrNo: Map<number, number | null>): RateRegister {
|
||||
// Rebuild the slab columns from unique slabOrders.
|
||||
const slabByOrder = new Map<number, SlabColumn>();
|
||||
for (const cell of d.slabs ?? []) {
|
||||
@@ -766,7 +509,12 @@ export class RateRegisterService {
|
||||
for (const cell of d.slabs ?? []) {
|
||||
let row = rowByProduct.get(cell.productSrNo);
|
||||
if (!row) {
|
||||
row = { srNo: cell.productSrNo, particulars: cell.productName, rates: new Array(slabs.length).fill(null) };
|
||||
row = {
|
||||
srNo: cell.productSrNo,
|
||||
particulars: cell.productName,
|
||||
glAccountId: glBySrNo.get(cell.productSrNo) ?? null,
|
||||
rates: new Array(slabs.length).fill(null)
|
||||
};
|
||||
rowByProduct.set(cell.productSrNo, row);
|
||||
}
|
||||
let value: RateCell;
|
||||
@@ -851,8 +599,48 @@ export class RateRegisterService {
|
||||
fallbackNoCibilSlab: r.fallbackNoCibilSlab,
|
||||
fallbackBelowMin: r.fallbackBelowMin,
|
||||
note: r.note,
|
||||
products: r.rows.map((row) => ({
|
||||
srNo: row.srNo,
|
||||
name: row.particulars,
|
||||
glAccountId: row.glAccountId ?? null
|
||||
})),
|
||||
slabs,
|
||||
cibilSlabs
|
||||
};
|
||||
}
|
||||
|
||||
// ---- GL accounts for the particular → GL mapping ------------------------------
|
||||
|
||||
private glOptionsCache: GlOption[] | null = null;
|
||||
|
||||
/** Posting GL accounts the user can map a particular to. Cached after the first successful load. */
|
||||
getGlOptions(): Observable<GlOption[]> {
|
||||
if (this.glOptionsCache) {
|
||||
return of(this.glOptionsCache);
|
||||
}
|
||||
return this.productsService.getLoanRegisterGlAccounts().pipe(
|
||||
map((accounts: any[]) =>
|
||||
(accounts ?? [])
|
||||
// The server filters on type=1 (ASSET); this keeps asset-only even if that param is ignored.
|
||||
.filter((a) => a.type == null || a.type.value === 'ASSET' || a.type.id === 1)
|
||||
.sort((a, b) => String(a.glCode ?? '').localeCompare(String(b.glCode ?? '')))
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
label: a.glCode ? `${a.glCode} - ${a.name}` : a.name
|
||||
}))
|
||||
),
|
||||
tap((options) => {
|
||||
this.glOptionsCache = options;
|
||||
}),
|
||||
catchError(() => of([] as GlOption[]))
|
||||
);
|
||||
}
|
||||
|
||||
/** Display label for a mapped GL id, or null when unmapped or the account list is not loaded. */
|
||||
glLabel(id: number | null | undefined): string | null {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
return this.glOptionsCache?.find((o) => o.id === id)?.label ?? `GL #${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +824,28 @@ export class ProductsService {
|
||||
return this.http.get(`/loanproducts/baseinterestrates/${id}`, this.silentCtx());
|
||||
}
|
||||
|
||||
/** Submitted Loan Rate Register versions still awaiting a checker (maker-checker entries with their payload). */
|
||||
getPendingLoanRateRegisters(): Observable<any> {
|
||||
const params = new HttpParams().set('entityName', 'LOANBASEINTERESTRATE');
|
||||
return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
|
||||
}
|
||||
|
||||
/** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */
|
||||
getLoanRateRegisterAuditDetail(id: number): Observable<any> {
|
||||
return this.http.get(`/audits/${id}`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
|
||||
}
|
||||
|
||||
/** GL accounts a particular can be mapped to: asset (type 1) posting (detail) accounts that are not disabled. */
|
||||
getLoanRegisterGlAccounts(): Observable<any> {
|
||||
const params = new HttpParams().set('type', '1').set('usage', '1').set('disabled', 'false');
|
||||
return this.http.get('/glaccounts', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
|
||||
}
|
||||
|
||||
/** Returns the master list of particulars (loan types) that make up the Loan Rate Register chart rows. */
|
||||
getLoanRateRegisterProducts(): Observable<any> {
|
||||
return this.http.get('/loanproducts/baseinterestrates/products', this.silentCtx());
|
||||
}
|
||||
|
||||
/** Submits a new Loan Rate Register version. If maker-checker is enabled, the response's
|
||||
* `rollbackTransaction` flag is true and the row sits in the Approvals tab until a checker approves. */
|
||||
saveLoanBaseInterestRate(payload: any): Observable<any> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '261007',
|
||||
'hash': '7c868ae07'
|
||||
'version': '261008',
|
||||
'hash': 'a5a64d4ef'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user