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 idParam = this.route.snapshot.queryParamMap.get('id');
|
||||||
const id = idParam ? Number(idParam) : null;
|
const id = idParam ? Number(idParam) : null;
|
||||||
this.service.list().subscribe((all) => {
|
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();
|
this.active = this.service.getActive();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-2
@@ -55,6 +55,9 @@
|
|||||||
{{ rows.length }} products × {{ slabs.length }} amount slabs
|
{{ rows.length }} products × {{ slabs.length }} amount slabs
|
||||||
<span *ngIf="canEdit">· click any cell to edit</span>
|
<span *ngIf="canEdit">· click any cell to edit</span>
|
||||||
<span *ngIf="!canEdit">· read-only view</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>
|
||||||
<div class="er__grid-actions" *ngIf="canEdit">
|
<div class="er__grid-actions" *ngIf="canEdit">
|
||||||
<button mat-stroked-button class="er__dash-btn" (click)="addProduct()">
|
<button mat-stroked-button class="er__dash-btn" (click)="addProduct()">
|
||||||
@@ -65,6 +68,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="er__grid-scroll">
|
||||||
<table class="er__grid">
|
<table class="er__grid">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -82,6 +101,25 @@
|
|||||||
<td class="er__col-particulars">
|
<td class="er__col-particulars">
|
||||||
<span class="er__srno">{{ row.srNo }}</span
|
<span class="er__srno">{{ row.srNo }}</span
|
||||||
>{{ row.particulars }}
|
>{{ 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>
|
||||||
<td
|
<td
|
||||||
*ngFor="let cell of row.rates; let j = index"
|
*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>
|
<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-note">Rates in % p.a. · N.A. = slab not offered · formulas resolved at sanction</div>
|
||||||
<div class="er__footer-actions" *ngIf="canEdit">
|
<div class="er__footer-actions" *ngIf="canEdit">
|
||||||
<button mat-stroked-button>Save draft</button>
|
<button mat-stroked-button (click)="saveDraft()">Save draft</button>
|
||||||
<button mat-flat-button class="er__submit" (click)="submitForApproval()">
|
<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
|
<i class="fas fa-check"></i> Submit for approval
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 {
|
&__readonly-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+88
-8
@@ -6,7 +6,8 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* 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 { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -18,7 +19,8 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
import { MatNativeDateModule } from '@angular/material/core';
|
import { MatNativeDateModule } from '@angular/material/core';
|
||||||
import { RateRegisterService } from '../rate-register.service';
|
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
|
* 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,
|
MatSelectModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatDatepickerModule,
|
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 service = inject(RateRegisterService);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
@@ -58,11 +63,42 @@ export class EditRegisterComponent implements OnInit {
|
|||||||
|
|
||||||
@ViewChild('cellInput') cellInput?: ElementRef<HTMLInputElement>;
|
@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 {
|
ngOnInit(): void {
|
||||||
|
this.service.getGlOptions().subscribe((options) => (this.glOptions = options));
|
||||||
const idParam = this.route.snapshot.queryParamMap.get('id');
|
const idParam = this.route.snapshot.queryParamMap.get('id');
|
||||||
const id = idParam ? Number(idParam) : null;
|
const id = idParam ? Number(idParam) : null;
|
||||||
this.service.list().subscribe((all) => {
|
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.active = this.service.getActive();
|
||||||
this.slabs = this.register.slabs;
|
this.slabs = this.register.slabs;
|
||||||
this.rows = this.register.rows;
|
this.rows = this.register.rows;
|
||||||
@@ -173,22 +209,66 @@ export class EditRegisterComponent implements OnInit {
|
|||||||
this.editing = null;
|
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 {
|
addProduct(): void {
|
||||||
if (!this.canEdit) {
|
if (!this.canEdit) {
|
||||||
return;
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1;
|
const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1;
|
||||||
this.rows.push({
|
this.rows.push({
|
||||||
srNo: next,
|
srNo: next,
|
||||||
particulars: name.trim(),
|
particulars: name,
|
||||||
|
glAccountId: this.newProductGl,
|
||||||
rates: Array(this.slabs.length).fill(null),
|
rates: Array(this.slabs.length).fill(null),
|
||||||
isFormula: false
|
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. */
|
/** 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-icon"><i class="fas fa-folder-open"></i></div>
|
||||||
<div class="lirr__empty-title">No rate register versions yet</div>
|
<div class="lirr__empty-title">No rate register versions yet</div>
|
||||||
<div class="lirr__empty-sub">
|
<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
|
Create the first version to publish rates for all loan products. It starts with the standard loan types (rates
|
||||||
approved Excel chart (34 products × 10 amount slabs + 5 CIBIL bands) — you can edit everything before
|
blank) and 5 CIBIL bands — fill in the rates, add any bank-specific products, and submit for approval.
|
||||||
submitting for approval.
|
|
||||||
</div>
|
</div>
|
||||||
<button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()">
|
<button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()">
|
||||||
<i class="fas fa-plus"></i> Create first version
|
<i class="fas fa-plus"></i> Create first version
|
||||||
@@ -141,6 +140,9 @@
|
|||||||
<td class="lirr__panel-grid-first">
|
<td class="lirr__panel-grid-first">
|
||||||
<span class="lirr__panel-grid-srno">{{ row.srNo }}</span
|
<span class="lirr__panel-grid-srno">{{ row.srNo }}</span
|
||||||
>{{ row.particulars }}
|
>{{ 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>
|
||||||
<td *ngFor="let cell of row.rates" class="lirr__panel-grid-num">{{ display(cell) }}</td>
|
<td *ngFor="let cell of row.rates" class="lirr__panel-grid-num">{{ display(cell) }}</td>
|
||||||
</tr>
|
</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 {
|
&__history-foot {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid #f1f5f9;
|
||||||
|
|||||||
+11
-4
@@ -38,9 +38,15 @@ export class LoanInterestRateRegisterComponent implements OnInit {
|
|||||||
active?: RateRegister;
|
active?: RateRegister;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Load the GL list once so mapped particulars show a readable label instead of "GL #id".
|
||||||
|
this.service.getGlOptions().subscribe();
|
||||||
this.reload();
|
this.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
glLabel(id: number | null | undefined): string | null {
|
||||||
|
return this.service.glLabel(id);
|
||||||
|
}
|
||||||
|
|
||||||
private reload(): void {
|
private reload(): void {
|
||||||
this.service.list().subscribe((rows) => {
|
this.service.list().subscribe((rows) => {
|
||||||
this.registers = 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. */
|
/** Scaffold the very first version — used only when the register has no versions at all. */
|
||||||
createFirstVersion(): void {
|
createFirstVersion(): void {
|
||||||
const draft = this.service.createFirstVersion();
|
this.service.createFirstVersion().subscribe((draft) => {
|
||||||
this.snack.open('Blank draft created — fill in rates and submit for approval', 'OK', { duration: 3000 });
|
this.snack.open('Draft created — fill in rates and submit for approval', 'OK', { duration: 3000 });
|
||||||
this.reload();
|
this.reload();
|
||||||
this.edit(draft);
|
this.edit(draft);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the Compare screen for a pending register (checker view). */
|
/** Open the Compare screen for a pending register (checker view). */
|
||||||
|
|||||||
@@ -21,9 +21,17 @@ export interface RateChartRow {
|
|||||||
srNo: number;
|
srNo: number;
|
||||||
particulars: string;
|
particulars: string;
|
||||||
isFormula?: boolean;
|
isFormula?: boolean;
|
||||||
|
/** GL account this particular is mapped to. null/undefined = "GL not mapped". */
|
||||||
|
glAccountId?: number | null;
|
||||||
rates: RateCell[];
|
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 type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent';
|
||||||
|
|
||||||
export interface CibilSlab {
|
export interface CibilSlab {
|
||||||
|
|||||||
+12
-110
@@ -48,121 +48,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Consolidated inline-diff rate chart -->
|
<mifosx-rate-register-diff
|
||||||
<div class="pr__card">
|
[slabs]="slabs"
|
||||||
<div class="pr__card-head">
|
[draftRows]="draftRows"
|
||||||
<div>
|
[baselineRows]="baselineRows"
|
||||||
<div class="pr__card-title">Rate chart — inline diff</div>
|
[draftCibil]="draftCibil"
|
||||||
<div class="pr__card-sub">Changed cells highlighted · unchanged cells show single value</div>
|
[baselineCibil]="baselineCibil"
|
||||||
</div>
|
></mifosx-rate-register-diff>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="pr__footer">
|
<div class="pr__footer">
|
||||||
<button mat-stroked-button (click)="backToEdit()">{{ canSubmit ? 'Cancel' : 'Back' }}</button>
|
<button mat-stroked-button (click)="backToEdit()">{{ canSubmit ? 'Cancel' : 'Back' }}</button>
|
||||||
<div class="pr__footer-note" *ngIf="canSubmit">
|
<div class="pr__footer-note" *ngIf="canSubmit">
|
||||||
Submission will create a maker-checker request · the register becomes effective only after a checker approves it.
|
Submission will create a maker-checker request · the register becomes effective only after a checker approves it.
|
||||||
</div>
|
</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">
|
<div class="pr__footer-note" *ngIf="!canSubmit">
|
||||||
Review-only — this version has already been submitted ({{ draft.status }}).
|
Review-only — this version has already been submitted ({{ draft.status }}).
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +74,7 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
class="pr__submit"
|
class="pr__submit"
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[disabled]="submitting || changedCellCount() + changedCibilCount() === 0"
|
[disabled]="submitting || unmappedCount() > 0 || changedCellCount() + changedCibilCount() === 0"
|
||||||
>
|
>
|
||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
|
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
|
||||||
|
|||||||
-177
@@ -159,183 +159,6 @@
|
|||||||
font-weight: 700;
|
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 -----------------------------------------------------------
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
|
|||||||
+25
-108
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { RateRegisterService } from '../rate-register.service';
|
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.
|
* 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'],
|
styleUrls: ['./preview-register.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatSlideToggleModule
|
RateRegisterDiffComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PreviewRegisterComponent implements OnInit {
|
export class PreviewRegisterComponent implements OnInit {
|
||||||
@@ -47,25 +46,33 @@ export class PreviewRegisterComponent implements OnInit {
|
|||||||
active?: RateRegister;
|
active?: RateRegister;
|
||||||
|
|
||||||
slabs: SlabColumn[] = [];
|
slabs: SlabColumn[] = [];
|
||||||
draftRows: { srNo: number; particulars: string; rates: RateCell[] }[] = [];
|
draftRows: RateChartRow[] = [];
|
||||||
baselineRows: { srNo: number; particulars: string; rates: RateCell[] }[] = [];
|
baselineRows: RateChartRow[] = [];
|
||||||
draftCibil: CibilSlab[] = [];
|
draftCibil: CibilSlab[] = [];
|
||||||
baselineCibil: CibilSlab[] = [];
|
baselineCibil: CibilSlab[] = [];
|
||||||
|
|
||||||
submitting = false;
|
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. */
|
/** Submit button is live only for a Draft. A Pending Approval or Active register is review-only here. */
|
||||||
get canSubmit(): boolean {
|
get canSubmit(): boolean {
|
||||||
return this.draft?.status === 'Draft';
|
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 {
|
ngOnInit(): void {
|
||||||
const idParam = this.route.snapshot.queryParamMap.get('id');
|
const idParam = this.route.snapshot.queryParamMap.get('id');
|
||||||
const id = idParam ? Number(idParam) : null;
|
const id = idParam ? Number(idParam) : null;
|
||||||
this.service.list().subscribe((all) => {
|
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.active = this.service.getActive();
|
||||||
this.slabs = this.draft.slabs;
|
this.slabs = this.draft.slabs;
|
||||||
this.draftRows = this.draft.rows;
|
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 {
|
changedCellCount(): number {
|
||||||
let n = 0;
|
return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length);
|
||||||
for (const row of this.draftRows) {
|
|
||||||
for (let j = 0; j < this.slabs.length; j++) {
|
|
||||||
if (this.cellChanged(row.srNo, j)) {
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
changedCibilCount(): number {
|
changedCibilCount(): number {
|
||||||
let n = 0;
|
return countChangedCibil(this.draftCibil, this.baselineCibil);
|
||||||
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)}%`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens the mock backend call. `rollbackTransaction:true` → row sits in checker inbox. */
|
/** 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.
|
// we still unlock the UI so the user can retry instead of staring at a spinner forever.
|
||||||
this.submitting = false;
|
this.submitting = false;
|
||||||
console.error('[PreviewRegister] submit failed', err);
|
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 {
|
backToEdit(): void {
|
||||||
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.draft.id } });
|
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 { 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 { ProductsService } from '../products.service';
|
||||||
import {
|
import {
|
||||||
CibilSlab,
|
CibilSlab,
|
||||||
|
GlOption,
|
||||||
RateCell,
|
RateCell,
|
||||||
RateChartRow,
|
RateChartRow,
|
||||||
RateRegister,
|
RateRegister,
|
||||||
@@ -21,10 +22,9 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Central Loan Interest Rate Register service.
|
* Central Loan Interest Rate Register service.
|
||||||
*
|
*
|
||||||
* Primary data path is HTTP (/loanproducts/baseinterestrates). On HTTP failure (backend not yet
|
* Data comes from HTTP (/loanproducts/baseinterestrates); there is no built-in sample data. If the
|
||||||
* deployed, network error) the service transparently falls back to in-memory seeded data so the UI
|
* backend cannot be reached the list is empty apart from local Drafts (not-yet-submitted new
|
||||||
* never breaks during development. Local Drafts (not-yet-submitted new versions) live only in
|
* versions), which live only in memory and are merged in front of server data on every list().
|
||||||
* memory and are merged in front of server data on every list().
|
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class RateRegisterService {
|
export class RateRegisterService {
|
||||||
@@ -37,7 +37,100 @@ export class RateRegisterService {
|
|||||||
private cache: RateRegister[] = [];
|
private cache: RateRegister[] = [];
|
||||||
|
|
||||||
/** In-memory drafts the maker has created but not yet submitted. Preserved across list() refreshes. */
|
/** 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[] = [
|
private readonly slabs: SlabColumn[] = [
|
||||||
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
|
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
|
||||||
@@ -52,304 +145,6 @@ export class RateRegisterService {
|
|||||||
{ key: 's10', label: '> 25,00,000', amountUpto: null }
|
{ 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:
|
// Credit-score bands per the bank's approved register:
|
||||||
// above 700 = base rate (no change), below that stepped up by 0.5% per band.
|
// 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.
|
// 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 }
|
{ 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.
|
* The standard loan types every bank offers. Same list the backend seeds into
|
||||||
private readonly seedRegisters: RateRegister[] = [
|
* m_loan_rate_register_product; used only if that endpoint cannot be reached when the first
|
||||||
this.seedRegister({
|
* version is created. Any other particular is added by the bank itself.
|
||||||
id: 1001,
|
*/
|
||||||
name: 'Interest Rate of Loans — Effective 01 April 2027',
|
private readonly defaultParticulars: { srNo: number; name: string }[] = [
|
||||||
effectiveFrom: '2027-04-01',
|
{ srNo: 1, name: 'Housing Loan (Purchase/Construction)' },
|
||||||
effectiveTo: '2028-03-31',
|
{ srNo: 2, name: 'Home Repair/Renovation' },
|
||||||
status: 'Draft',
|
{ srNo: 3, name: 'Personal Loan (Unsecured)' },
|
||||||
createdBy: 'Sonali',
|
{ srNo: 4, name: 'Personal Loan (Secured/Property)' },
|
||||||
lastModified: '2026-10-01',
|
{ srNo: 5, name: 'Two Wheeler Loan' },
|
||||||
note: 'Scheduled · future effective'
|
{ srNo: 6, name: 'Car Loan (New)' },
|
||||||
// No pre-seeded deltas — Draft starts identical to the Active register.
|
{ srNo: 7, name: 'Used Car/Vehicle Loan' },
|
||||||
// Only edits made in the UI will show as diffs on the Compare screen.
|
{ srNo: 8, name: 'Commercial Vehicle Loan' },
|
||||||
}),
|
{ srNo: 9, name: 'Gold Loan' },
|
||||||
this.seedRegister({
|
{ srNo: 10, name: 'Business Term Loan' },
|
||||||
id: 1002,
|
{ srNo: 11, name: 'Machinery & Equipment Loan' },
|
||||||
name: 'Interest Rate of Loans — Effective 01 April 2026',
|
{ srNo: 12, name: 'Cash Credit' },
|
||||||
effectiveFrom: '2026-04-01',
|
{ srNo: 13, name: 'Overdraft' },
|
||||||
effectiveTo: '2027-03-31',
|
{ srNo: 14, name: 'Agriculture Loan' },
|
||||||
status: 'Active',
|
{ srNo: 15, name: 'Loan against FDR' },
|
||||||
createdBy: 'Admin',
|
{ srNo: 16, name: 'Loan against RD' }
|
||||||
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'
|
|
||||||
})
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all registers (local drafts + server versions). Tries HTTP first; on failure OR when
|
* List all registers (local drafts + server versions). If the backend cannot be reached only the
|
||||||
* the server returns an empty collection (fresh DB, nothing seeded yet) falls back to the
|
* local drafts are returned: there is no built-in sample data. A successful but empty response is a
|
||||||
* built-in seed registers so the UI always has something to show in development.
|
* genuinely empty register and shows the "Create first version" state.
|
||||||
*/
|
*/
|
||||||
list(): Observable<RateRegister[]> {
|
list(): Observable<RateRegister[]> {
|
||||||
if (!this.useHttp) {
|
if (!this.useHttp) {
|
||||||
this.cache = [
|
this.cache = [...this.localDrafts];
|
||||||
...this.localDrafts,
|
|
||||||
...this.seedRegisters
|
|
||||||
];
|
|
||||||
return of(this.cache);
|
return of(this.cache);
|
||||||
}
|
}
|
||||||
return this.productsService.getLoanBaseInterestRates().pipe(
|
// Approved versions, the particulars master (each row's GL) and the versions still awaiting a
|
||||||
map((dtos: any[]) => (dtos ?? []).map((d) => this.fromBackend(d))),
|
// checker. A failure of the master or the pending lookup must not hide the approved versions.
|
||||||
catchError((err) => {
|
return forkJoin({
|
||||||
console.warn('[RateRegisterService] HTTP unavailable, using seeded data —', err?.message ?? err);
|
versions: this.productsService.getLoanBaseInterestRates(),
|
||||||
this.useHttp = false;
|
products: this.productsService.getLoanRateRegisterProducts().pipe(catchError(() => of([]))),
|
||||||
return of([...this.seedRegisters]);
|
pending: this.productsService.getPendingLoanRateRegisters().pipe(
|
||||||
}),
|
// The list omits each entry's payload, so fetch the detail of every pending entry.
|
||||||
map((serverVersions) => {
|
switchMap((entries: any) => {
|
||||||
if (serverVersions.length === 0) {
|
const items: any[] = (Array.isArray(entries) ? entries : (entries?.pageItems ?? [])).filter(
|
||||||
console.info('[RateRegisterService] Backend returned no versions — using seeded demo data.');
|
(e: any) => (e?.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE'
|
||||||
return [...this.seedRegisters];
|
);
|
||||||
|
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) => {
|
tap((effectiveVersions) => {
|
||||||
this.cache = [
|
this.cache = [
|
||||||
@@ -462,10 +252,10 @@ export class RateRegisterService {
|
|||||||
return of(this.localDrafts.find((r) => r.id === id));
|
return of(this.localDrafts.find((r) => r.id === id));
|
||||||
}
|
}
|
||||||
if (!this.useHttp) {
|
if (!this.useHttp) {
|
||||||
return of(this.seedRegisters.find((r) => r.id === id));
|
return of(undefined);
|
||||||
}
|
}
|
||||||
return this.productsService.getLoanBaseInterestRateById(id).pipe(
|
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))
|
catchError(() => of(undefined))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -476,12 +266,9 @@ export class RateRegisterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The one shown by default on list → edit navigation. */
|
/** The one shown by default on list → edit navigation. */
|
||||||
getActiveOrFirstDraft(): RateRegister {
|
getActiveOrFirstDraft(): RateRegister | undefined {
|
||||||
return (
|
return (
|
||||||
this.cache.find((r) => r.status === 'Draft') ??
|
this.cache.find((r) => r.status === 'Draft') ?? this.cache.find((r) => r.status === 'Active') ?? this.cache[0]
|
||||||
this.cache.find((r) => r.status === 'Active') ??
|
|
||||||
this.cache[0] ??
|
|
||||||
this.seedRegisters[0]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,37 +294,20 @@ export class RateRegisterService {
|
|||||||
return of({ rollbackTransaction: false });
|
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);
|
const payload = this.toBackend(draft);
|
||||||
return this.productsService.saveLoanBaseInterestRate(payload).pipe(
|
return this.productsService.saveLoanBaseInterestRate(payload).pipe(
|
||||||
map((res: any) => {
|
map((res: any) => {
|
||||||
const rollback = res?.rollbackTransaction === true;
|
const rollback = res?.rollbackTransaction === true;
|
||||||
const commandId = res?.commandId;
|
const commandId = res?.commandId;
|
||||||
if (rollback) {
|
// Either way the server now owns this version (a pending maker-checker entry, or the new
|
||||||
// Keep the local Draft visible as "Pending Approval" until the checker acts.
|
// Active one), so the local draft goes. list() brings the server copy back on its next call.
|
||||||
draft.status = 'Pending Approval';
|
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
|
||||||
draft.lastModified = new Date().toISOString().slice(0, 10);
|
this.cache = this.cache.filter((c) => c.id !== id);
|
||||||
} else {
|
this.saveDrafts();
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
return { rollbackTransaction: rollback, commandId };
|
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
|
* Scaffold the first-ever Draft. Rows come from the backend's master list of particulars
|
||||||
* template (34 products × 10 slabs + 5 CIBIL bands) so the user has a sensible starting point to
|
* (/loanproducts/baseinterestrates/products) with every rate blank, so the bank fills in its own
|
||||||
* tweak and submit as the first-ever Active version. Carries a negative id like other local drafts.
|
* 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 {
|
createFirstVersion(): Observable<RateRegister> {
|
||||||
const template = this.seedRegisters.find((r) => r.status === 'Active') ?? this.seedRegisters[0];
|
const fromTemplate = (): RateRegister =>
|
||||||
const newId = this.nextLocalDraftId();
|
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 today = new Date().toISOString().slice(0, 10);
|
||||||
const draft: RateRegister = {
|
const draft: RateRegister = {
|
||||||
id: newId,
|
id: this.nextLocalDraftId(),
|
||||||
name: 'Interest Rate of Loans — new version',
|
name: 'Interest Rate of Loans — new version',
|
||||||
effectiveFrom: today,
|
effectiveFrom: today,
|
||||||
effectiveTo: null,
|
effectiveTo: null,
|
||||||
@@ -586,22 +391,23 @@ export class RateRegisterService {
|
|||||||
status: 'Draft',
|
status: 'Draft',
|
||||||
createdBy: 'Current User',
|
createdBy: 'Current User',
|
||||||
lastModified: today,
|
lastModified: today,
|
||||||
note: 'First Loan Interest Rate Register — seeded from Excel template',
|
note,
|
||||||
slabs: template.slabs,
|
slabs: this.slabs,
|
||||||
rows: this.cloneRows(template.rows),
|
rows,
|
||||||
cibilSlabs: this.cloneCibilSlabs(template.cibilSlabs),
|
cibilSlabs: this.cloneCibilSlabs(cibilTemplate),
|
||||||
// Baseline is empty rows for the first version — compare screen will show every cell as "new".
|
// Baseline is blank for a first version, so Compare shows every filled cell as new.
|
||||||
baselineRows: template.rows.map((r) => ({
|
baselineRows: rows.map((r) => ({
|
||||||
srNo: r.srNo,
|
srNo: r.srNo,
|
||||||
particulars: r.particulars,
|
particulars: r.particulars,
|
||||||
rates: new Array(r.rates.length).fill(null)
|
rates: new Array(r.rates.length).fill(null)
|
||||||
})),
|
})),
|
||||||
baselineCibilSlabs: [],
|
baselineCibilSlabs: [],
|
||||||
fallbackNoCibilSlab: template.fallbackNoCibilSlab,
|
fallbackNoCibilSlab: 2,
|
||||||
fallbackBelowMin: template.fallbackBelowMin
|
fallbackBelowMin: 'block'
|
||||||
};
|
};
|
||||||
this.localDrafts.unshift(draft);
|
this.localDrafts.unshift(draft);
|
||||||
this.cache.unshift(draft);
|
this.cache.unshift(draft);
|
||||||
|
this.saveDrafts();
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,7 +417,7 @@ export class RateRegisterService {
|
|||||||
* with server-assigned ids.
|
* with server-assigned ids.
|
||||||
*/
|
*/
|
||||||
duplicateAsDraft(sourceId: number): RateRegister | undefined {
|
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) {
|
if (!source) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -637,6 +443,7 @@ export class RateRegisterService {
|
|||||||
};
|
};
|
||||||
this.localDrafts.unshift(copy);
|
this.localDrafts.unshift(copy);
|
||||||
this.cache.unshift(copy);
|
this.cache.unshift(copy);
|
||||||
|
this.saveDrafts();
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,76 +463,12 @@ export class RateRegisterService {
|
|||||||
return (minId <= 0 ? minId : 0) - 1;
|
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[] {
|
private cloneRows(src: RateChartRow[]): RateChartRow[] {
|
||||||
return src.map((r) => ({
|
return src.map((r) => ({
|
||||||
srNo: r.srNo,
|
srNo: r.srNo,
|
||||||
particulars: r.particulars,
|
particulars: r.particulars,
|
||||||
isFormula: r.isFormula,
|
isFormula: r.isFormula,
|
||||||
|
glAccountId: r.glAccountId ?? null,
|
||||||
rates: [...r.rates]
|
rates: [...r.rates]
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -740,7 +483,7 @@ export class RateRegisterService {
|
|||||||
// that gap.
|
// that gap.
|
||||||
|
|
||||||
/** Map a backend {@code LoanRateRegisterData} payload into a UI RateRegister. */
|
/** 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.
|
// Rebuild the slab columns from unique slabOrders.
|
||||||
const slabByOrder = new Map<number, SlabColumn>();
|
const slabByOrder = new Map<number, SlabColumn>();
|
||||||
for (const cell of d.slabs ?? []) {
|
for (const cell of d.slabs ?? []) {
|
||||||
@@ -766,7 +509,12 @@ export class RateRegisterService {
|
|||||||
for (const cell of d.slabs ?? []) {
|
for (const cell of d.slabs ?? []) {
|
||||||
let row = rowByProduct.get(cell.productSrNo);
|
let row = rowByProduct.get(cell.productSrNo);
|
||||||
if (!row) {
|
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);
|
rowByProduct.set(cell.productSrNo, row);
|
||||||
}
|
}
|
||||||
let value: RateCell;
|
let value: RateCell;
|
||||||
@@ -851,8 +599,48 @@ export class RateRegisterService {
|
|||||||
fallbackNoCibilSlab: r.fallbackNoCibilSlab,
|
fallbackNoCibilSlab: r.fallbackNoCibilSlab,
|
||||||
fallbackBelowMin: r.fallbackBelowMin,
|
fallbackBelowMin: r.fallbackBelowMin,
|
||||||
note: r.note,
|
note: r.note,
|
||||||
|
products: r.rows.map((row) => ({
|
||||||
|
srNo: row.srNo,
|
||||||
|
name: row.particulars,
|
||||||
|
glAccountId: row.glAccountId ?? null
|
||||||
|
})),
|
||||||
slabs,
|
slabs,
|
||||||
cibilSlabs
|
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());
|
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
|
/** 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. */
|
* `rollbackTransaction` flag is true and the row sits in the Approvals tab until a checker approves. */
|
||||||
saveLoanBaseInterestRate(payload: any): Observable<any> {
|
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),
|
// 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
|
// or entityName is unmapped — fall back to the detail dialog so the checker
|
||||||
// can still view the command payload and action the entry.
|
// 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
|
this.dialog
|
||||||
.open(ViewCheckerInboxComponent, {
|
.open(ViewCheckerInboxComponent, {
|
||||||
data: { checkerId: row.id },
|
data: { checkerId: row.id },
|
||||||
autoFocus: false
|
autoFocus: false,
|
||||||
|
...(wide ? { width: '1200px', maxWidth: '95vw' } : {})
|
||||||
})
|
})
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.subscribe((result: any) => {
|
.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/.
|
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. -->
|
<!-- The heading lives here in both modes, so a dialog does not end up showing it twice. -->
|
||||||
<div class="vci-head">
|
<div class="vci-head">
|
||||||
<span class="vci-head__title">{{ headingTitle }}</span>
|
<span class="vci-head__title">{{ headingTitle }}</span>
|
||||||
@@ -114,10 +114,30 @@
|
|||||||
</div>
|
</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. -->
|
<!-- UPDATE: only the fields this command actually moves, as OLD -> NEW. -->
|
||||||
@if (showChanges) {
|
@if (showChanges) {
|
||||||
<div class="vci-section">
|
<div class="vci-section">
|
||||||
<div class="vci-section__title">Changes Requested ({{ changes.length }})</div>
|
<div class="vci-section__title">Changes Requested ({{ changes.length }})</div>
|
||||||
|
@if (changesBaseline) {
|
||||||
|
<p class="vci-nochange">{{ changesBaseline }}</p>
|
||||||
|
}
|
||||||
@if (changes.length === 0) {
|
@if (changes.length === 0) {
|
||||||
<p class="vci-nochange">This request does not change any value.</p>
|
<p class="vci-nochange">This request does not change any value.</p>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -143,7 +163,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- CREATE: submitted data grouped into readable sections. -->
|
<!-- 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) {
|
@for (section of createSections; track section.title) {
|
||||||
<div class="vci-section">
|
<div class="vci-section">
|
||||||
<div class="vci-section__title">{{ section.title }}</div>
|
<div class="vci-section__title">{{ section.title }}</div>
|
||||||
@@ -157,7 +177,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- DELETE summary, role-permission changes, and the fallback payload all render here. -->
|
<!-- 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">
|
||||||
<div class="vci-section__title">
|
<div class="vci-section__title">
|
||||||
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }}
|
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ $bg-page: #f8fafc;
|
|||||||
border-radius: 10px;
|
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) {
|
&:not(&--dialog) {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
groupCreateFields,
|
groupCreateFields,
|
||||||
humanizeKey
|
humanizeKey
|
||||||
} from '../command-diff.util';
|
} from '../command-diff.util';
|
||||||
|
import { diffLoanRateRegister, isLoanBaseInterestRateCreate } from '../loan-rate-register-diff.util';
|
||||||
import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch';
|
import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch';
|
||||||
|
|
||||||
/** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */
|
/** 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 { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||||
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
||||||
import { OrganizationService } from 'app/organization/organization.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
|
* 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,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
DateFormatPipe
|
DateFormatPipe,
|
||||||
|
RateRegisterDiffComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ViewCheckerInboxComponent {
|
export class ViewCheckerInboxComponent {
|
||||||
@@ -150,6 +168,7 @@ export class ViewCheckerInboxComponent {
|
|||||||
private fixedDepositsService = inject(FixedDepositsService);
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
private recurringDepositsService = inject(RecurringDepositsService);
|
private recurringDepositsService = inject(RecurringDepositsService);
|
||||||
private organizationService = inject(OrganizationService);
|
private organizationService = inject(OrganizationService);
|
||||||
|
private rateRegisterService = inject(RateRegisterService);
|
||||||
|
|
||||||
/** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */
|
/** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */
|
||||||
private get entityFetchServices(): EntityFetchServices {
|
private get entityFetchServices(): EntityFetchServices {
|
||||||
@@ -196,10 +215,22 @@ export class ViewCheckerInboxComponent {
|
|||||||
|
|
||||||
/** UPDATE review: the fields this command actually moves, as OLD -> NEW. */
|
/** UPDATE review: the fields this command actually moves, as OLD -> NEW. */
|
||||||
changes: CommandChange[] = [];
|
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. */
|
/** True once the current entity was read and a real comparison is on screen. */
|
||||||
showChanges = false;
|
showChanges = false;
|
||||||
/** True while the current entity is being fetched for comparison. */
|
/** True while the current entity is being fetched for comparison. */
|
||||||
comparing = false;
|
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. */
|
/** True when the screen is describing a record that is about to be deleted. */
|
||||||
deletingEntity = false;
|
deletingEntity = false;
|
||||||
/** CREATE review: submitted data bucketed into readable sections. */
|
/** CREATE review: submitted data bucketed into readable sections. */
|
||||||
@@ -354,6 +385,8 @@ export class ViewCheckerInboxComponent {
|
|||||||
this.comparing = false;
|
this.comparing = false;
|
||||||
this.deletingEntity = false;
|
this.deletingEntity = false;
|
||||||
this.createSections = [];
|
this.createSections = [];
|
||||||
|
this.changesBaseline = '';
|
||||||
|
this.rateDiff = null;
|
||||||
// commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined)
|
// 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.
|
// throws — which as a route only broke the page, but in a dialog would leave an empty shell open.
|
||||||
try {
|
try {
|
||||||
@@ -377,6 +410,9 @@ export class ViewCheckerInboxComponent {
|
|||||||
} else if (action === 'DELETE') {
|
} else if (action === 'DELETE') {
|
||||||
this.loadDeleteSummary(detail);
|
this.loadDeleteSummary(detail);
|
||||||
} else if (action === 'CREATE') {
|
} else if (action === 'CREATE') {
|
||||||
|
if (isLoanBaseInterestRateCreate(detail?.entityName, detail?.actionName)) {
|
||||||
|
this.loadLoanRateRegisterChanges(detail);
|
||||||
|
}
|
||||||
this.createSections = this.buildCreateSections(this.jsondata);
|
this.createSections = this.buildCreateSections(this.jsondata);
|
||||||
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
|
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
|
||||||
this.resolveGlAccountCreateLabels(detail, this.jsondata);
|
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.
|
* 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.
|
* Reads the record instead and shows the few fields that identify it.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '261007',
|
'version': '261008',
|
||||||
'hash': '7c868ae07'
|
'hash': 'a5a64d4ef'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user