Add interest loan related UI
This commit is contained in:
+68
-138
@@ -7,6 +7,23 @@
|
||||
-->
|
||||
|
||||
<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-icon">CS</div>
|
||||
<div class="ca__header-text">
|
||||
@@ -16,151 +33,64 @@
|
||||
<button mat-stroked-button (click)="backToRegister()">← Back to register</button>
|
||||
</div>
|
||||
|
||||
<div class="ca__body">
|
||||
<div class="ca__left">
|
||||
<div class="ca__card">
|
||||
<div class="ca__card-head">
|
||||
<div class="ca__card-title">Credit score slabs</div>
|
||||
<button mat-stroked-button class="ca__dash-btn"><i class="fas fa-plus"></i> Add slab</button>
|
||||
</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 class="ca__card">
|
||||
<div class="ca__card-head">
|
||||
<div>
|
||||
<div class="ca__card-title">Credit score slabs</div>
|
||||
<div class="ca__card-sub">
|
||||
Only the Adjustment % is editable · changes apply to new sanctions after the register is approved
|
||||
</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 class="ca__right">
|
||||
<div class="ca__card">
|
||||
<div class="ca__card-head">
|
||||
<div class="ca__card-title">Live preview</div>
|
||||
<div class="ca__card-hint">pick a scenario</div>
|
||||
</div>
|
||||
<div class="ca__card-body">
|
||||
<div class="ca__preview-grid">
|
||||
<div class="ca__field">
|
||||
<label>PRODUCT</label>
|
||||
<select [(ngModel)]="previewProductSrNo">
|
||||
<option *ngFor="let row of register.rows" [value]="row.srNo">{{ row.particulars }}</option>
|
||||
</select>
|
||||
<table class="ca__editgrid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ca__col-ix">#</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 class="ca__col-del"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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 class="ca__field">
|
||||
<label>LOAN AMOUNT</label>
|
||||
<input type="number" [(ngModel)]="previewAmount" />
|
||||
</div>
|
||||
<div class="ca__field">
|
||||
<label>CIBIL SCORE</label>
|
||||
<input type="number" min="0" max="900" [(ngModel)]="previewCibil" />
|
||||
</div>
|
||||
<div class="ca__field">
|
||||
<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>
|
||||
</td>
|
||||
<td class="ca__col-del">
|
||||
<button *ngIf="canEdit" type="button" class="ca__delete-btn" title="Delete slab" (click)="deleteSlab(i)">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="ca__footer">
|
||||
<button mat-stroked-button (click)="backToRegister()">← Register</button>
|
||||
<div class="ca__footer-note">Changes apply to new sanctions once the register is approved</div>
|
||||
<button mat-flat-button color="primary" (click)="save()">Save adjustments</button>
|
||||
<button mat-stroked-button (click)="backToRegister()">{{ canEdit ? 'Cancel' : 'Back to register' }}</button>
|
||||
<button *ngIf="canEdit" mat-flat-button color="primary" (click)="save()">Save adjustments</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+296
-226
@@ -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,<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;
|
||||
}
|
||||
}
|
||||
|
||||
+65
-93
@@ -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<string, string> = {
|
||||
'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();
|
||||
}
|
||||
}
|
||||
|
||||
+31
-11
@@ -7,27 +7,44 @@
|
||||
-->
|
||||
|
||||
<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__field">
|
||||
<label>REGISTER NAME</label>
|
||||
<input type="text" [(ngModel)]="register.name" />
|
||||
<input type="text" [(ngModel)]="register.name" [readonly]="!canEdit" />
|
||||
</div>
|
||||
<div class="er__field er__field--date">
|
||||
<label>EFFECTIVE FROM</label>
|
||||
<input type="date" [(ngModel)]="register.effectiveFrom" />
|
||||
<input type="date" [(ngModel)]="register.effectiveFrom" [readonly]="!canEdit" />
|
||||
</div>
|
||||
<div class="er__field er__field--date">
|
||||
<label>EFFECTIVE TO</label>
|
||||
<input type="date" [(ngModel)]="register.effectiveTo" />
|
||||
<input type="date" [(ngModel)]="register.effectiveTo" [readonly]="!canEdit" />
|
||||
</div>
|
||||
<div class="er__field er__field--curr">
|
||||
<label>CURRENCY</label>
|
||||
<select [(ngModel)]="register.currency">
|
||||
<select [(ngModel)]="register.currency" [disabled]="!canEdit">
|
||||
<option value="INR">INR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -35,9 +52,11 @@
|
||||
<div class="er__grid-head">
|
||||
<div class="er__grid-title">Rate chart grid</div>
|
||||
<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 class="er__grid-actions">
|
||||
<div class="er__grid-actions" *ngIf="canEdit">
|
||||
<button mat-stroked-button class="er__dash-btn" (click)="addProduct()">
|
||||
<i class="fas fa-plus"></i> Add product
|
||||
</button>
|
||||
@@ -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)"
|
||||
>
|
||||
<input
|
||||
*ngIf="isEditing(i, j); else cellView"
|
||||
@@ -95,16 +115,16 @@
|
||||
<div class="er__cibil-text">
|
||||
<div class="er__cibil-title">CIBIL / credit score adjustment</div>
|
||||
<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>
|
||||
<a class="er__cibil-edit">Edit →</a>
|
||||
</div>
|
||||
|
||||
<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-actions">
|
||||
<div class="er__footer-actions" *ngIf="canEdit">
|
||||
<button mat-stroked-button>Save draft</button>
|
||||
<button mat-flat-button class="er__submit" (click)="submitForApproval()">
|
||||
<i class="fas fa-check"></i> Submit for approval
|
||||
|
||||
+94
@@ -14,6 +14,91 @@
|
||||
flex-direction: column;
|
||||
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 {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -207,6 +292,15 @@
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&--readonly {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--na {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
+44
-2
@@ -48,6 +48,7 @@ export class EditRegisterComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
register!: RateRegister;
|
||||
active?: RateRegister;
|
||||
slabs: SlabColumn[] = [];
|
||||
rows: RateChartRow[] = [];
|
||||
|
||||
@@ -62,11 +63,41 @@ export class EditRegisterComponent implements OnInit {
|
||||
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();
|
||||
this.slabs = this.register.slabs;
|
||||
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. */
|
||||
display(cell: RateCell): string {
|
||||
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. */
|
||||
startEdit(rowIdx: number, colIdx: number, current: RateCell): void {
|
||||
if (!this.canEdit) {
|
||||
return;
|
||||
}
|
||||
this.editing = { row: rowIdx, col: colIdx };
|
||||
if (current === null) {
|
||||
this.editValue = '';
|
||||
@@ -141,6 +175,9 @@ export class EditRegisterComponent implements OnInit {
|
||||
|
||||
/** Append a new empty product row at the bottom of the grid. */
|
||||
addProduct(): void {
|
||||
if (!this.canEdit) {
|
||||
return;
|
||||
}
|
||||
const name = window.prompt('New product name', 'New product');
|
||||
if (!name) {
|
||||
return;
|
||||
@@ -156,6 +193,9 @@ export class EditRegisterComponent implements OnInit {
|
||||
|
||||
/** Append a new amount slab column; every existing row grows by one blank cell. */
|
||||
addSlab(): void {
|
||||
if (!this.canEdit) {
|
||||
return;
|
||||
}
|
||||
const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab');
|
||||
if (!label) {
|
||||
return;
|
||||
@@ -178,9 +218,9 @@ export class EditRegisterComponent implements OnInit {
|
||||
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 {
|
||||
this.register.status = 'Active';
|
||||
this.router.navigate(['/products/loan-interest-rate-register']);
|
||||
this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: this.register.id } });
|
||||
}
|
||||
|
||||
statusChipClass(): string {
|
||||
@@ -189,6 +229,8 @@ export class EditRegisterComponent implements OnInit {
|
||||
return 'chip chip--active';
|
||||
case 'Draft':
|
||||
return 'chip chip--draft';
|
||||
case 'Pending Approval':
|
||||
return 'chip chip--pending';
|
||||
default:
|
||||
return 'chip chip--expired';
|
||||
}
|
||||
|
||||
+138
-40
@@ -11,16 +11,16 @@
|
||||
<div class="lirr__header-icon"><i class="fas fa-percentage"></i></div>
|
||||
<div class="lirr__header-text">
|
||||
<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>
|
||||
<button mat-stroked-button class="lirr__excel-btn">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
Excel
|
||||
</button>
|
||||
<button mat-flat-button color="primary" class="lirr__create-btn" (click)="create()">
|
||||
<i class="fas fa-plus"></i>
|
||||
Create register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="lirr__body">
|
||||
@@ -38,46 +38,144 @@
|
||||
<div class="lirr__kpi">
|
||||
<div class="lirr__kpi-label">CIBIL ADJUSTMENTS</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 class="lirr__table-card">
|
||||
<div class="lirr__table-head">
|
||||
<div class="lirr__table-title">All registers</div>
|
||||
<div class="lirr__history">
|
||||
<div class="lirr__history-head">
|
||||
<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>
|
||||
<table class="lirr__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Effective from</th>
|
||||
<th>Effective to</th>
|
||||
<th>Status</th>
|
||||
<th>Created by</th>
|
||||
<th>Last modified</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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 class="lirr__empty" *ngIf="registers.length === 0">
|
||||
<div class="lirr__empty-icon"><i class="fas fa-folder-open"></i></div>
|
||||
<div class="lirr__empty-title">No rate register versions yet</div>
|
||||
<div class="lirr__empty-sub">
|
||||
Create the first version to publish rates for all loan products. The template comes pre-filled from the bank's
|
||||
approved Excel chart (34 products × 10 amount slabs + 5 CIBIL bands) — you can edit everything before
|
||||
submitting for approval.
|
||||
</div>
|
||||
<button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()">
|
||||
<i class="fas fa-plus"></i> Create first version
|
||||
</button>
|
||||
</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>
|
||||
|
||||
+296
@@ -174,6 +174,53 @@
|
||||
text-align: right;
|
||||
color: #64748b;
|
||||
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 {
|
||||
@@ -182,6 +229,250 @@
|
||||
font-size: 12px;
|
||||
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 {
|
||||
@@ -200,6 +491,11 @@
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
&--expired {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
+116
-4
@@ -9,9 +9,11 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
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 { 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
|
||||
@@ -23,17 +25,23 @@ import { RateRegister } from './models/rate-register.model';
|
||||
styleUrls: ['./loan-interest-rate-register.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
DatePipe
|
||||
DatePipe,
|
||||
MatExpansionModule
|
||||
]
|
||||
})
|
||||
export class LoanInterestRateRegisterComponent implements OnInit {
|
||||
private readonly service = inject(RateRegisterService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snack = inject(MatSnackBar);
|
||||
|
||||
registers: RateRegister[] = [];
|
||||
active?: RateRegister;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.service.list().subscribe((rows) => {
|
||||
this.registers = rows;
|
||||
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 } });
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.router.navigate(['/products/loan-interest-rate-register/edit']);
|
||||
preview(register: RateRegister): void {
|
||||
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 {
|
||||
@@ -54,6 +93,8 @@ export class LoanInterestRateRegisterComponent implements OnInit {
|
||||
return 'chip chip--active';
|
||||
case 'Draft':
|
||||
return 'chip chip--draft';
|
||||
case 'Pending Approval':
|
||||
return 'chip chip--pending';
|
||||
case 'Expired':
|
||||
return 'chip chip--expired';
|
||||
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 {
|
||||
if (!this.active) {
|
||||
return '—';
|
||||
@@ -68,6 +120,66 @@ export class LoanInterestRateRegisterComponent implements OnInit {
|
||||
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 {
|
||||
if (!iso) {
|
||||
return '—';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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 {
|
||||
key: string;
|
||||
@@ -24,7 +24,7 @@ export interface RateChartRow {
|
||||
rates: RateCell[];
|
||||
}
|
||||
|
||||
export type RiskTier = 'Poor' | 'Fair' | 'Good' | 'V. Good' | 'Excellent';
|
||||
export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent';
|
||||
|
||||
export interface CibilSlab {
|
||||
from: number;
|
||||
@@ -47,6 +47,9 @@ export interface RateRegister {
|
||||
slabs: SlabColumn[];
|
||||
rows: RateChartRow[];
|
||||
cibilSlabs: CibilSlab[];
|
||||
/** Immutable snapshot of the last-approved state. Compare screen diffs against this. */
|
||||
baselineRows: RateChartRow[];
|
||||
baselineCibilSlabs: CibilSlab[];
|
||||
fallbackNoCibilSlab: number;
|
||||
fallbackBelowMin: 'use-min-slab' | 'block';
|
||||
}
|
||||
|
||||
+181
@@ -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>
|
||||
+362
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
@@ -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/.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from './models/rate-register.model';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable, catchError, map, of, tap } from 'rxjs';
|
||||
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).
|
||||
* Backing API will land later; today the service only serves the demo data in memory so the
|
||||
* screens can be driven end-to-end without a server.
|
||||
* Primary data path is HTTP (/loanproducts/baseinterestrates). On HTTP failure (backend not yet
|
||||
* deployed, network error) the service transparently falls back to in-memory seeded data so the UI
|
||||
* never breaks during development. Local Drafts (not-yet-submitted new versions) live only in
|
||||
* memory and are merged in front of server data on every list().
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
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[] = [
|
||||
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
|
||||
{ key: 's2', label: 'Upto 25,000', amountUpto: 25000 },
|
||||
@@ -64,7 +84,7 @@ export class RateRegisterService {
|
||||
),
|
||||
this.row(
|
||||
3,
|
||||
'Business Term Loan for running',
|
||||
'Business Term Loan for running business',
|
||||
[
|
||||
18,
|
||||
18,
|
||||
@@ -138,7 +158,7 @@ export class RateRegisterService {
|
||||
8,
|
||||
8
|
||||
]),
|
||||
this.row(8, 'House Repairing / Renovation', [
|
||||
this.row(8, 'House Repairing, Renovation & Expansion Loan', [
|
||||
null,
|
||||
null,
|
||||
15,
|
||||
@@ -167,10 +187,10 @@ export class RateRegisterService {
|
||||
]
|
||||
),
|
||||
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(
|
||||
12,
|
||||
'Loan against Warehouse Receipt (B)',
|
||||
'Loan against Warehouse Receipt (Traders)',
|
||||
[
|
||||
10.5,
|
||||
10.5,
|
||||
@@ -237,7 +257,7 @@ export class RateRegisterService {
|
||||
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,
|
||||
13,
|
||||
@@ -261,7 +281,7 @@ export class RateRegisterService {
|
||||
10,
|
||||
10
|
||||
]),
|
||||
this.row(20, 'Old Vehicle Loan (Commercial)', [
|
||||
this.row(20, 'Old Vehicle Loan (Commercial & Three-wheeler)', [
|
||||
null,
|
||||
null,
|
||||
15,
|
||||
@@ -317,7 +337,7 @@ export class RateRegisterService {
|
||||
'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.row(26, 'Loan against Staff Service Fund', this.flat(7)),
|
||||
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))
|
||||
];
|
||||
|
||||
// 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[] = [
|
||||
{ from: 0, to: 649, tier: 'Poor', type: 'Flat', adjustmentPct: 1.0 },
|
||||
{ from: 650, to: 749, tier: 'Fair', type: 'Flat', adjustmentPct: 0.5 },
|
||||
{ from: 750, to: 799, tier: 'Good', type: 'Flat', adjustmentPct: 0 },
|
||||
{ from: 800, to: 850, tier: 'V. Good', type: 'Flat', adjustmentPct: -0.1 },
|
||||
{ from: 851, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: -0.2 }
|
||||
{ from: 0, to: 500, tier: 'V. Poor', type: 'Flat', adjustmentPct: 2.0 },
|
||||
{ from: 501, to: 600, tier: 'Poor', type: 'Flat', adjustmentPct: 1.5 },
|
||||
{ from: 601, to: 650, tier: 'Fair', type: 'Flat', adjustmentPct: 1.0 },
|
||||
{ from: 651, to: 700, tier: 'Good', type: 'Flat', adjustmentPct: 0.5 },
|
||||
{ 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,
|
||||
name: 'Q1 2027 Rates',
|
||||
effectiveFrom: '2027-01-01',
|
||||
effectiveTo: '2027-03-31',
|
||||
currency: 'INR',
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2027',
|
||||
effectiveFrom: '2027-04-01',
|
||||
effectiveTo: '2028-03-31',
|
||||
status: 'Draft',
|
||||
createdBy: 'Sonali',
|
||||
lastModified: '2026-10-01',
|
||||
note: 'Scheduled · future effective',
|
||||
slabs: this.slabs,
|
||||
rows: this.rows,
|
||||
cibilSlabs: this.cibilSlabs,
|
||||
fallbackNoCibilSlab: 2,
|
||||
fallbackBelowMin: 'block'
|
||||
},
|
||||
{
|
||||
note: 'Scheduled · future effective'
|
||||
// No pre-seeded deltas — Draft starts identical to the Active register.
|
||||
// Only edits made in the UI will show as diffs on the Compare screen.
|
||||
}),
|
||||
this.seedRegister({
|
||||
id: 1002,
|
||||
name: 'Interest Rate of Loans — Oct 2026',
|
||||
effectiveFrom: '2026-10-01',
|
||||
effectiveTo: '2026-12-31',
|
||||
currency: 'INR',
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2026',
|
||||
effectiveFrom: '2026-04-01',
|
||||
effectiveTo: '2027-03-31',
|
||||
status: 'Active',
|
||||
createdBy: 'Admin',
|
||||
lastModified: '2026-09-25',
|
||||
note: 'Approved by Board on 25 Sep',
|
||||
slabs: this.slabs,
|
||||
rows: this.rows,
|
||||
cibilSlabs: this.cibilSlabs,
|
||||
fallbackNoCibilSlab: 2,
|
||||
fallbackBelowMin: 'block'
|
||||
},
|
||||
{
|
||||
lastModified: '2026-03-25',
|
||||
note: 'Approved by Board · Nagrik Sahakari Bank Maryadit Vidisha'
|
||||
}),
|
||||
this.seedRegister({
|
||||
id: 1003,
|
||||
name: 'Q3 2026 Rates',
|
||||
effectiveFrom: '2026-07-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',
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2025',
|
||||
effectiveFrom: '2025-04-01',
|
||||
effectiveTo: '2026-03-31',
|
||||
currency: 'INR',
|
||||
status: 'Expired',
|
||||
createdBy: 'Admin',
|
||||
lastModified: '2025-12-20',
|
||||
slabs: this.slabs,
|
||||
rows: this.rows,
|
||||
cibilSlabs: this.cibilSlabs,
|
||||
fallbackNoCibilSlab: 2,
|
||||
fallbackBelowMin: 'block'
|
||||
}
|
||||
lastModified: '2025-03-20'
|
||||
}),
|
||||
this.seedRegister({
|
||||
id: 1004,
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2024',
|
||||
effectiveFrom: '2024-04-01',
|
||||
effectiveTo: '2025-03-31',
|
||||
status: 'Expired',
|
||||
createdBy: 'Admin',
|
||||
lastModified: '2024-03-18'
|
||||
}),
|
||||
this.seedRegister({
|
||||
id: 1005,
|
||||
name: 'Interest Rate of Loans — Effective 01 April 2023',
|
||||
effectiveFrom: '2023-04-01',
|
||||
effectiveTo: '2024-03-31',
|
||||
status: 'Expired',
|
||||
createdBy: 'Admin',
|
||||
lastModified: '2023-03-22'
|
||||
})
|
||||
];
|
||||
|
||||
/**
|
||||
* List all registers (local drafts + server versions). Tries HTTP first; on failure OR when
|
||||
* 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[]> {
|
||||
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> {
|
||||
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. */
|
||||
getActiveOrFirstDraft(): RateRegister {
|
||||
return (
|
||||
this.registers.find((r) => r.status === 'Draft') ??
|
||||
this.registers.find((r) => r.status === 'Active') ??
|
||||
this.registers[0]
|
||||
this.cache.find((r) => r.status === 'Draft') ??
|
||||
this.cache.find((r) => r.status === 'Active') ??
|
||||
this.cache[0] ??
|
||||
this.seedRegisters[0]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +493,169 @@ export class RateRegisterService {
|
||||
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 {
|
||||
return { srNo, particulars, rates };
|
||||
}
|
||||
@@ -454,4 +667,192 @@ export class RateRegisterService {
|
||||
private flat(value: number | null): (number | null)[] {
|
||||
return Array(10).fill(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a register with its OWN deep-cloned rows and CIBIL slabs — editing one register must
|
||||
* not mutate another. Optional overrides let a Draft start with real diffs vs the Active one.
|
||||
*/
|
||||
private seedRegister(spec: {
|
||||
id: number;
|
||||
name: string;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string | null;
|
||||
status: RateRegister['status'];
|
||||
createdBy: string;
|
||||
lastModified: string;
|
||||
note?: string;
|
||||
rateOverrides?: { srNo: number; colIdx: number; value: number | string | null }[];
|
||||
cibilOverrides?: { index: number; adjustmentPct: number }[];
|
||||
}): RateRegister {
|
||||
const rows = this.cloneRows(this.rows);
|
||||
const cibilSlabs = this.cloneCibilSlabs(this.cibilSlabs);
|
||||
for (const o of spec.rateOverrides ?? []) {
|
||||
const r = rows.find((x) => x.srNo === o.srNo);
|
||||
if (r) {
|
||||
r.rates[o.colIdx] = o.value;
|
||||
}
|
||||
}
|
||||
for (const o of spec.cibilOverrides ?? []) {
|
||||
const s = cibilSlabs[o.index];
|
||||
if (s) {
|
||||
s.adjustmentPct = o.adjustmentPct;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: spec.id,
|
||||
name: spec.name,
|
||||
effectiveFrom: spec.effectiveFrom,
|
||||
effectiveTo: spec.effectiveTo,
|
||||
currency: 'INR',
|
||||
status: spec.status,
|
||||
createdBy: spec.createdBy,
|
||||
lastModified: spec.lastModified,
|
||||
note: spec.note,
|
||||
slabs: this.slabs, // slab columns are structural; safe to share
|
||||
rows,
|
||||
cibilSlabs,
|
||||
// Frozen baseline (last-approved state). Lives separately from `rows`/`cibilSlabs` so edits
|
||||
// to the register are visible as diffs on the Compare screen, even when the register being
|
||||
// edited happens to be the one that is currently Active.
|
||||
baselineRows: this.cloneRows(rows),
|
||||
baselineCibilSlabs: this.cloneCibilSlabs(cibilSlabs),
|
||||
fallbackNoCibilSlab: 2,
|
||||
fallbackBelowMin: 'block'
|
||||
};
|
||||
}
|
||||
|
||||
private cloneRows(src: RateChartRow[]): RateChartRow[] {
|
||||
return src.map((r) => ({
|
||||
srNo: r.srNo,
|
||||
particulars: r.particulars,
|
||||
isFormula: r.isFormula,
|
||||
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 { EditRegisterComponent } from './loan-interest-rate-register/edit-register/edit-register.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 { CollateralsResolver } from './collaterals/collaterals.resolver';
|
||||
import { CollateralTemplateResolver } from './collaterals/collaterals-template.resolver';
|
||||
@@ -914,6 +915,11 @@ const routes: Routes = [
|
||||
path: 'cibil',
|
||||
component: CibilAdjustmentComponent,
|
||||
data: { title: 'CIBIL Adjustment', breadcrumb: 'CIBIL Adjustment' }
|
||||
},
|
||||
{
|
||||
path: 'preview',
|
||||
component: PreviewRegisterComponent,
|
||||
data: { title: 'Compare Changes', breadcrumb: 'Compare' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
/** Angular Imports */
|
||||
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 */
|
||||
import { Observable } from 'rxjs';
|
||||
@@ -800,6 +801,35 @@ export class ProductsService {
|
||||
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. */
|
||||
getLoanInterestRateCategories(activeOnly = false): Observable<any> {
|
||||
const url = activeOnly
|
||||
|
||||
+8
-2
@@ -20,8 +20,14 @@
|
||||
// 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
|
||||
// unknown tenant.
|
||||
window["env"]["fineractPlatformTenantId"] = 'lincpay';
|
||||
window["env"]["fineractPlatformTenantIds"] = 'lincpay';
|
||||
window["env"]["fineractPlatformTenantId"] = 'default';
|
||||
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']['tenantLogoUrlDark'] = '';
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260921',
|
||||
'hash': '3c363d03'
|
||||
'version': '261007',
|
||||
'hash': '7c868ae07'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user