Add interest loan related UI

This commit is contained in:
2026-10-07 15:30:15 +05:30
parent 7c868ae073
commit 0ea57b3f82
18 changed files with 2445 additions and 612 deletions
@@ -7,6 +7,23 @@
--> -->
<div class="ca" *ngIf="register"> <div class="ca" *ngIf="register">
<div class="ca__readonly-banner" *ngIf="!canEdit">
<i class="fas fa-lock"></i>
<div>
<div class="ca__readonly-title">Read-only — {{ register.status }}</div>
<div class="ca__readonly-sub">{{ readOnlyReason() }}</div>
</div>
</div>
<div class="ca__active-strip" *ngIf="active">
<span class="ca__active-dot">●</span>
<span class="ca__active-label">CURRENT ACTIVE</span>
<span class="ca__active-name">{{ active.name }}</span>
<span class="ca__active-sub">· effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}</span>
<span class="ca__active-flag" *ngIf="isActiveRegister()">You are viewing the Active register</span>
<a class="ca__active-link" *ngIf="!isActiveRegister()" (click)="viewActive()">View active →</a>
</div>
<div class="ca__header"> <div class="ca__header">
<div class="ca__header-icon">CS</div> <div class="ca__header-icon">CS</div>
<div class="ca__header-text"> <div class="ca__header-text">
@@ -16,151 +33,64 @@
<button mat-stroked-button (click)="backToRegister()">← Back to register</button> <button mat-stroked-button (click)="backToRegister()">← Back to register</button>
</div> </div>
<div class="ca__body"> <div class="ca__card">
<div class="ca__left"> <div class="ca__card-head">
<div class="ca__card"> <div>
<div class="ca__card-head"> <div class="ca__card-title">Credit score slabs</div>
<div class="ca__card-title">Credit score slabs</div> <div class="ca__card-sub">
<button mat-stroked-button class="ca__dash-btn"><i class="fas fa-plus"></i> Add slab</button> Only the Adjustment % is editable · changes apply to new sanctions after the register is approved
</div>
<table class="ca__table">
<thead>
<tr>
<th>#</th>
<th class="ca__num">Score from</th>
<th class="ca__num">Score to</th>
<th>Risk tier</th>
<th>Type</th>
<th class="ca__num">Adjustment</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of register.cibilSlabs; let i = index" [class.ca__row-alt]="i % 2 === 1">
<td>{{ i + 1 }}</td>
<td class="ca__num">{{ slab.from }}</td>
<td class="ca__num">{{ slab.to }}</td>
<td>
<span [class]="tierClass(slab.tier)">{{ slab.tier }}</span>
</td>
<td>{{ slab.type }}</td>
<td class="ca__num">
<span [class]="adjustmentClass(slab.adjustmentPct)">{{ formatPct(slab.adjustmentPct) }}</span>
</td>
<td class="ca__dots">⋯</td>
</tr>
</tbody>
</table>
</div>
<div class="ca__card">
<div class="ca__card-body">
<div class="ca__card-title">Fallback behaviour</div>
<div class="ca__fallback-grid">
<div class="ca__field">
<label>NO CIBIL AVAILABLE</label>
<select [(ngModel)]="register.fallbackNoCibilSlab">
<option *ngFor="let slab of register.cibilSlabs; let i = index" [value]="i + 1">
Use slab {{ i + 1 }} ({{ slab.tier }}, {{ formatPct(slab.adjustmentPct) }})
</option>
</select>
</div>
<div class="ca__field">
<label>CIBIL BELOW MIN SLAB</label>
<select [(ngModel)]="register.fallbackBelowMin">
<option value="use-min-slab">Use minimum slab adjustment</option>
<option value="block">Block loan — manual approval</option>
</select>
</div>
</div>
</div> </div>
</div> </div>
<button *ngIf="canEdit" mat-stroked-button class="ca__dash-btn" (click)="addSlab()">
<i class="fas fa-plus"></i> Add slab
</button>
</div> </div>
<div class="ca__right"> <table class="ca__editgrid">
<div class="ca__card"> <thead>
<div class="ca__card-head"> <tr>
<div class="ca__card-title">Live preview</div> <th class="ca__col-ix">#</th>
<div class="ca__card-hint">pick a scenario</div> <th class="ca__num">Score from</th>
</div> <th class="ca__num">Score to</th>
<div class="ca__card-body"> <th>Risk tier</th>
<div class="ca__preview-grid"> <th>Type</th>
<div class="ca__field"> <th class="ca__num">Adjustment</th>
<label>PRODUCT</label> <th class="ca__col-del"></th>
<select [(ngModel)]="previewProductSrNo"> </tr>
<option *ngFor="let row of register.rows" [value]="row.srNo">{{ row.particulars }}</option> </thead>
</select> <tbody>
<tr *ngFor="let slab of register.cibilSlabs; let i = index">
<td class="ca__col-ix">{{ i + 1 }}</td>
<td class="ca__num ca__cell-readonly">{{ slab.from }}</td>
<td class="ca__num ca__cell-readonly">{{ slab.to }}</td>
<td>
<span [class]="tierClass(slab.tier)">{{ slab.tier }}</span>
</td>
<td class="ca__cell-muted">{{ slab.type }}</td>
<td class="ca__num">
<div class="ca__adj-wrap">
<input
[class]="adjustInputClass(slab.adjustmentPct)"
type="number"
step="0.05"
[(ngModel)]="slab.adjustmentPct"
[readonly]="!canEdit"
/>
<span class="ca__adj-suffix">%</span>
</div> </div>
<div class="ca__field"> </td>
<label>LOAN AMOUNT</label> <td class="ca__col-del">
<input type="number" [(ngModel)]="previewAmount" /> <button *ngIf="canEdit" type="button" class="ca__delete-btn" title="Delete slab" (click)="deleteSlab(i)">
</div> <i class="fas fa-trash-alt"></i>
<div class="ca__field"> </button>
<label>CIBIL SCORE</label> </td>
<input type="number" min="0" max="900" [(ngModel)]="previewCibil" /> </tr>
</div> </tbody>
<div class="ca__field"> </table>
<label>TENURE (MONTHS)</label>
<input type="number" [(ngModel)]="previewTenureMonths" />
</div>
</div>
<div class="ca__derivation">
<div class="ca__derivation-label">RATE DERIVATION</div>
<div class="ca__derivation-row">
<span>Chart cell — {{ productLabel(previewProductSrNo) }}</span>
<span class="ca__derivation-val">{{
chartCell() !== null ? (chartCell() | number: '1.2-2') + '%' : 'N.A.'
}}</span>
</div>
<div class="ca__derivation-row">
<span>CIBIL {{ previewCibil }} ({{ cibilSlab()?.tier ?? '—' }})</span>
<span
class="ca__derivation-val"
[class.ca__derivation-val--pos]="(cibilSlab()?.adjustmentPct ?? 0) < 0"
[class.ca__derivation-val--neg]="(cibilSlab()?.adjustmentPct ?? 0) > 0"
>
{{ formatPct(cibilSlab()?.adjustmentPct ?? 0) }}
</span>
</div>
<div class="ca__derivation-row ca__derivation-row--muted">
<span>Product floor / ceiling</span>
<span>no clamp</span>
</div>
<div class="ca__divider"></div>
<div class="ca__final">
<span>Final rate</span>
<span class="ca__final-val">{{ finalRate() }}</span>
</div>
</div>
</div>
</div>
<div class="ca__card">
<div class="ca__card-body">
<div class="ca__card-title">What changes if…</div>
<div class="ca__scenarios">
<div class="ca__scenario">
<span>CIBIL = 700</span>
<span>{{ rateFor(700) }}</span>
</div>
<div class="ca__scenario">
<span>CIBIL = 870</span>
<span class="ca__scenario-val--pos">{{ rateFor(870) }}</span>
</div>
<div class="ca__scenario">
<span>No CIBIL fetched</span>
<span class="ca__scenario-val--warn">{{ rateForFallback() }}</span>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="ca__footer"> <div class="ca__footer">
<button mat-stroked-button (click)="backToRegister()">← Register</button> <button mat-stroked-button (click)="backToRegister()">{{ canEdit ? 'Cancel' : 'Back to register' }}</button>
<div class="ca__footer-note">Changes apply to new sanctions once the register is approved</div> <button *ngIf="canEdit" mat-flat-button color="primary" (click)="save()">Save adjustments</button>
<button mat-flat-button color="primary" (click)="save()">Save adjustments</button>
</div> </div>
</div> </div>
@@ -9,11 +9,96 @@
.ca { .ca {
background: #f8fafc; background: #f8fafc;
min-height: calc(100vh - 160px); min-height: calc(100vh - 160px);
padding: 20px 32px; padding: 24px 32px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
&__active-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 10px;
font-size: 12px;
color: #065f46;
}
&__active-dot {
color: #059669;
font-size: 14px;
line-height: 1;
}
&__active-label {
font-size: 10px;
letter-spacing: 0.5px;
font-weight: 700;
color: #065f46;
}
&__active-name {
font-weight: 600;
color: #064e3b;
}
&__active-sub {
color: #047857;
}
&__active-flag {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: #047857;
background: #d1fae5;
padding: 3px 10px;
border-radius: 999px;
}
&__active-link {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #047857;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__readonly-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-left: 4px solid #d97706;
border-radius: 10px;
color: #78350f;
i {
font-size: 16px;
color: #d97706;
}
}
&__readonly-title {
font-size: 13px;
font-weight: 700;
color: #78350f;
}
&__readonly-sub {
margin-top: 2px;
font-size: 12px;
color: #92400e;
}
&__header { &__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -21,20 +106,21 @@
padding: 20px 24px; padding: 20px 24px;
background: white; background: white;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 10px; border-radius: 12px;
} }
&__header-icon { &__header-icon {
width: 40px; width: 42px;
height: 40px; height: 42px;
border-radius: 9px; border-radius: 10px;
background: #dbeafe; background: #dbeafe;
color: #1d4ed8; color: #1d4ed8;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 15px; font-size: 14px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.3px;
} }
&__header-text { &__header-text {
@@ -44,6 +130,7 @@
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #0f172a;
} }
p { p {
@@ -53,282 +140,184 @@
} }
} }
&__body {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 16px;
}
&__left,
&__right {
display: flex;
flex-direction: column;
gap: 14px;
}
&__card { &__card {
background: white; background: white;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 10px; border-radius: 12px;
overflow: hidden; overflow: hidden;
} }
&__card-head { &__card-head {
padding: 12px 16px; padding: 16px 20px;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 10px; justify-content: space-between;
}
&__card-title {
font-size: 14px;
font-weight: 600;
}
&__card-hint {
margin-left: auto;
font-size: 11px;
color: #64748b;
}
&__card-body {
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 12px; gap: 12px;
} }
&__card-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__card-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__dash-btn { &__dash-btn {
margin-left: auto; height: 32px;
height: 28px; font-size: 12px;
font-size: 11px;
font-weight: 600; font-weight: 600;
color: #1d4ed8; color: #1d4ed8;
border: 1px dashed #1d4ed8; border: 1px dashed #1d4ed8;
padding: 0 14px;
i { i {
margin-right: 3px; margin-right: 6px;
} }
} }
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead tr {
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
th {
text-align: left;
padding: 9px 14px;
font-weight: 600;
}
}
tbody tr {
border-top: 1px solid #f1f5f9;
td {
padding: 11px 14px;
}
}
}
&__row-alt {
background: #fafbfc;
}
&__num { &__num {
text-align: right; text-align: right;
} }
&__dots { &__col-ix {
width: 44px;
text-align: center; text-align: center;
color: #94a3b8; color: #94a3b8;
cursor: pointer; font-weight: 600;
} }
&__fallback-grid, &__col-del {
&__preview-grid { width: 48px;
display: grid; text-align: center;
grid-template-columns: 1fr 1fr;
gap: 14px;
} }
&__field { &__cell-muted {
color: #64748b;
}
&__cell-readonly {
color: #0f172a;
font-variant-numeric: tabular-nums;
padding: 0 22px;
}
&__footer {
display: flex; display: flex;
flex-direction: column; justify-content: flex-end;
gap: 10px;
padding: 4px 2px;
}
}
label { /* ---- Editable grid ---------------------------------------------------- */
font-size: 11px;
.ca__editgrid {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead tr {
background: #f8fafc;
th {
text-align: left;
padding: 10px 16px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b; color: #64748b;
font-weight: 600; font-weight: 600;
letter-spacing: 0.3px; border-bottom: 1px solid #e2e8f0;
margin-bottom: 4px;
}
input, &.ca__num {
select { text-align: right;
height: 36px;
border: 1px solid #cbd5e1;
background: white;
border-radius: 6px;
padding: 0 12px;
font-size: 13px;
color: #0f172a;
font-family: inherit;
&:focus {
outline: 2px solid #1d4ed8;
outline-offset: -1px;
} }
} }
} }
&__derivation { tbody tr {
padding: 12px 14px; border-bottom: 1px solid #f1f5f9;
background: #f8fafc; transition: background 0.12s ease;
border-radius: 8px;
}
&__derivation-label { &:hover {
font-size: 11px; background: #f8fafc;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 8px;
}
&__derivation-row { .ca__delete-btn {
display: flex; opacity: 1;
justify-content: space-between; }
font-size: 12px;
padding: 3px 0;
&--muted {
color: #64748b;
}
}
&__derivation-val {
font-weight: 600;
&--pos {
color: #059669;
} }
&--neg { &:last-child {
color: #dc2626; border-bottom: 0;
}
td {
padding: 8px 12px;
vertical-align: middle;
} }
} }
}
&__divider { /* Borderless cell input — gets a visible border on hover/focus. */
height: 1px; .ca__ghost-input {
background: #e2e8f0; width: 100%;
margin: 8px 0; height: 32px;
} border: 1px solid transparent;
background: transparent;
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
color: #0f172a;
font-family: inherit;
box-sizing: border-box;
&__final { &:hover {
display: flex; border-color: #e2e8f0;
justify-content: space-between;
align-items: center;
span:first-child {
font-size: 12px;
font-weight: 600;
}
}
&__final-val {
font-size: 20px;
font-weight: 700;
color: #1d4ed8;
}
&__scenarios {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: #334155;
}
&__scenario {
padding: 8px 10px;
background: #f8fafc;
border-radius: 6px;
display: flex;
justify-content: space-between;
span:last-child {
font-weight: 600;
}
}
&__scenario-val--pos {
color: #059669;
}
&__scenario-val--warn {
color: #d97706;
}
&__footer {
background: white; background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
} }
&__footer-note { &:focus {
font-size: 11px; outline: none;
color: #64748b; border-color: #1d4ed8;
background: white;
box-shadow: 0 0 0 3px rgb(29 78 216 / 10%);
}
&--num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Hide number input spinners for a cleaner look. */
&[type='number']::-webkit-inner-spin-button,
&[type='number']::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
&[type='number'] {
appearance: textfield;
} }
} }
.tier { /* Adjustment input — coloured by sign, with trailing % suffix. */
padding: 3px 8px; .ca__adj-wrap {
border-radius: 4px; display: inline-flex;
font-size: 11px; align-items: center;
font-weight: 600; justify-content: flex-end;
gap: 2px;
&--poor { width: 100%;
background: #fee2e2;
color: #991b1b;
}
&--fair {
background: #fef3c7;
color: #92400e;
}
&--good {
background: #dbeafe;
color: #1e40af;
}
&--vgood {
background: #dcfce7;
color: #166534;
}
&--excellent {
background: #d1fae5;
color: #065f46;
}
} }
.adj { .ca__adj-input {
font-weight: 600; width: 72px;
font-weight: 700;
&--negative { &--negative {
color: #059669; color: #059669;
@@ -342,3 +331,84 @@
color: #dc2626; color: #dc2626;
} }
} }
.ca__adj-suffix {
color: #94a3b8;
font-size: 12px;
font-weight: 600;
}
/* Chip-shaped tier select — background matches the current tier. */
.ca__tier-select {
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%2364748b' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
padding: 4px 26px 4px 12px;
height: 28px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgb(29 78 216 / 15%);
}
&--vpoor {
background-color: #fecaca;
color: #7f1d1d;
}
&--poor {
background-color: #fee2e2;
color: #991b1b;
}
&--fair {
background-color: #fef3c7;
color: #92400e;
}
&--good {
background-color: #dbeafe;
color: #1e40af;
}
&--excellent {
background-color: #d1fae5;
color: #065f46;
}
}
/* Trash button — hidden until row hover. */
.ca__delete-btn {
width: 28px;
height: 28px;
border: 1px solid transparent;
background: transparent;
color: #94a3b8;
border-radius: 6px;
font-size: 12px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition:
opacity 0.12s ease,
color 0.12s ease,
border-color 0.12s ease,
background 0.12s ease;
&:hover {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
&:focus-visible {
opacity: 1;
}
}
@@ -11,15 +11,13 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { RateRegisterService } from '../rate-register.service'; import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateRegister } from '../models/rate-register.model'; import { CibilSlab, RateRegister } from '../models/rate-register.model';
/** /**
* CIBIL/credit-score adjustment editor for a Rate Register — slab table, fallback behaviour * CIBIL / credit-score slab editor.
* and a live preview that resolves {product, loan amount, CIBIL} down to the final rate. * Score range, tier and type are fixed per slab; only the Adjustment % is editable.
* New slabs can be appended (and removed) via Add slab / row trash.
*/ */
@Component({ @Component({
selector: 'mifosx-cibil-adjustment', selector: 'mifosx-cibil-adjustment',
@@ -28,10 +26,7 @@ import { CibilSlab, RateRegister } from '../models/rate-register.model';
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
MatButtonModule, MatButtonModule
MatFormFieldModule,
MatInputModule,
MatSelectModule
] ]
}) })
export class CibilAdjustmentComponent implements OnInit { export class CibilAdjustmentComponent implements OnInit {
@@ -40,123 +35,100 @@ export class CibilAdjustmentComponent implements OnInit {
private readonly router = inject(Router); private readonly router = inject(Router);
register!: RateRegister; register!: RateRegister;
active?: RateRegister;
// Live preview inputs.
previewProductSrNo = 7; // House Purchase / Construction
previewAmount = 2500000;
previewCibil = 820;
previewTenureMonths = 120;
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.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
this.active = this.service.getActive();
}); });
} }
isActiveRegister(): boolean {
return !!this.active && this.active.id === this.register?.id;
}
viewActive(): void {
if (this.active) {
this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.active.id } });
}
}
/** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */
get canEdit(): boolean {
return this.register?.status === 'Draft';
}
readOnlyReason(): string {
switch (this.register?.status) {
case 'Pending Approval':
return 'This version has been submitted and is awaiting checker approval. No further edits allowed.';
case 'Active':
return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes.';
case 'Expired':
return 'This is an inactive historical version. Read-only.';
default:
return 'Read-only.';
}
}
tierClass(tier: CibilSlab['tier']): string { tierClass(tier: CibilSlab['tier']): string {
const map: Record<string, string> = { const map: Record<string, string> = {
'V. Poor': 'tier tier--vpoor',
Poor: 'tier tier--poor', Poor: 'tier tier--poor',
Fair: 'tier tier--fair', Fair: 'tier tier--fair',
Good: 'tier tier--good', Good: 'tier tier--good',
'V. Good': 'tier tier--vgood',
Excellent: 'tier tier--excellent' Excellent: 'tier tier--excellent'
}; };
return map[tier] ?? 'tier'; return map[tier] ?? 'tier';
} }
adjustmentClass(pct: number): string { /** Classes to colour the adjustment number by sign/magnitude. */
adjustInputClass(pct: number): string {
const base = 'ca__ghost-input ca__ghost-input--num ca__adj-input';
if (pct < 0) { if (pct < 0) {
return 'adj adj--negative'; return `${base} ca__adj-input--negative`;
}
if (pct >= 1) {
return `${base} ca__adj-input--high`;
} }
if (pct > 0) { if (pct > 0) {
return pct >= 1 ? 'adj adj--high' : 'adj adj--positive'; return `${base} ca__adj-input--positive`;
} }
return 'adj'; return base;
} }
formatPct(pct: number): string { /** Append a new slab continuing from the current highest "score to". */
if (pct === 0) { addSlab(): void {
return '0.00%'; if (!this.canEdit) {
return;
} }
const sign = pct > 0 ? '+' : '−'; const lastTo = this.register.cibilSlabs.length ? Math.max(...this.register.cibilSlabs.map((s) => s.to)) : 0;
return `${sign}${Math.abs(pct).toFixed(2)}%`; const from = Math.min(lastTo + 1, 900);
this.register.cibilSlabs.push({
from,
to: Math.min(from + 50, 900),
tier: 'Fair',
type: 'Flat',
adjustmentPct: 0
});
} }
/** Looks up the chart cell for the currently previewed scenario. */ deleteSlab(index: number): void {
private chartRateFor(productSrNo: number, amount: number): number | null { if (!this.canEdit) {
const row = this.register.rows.find((r) => r.srNo === productSrNo); return;
if (!row) {
return null;
} }
for (let i = 0; i < this.register.slabs.length; i++) { this.register.cibilSlabs.splice(index, 1);
const slab = this.register.slabs[i];
const upto = slab.amountUpto;
if (upto === null || amount <= upto) {
const cell = row.rates[i];
if (cell === null) {
return null;
}
if (typeof cell === 'string') {
// Formula cell — in a real derive we'd resolve against the pledged deposit's rate.
return null;
}
return cell;
}
}
return null;
} }
private cibilAdjustmentFor(score: number): CibilSlab | null { /** Edits are kept in the in-memory service (the register reference is shared). */
return this.register.cibilSlabs.find((s) => score >= s.from && score <= s.to) ?? null; save(): void {
}
chartCell(): number | null {
return this.chartRateFor(this.previewProductSrNo, this.previewAmount);
}
cibilSlab(): CibilSlab | null {
return this.cibilAdjustmentFor(this.previewCibil);
}
finalRate(): string {
const base = this.chartCell();
const slab = this.cibilSlab();
if (base === null) {
return 'N.A.';
}
const adjustment = slab?.adjustmentPct ?? 0;
return `${(base + adjustment).toFixed(2)}% p.a.`;
}
productLabel(srNo: number): string {
return this.register.rows.find((r) => r.srNo === srNo)?.particulars ?? '—';
}
rateFor(cibil: number): string {
const base = this.chartRateFor(this.previewProductSrNo, this.previewAmount);
if (base === null) {
return 'N.A.';
}
const slab = this.cibilAdjustmentFor(cibil);
return `${(base + (slab?.adjustmentPct ?? 0)).toFixed(2)}%`;
}
rateForFallback(): string {
const base = this.chartRateFor(this.previewProductSrNo, this.previewAmount);
if (base === null) {
return 'N.A.';
}
const fallback = this.register.cibilSlabs[this.register.fallbackNoCibilSlab - 1];
return `${(base + (fallback?.adjustmentPct ?? 0)).toFixed(2)}%`;
}
backToRegister(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } }); this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } });
} }
save(): void { backToRegister(): void {
this.backToRegister(); this.save();
} }
} }
@@ -7,27 +7,44 @@
--> -->
<div class="er" *ngIf="register"> <div class="er" *ngIf="register">
<div class="er__active-strip" *ngIf="active">
<span class="er__active-dot">●</span>
<span class="er__active-label">CURRENT ACTIVE</span>
<span class="er__active-name">{{ active.name }}</span>
<span class="er__active-sub">· effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}</span>
<span class="er__active-flag" *ngIf="isActiveRegister()">You are viewing the Active register</span>
<a class="er__active-link" *ngIf="!isActiveRegister()" (click)="viewActive()">View active →</a>
</div>
<div class="er__readonly-banner" *ngIf="!canEdit">
<i class="fas fa-lock"></i>
<div>
<div class="er__readonly-title">Read-only — {{ register.status }}</div>
<div class="er__readonly-sub">{{ readOnlyReason() }}</div>
</div>
</div>
<div class="er__meta-bar"> <div class="er__meta-bar">
<div class="er__field"> <div class="er__field">
<label>REGISTER NAME</label> <label>REGISTER NAME</label>
<input type="text" [(ngModel)]="register.name" /> <input type="text" [(ngModel)]="register.name" [readonly]="!canEdit" />
</div> </div>
<div class="er__field er__field--date"> <div class="er__field er__field--date">
<label>EFFECTIVE FROM</label> <label>EFFECTIVE FROM</label>
<input type="date" [(ngModel)]="register.effectiveFrom" /> <input type="date" [(ngModel)]="register.effectiveFrom" [readonly]="!canEdit" />
</div> </div>
<div class="er__field er__field--date"> <div class="er__field er__field--date">
<label>EFFECTIVE TO</label> <label>EFFECTIVE TO</label>
<input type="date" [(ngModel)]="register.effectiveTo" /> <input type="date" [(ngModel)]="register.effectiveTo" [readonly]="!canEdit" />
</div> </div>
<div class="er__field er__field--curr"> <div class="er__field er__field--curr">
<label>CURRENCY</label> <label>CURRENCY</label>
<select [(ngModel)]="register.currency"> <select [(ngModel)]="register.currency" [disabled]="!canEdit">
<option value="INR">INR</option> <option value="INR">INR</option>
<option value="USD">USD</option> <option value="USD">USD</option>
</select> </select>
</div> </div>
<button mat-stroked-button class="er__meta-btn"><i class="fas fa-arrow-down"></i> Import</button> <button mat-stroked-button class="er__meta-btn" *ngIf="canEdit"><i class="fas fa-arrow-down"></i> Import</button>
<button mat-stroked-button class="er__meta-btn"><i class="fas fa-arrow-up"></i> Export</button> <button mat-stroked-button class="er__meta-btn"><i class="fas fa-arrow-up"></i> Export</button>
</div> </div>
@@ -35,9 +52,11 @@
<div class="er__grid-head"> <div class="er__grid-head">
<div class="er__grid-title">Rate chart grid</div> <div class="er__grid-title">Rate chart grid</div>
<div class="er__grid-sub"> <div class="er__grid-sub">
{{ rows.length }} products × {{ slabs.length }} amount slabs · click any cell to edit {{ rows.length }} products × {{ slabs.length }} amount slabs
<span *ngIf="canEdit">· click any cell to edit</span>
<span *ngIf="!canEdit">· read-only view</span>
</div> </div>
<div class="er__grid-actions"> <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()">
<i class="fas fa-plus"></i> Add product <i class="fas fa-plus"></i> Add product
</button> </button>
@@ -70,7 +89,8 @@
[class.er__cell--na]="isNa(cell)" [class.er__cell--na]="isNa(cell)"
[class.er__cell--formula]="isFormulaRow(row)" [class.er__cell--formula]="isFormulaRow(row)"
[class.er__cell--editing]="isEditing(i, j)" [class.er__cell--editing]="isEditing(i, j)"
(click)="!isEditing(i, j) && startEdit(i, j, cell)" [class.er__cell--readonly]="!canEdit"
(click)="canEdit && !isEditing(i, j) && startEdit(i, j, cell)"
> >
<input <input
*ngIf="isEditing(i, j); else cellView" *ngIf="isEditing(i, j); else cellView"
@@ -95,16 +115,16 @@
<div class="er__cibil-text"> <div class="er__cibil-text">
<div class="er__cibil-title">CIBIL / credit score adjustment</div> <div class="er__cibil-title">CIBIL / credit score adjustment</div>
<div class="er__cibil-sub"> <div class="er__cibil-sub">
{{ register.cibilSlabs.length }} slabs · +1.00% (poor) to −0.20% (excellent) · applied on top of chart rate {{ register.cibilSlabs.length }} slabs · +2.00% (very poor) to base (above 700) · applied on top of chart rate
</div> </div>
</div> </div>
<a class="er__cibil-edit">Edit →</a> <a class="er__cibil-edit">Edit →</a>
</div> </div>
<div class="er__footer"> <div class="er__footer">
<button mat-stroked-button (click)="backToList()">Cancel</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"> <div class="er__footer-actions" *ngIf="canEdit">
<button mat-stroked-button>Save draft</button> <button mat-stroked-button>Save draft</button>
<button mat-flat-button class="er__submit" (click)="submitForApproval()"> <button mat-flat-button class="er__submit" (click)="submitForApproval()">
<i class="fas fa-check"></i> Submit for approval <i class="fas fa-check"></i> Submit for approval
@@ -14,6 +14,91 @@
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
&__active-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 9px;
font-size: 12px;
color: #065f46;
}
&__active-dot {
color: #059669;
font-size: 14px;
line-height: 1;
}
&__active-label {
font-size: 10px;
letter-spacing: 0.5px;
font-weight: 700;
color: #065f46;
}
&__active-name {
font-weight: 600;
color: #064e3b;
}
&__active-sub {
color: #047857;
}
&__active-flag {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: #047857;
background: #d1fae5;
padding: 3px 10px;
border-radius: 999px;
}
&__active-link {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #047857;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__readonly-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-left: 4px solid #d97706;
border-radius: 9px;
color: #78350f;
i {
font-size: 16px;
color: #d97706;
}
}
&__readonly-title {
font-size: 13px;
font-weight: 700;
color: #78350f;
}
&__readonly-sub {
margin-top: 2px;
font-size: 12px;
color: #92400e;
}
&__meta-bar { &__meta-bar {
background: white; background: white;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -207,6 +292,15 @@
outline-offset: -1px; outline-offset: -1px;
} }
&--readonly {
cursor: default;
&:hover {
background: transparent;
outline: none;
}
}
&--na { &--na {
color: #cbd5e1; color: #cbd5e1;
} }
@@ -48,6 +48,7 @@ export class EditRegisterComponent implements OnInit {
private readonly router = inject(Router); private readonly router = inject(Router);
register!: RateRegister; register!: RateRegister;
active?: RateRegister;
slabs: SlabColumn[] = []; slabs: SlabColumn[] = [];
rows: RateChartRow[] = []; rows: RateChartRow[] = [];
@@ -62,11 +63,41 @@ export class EditRegisterComponent implements OnInit {
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(); this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
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;
}); });
} }
isActiveRegister(): boolean {
return !!this.active && this.active.id === this.register?.id;
}
/** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */
get canEdit(): boolean {
return this.register?.status === 'Draft';
}
/** Status label shown on the read-only banner. */
readOnlyReason(): string {
switch (this.register?.status) {
case 'Pending Approval':
return 'This version has been submitted and is awaiting checker approval. No further edits allowed.';
case 'Active':
return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes as a new Draft.';
case 'Expired':
return 'This is an inactive historical version. Read-only.';
default:
return 'Read-only.';
}
}
viewActive(): void {
if (this.active) {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.active.id } });
}
}
/** Formatted display for a cell. */ /** Formatted display for a cell. */
display(cell: RateCell): string { display(cell: RateCell): string {
if (cell === null) { if (cell === null) {
@@ -88,6 +119,9 @@ export class EditRegisterComponent implements OnInit {
/** Click on a cell → open it as an editable input, pre-filled with the current value. */ /** Click on a cell → open it as an editable input, pre-filled with the current value. */
startEdit(rowIdx: number, colIdx: number, current: RateCell): void { startEdit(rowIdx: number, colIdx: number, current: RateCell): void {
if (!this.canEdit) {
return;
}
this.editing = { row: rowIdx, col: colIdx }; this.editing = { row: rowIdx, col: colIdx };
if (current === null) { if (current === null) {
this.editValue = ''; this.editValue = '';
@@ -141,6 +175,9 @@ export class EditRegisterComponent implements OnInit {
/** Append a new empty product row at the bottom of the grid. */ /** Append a new empty product row at the bottom of the grid. */
addProduct(): void { addProduct(): void {
if (!this.canEdit) {
return;
}
const name = window.prompt('New product name', 'New product'); const name = window.prompt('New product name', 'New product');
if (!name) { if (!name) {
return; return;
@@ -156,6 +193,9 @@ export class EditRegisterComponent implements OnInit {
/** 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. */
addSlab(): void { addSlab(): void {
if (!this.canEdit) {
return;
}
const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab'); const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab');
if (!label) { if (!label) {
return; return;
@@ -178,9 +218,9 @@ export class EditRegisterComponent implements OnInit {
this.router.navigate(['/products/loan-interest-rate-register']); this.router.navigate(['/products/loan-interest-rate-register']);
} }
/** Opens the Compare screen; the actual submit happens from there once the maker has reviewed the diff. */
submitForApproval(): void { submitForApproval(): void {
this.register.status = 'Active'; this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: this.register.id } });
this.router.navigate(['/products/loan-interest-rate-register']);
} }
statusChipClass(): string { statusChipClass(): string {
@@ -189,6 +229,8 @@ export class EditRegisterComponent implements OnInit {
return 'chip chip--active'; return 'chip chip--active';
case 'Draft': case 'Draft':
return 'chip chip--draft'; return 'chip chip--draft';
case 'Pending Approval':
return 'chip chip--pending';
default: default:
return 'chip chip--expired'; return 'chip chip--expired';
} }
@@ -11,16 +11,16 @@
<div class="lirr__header-icon"><i class="fas fa-percentage"></i></div> <div class="lirr__header-icon"><i class="fas fa-percentage"></i></div>
<div class="lirr__header-text"> <div class="lirr__header-text">
<h2>Loan Interest Rate Register</h2> <h2>Loan Interest Rate Register</h2>
<p>Central CIBIL-based rate chart used across all loan products</p> <p>
Manage the central CIBIL-based rate chart used across all loan products. Submitting a new version routes through
maker-checker — once approved, the current Active version is <strong>deactivated</strong> and the new one
<strong>activated</strong>.
</p>
</div> </div>
<button mat-stroked-button class="lirr__excel-btn"> <button mat-stroked-button class="lirr__excel-btn">
<i class="fas fa-exchange-alt"></i> <i class="fas fa-exchange-alt"></i>
Excel Excel
</button> </button>
<button mat-flat-button color="primary" class="lirr__create-btn" (click)="create()">
<i class="fas fa-plus"></i>
Create register
</button>
</div> </div>
<div class="lirr__body"> <div class="lirr__body">
@@ -38,46 +38,144 @@
<div class="lirr__kpi"> <div class="lirr__kpi">
<div class="lirr__kpi-label">CIBIL ADJUSTMENTS</div> <div class="lirr__kpi-label">CIBIL ADJUSTMENTS</div>
<div class="lirr__kpi-value">{{ active?.cibilSlabs?.length ?? 0 }} slabs</div> <div class="lirr__kpi-value">{{ active?.cibilSlabs?.length ?? 0 }} slabs</div>
<div class="lirr__kpi-sub">+1.00% → −0.20%</div> <div class="lirr__kpi-sub">+2.00% (poor) → base (above 700)</div>
</div> </div>
</div> </div>
<div class="lirr__table-card"> <div class="lirr__history">
<div class="lirr__table-head"> <div class="lirr__history-head">
<div class="lirr__table-title">All registers</div> <div>
<div class="lirr__history-title">Loan Interest Rate Register — Version History</div>
<div class="lirr__history-sub">All versions, newest first · click a version to expand its rate chart</div>
</div>
</div> </div>
<table class="lirr__table">
<thead> <div class="lirr__empty" *ngIf="registers.length === 0">
<tr> <div class="lirr__empty-icon"><i class="fas fa-folder-open"></i></div>
<th>Name</th> <div class="lirr__empty-title">No rate register versions yet</div>
<th>Effective from</th> <div class="lirr__empty-sub">
<th>Effective to</th> Create the first version to publish rates for all loan products. The template comes pre-filled from the bank's
<th>Status</th> approved Excel chart (34 products × 10 amount slabs + 5 CIBIL bands) — you can edit everything before
<th>Created by</th> submitting for approval.
<th>Last modified</th> </div>
<th></th> <button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()">
</tr> <i class="fas fa-plus"></i> Create first version
</thead> </button>
<tbody>
<tr *ngFor="let r of registers" [class.lirr__row--active]="r.status === 'Active'" (click)="edit(r)">
<td>
<div class="lirr__name">{{ r.name }}</div>
<div class="lirr__name-sub" *ngIf="r.note">{{ r.note }}</div>
</td>
<td>{{ r.effectiveFrom | date: 'dd MMM yyyy' }}</td>
<td>{{ r.effectiveTo ? (r.effectiveTo | date: 'dd MMM yyyy') : '—' }}</td>
<td>
<span [class]="statusChipClass(r.status)">{{ r.status === 'Active' ? '● Active' : r.status }}</span>
</td>
<td>{{ r.createdBy }}</td>
<td>{{ r.lastModified | date: 'dd MMM yyyy' }}</td>
<td class="lirr__row-actions">{{ r.status === 'Draft' ? 'Edit · Submit' : 'View' }}</td>
</tr>
</tbody>
</table>
<div class="lirr__table-foot">
<div>Showing 1–{{ registers.length }} of {{ registers.length }}</div>
</div> </div>
<mat-accordion class="lirr__accordion" multi *ngIf="registers.length > 0">
<mat-expansion-panel
*ngFor="let r of registers"
class="lirr__panel"
[class.lirr__panel--active]="r.status === 'Active'"
[expanded]="r.status === 'Active'"
>
<mat-expansion-panel-header class="lirr__panel-header">
<mat-panel-title class="lirr__panel-title">
<span class="lirr__panel-name">{{ r.name }}</span>
<span [class]="statusChipClass(r.status)">{{ statusLabel(r.status) }}</span>
</mat-panel-title>
<mat-panel-description class="lirr__panel-desc">
<span class="lirr__panel-range">{{ effectiveRange(r) }}</span>
<span class="lirr__panel-actions">
<ng-container [ngSwitch]="r.status">
<ng-container *ngSwitchCase="'Pending Approval'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openCompare(r, $event)"
>
Compare
</button>
<span class="lirr__row-note">Awaiting checker · action from Approvals tab</span>
</ng-container>
<ng-container *ngSwitchCase="'Draft'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openEditor(r, $event)"
>
Edit · Submit
</button>
</ng-container>
<ng-container *ngSwitchCase="'Active'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="editAsNewVersion(r, $event)"
>
Edit (new version)
</button>
</ng-container>
<ng-container *ngSwitchDefault>
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openEditor(r, $event)"
>
View
</button>
</ng-container>
</ng-container>
</span>
</mat-panel-description>
</mat-expansion-panel-header>
<div class="lirr__panel-body">
<div class="lirr__panel-meta" *ngIf="r.note">{{ r.note }}</div>
<div class="lirr__panel-meta">
Created by {{ r.createdBy }} · Last modified {{ r.lastModified | date: 'dd MMM yyyy' }}
</div>
<h4 class="lirr__panel-section">Rate chart ({{ r.rows.length }} products × {{ r.slabs.length }} slabs)</h4>
<div class="lirr__panel-scroll">
<table class="lirr__panel-grid">
<thead>
<tr>
<th class="lirr__panel-grid-first">Particulars</th>
<th *ngFor="let s of r.slabs" class="lirr__panel-grid-num">{{ s.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of r.rows">
<td class="lirr__panel-grid-first">
<span class="lirr__panel-grid-srno">{{ row.srNo }}</span
>{{ row.particulars }}
</td>
<td *ngFor="let cell of row.rates" class="lirr__panel-grid-num">{{ display(cell) }}</td>
</tr>
</tbody>
</table>
</div>
<h4 class="lirr__panel-section">CIBIL / credit-score slabs</h4>
<table class="lirr__panel-cibil">
<thead>
<tr>
<th>#</th>
<th>Score from</th>
<th>Score to</th>
<th>Risk tier</th>
<th>Type</th>
<th>Adjustment</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of r.cibilSlabs; let i = index">
<td>{{ i + 1 }}</td>
<td>{{ slab.from }}</td>
<td>{{ slab.to }}</td>
<td>{{ slab.tier }}</td>
<td>{{ slab.type }}</td>
<td>{{ formatPct(slab.adjustmentPct) }}</td>
</tr>
</tbody>
</table>
</div>
</mat-expansion-panel>
</mat-accordion>
<div class="lirr__history-foot">Showing {{ registers.length }} versions</div>
</div> </div>
</div> </div>
</div> </div>
@@ -174,6 +174,53 @@
text-align: right; text-align: right;
color: #64748b; color: #64748b;
padding-right: 20px !important; padding-right: 20px !important;
white-space: nowrap;
}
&__row-btn {
height: 28px;
font-size: 11px;
font-weight: 600;
padding: 0 10px;
margin-left: 6px;
border-radius: 6px;
&--neutral {
color: #334155;
border-color: #cbd5e1;
background: white;
&:hover {
background: #f8fafc;
}
}
&--approve {
color: #065f46;
border-color: #a7f3d0;
background: #ecfdf5;
&:hover {
background: #d1fae5;
}
}
&--reject {
color: #991b1b;
border-color: #fecaca;
background: #fef2f2;
&:hover {
background: #fee2e2;
}
}
}
&__row-note {
font-size: 11px;
color: #64748b;
font-style: italic;
margin-left: 10px;
} }
&__table-foot { &__table-foot {
@@ -182,6 +229,250 @@
font-size: 12px; font-size: 12px;
color: #64748b; color: #64748b;
} }
/* ---- Version History accordion (CBS BIR style) ------------------------ */
&__history {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
&__history-head {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&__history-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__history-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__accordion {
display: block;
padding: 8px 12px 16px;
}
&__panel {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
margin-bottom: 10px;
box-shadow: none !important;
overflow: hidden;
&.mat-expanded {
border-color: #cbd5e1 !important;
}
&--active {
border-left: 3px solid #10b981 !important;
}
}
&__panel-header {
padding: 0 20px !important;
height: 56px;
}
&__panel-title {
display: flex !important;
align-items: center !important;
gap: 12px !important;
flex: 0 0 auto !important;
margin-right: 24px !important;
}
&__panel-name {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
&__panel-desc {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 12px;
flex: 1 1 auto !important;
color: #475569 !important;
}
&__panel-range {
font-size: 12px;
color: #475569;
font-variant-numeric: tabular-nums;
}
&__panel-actions {
display: inline-flex;
gap: 6px;
align-items: center;
}
&__panel-body {
padding: 8px 20px 20px;
font-size: 12px;
color: #334155;
display: flex;
flex-direction: column;
gap: 10px;
}
&__panel-meta {
font-size: 12px;
color: #64748b;
}
&__panel-section {
margin: 10px 0 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
font-weight: 700;
}
&__panel-scroll {
overflow: auto;
max-height: 360px;
border: 1px solid #f1f5f9;
border-radius: 8px;
}
&__panel-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;
letter-spacing: 0.3px;
font-weight: 600;
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
tbody td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
}
}
&__panel-grid-first {
text-align: left;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
&__panel-grid-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
&__panel-grid-srno {
display: inline-block;
min-width: 22px;
color: #94a3b8;
font-weight: 600;
margin-right: 6px;
}
&__panel-cibil {
width: 100%;
border-collapse: collapse;
font-size: 12px;
border: 1px solid #f1f5f9;
border-radius: 8px;
overflow: hidden;
thead th {
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
tbody td {
padding: 8px 12px;
border-bottom: 1px solid #f1f5f9;
}
}
&__history-foot {
padding: 10px 20px;
border-top: 1px solid #f1f5f9;
font-size: 12px;
color: #64748b;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 56px 24px 48px;
text-align: center;
}
&__empty-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: #ede9fe;
color: #6d28d9;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 4px;
}
&__empty-title {
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
&__empty-sub {
font-size: 13px;
color: #64748b;
max-width: 560px;
line-height: 1.5;
}
&__empty-btn {
margin-top: 8px;
i {
margin-right: 6px;
}
}
} }
.chip { .chip {
@@ -200,6 +491,11 @@
color: #92400e; color: #92400e;
} }
&--pending {
background: #ede9fe;
color: #5b21b6;
}
&--expired { &--expired {
background: #f1f5f9; background: #f1f5f9;
color: #475569; color: #475569;
@@ -9,9 +9,11 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service'; import { RateRegisterService } from './rate-register.service';
import { RateRegister } from './models/rate-register.model'; import { CibilSlab, RateCell, RateChartRow, RateRegister, SlabColumn } from './models/rate-register.model';
/** /**
* List screen for the central Loan Interest Rate Register — three KPI cards followed by the * List screen for the central Loan Interest Rate Register — three KPI cards followed by the
@@ -23,17 +25,23 @@ import { RateRegister } from './models/rate-register.model';
styleUrls: ['./loan-interest-rate-register.component.scss'], styleUrls: ['./loan-interest-rate-register.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
DatePipe DatePipe,
MatExpansionModule
] ]
}) })
export class LoanInterestRateRegisterComponent implements OnInit { export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService); private readonly service = inject(RateRegisterService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
registers: RateRegister[] = []; registers: RateRegister[] = [];
active?: RateRegister; active?: RateRegister;
ngOnInit(): void { ngOnInit(): void {
this.reload();
}
private reload(): void {
this.service.list().subscribe((rows) => { this.service.list().subscribe((rows) => {
this.registers = rows; this.registers = rows;
this.active = rows.find((r) => r.status === 'Active'); this.active = rows.find((r) => r.status === 'Active');
@@ -44,8 +52,39 @@ export class LoanInterestRateRegisterComponent implements OnInit {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: register.id } }); this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: register.id } });
} }
create(): void { preview(register: RateRegister): void {
this.router.navigate(['/products/loan-interest-rate-register/edit']); this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: register.id } });
}
/** Route by status: Pending → Compare screen (checker view); otherwise the editor. */
openRow(register: RateRegister): void {
if (register.status === 'Pending Approval') {
this.preview(register);
} else {
this.edit(register);
}
}
/** Checker approves the pending register. The previously-Active one is deactivated. */
approve(register: RateRegister, event: Event): void {
event.stopPropagation();
this.service.approve(register.id).subscribe((updated) => {
if (updated) {
this.snack.open(`${updated.name} activated`, 'OK', { duration: 3000 });
this.reload();
}
});
}
/** Checker rejects the pending register — it goes back to Draft. */
reject(register: RateRegister, event: Event): void {
event.stopPropagation();
this.service.reject(register.id).subscribe((updated) => {
if (updated) {
this.snack.open(`${updated.name} sent back to Draft`, 'OK', { duration: 3000 });
this.reload();
}
});
} }
statusChipClass(status: RateRegister['status']): string { statusChipClass(status: RateRegister['status']): string {
@@ -54,6 +93,8 @@ export class LoanInterestRateRegisterComponent implements OnInit {
return 'chip chip--active'; return 'chip chip--active';
case 'Draft': case 'Draft':
return 'chip chip--draft'; return 'chip chip--draft';
case 'Pending Approval':
return 'chip chip--pending';
case 'Expired': case 'Expired':
return 'chip chip--expired'; return 'chip chip--expired';
default: default:
@@ -61,6 +102,17 @@ export class LoanInterestRateRegisterComponent implements OnInit {
} }
} }
statusLabel(status: RateRegister['status']): string {
switch (status) {
case 'Active':
return '● Active';
case 'Pending Approval':
return '⏳ Pending Approval';
default:
return status;
}
}
activeWindow(): string { activeWindow(): string {
if (!this.active) { if (!this.active) {
return '—'; return '—';
@@ -68,6 +120,66 @@ export class LoanInterestRateRegisterComponent implements OnInit {
return `${this.formatShort(this.active.effectiveFrom)} → ${this.formatShort(this.active.effectiveTo)}`; return `${this.formatShort(this.active.effectiveFrom)} → ${this.formatShort(this.active.effectiveTo)}`;
} }
/** Panel header subtitle — effective date range, with "Current" for the active one. */
effectiveRange(r: RateRegister): string {
const from = this.formatShort(r.effectiveFrom);
const to = r.status === 'Active' ? 'Current' : this.formatShort(r.effectiveTo);
return `From ${from} To ${to}`;
}
/** Compact display of a rate cell (for the read-only expanded view). */
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
formatPct(pct: number): string {
if (pct === 0) {
return '0.00%';
}
const sign = pct > 0 ? '+' : '−';
return `${sign}${Math.abs(pct).toFixed(2)}%`;
}
/** Open this register's full editor (grid) — stops the row click from toggling the panel. */
openEditor(r: RateRegister, event: Event): void {
event.stopPropagation();
this.edit(r);
}
/**
* "Edit (new version)" on Active: duplicate the Active register as a new Draft first, then
* open the Draft in the editor. The live Active is never mutated this way.
*/
editAsNewVersion(r: RateRegister, event: Event): void {
event.stopPropagation();
const draft = this.service.duplicateAsDraft(r.id);
if (draft) {
this.snack.open('New draft created from Active — editing the copy', 'OK', { duration: 2500 });
this.reload();
this.edit(draft);
}
}
/** Scaffold the very first version — used only when the register has no versions at all. */
createFirstVersion(): void {
const draft = this.service.createFirstVersion();
this.snack.open('Blank draft created — fill in rates and submit for approval', 'OK', { duration: 3000 });
this.reload();
this.edit(draft);
}
/** Open the Compare screen for a pending register (checker view). */
openCompare(r: RateRegister, event: Event): void {
event.stopPropagation();
this.preview(r);
}
private formatShort(iso: string | null): string { private formatShort(iso: string | null): string {
if (!iso) { if (!iso) {
return '—'; return '—';
@@ -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/.
*/ */
export type RegisterStatus = 'Active' | 'Draft' | 'Expired' | 'Scheduled'; export type RegisterStatus = 'Active' | 'Draft' | 'Pending Approval' | 'Expired' | 'Scheduled';
export interface SlabColumn { export interface SlabColumn {
key: string; key: string;
@@ -24,7 +24,7 @@ export interface RateChartRow {
rates: RateCell[]; rates: RateCell[];
} }
export type RiskTier = 'Poor' | 'Fair' | 'Good' | 'V. Good' | 'Excellent'; export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent';
export interface CibilSlab { export interface CibilSlab {
from: number; from: number;
@@ -47,6 +47,9 @@ export interface RateRegister {
slabs: SlabColumn[]; slabs: SlabColumn[];
rows: RateChartRow[]; rows: RateChartRow[];
cibilSlabs: CibilSlab[]; cibilSlabs: CibilSlab[];
/** Immutable snapshot of the last-approved state. Compare screen diffs against this. */
baselineRows: RateChartRow[];
baselineCibilSlabs: CibilSlab[];
fallbackNoCibilSlab: number; fallbackNoCibilSlab: number;
fallbackBelowMin: 'use-min-slab' | 'block'; fallbackBelowMin: 'use-min-slab' | 'block';
} }
@@ -0,0 +1,181 @@
<!--
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/.
-->
<div class="pr" *ngIf="draft">
<div class="pr__header">
<div class="pr__header-icon">⇄</div>
<div class="pr__header-text">
<h2>Compare changes</h2>
<p>Review the diff before submitting · each changed cell shows OLD → NEW with yellow highlight</p>
</div>
<button mat-stroked-button (click)="backToEdit()">← Back to edit</button>
</div>
<div class="pr__summary">
<div class="pr__summary-item">
<div class="pr__summary-label">Draft</div>
<div class="pr__summary-value">{{ draft.name }}</div>
</div>
<div class="pr__summary-item">
<div class="pr__summary-label">Rate cells changed</div>
<div class="pr__summary-value pr__summary-value--count">{{ changedCellCount() }}</div>
</div>
<div class="pr__summary-item">
<div class="pr__summary-label">CIBIL slabs changed</div>
<div class="pr__summary-value pr__summary-value--count">{{ changedCibilCount() }}</div>
</div>
</div>
<!-- Side-by-side baseline/draft context strip -->
<div class="pr__version-strip">
<div class="pr__version pr__version--old">
<div class="pr__version-pill pr__version-pill--old">OLD · WILL BE DEACTIVATED</div>
<div class="pr__version-name">{{ active?.name ?? 'No active register' }}</div>
<div class="pr__version-sub" *ngIf="active">
Effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}
</div>
</div>
<div class="pr__version-arrow">→</div>
<div class="pr__version pr__version--new">
<div class="pr__version-pill pr__version-pill--new">NEW · WILL BE ACTIVATED</div>
<div class="pr__version-name">{{ draft.name }}</div>
<div class="pr__version-sub">Effective {{ draft.effectiveFrom }} → {{ draft.effectiveTo || '—' }}</div>
</div>
</div>
<!-- Consolidated inline-diff rate chart -->
<div class="pr__card">
<div class="pr__card-head">
<div>
<div class="pr__card-title">Rate chart — inline diff</div>
<div class="pr__card-sub">Changed cells highlighted · unchanged cells show single value</div>
</div>
<mat-slide-toggle *ngIf="changedCellCount() > 0" [(ngModel)]="showOnlyChanged" class="pr__toggle">
Show only changed rows
</mat-slide-toggle>
</div>
<div class="pr__empty" *ngIf="changedCellCount() === 0 && changedCibilCount() === 0">
<i class="fas fa-equals"></i>
<div class="pr__empty-title">No changes detected</div>
<div class="pr__empty-sub">
Draft is identical to the current Active register. Edit the Draft first to see diffs here.
</div>
</div>
<div class="pr__scroll" *ngIf="changedCellCount() > 0">
<table class="pr__grid">
<thead>
<tr>
<th class="pr__col-particulars">Particulars</th>
<th *ngFor="let s of slabs" class="pr__col-rate">{{ s.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of visibleRows" [class.pr__row--changed]="rowHasChanges(row.srNo)">
<td class="pr__col-particulars">
<span class="pr__srno">{{ row.srNo }}</span
>{{ row.particulars }}
</td>
<td
*ngFor="let _ of slabs; let j = index"
class="pr__col-rate"
[class.pr__cell-changed]="cellChanged(row.srNo, j)"
>
<ng-container *ngIf="cellChanged(row.srNo, j); else sameCell">
<span class="pr__old">{{ display(baselineCell(row.srNo, j)) }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ display(draftCell(row.srNo, j)) }}</span>
</ng-container>
<ng-template #sameCell>{{ display(draftCell(row.srNo, j)) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CIBIL slabs inline-diff -->
<div class="pr__card" *ngIf="changedCibilCount() > 0">
<div class="pr__card-head">
<div>
<div class="pr__card-title">CIBIL slabs — inline diff</div>
<div class="pr__card-sub">Changed fields shown as OLD → NEW · unchanged fields render once</div>
</div>
</div>
<table class="pr__cibil">
<thead>
<tr>
<th>#</th>
<th>Score from</th>
<th>Score to</th>
<th>Tier</th>
<th>Adjustment</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of draftCibil; let i = index" [class.pr__row--changed]="cibilChanged(i)">
<td>{{ i + 1 }}</td>
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'from')">
<ng-container *ngIf="cibilFieldChanged(i, 'from'); else sameFrom">
<span class="pr__old">{{ baselineCibil[i]?.from ?? '—' }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ slab.from }}</span>
</ng-container>
<ng-template #sameFrom>{{ slab.from }}</ng-template>
</td>
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'to')">
<ng-container *ngIf="cibilFieldChanged(i, 'to'); else sameTo">
<span class="pr__old">{{ baselineCibil[i]?.to ?? '—' }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ slab.to }}</span>
</ng-container>
<ng-template #sameTo>{{ slab.to }}</ng-template>
</td>
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'tier')">
<ng-container *ngIf="cibilFieldChanged(i, 'tier'); else sameTier">
<span class="pr__old">{{ baselineCibil[i]?.tier ?? '—' }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ slab.tier }}</span>
</ng-container>
<ng-template #sameTier>{{ slab.tier }}</ng-template>
</td>
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'adjustmentPct')">
<ng-container *ngIf="cibilFieldChanged(i, 'adjustmentPct'); else sameAdj">
<span class="pr__old">{{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ formatPct(slab.adjustmentPct) }}</span>
</ng-container>
<ng-template #sameAdj>{{ formatPct(slab.adjustmentPct) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pr__footer">
<button mat-stroked-button (click)="backToEdit()">{{ canSubmit ? 'Cancel' : 'Back' }}</button>
<div class="pr__footer-note" *ngIf="canSubmit">
Submission will create a maker-checker request · the register becomes effective only after a checker approves it.
</div>
<div class="pr__footer-note" *ngIf="!canSubmit">
Review-only — this version has already been submitted ({{ draft.status }}).
</div>
<button
*ngIf="canSubmit"
mat-flat-button
color="primary"
class="pr__submit"
(click)="submit()"
[disabled]="submitting || changedCellCount() + changedCibilCount() === 0"
>
<i class="fas fa-paper-plane"></i>
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
</button>
</div>
</div>
@@ -0,0 +1,362 @@
/**
* 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/.
*/
.pr {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 16px;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__header-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: #ede9fe;
color: #6d28d9;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #0f172a;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__summary {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__summary-item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__summary-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
font-weight: 600;
}
&__summary-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
&--count {
font-size: 20px;
font-weight: 700;
color: #6d28d9;
}
}
// ---- OLD → NEW version strip ------------------------------------------
&__version-strip {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
align-items: stretch;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__version {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 14px;
border-radius: 8px;
&--old {
background: #fef2f2;
border-left: 3px solid #dc2626;
}
&--new {
background: #f0fdf4;
border-left: 3px solid #059669;
}
}
&__version-pill {
display: inline-block;
width: fit-content;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 3px 10px;
border-radius: 999px;
&--old {
background: #fee2e2;
color: #991b1b;
}
&--new {
background: #d1fae5;
color: #065f46;
}
}
&__version-name {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
&__version-sub {
font-size: 11px;
color: #64748b;
}
&__version-arrow {
align-self: center;
font-size: 24px;
color: #94a3b8;
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 {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__footer-note {
flex: 1;
font-size: 12px;
color: #64748b;
}
&__submit {
i {
margin-right: 6px;
}
}
}
@@ -0,0 +1,210 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateCell, RateRegister, SlabColumn } from '../models/rate-register.model';
/**
* Inline-diff Compare screen for a Loan Interest Rate Register draft.
*
* Shows a single consolidated matrix where every cell renders either its unchanged value or an
* "OLD → NEW" diff with highlight. A "Show only changed rows" toggle filters out rows with no
* changes so the reviewer can focus on what moved.
*
* Submit calls the mock backend; a `rollbackTransaction:true` response means the row has landed
* in the checker inbox and effective activation waits for approval.
*/
@Component({
selector: 'mifosx-preview-register',
templateUrl: './preview-register.component.html',
styleUrls: ['./preview-register.component.scss'],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatSlideToggleModule
]
})
export class PreviewRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
draft!: RateRegister;
active?: RateRegister;
slabs: SlabColumn[] = [];
draftRows: { srNo: number; particulars: string; rates: RateCell[] }[] = [];
baselineRows: { srNo: number; particulars: string; rates: RateCell[] }[] = [];
draftCibil: CibilSlab[] = [];
baselineCibil: CibilSlab[] = [];
submitting = false;
/** User toggle to collapse the chart to only the rows that changed. */
showOnlyChanged = true;
/** Submit button is live only for a Draft. A Pending Approval or Active register is review-only here. */
get canSubmit(): boolean {
return this.draft?.status === 'Draft';
}
ngOnInit(): void {
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
this.draft = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
this.active = this.service.getActive();
this.slabs = this.draft.slabs;
this.draftRows = this.draft.rows;
this.draftCibil = this.draft.cibilSlabs;
// Compare always diffs against a frozen baseline — the currently-Active register's
// last-approved snapshot, OR the draft's own baseline when editing Active in place.
const base = this.draft.id === this.active?.id ? this.draft : (this.active ?? this.draft);
this.baselineRows = base.baselineRows;
this.baselineCibil = base.baselineCibilSlabs;
});
}
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
/** The baseline (OLD) value of a cell — null if the row did not exist in the baseline. */
baselineCell(srNo: number, colIdx: number): RateCell {
return this.baselineRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
}
/** The draft (NEW) value of a cell. */
draftCell(srNo: number, colIdx: number): RateCell {
return this.draftRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
}
/** True when the same {product, amount-slab} cell differs between baseline and draft. */
cellChanged(srNo: number, colIdx: number): boolean {
return !this.sameCell(this.baselineCell(srNo, colIdx), this.draftCell(srNo, colIdx));
}
/** True when a product row has at least one changed cell — used by the "show only changed" filter. */
rowHasChanges(srNo: number): boolean {
for (let j = 0; j < this.slabs.length; j++) {
if (this.cellChanged(srNo, j)) {
return true;
}
}
return false;
}
/** Rows the chart should actually render — all rows, or only the ones with any diff. */
get visibleRows(): { srNo: number; particulars: string; rates: RateCell[] }[] {
if (!this.showOnlyChanged || this.changedCellCount() === 0) {
return this.draftRows;
}
return this.draftRows.filter((r) => this.rowHasChanges(r.srNo));
}
cibilChanged(index: number): boolean {
const a = this.baselineCibil[index];
const d = this.draftCibil[index];
if (!a || !d) {
return true;
}
return a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct;
}
/** Which CIBIL field(s) differ between baseline and draft — drives per-field highlight. */
cibilFieldChanged(index: number, field: 'from' | 'to' | 'tier' | 'adjustmentPct'): boolean {
const a = this.baselineCibil[index];
const d = this.draftCibil[index];
if (!a || !d) {
return true;
}
return a[field] !== d[field];
}
changedCellCount(): number {
let n = 0;
for (const row of this.draftRows) {
for (let j = 0; j < this.slabs.length; j++) {
if (this.cellChanged(row.srNo, j)) {
n++;
}
}
}
return n;
}
changedCibilCount(): number {
let n = 0;
const max = Math.max(this.draftCibil.length, this.baselineCibil.length);
for (let i = 0; i < max; i++) {
if (this.cibilChanged(i)) {
n++;
}
}
return n;
}
formatPct(pct: number): string {
if (pct === 0) {
return '0.00%';
}
const sign = pct > 0 ? '+' : '−';
return `${sign}${Math.abs(pct).toFixed(2)}%`;
}
/** Opens the mock backend call. `rollbackTransaction:true` → row sits in checker inbox. */
submit(): void {
if (this.submitting) {
return;
}
this.submitting = true;
this.service.submitForApproval(this.draft.id).subscribe({
next: (res) => {
this.submitting = false;
const msg = res.rollbackTransaction ? 'Submitted — awaiting checker approval' : 'Changes activated';
this.snack.open(msg, 'OK', { duration: 3500 });
this.router.navigate(['/products/loan-interest-rate-register']);
},
error: (err) => {
// Belt-and-braces guard — the service already catches, but if anything slips through
// we still unlock the UI so the user can retry instead of staring at a spinner forever.
this.submitting = false;
console.error('[PreviewRegister] submit failed', err);
this.snack.open('Submit failed — check backend status and try again', 'Dismiss', { duration: 4500 });
}
});
}
backToEdit(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.draft.id } });
}
private sameCell(a: RateCell, b: RateCell): boolean {
if (a === null && b === null) {
return true;
}
if (typeof a === 'number' && typeof b === 'number') {
return Math.abs(a - b) < 1e-9;
}
return a === b;
}
}
@@ -6,19 +6,39 @@
* 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 { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, catchError, map, of, tap } from 'rxjs';
import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from './models/rate-register.model'; import { ProductsService } from '../products.service';
import {
CibilSlab,
RateCell,
RateChartRow,
RateRegister,
RegisterStatus,
SlabColumn
} from './models/rate-register.model';
/** /**
* In-memory stand-in for the central Loan Interest Rate Register. * Central Loan Interest Rate Register service.
* *
* Mirrors the Excel chart the bank's Board approves each quarter (34 products × 10 amount slabs). * Primary data path is HTTP (/loanproducts/baseinterestrates). On HTTP failure (backend not yet
* Backing API will land later; today the service only serves the demo data in memory so the * deployed, network error) the service transparently falls back to in-memory seeded data so the UI
* screens can be driven end-to-end without a server. * never breaks during development. Local Drafts (not-yet-submitted new versions) live only in
* memory and are merged in front of server data on every list().
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class RateRegisterService { export class RateRegisterService {
private readonly productsService = inject(ProductsService);
/** True while the HTTP backend is reachable. Flipped to false on first HTTP failure (dev mode fallback). */
private useHttp = true;
/** Snapshot of the last list() result — getActive() / getActiveOrFirstDraft() read from this. */
private cache: RateRegister[] = [];
/** In-memory drafts the maker has created but not yet submitted. Preserved across list() refreshes. */
private localDrafts: RateRegister[] = [];
private readonly slabs: SlabColumn[] = [ private readonly slabs: SlabColumn[] = [
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 }, { key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
{ key: 's2', label: 'Upto 25,000', amountUpto: 25000 }, { key: 's2', label: 'Upto 25,000', amountUpto: 25000 },
@@ -64,7 +84,7 @@ export class RateRegisterService {
), ),
this.row( this.row(
3, 3,
'Business Term Loan for running', 'Business Term Loan for running business',
[ [
18, 18,
18, 18,
@@ -138,7 +158,7 @@ export class RateRegisterService {
8, 8,
8 8
]), ]),
this.row(8, 'House Repairing / Renovation', [ this.row(8, 'House Repairing, Renovation & Expansion Loan', [
null, null,
null, null,
15, 15,
@@ -167,10 +187,10 @@ export class RateRegisterService {
] ]
), ),
this.row(10, 'Loan against Jewellery', this.flat(10.8)), this.row(10, 'Loan against Jewellery', this.flat(10.8)),
this.row(11, 'Loan against Warehouse Receipt (A)', this.flat(13)), this.row(11, 'Loan against Warehouse Receipt (Farmers)', this.flat(13)),
this.row( this.row(
12, 12,
'Loan against Warehouse Receipt (B)', 'Loan against Warehouse Receipt (Traders)',
[ [
10.5, 10.5,
10.5, 10.5,
@@ -237,7 +257,7 @@ export class RateRegisterService {
8.25, 8.25,
8.25 8.25
]), ]),
this.row(18, 'Old Car Loan (4-Wheel Domestic)', [ this.row(18, 'Old Car Loan (4-Wheel Domestic) & Refinance', [
null, null,
null, null,
13, 13,
@@ -261,7 +281,7 @@ export class RateRegisterService {
10, 10,
10 10
]), ]),
this.row(20, 'Old Vehicle Loan (Commercial)', [ this.row(20, 'Old Vehicle Loan (Commercial & Three-wheeler)', [
null, null,
null, null,
15, 15,
@@ -317,7 +337,7 @@ export class RateRegisterService {
'0.50% + FD', '0.50% + FD',
'0.50% + FD' '0.50% + FD'
]), ]),
this.row(24, 'Loan against Daily Deposit & Gold', this.flat(10)), 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.formulaRow(25, 'Loan against Recurring Deposit', Array(10).fill('1% + RD')),
this.row(26, 'Loan against Staff Service Fund', this.flat(7)), this.row(26, 'Loan against Staff Service Fund', this.flat(7)),
this.row(27, 'Over Draft A/c', this.flat(null)), this.row(27, 'Over Draft A/c', this.flat(null)),
@@ -330,108 +350,138 @@ export class RateRegisterService {
this.row(34, 'OD to Staff (upto 2 lakh)', this.flat(null)) this.row(34, 'OD to Staff (upto 2 lakh)', this.flat(null))
]; ];
// Credit-score bands per the bank's approved register:
// above 700 = base rate (no change), below that stepped up by 0.5% per band.
// No discount band — the register never gives a negative adjustment.
private readonly cibilSlabs: CibilSlab[] = [ private readonly cibilSlabs: CibilSlab[] = [
{ from: 0, to: 649, tier: 'Poor', type: 'Flat', adjustmentPct: 1.0 }, { from: 0, to: 500, tier: 'V. Poor', type: 'Flat', adjustmentPct: 2.0 },
{ from: 650, to: 749, tier: 'Fair', type: 'Flat', adjustmentPct: 0.5 }, { from: 501, to: 600, tier: 'Poor', type: 'Flat', adjustmentPct: 1.5 },
{ from: 750, to: 799, tier: 'Good', type: 'Flat', adjustmentPct: 0 }, { from: 601, to: 650, tier: 'Fair', type: 'Flat', adjustmentPct: 1.0 },
{ from: 800, to: 850, tier: 'V. Good', type: 'Flat', adjustmentPct: -0.1 }, { from: 651, to: 700, tier: 'Good', type: 'Flat', adjustmentPct: 0.5 },
{ from: 851, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: -0.2 } { from: 701, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: 0 }
]; ];
private readonly registers: RateRegister[] = [ // Seed data used ONLY when the HTTP backend is unreachable (dev fallback). Each entry has its own
{ // deep-cloned rows & CIBIL slabs so edits to one register do not mutate another.
private readonly seedRegisters: RateRegister[] = [
this.seedRegister({
id: 1001, id: 1001,
name: 'Q1 2027 Rates', name: 'Interest Rate of Loans — Effective 01 April 2027',
effectiveFrom: '2027-01-01', effectiveFrom: '2027-04-01',
effectiveTo: '2027-03-31', effectiveTo: '2028-03-31',
currency: 'INR',
status: 'Draft', status: 'Draft',
createdBy: 'Sonali', createdBy: 'Sonali',
lastModified: '2026-10-01', lastModified: '2026-10-01',
note: 'Scheduled · future effective', note: 'Scheduled · future effective'
slabs: this.slabs, // No pre-seeded deltas — Draft starts identical to the Active register.
rows: this.rows, // Only edits made in the UI will show as diffs on the Compare screen.
cibilSlabs: this.cibilSlabs, }),
fallbackNoCibilSlab: 2, this.seedRegister({
fallbackBelowMin: 'block'
},
{
id: 1002, id: 1002,
name: 'Interest Rate of Loans — Oct 2026', name: 'Interest Rate of Loans — Effective 01 April 2026',
effectiveFrom: '2026-10-01', effectiveFrom: '2026-04-01',
effectiveTo: '2026-12-31', effectiveTo: '2027-03-31',
currency: 'INR',
status: 'Active', status: 'Active',
createdBy: 'Admin', createdBy: 'Admin',
lastModified: '2026-09-25', lastModified: '2026-03-25',
note: 'Approved by Board on 25 Sep', note: 'Approved by Board · Nagrik Sahakari Bank Maryadit Vidisha'
slabs: this.slabs, }),
rows: this.rows, this.seedRegister({
cibilSlabs: this.cibilSlabs,
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
},
{
id: 1003, id: 1003,
name: 'Q3 2026 Rates', name: 'Interest Rate of Loans — Effective 01 April 2025',
effectiveFrom: '2026-07-01', effectiveFrom: '2025-04-01',
effectiveTo: '2026-09-30',
currency: 'INR',
status: 'Expired',
createdBy: 'Admin',
lastModified: '2026-06-15',
slabs: this.slabs,
rows: this.rows,
cibilSlabs: this.cibilSlabs,
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
},
{
id: 1004,
name: 'Q2 2026 Rates',
effectiveFrom: '2026-04-01',
effectiveTo: '2026-06-30',
currency: 'INR',
status: 'Expired',
createdBy: 'Admin',
lastModified: '2026-03-10',
slabs: this.slabs,
rows: this.rows,
cibilSlabs: this.cibilSlabs,
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
},
{
id: 1005,
name: 'Q1 2026 Rates',
effectiveFrom: '2026-01-01',
effectiveTo: '2026-03-31', effectiveTo: '2026-03-31',
currency: 'INR',
status: 'Expired', status: 'Expired',
createdBy: 'Admin', createdBy: 'Admin',
lastModified: '2025-12-20', lastModified: '2025-03-20'
slabs: this.slabs, }),
rows: this.rows, this.seedRegister({
cibilSlabs: this.cibilSlabs, id: 1004,
fallbackNoCibilSlab: 2, name: 'Interest Rate of Loans — Effective 01 April 2024',
fallbackBelowMin: 'block' 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
* the server returns an empty collection (fresh DB, nothing seeded yet) falls back to the
* built-in seed registers so the UI always has something to show in development.
*/
list(): Observable<RateRegister[]> { list(): Observable<RateRegister[]> {
return of(this.registers); if (!this.useHttp) {
this.cache = [
...this.localDrafts,
...this.seedRegisters
];
return of(this.cache);
}
return this.productsService.getLoanBaseInterestRates().pipe(
map((dtos: any[]) => (dtos ?? []).map((d) => this.fromBackend(d))),
catchError((err) => {
console.warn('[RateRegisterService] HTTP unavailable, using seeded data —', err?.message ?? err);
this.useHttp = false;
return of([...this.seedRegisters]);
}),
map((serverVersions) => {
if (serverVersions.length === 0) {
console.info('[RateRegisterService] Backend returned no versions — using seeded demo data.');
return [...this.seedRegisters];
}
return serverVersions;
}),
tap((effectiveVersions) => {
this.cache = [
...this.localDrafts,
...effectiveVersions
];
}),
map(() => this.cache)
);
} }
get(id: number): Observable<RateRegister | undefined> { get(id: number): Observable<RateRegister | undefined> {
return of(this.registers.find((r) => r.id === id)); const cached = this.cache.find((r) => r.id === id);
if (cached) {
return of(cached);
}
// Local draft lookup (negative ids never hit the server).
if (id < 0) {
return of(this.localDrafts.find((r) => r.id === id));
}
if (!this.useHttp) {
return of(this.seedRegisters.find((r) => r.id === id));
}
return this.productsService.getLoanBaseInterestRateById(id).pipe(
map((d: any) => (d ? this.fromBackend(d) : undefined)),
catchError(() => of(undefined))
);
}
/** The one currently in force. Used by the Compare screen as the "left-hand" baseline. */
getActive(): RateRegister | undefined {
return this.cache.find((r) => r.status === 'Active');
} }
/** The one shown by default on list → edit navigation. */ /** The one shown by default on list → edit navigation. */
getActiveOrFirstDraft(): RateRegister { getActiveOrFirstDraft(): RateRegister {
return ( return (
this.registers.find((r) => r.status === 'Draft') ?? this.cache.find((r) => r.status === 'Draft') ??
this.registers.find((r) => r.status === 'Active') ?? this.cache.find((r) => r.status === 'Active') ??
this.registers[0] this.cache[0] ??
this.seedRegisters[0]
); );
} }
@@ -443,6 +493,169 @@ export class RateRegisterService {
return this.cibilSlabs; return this.cibilSlabs;
} }
// ---- maker-checker (real HTTP when backend is up, mock when not) -----------------
/**
* Submit a Draft for approval. HTTP path POSTs to /loanproducts/baseinterestrates — on a
* maker-checker-gated response ({@code rollbackTransaction=true}) the Draft stays visible as
* "Pending Approval" until the checker acts. On an un-gated response (direct commit) the Draft
* is removed and the register list is re-fetched so the new Active appears.
*/
submitForApproval(id: number): Observable<{ rollbackTransaction: boolean; commandId?: number }> {
const draft = this.localDrafts.find((x) => x.id === id) ?? this.cache.find((x) => x.id === id);
if (!draft) {
return of({ rollbackTransaction: false });
}
if (!this.useHttp) {
// Dev fallback — flip status locally so the UI can still demo the flow.
draft.status = 'Pending Approval';
draft.lastModified = new Date().toISOString().slice(0, 10);
return of({ rollbackTransaction: true, commandId: Math.floor(Math.random() * 100000) });
}
const payload = this.toBackend(draft);
return this.productsService.saveLoanBaseInterestRate(payload).pipe(
map((res: any) => {
const rollback = res?.rollbackTransaction === true;
const commandId = res?.commandId;
if (rollback) {
// Keep the local Draft visible as "Pending Approval" until the checker acts.
draft.status = 'Pending Approval';
draft.lastModified = new Date().toISOString().slice(0, 10);
} else {
// Direct commit — drop the local draft; the server now owns the new Active version.
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
this.cache = this.cache.filter((c) => c.id !== id);
}
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 });
})
);
}
/**
* Checker approves a Pending register — it becomes Active and the previously-Active one is
* deactivated. In real use the Approvals tab calls Fineract's /makercheckers/{id}?command=approve;
* this method remains as a local-only stub for the dev-fallback path.
*/
approve(id: number): Observable<RateRegister | undefined> {
const pending = this.cache.find((x) => x.id === id);
if (!pending || pending.status !== 'Pending Approval') {
return of(undefined);
}
const today = new Date().toISOString().slice(0, 10);
const previouslyActive = this.cache.find((x) => x.status === 'Active');
if (previouslyActive) {
previouslyActive.status = 'Expired';
previouslyActive.effectiveTo = today;
previouslyActive.lastModified = today;
}
pending.status = 'Active';
pending.effectiveFrom = today;
pending.lastModified = today;
pending.baselineRows = this.cloneRows(pending.rows);
pending.baselineCibilSlabs = this.cloneCibilSlabs(pending.cibilSlabs);
// Promote the local draft to the server-shaped view by moving it out of localDrafts.
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
return of(pending);
}
/**
* Scaffold a brand-new local Draft when the backend has no versions yet. Uses the Excel-sourced
* template (34 products × 10 slabs + 5 CIBIL bands) so the user has a sensible starting point to
* tweak and submit as the first-ever Active version. Carries a negative id like other local drafts.
*/
createFirstVersion(): RateRegister {
const template = this.seedRegisters.find((r) => r.status === 'Active') ?? this.seedRegisters[0];
const newId = this.nextLocalDraftId();
const today = new Date().toISOString().slice(0, 10);
const draft: RateRegister = {
id: newId,
name: 'Interest Rate of Loans — new version',
effectiveFrom: today,
effectiveTo: null,
currency: 'INR',
status: 'Draft',
createdBy: 'Current User',
lastModified: today,
note: 'First Loan Interest Rate Register — seeded from Excel template',
slabs: template.slabs,
rows: this.cloneRows(template.rows),
cibilSlabs: this.cloneCibilSlabs(template.cibilSlabs),
// Baseline is empty rows for the first version — compare screen will show every cell as "new".
baselineRows: template.rows.map((r) => ({
srNo: r.srNo,
particulars: r.particulars,
rates: new Array(r.rates.length).fill(null)
})),
baselineCibilSlabs: [],
fallbackNoCibilSlab: template.fallbackNoCibilSlab,
fallbackBelowMin: template.fallbackBelowMin
};
this.localDrafts.unshift(draft);
this.cache.unshift(draft);
return draft;
}
/**
* Duplicate the current Active (or any register) as a new local Draft so the user can propose
* changes without touching the live Active. Local Drafts carry a negative id to never collide
* with server-assigned ids.
*/
duplicateAsDraft(sourceId: number): RateRegister | undefined {
const source = this.cache.find((r) => r.id === sourceId) ?? this.seedRegisters.find((r) => r.id === sourceId);
if (!source) {
return undefined;
}
const newId = this.nextLocalDraftId();
const today = new Date().toISOString().slice(0, 10);
const copy: RateRegister = {
id: newId,
name: `${source.name} — new version`,
effectiveFrom: source.effectiveFrom,
effectiveTo: source.effectiveTo,
currency: source.currency,
status: 'Draft',
createdBy: 'Current User',
lastModified: today,
note: `Draft derived from ${source.name}`,
slabs: source.slabs,
rows: this.cloneRows(source.rows),
cibilSlabs: this.cloneCibilSlabs(source.cibilSlabs),
baselineRows: this.cloneRows(source.rows),
baselineCibilSlabs: this.cloneCibilSlabs(source.cibilSlabs),
fallbackNoCibilSlab: source.fallbackNoCibilSlab,
fallbackBelowMin: source.fallbackBelowMin
};
this.localDrafts.unshift(copy);
this.cache.unshift(copy);
return copy;
}
/** Checker rejects the pending register — it goes back to Draft (local-only fallback). */
reject(id: number): Observable<RateRegister | undefined> {
const pending = this.cache.find((x) => x.id === id);
if (!pending || pending.status !== 'Pending Approval') {
return of(undefined);
}
pending.status = 'Draft';
pending.lastModified = new Date().toISOString().slice(0, 10);
return of(pending);
}
private nextLocalDraftId(): number {
const minId = this.localDrafts.length ? Math.min(...this.localDrafts.map((d) => d.id)) : 0;
return (minId <= 0 ? minId : 0) - 1;
}
private row(srNo: number, particulars: string, rates: (number | null)[]): RateChartRow { private row(srNo: number, particulars: string, rates: (number | null)[]): RateChartRow {
return { srNo, particulars, rates }; return { srNo, particulars, rates };
} }
@@ -454,4 +667,192 @@ export class RateRegisterService {
private flat(value: number | null): (number | null)[] { private flat(value: number | null): (number | null)[] {
return Array(10).fill(value); return Array(10).fill(value);
} }
/**
* Build a register with its OWN deep-cloned rows and CIBIL slabs — editing one register must
* not mutate another. Optional overrides let a Draft start with real diffs vs the Active one.
*/
private seedRegister(spec: {
id: number;
name: string;
effectiveFrom: string;
effectiveTo: string | null;
status: RateRegister['status'];
createdBy: string;
lastModified: string;
note?: string;
rateOverrides?: { srNo: number; colIdx: number; value: number | string | null }[];
cibilOverrides?: { index: number; adjustmentPct: number }[];
}): RateRegister {
const rows = this.cloneRows(this.rows);
const cibilSlabs = this.cloneCibilSlabs(this.cibilSlabs);
for (const o of spec.rateOverrides ?? []) {
const r = rows.find((x) => x.srNo === o.srNo);
if (r) {
r.rates[o.colIdx] = o.value;
}
}
for (const o of spec.cibilOverrides ?? []) {
const s = cibilSlabs[o.index];
if (s) {
s.adjustmentPct = o.adjustmentPct;
}
}
return {
id: spec.id,
name: spec.name,
effectiveFrom: spec.effectiveFrom,
effectiveTo: spec.effectiveTo,
currency: 'INR',
status: spec.status,
createdBy: spec.createdBy,
lastModified: spec.lastModified,
note: spec.note,
slabs: this.slabs, // slab columns are structural; safe to share
rows,
cibilSlabs,
// Frozen baseline (last-approved state). Lives separately from `rows`/`cibilSlabs` so edits
// to the register are visible as diffs on the Compare screen, even when the register being
// edited happens to be the one that is currently Active.
baselineRows: this.cloneRows(rows),
baselineCibilSlabs: this.cloneCibilSlabs(cibilSlabs),
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
};
}
private cloneRows(src: RateChartRow[]): RateChartRow[] {
return src.map((r) => ({
srNo: r.srNo,
particulars: r.particulars,
isFormula: r.isFormula,
rates: [...r.rates]
}));
}
private cloneCibilSlabs(src: CibilSlab[]): CibilSlab[] {
return src.map((s) => ({ ...s }));
}
// ---- backend <-> UI mapping -----------------------------------------------
// Backend sends/receives slabs as a flat list of {productSrNo, slabOrder, cell} rows; the UI
// works with a matrix: SlabColumn[] + RateChartRow[] (34 × 10). The two mappers below bridge
// that gap.
/** Map a backend {@code LoanRateRegisterData} payload into a UI RateRegister. */
private fromBackend(d: any): RateRegister {
// Rebuild the slab columns from unique slabOrders.
const slabByOrder = new Map<number, SlabColumn>();
for (const cell of d.slabs ?? []) {
if (!slabByOrder.has(cell.slabOrder)) {
slabByOrder.set(cell.slabOrder, {
key: `s${cell.slabOrder + 1}`,
label: cell.slabLabel,
amountUpto: cell.amountUpto ?? null
});
}
}
const slabs = [...slabByOrder.entries()]
.sort((a, b) => a[0] - b[0])
.map(
([
,
s
]) => s
);
// Group cells by product to build rows.
const rowByProduct = new Map<number, RateChartRow>();
for (const cell of d.slabs ?? []) {
let row = rowByProduct.get(cell.productSrNo);
if (!row) {
row = { srNo: cell.productSrNo, particulars: cell.productName, rates: new Array(slabs.length).fill(null) };
rowByProduct.set(cell.productSrNo, row);
}
let value: RateCell;
if (cell.isNa) {
value = null;
} else if (cell.isFormula && cell.rateFormula) {
value = cell.rateFormula;
row.isFormula = true;
} else {
value = cell.rateValue ?? null;
}
row.rates[cell.slabOrder] = value;
}
const rows = [...rowByProduct.values()].sort((a, b) => a.srNo - b.srNo);
const cibilSlabs: CibilSlab[] = (d.cibilSlabs ?? [])
.slice()
.sort((a: any, b: any) => a.displayOrder - b.displayOrder)
.map((c: any) => ({
from: c.scoreFrom,
to: c.scoreTo,
tier: c.tier,
type: c.slabType ?? 'Flat',
adjustmentPct: Number(c.adjustmentPct)
}));
const status: RegisterStatus = d.isActive ? 'Active' : 'Expired';
return {
id: d.id,
name: d.name,
effectiveFrom: d.effectiveFromDate,
effectiveTo: d.effectiveToDate ?? null,
currency: d.currencyCode ?? 'INR',
status,
createdBy: d.createdById != null ? `User #${d.createdById}` : 'System',
lastModified: (d.createdDate ?? d.effectiveFromDate ?? '').toString().slice(0, 10),
note: d.note,
slabs,
rows,
cibilSlabs,
baselineRows: this.cloneRows(rows),
baselineCibilSlabs: this.cloneCibilSlabs(cibilSlabs),
fallbackNoCibilSlab: d.fallbackNoCibilSlab ?? 2,
fallbackBelowMin: (d.fallbackBelowMin as 'use-min-slab' | 'block') ?? 'block'
};
}
/** Serialise a UI RateRegister into the JSON shape POST /loanproducts/baseinterestrates expects. */
private toBackend(r: RateRegister): any {
const slabs: any[] = [];
for (const row of r.rows) {
for (let j = 0; j < r.slabs.length; j++) {
const cell = row.rates[j];
const slabCol = r.slabs[j];
const isNa = cell === null;
const isFormula = typeof cell === 'string';
slabs.push({
productSrNo: row.srNo,
productName: row.particulars,
slabOrder: j,
slabLabel: slabCol.label,
amountUpto: slabCol.amountUpto,
rateValue: isNa || isFormula ? null : cell,
rateFormula: isFormula ? cell : null,
isFormula,
isNa
});
}
}
const cibilSlabs = r.cibilSlabs.map((c, i) => ({
scoreFrom: c.from,
scoreTo: c.to,
tier: c.tier,
slabType: c.type,
adjustmentPct: c.adjustmentPct,
displayOrder: i
}));
return {
name: r.name,
effectiveFromDate: r.effectiveFrom,
currencyCode: r.currency,
fallbackNoCibilSlab: r.fallbackNoCibilSlab,
fallbackBelowMin: r.fallbackBelowMin,
note: r.note,
slabs,
cibilSlabs
};
}
} }
@@ -109,6 +109,7 @@ import { BaseFdInterestRateChartComponent } from './fixed-deposit-products/base-
import { LoanInterestRateRegisterComponent } from './loan-interest-rate-register/loan-interest-rate-register.component'; import { LoanInterestRateRegisterComponent } from './loan-interest-rate-register/loan-interest-rate-register.component';
import { EditRegisterComponent } from './loan-interest-rate-register/edit-register/edit-register.component'; import { EditRegisterComponent } from './loan-interest-rate-register/edit-register/edit-register.component';
import { CibilAdjustmentComponent } from './loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component'; import { CibilAdjustmentComponent } from './loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component';
import { PreviewRegisterComponent } from './loan-interest-rate-register/preview-register/preview-register.component';
import { CollateralResolver } from './collaterals/collateral.resolver'; import { CollateralResolver } from './collaterals/collateral.resolver';
import { CollateralsResolver } from './collaterals/collaterals.resolver'; import { CollateralsResolver } from './collaterals/collaterals.resolver';
import { CollateralTemplateResolver } from './collaterals/collaterals-template.resolver'; import { CollateralTemplateResolver } from './collaterals/collaterals-template.resolver';
@@ -914,6 +915,11 @@ const routes: Routes = [
path: 'cibil', path: 'cibil',
component: CibilAdjustmentComponent, component: CibilAdjustmentComponent,
data: { title: 'CIBIL Adjustment', breadcrumb: 'CIBIL Adjustment' } data: { title: 'CIBIL Adjustment', breadcrumb: 'CIBIL Adjustment' }
},
{
path: 'preview',
component: PreviewRegisterComponent,
data: { title: 'Compare Changes', breadcrumb: 'Compare' }
} }
] ]
}, },
+31 -1
View File
@@ -8,7 +8,8 @@
/** Angular Imports */ /** Angular Imports */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */ /** rxjs Imports */
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -800,6 +801,35 @@ export class ProductsService {
return this.http.post('/fixeddepositproducts/baseinterestrates', payload); return this.http.post('/fixeddepositproducts/baseinterestrates', payload);
} }
// ---- Loan Interest Rate Register (CBS Loan Base Interest Rate) -----------
// Reads carry SILENT_HTTP_ERROR so a backend not-yet-deployed (404) doesn't surface the global
// error modal — rate-register.service.ts has its own catchError that falls back to seeded data.
private silentCtx(): { context: HttpContext } {
return { context: new HttpContext().set(SILENT_HTTP_ERROR, true) };
}
/** Returns all Loan Rate Register versions, newest-effective first (for Version History accordion). */
getLoanBaseInterestRates(): Observable<any> {
return this.http.get('/loanproducts/baseinterestrates', this.silentCtx());
}
/** Returns the currently in-force Loan Rate Register version (isActive=true), or null when none exists. */
getActiveLoanBaseInterestRate(): Observable<any> {
return this.http.get('/loanproducts/baseinterestrates/active', this.silentCtx());
}
/** Returns a specific Loan Rate Register version by id. */
getLoanBaseInterestRateById(id: number): Observable<any> {
return this.http.get(`/loanproducts/baseinterestrates/${id}`, this.silentCtx());
}
/** Submits a new Loan Rate Register version. If maker-checker is enabled, the response's
* `rollbackTransaction` flag is true and the row sits in the Approvals tab until a checker approves. */
saveLoanBaseInterestRate(payload: any): Observable<any> {
return this.http.post('/loanproducts/baseinterestrates', payload, this.silentCtx());
}
/** Returns loan interest rate categories. Pass activeOnly=true to get only active ones. */ /** Returns loan interest rate categories. Pass activeOnly=true to get only active ones. */
getLoanInterestRateCategories(activeOnly = false): Observable<any> { getLoanInterestRateCategories(activeOnly = false): Observable<any> {
const url = activeOnly const url = activeOnly
+8 -2
View File
@@ -20,8 +20,14 @@
// Tenants the login screen offers, and which one it starts on. Every identifier here has to exist // Tenants the login screen offers, and which one it starts on. Every identifier here has to exist
// in the tenants table of the tenant-store database, or the platform rejects the request as an // in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant. // unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'lincpay'; window["env"]["fineractPlatformTenantId"] = 'default';
window["env"]["fineractPlatformTenantIds"] = 'lincpay'; window["env"]["fineractPlatformTenantIds"] = 'default';
// Force-override any stale localStorage tenant from a previous (lincpay) session.
try {
localStorage.setItem('mifosXTenantIdentifier', 'default');
localStorage.setItem('mifosXTenantIdentifiers', JSON.stringify(['default']));
} catch (e) { /* localStorage unavailable (private mode etc.) */ }
window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = ''; window['env']['tenantLogoUrlDark'] = '';
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260921', 'version': '261007',
'hash': '3c363d03' 'hash': '7c868ae07'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };