From 0ea57b3f82afabd49654dd49cf938829d4a11be0 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Wed, 7 Oct 2026 15:30:15 +0530 Subject: [PATCH] Add interest loan related UI --- .../cibil-adjustment.component.html | 206 ++----- .../cibil-adjustment.component.scss | 522 +++++++++------- .../cibil-adjustment.component.ts | 158 ++--- .../edit-register.component.html | 42 +- .../edit-register.component.scss | 94 +++ .../edit-register/edit-register.component.ts | 46 +- ...loan-interest-rate-register.component.html | 178 ++++-- ...loan-interest-rate-register.component.scss | 296 +++++++++ .../loan-interest-rate-register.component.ts | 120 +++- .../models/rate-register.model.ts | 7 +- .../preview-register.component.html | 181 ++++++ .../preview-register.component.scss | 362 +++++++++++ .../preview-register.component.ts | 210 +++++++ .../rate-register.service.ts | 583 +++++++++++++++--- src/app/products/products-routing.module.ts | 6 + src/app/products/products.service.ts | 32 +- src/assets/env.js | 10 +- src/environments/.env.ts | 4 +- 18 files changed, 2445 insertions(+), 612 deletions(-) create mode 100644 src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html create mode 100644 src/app/products/loan-interest-rate-register/preview-register/preview-register.component.scss create mode 100644 src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html index 92b80c9de..25a387ffa 100644 --- a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html @@ -7,6 +7,23 @@ -->
+
+ +
+
Read-only — {{ register.status }}
+
{{ readOnlyReason() }}
+
+
+ +
+ ● + CURRENT ACTIVE + {{ active.name }} + · effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }} + You are viewing the Active register + View active → +
+
CS
@@ -16,151 +33,64 @@
-
-
-
-
-
Credit score slabs
- -
- - - - - - - - - - - - - - - - - - - - - - - -
#Score fromScore toRisk tierTypeAdjustment
{{ i + 1 }}{{ slab.from }}{{ slab.to }} - {{ slab.tier }} - {{ slab.type }} - {{ formatPct(slab.adjustmentPct) }} - ⋯
-
- -
-
-
Fallback behaviour
-
-
- - -
-
- - -
-
+
+
+
+
Credit score slabs
+
+ Only the Adjustment % is editable · changes apply to new sanctions after the register is approved
+
-
-
-
-
Live preview
-
pick a scenario
-
-
-
-
- - + + + + + + + + + + + + + + + + + + + + + + + +
#Score fromScore toRisk tierTypeAdjustment
{{ i + 1 }}{{ slab.from }}{{ slab.to }} + {{ slab.tier }} + {{ slab.type }} +
+ + %
-
- - -
-
- - -
-
- - -
- - -
-
RATE DERIVATION
-
- Chart cell — {{ productLabel(previewProductSrNo) }} - {{ - chartCell() !== null ? (chartCell() | number: '1.2-2') + '%' : 'N.A.' - }} -
-
- CIBIL {{ previewCibil }} ({{ cibilSlab()?.tier ?? '—' }}) - - {{ formatPct(cibilSlab()?.adjustmentPct ?? 0) }} - -
-
- Product floor / ceiling - no clamp -
-
-
- Final rate - {{ finalRate() }} -
-
- - - -
-
-
What changes if…
-
-
- CIBIL = 700 - {{ rateFor(700) }} -
-
- CIBIL = 870 - {{ rateFor(870) }} -
-
- No CIBIL fetched - {{ rateForFallback() }} -
-
-
-
- +
+ +
diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss index 243151aa0..19f0c1489 100644 --- a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss @@ -9,11 +9,96 @@ .ca { background: #f8fafc; min-height: calc(100vh - 160px); - padding: 20px 32px; + padding: 24px 32px; display: flex; flex-direction: column; 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 { display: flex; align-items: center; @@ -21,20 +106,21 @@ padding: 20px 24px; background: white; border: 1px solid #e2e8f0; - border-radius: 10px; + border-radius: 12px; } &__header-icon { - width: 40px; - height: 40px; - border-radius: 9px; + width: 42px; + height: 42px; + border-radius: 10px; background: #dbeafe; color: #1d4ed8; display: flex; align-items: center; justify-content: center; - font-size: 15px; + font-size: 14px; font-weight: 700; + letter-spacing: 0.3px; } &__header-text { @@ -44,6 +130,7 @@ margin: 0; font-size: 20px; font-weight: 600; + color: #0f172a; } 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 { background: white; border: 1px solid #e2e8f0; - border-radius: 10px; + border-radius: 12px; overflow: hidden; } &__card-head { - padding: 12px 16px; + padding: 16px 20px; border-bottom: 1px solid #f1f5f9; display: flex; - align-items: center; - gap: 10px; - } - - &__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; + align-items: flex-start; + justify-content: space-between; gap: 12px; } + &__card-title { + font-size: 15px; + font-weight: 600; + color: #0f172a; + } + + &__card-sub { + margin-top: 2px; + font-size: 12px; + color: #64748b; + } + &__dash-btn { - margin-left: auto; - height: 28px; - font-size: 11px; + height: 32px; + font-size: 12px; font-weight: 600; color: #1d4ed8; border: 1px dashed #1d4ed8; + padding: 0 14px; 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 { text-align: right; } - &__dots { + &__col-ix { + width: 44px; text-align: center; color: #94a3b8; - cursor: pointer; + font-weight: 600; } - &__fallback-grid, - &__preview-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 14px; + &__col-del { + width: 48px; + text-align: center; } - &__field { + &__cell-muted { + color: #64748b; + } + + &__cell-readonly { + color: #0f172a; + font-variant-numeric: tabular-nums; + padding: 0 22px; + } + + &__footer { display: flex; - flex-direction: column; + justify-content: flex-end; + gap: 10px; + padding: 4px 2px; + } +} - label { - font-size: 11px; +/* ---- Editable grid ---------------------------------------------------- */ + +.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; font-weight: 600; - letter-spacing: 0.3px; - margin-bottom: 4px; - } + border-bottom: 1px solid #e2e8f0; - input, - select { - 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; + &.ca__num { + text-align: right; } } } - &__derivation { - padding: 12px 14px; - background: #f8fafc; - border-radius: 8px; - } + tbody tr { + border-bottom: 1px solid #f1f5f9; + transition: background 0.12s ease; - &__derivation-label { - font-size: 11px; - color: #64748b; - font-weight: 600; - letter-spacing: 0.3px; - margin-bottom: 8px; - } + &:hover { + background: #f8fafc; - &__derivation-row { - display: flex; - justify-content: space-between; - font-size: 12px; - padding: 3px 0; - - &--muted { - color: #64748b; - } - } - - &__derivation-val { - font-weight: 600; - - &--pos { - color: #059669; + .ca__delete-btn { + opacity: 1; + } } - &--neg { - color: #dc2626; + &:last-child { + border-bottom: 0; + } + + td { + padding: 8px 12px; + vertical-align: middle; } } +} - &__divider { - height: 1px; - background: #e2e8f0; - margin: 8px 0; - } +/* Borderless cell input — gets a visible border on hover/focus. */ +.ca__ghost-input { + width: 100%; + 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 { - display: flex; - 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 { + &:hover { + border-color: #e2e8f0; background: white; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 10px 16px; - display: flex; - align-items: center; - justify-content: space-between; } - &__footer-note { - font-size: 11px; - color: #64748b; + &:focus { + outline: none; + 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 { - padding: 3px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - - &--poor { - background: #fee2e2; - color: #991b1b; - } - - &--fair { - background: #fef3c7; - color: #92400e; - } - - &--good { - background: #dbeafe; - color: #1e40af; - } - - &--vgood { - background: #dcfce7; - color: #166534; - } - - &--excellent { - background: #d1fae5; - color: #065f46; - } +/* Adjustment input — coloured by sign, with trailing % suffix. */ +.ca__adj-wrap { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + width: 100%; } -.adj { - font-weight: 600; +.ca__adj-input { + width: 72px; + font-weight: 700; &--negative { color: #059669; @@ -342,3 +331,84 @@ 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,"); + 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; + } +} diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts index 4a183cabf..244bca647 100644 --- a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts @@ -11,15 +11,13 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; 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 { CibilSlab, RateRegister } from '../models/rate-register.model'; /** - * CIBIL/credit-score adjustment editor for a Rate Register — slab table, fallback behaviour - * and a live preview that resolves {product, loan amount, CIBIL} down to the final rate. + * CIBIL / credit-score slab editor. + * 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({ selector: 'mifosx-cibil-adjustment', @@ -28,10 +26,7 @@ import { CibilSlab, RateRegister } from '../models/rate-register.model'; imports: [ CommonModule, FormsModule, - MatButtonModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule + MatButtonModule ] }) export class CibilAdjustmentComponent implements OnInit { @@ -40,123 +35,100 @@ export class CibilAdjustmentComponent implements OnInit { private readonly router = inject(Router); register!: RateRegister; - - // Live preview inputs. - previewProductSrNo = 7; // House Purchase / Construction - previewAmount = 2500000; - previewCibil = 820; - previewTenureMonths = 120; + active?: RateRegister; ngOnInit(): void { const idParam = this.route.snapshot.queryParamMap.get('id'); const id = idParam ? Number(idParam) : null; this.service.list().subscribe((all) => { this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + 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 { const map: Record = { + 'V. Poor': 'tier tier--vpoor', Poor: 'tier tier--poor', Fair: 'tier tier--fair', Good: 'tier tier--good', - 'V. Good': 'tier tier--vgood', Excellent: 'tier tier--excellent' }; 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) { - return 'adj adj--negative'; + return `${base} ca__adj-input--negative`; + } + if (pct >= 1) { + return `${base} ca__adj-input--high`; } 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 { - if (pct === 0) { - return '0.00%'; + /** Append a new slab continuing from the current highest "score to". */ + addSlab(): void { + if (!this.canEdit) { + return; } - const sign = pct > 0 ? '+' : '−'; - return `${sign}${Math.abs(pct).toFixed(2)}%`; + const lastTo = this.register.cibilSlabs.length ? Math.max(...this.register.cibilSlabs.map((s) => s.to)) : 0; + 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. */ - private chartRateFor(productSrNo: number, amount: number): number | null { - const row = this.register.rows.find((r) => r.srNo === productSrNo); - if (!row) { - return null; + deleteSlab(index: number): void { + if (!this.canEdit) { + return; } - for (let i = 0; i < this.register.slabs.length; i++) { - 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; + this.register.cibilSlabs.splice(index, 1); } - private cibilAdjustmentFor(score: number): CibilSlab | null { - return this.register.cibilSlabs.find((s) => score >= s.from && score <= s.to) ?? null; - } - - 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 { + /** Edits are kept in the in-memory service (the register reference is shared). */ + save(): void { this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } }); } - save(): void { - this.backToRegister(); + backToRegister(): void { + this.save(); } } diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html index 02d72204d..8db7e5e86 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html @@ -7,27 +7,44 @@ -->
+
+ ● + CURRENT ACTIVE + {{ active.name }} + · effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }} + You are viewing the Active register + View active → +
+ +
+ +
+
Read-only — {{ register.status }}
+
{{ readOnlyReason() }}
+
+
+
- +
- +
- +
-
- +
@@ -35,9 +52,11 @@
Rate chart grid
- {{ rows.length }} products × {{ slabs.length }} amount slabs · click any cell to edit + {{ rows.length }} products × {{ slabs.length }} amount slabs + · click any cell to edit + · read-only view
-
+
@@ -70,7 +89,8 @@ [class.er__cell--na]="isNa(cell)" [class.er__cell--formula]="isFormulaRow(row)" [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)" >
CIBIL / credit score adjustment
- {{ 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
Edit →