From d61584f291dd89c31b3bbf91288f9e0593935ba5 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Thu, 8 Oct 2026 17:22:59 +0530 Subject: [PATCH 1/3] feat(loan-products): wire GL mapping, checker diff view, and approval flow for loan interest rate register - Add GL account picker (gl-picker.component.ts) for mapping each product row to a GL account - Extract shared diff logic into rate-register-diff.util.ts + loan-rate-register-diff.util.ts - Add rate-register-diff component to render before/after changes for checker approval - Rewire checker-inbox and view-checker-inbox to show loan interest rate register diffs - Refactor rate-register.service.ts (mock-to-real HTTP wiring, GL-aware payload mapping) - Simplify preview-register component, moving diff rendering into the shared diff component - Add glAccountId field to RateChartRow model Co-Authored-By: Claude Sonnet 5 --- .../cibil-adjustment.component.ts | 7 +- .../edit-register.component.html | 48 +- .../edit-register.component.scss | 73 ++ .../edit-register/edit-register.component.ts | 96 ++- .../gl-picker/gl-picker.component.ts | 135 +++ ...loan-interest-rate-register.component.html | 8 +- ...loan-interest-rate-register.component.scss | 16 + .../loan-interest-rate-register.component.ts | 15 +- .../models/rate-register.model.ts | 8 + .../preview-register.component.html | 122 +-- .../preview-register.component.scss | 177 ---- .../preview-register.component.ts | 133 +-- .../rate-register-diff.util.ts | 48 ++ .../rate-register-diff.component.html | 118 +++ .../rate-register-diff.component.scss | 206 +++++ .../rate-register-diff.component.ts | 121 +++ .../rate-register.service.ts | 766 +++++++----------- src/app/products/products.service.ts | 22 + .../checker-inbox/checker-inbox.component.ts | 5 +- src/app/tasks/loan-rate-register-diff.util.ts | 169 ++++ .../view-checker-inbox.component.html | 26 +- .../view-checker-inbox.component.scss | 5 + .../view-checker-inbox.component.ts | 91 ++- src/environments/.env.ts | 4 +- 24 files changed, 1510 insertions(+), 909 deletions(-) create mode 100644 src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts create mode 100644 src/app/products/loan-interest-rate-register/rate-register-diff.util.ts create mode 100644 src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.html create mode 100644 src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.scss create mode 100644 src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.ts create mode 100644 src/app/tasks/loan-rate-register-diff.util.ts diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts index 244bca647..701b5667c 100644 --- a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts @@ -41,7 +41,12 @@ export class CibilAdjustmentComponent implements OnInit { const idParam = this.route.snapshot.queryParamMap.get('id'); const id = idParam ? Number(idParam) : null; this.service.list().subscribe((all) => { - this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.register = found; this.active = this.service.getActive(); }); } diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html index 8db7e5e86..0ec403f4b 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html @@ -55,6 +55,9 @@ {{ rows.length }} products × {{ slabs.length }} amount slabs · click any cell to edit · read-only view + · {{ unmappedCount() }} without GL — map every product's GL before submitting
+
+
Add product
+
+
+ + +
+
+ + +
+ + +
+
{{ addProductError }}
+
@@ -82,6 +101,25 @@ diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss index 0756a126f..13cad796d 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss @@ -424,6 +424,22 @@ } } + &__gl-tag { + display: inline-block; + margin-left: 8px; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + } + &__history-foot { padding: 10px 20px; border-top: 1px solid #f1f5f9; diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts index 2f4265b80..2fc9f859b 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts @@ -38,9 +38,15 @@ export class LoanInterestRateRegisterComponent implements OnInit { active?: RateRegister; ngOnInit(): void { + // Load the GL list once so mapped particulars show a readable label instead of "GL #id". + this.service.getGlOptions().subscribe(); this.reload(); } + glLabel(id: number | null | undefined): string | null { + return this.service.glLabel(id); + } + private reload(): void { this.service.list().subscribe((rows) => { this.registers = rows; @@ -168,10 +174,11 @@ export class LoanInterestRateRegisterComponent implements OnInit { /** Scaffold the very first version — used only when the register has no versions at all. */ createFirstVersion(): void { - const draft = this.service.createFirstVersion(); - this.snack.open('Blank draft created — fill in rates and submit for approval', 'OK', { duration: 3000 }); - this.reload(); - this.edit(draft); + this.service.createFirstVersion().subscribe((draft) => { + this.snack.open('Draft created — fill in rates and submit for approval', 'OK', { duration: 3000 }); + this.reload(); + this.edit(draft); + }); } /** Open the Compare screen for a pending register (checker view). */ diff --git a/src/app/products/loan-interest-rate-register/models/rate-register.model.ts b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts index 943cc6029..8f25e359f 100644 --- a/src/app/products/loan-interest-rate-register/models/rate-register.model.ts +++ b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts @@ -21,9 +21,17 @@ export interface RateChartRow { srNo: number; particulars: string; isFormula?: boolean; + /** GL account this particular is mapped to. null/undefined = "GL not mapped". */ + glAccountId?: number | null; rates: RateCell[]; } +/** One selectable GL account for the particular-to-GL mapping. */ +export interface GlOption { + id: number; + label: string; +} + export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent'; export interface CibilSlab { diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html index a7262b2a9..8f258ebdf 100644 --- a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html +++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html @@ -48,121 +48,23 @@ - -
-
-
-
Rate chart — inline diff
-
Changed cells highlighted · unchanged cells show single value
-
- - Show only changed rows - -
- -
- -
No changes detected
-
- Draft is identical to the current Active register. Edit the Draft first to see diffs here. -
-
- -
-
{{ row.srNo }}{{ row.particulars }} +
+ {{ row.glAccountId == null ? 'GL not mapped' : glLabel(row) }} +
+
{{ canEdit ? 'Cancel' : 'Back to list' }} diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss index 78c8ccc5b..476337e6e 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss @@ -70,6 +70,79 @@ } } + &__gl { + margin: 3px 0 0 30px; + } + + &__gl-tag { + display: inline-block; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + white-space: nowrap; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + + &--editable { + cursor: pointer; + + &:hover { + outline: 1px solid #94a3b8; + } + } + } + + &__gl-select { + display: block; + margin: 4px 0 0 30px; + max-width: 340px; + } + + &__gl-warn { + color: #b45309; + font-weight: 600; + } + + &__add-panel { + padding: 14px 18px; + border-bottom: 1px solid #f1f5f9; + background: #f8fafc; + } + + &__add-title { + font-size: 13px; + font-weight: 600; + color: #0f172a; + margin-bottom: 10px; + } + + &__add-fields { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + + .er__field { + min-width: 240px; + } + + .er__field--gl { + min-width: 320px; + } + } + + &__add-error { + margin-top: 8px; + font-size: 12px; + color: #b91c1c; + } + &__readonly-banner { display: flex; align-items: center; diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts index 329cdd8f7..95472abde 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts @@ -6,7 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -18,7 +19,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { RateRegisterService } from '../rate-register.service'; -import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; +import { GlOption, RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; +import { GlPickerComponent } from '../gl-picker/gl-picker.component'; /** * Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10 @@ -39,10 +41,13 @@ import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate MatSelectModule, MatIconModule, MatDatepickerModule, - MatNativeDateModule + MatNativeDateModule, + GlPickerComponent ] }) -export class EditRegisterComponent implements OnInit { +export class EditRegisterComponent implements OnInit, OnDestroy { + private readonly snack = inject(MatSnackBar); + private readonly service = inject(RateRegisterService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -58,11 +63,42 @@ export class EditRegisterComponent implements OnInit { @ViewChild('cellInput') cellInput?: ElementRef; + /** Leaving the page keeps the draft: edits are saved so a reload or a trip to Compare does not lose them. */ + ngOnDestroy(): void { + if (this.canEdit) { + this.service.saveDrafts(); + } + } + + saveDraft(): void { + this.service.saveDrafts(); + this.snack.open('Draft saved', 'OK', { duration: 2000 }); + } + + /** Posting GL accounts for the particular → GL mapping. */ + glOptions: GlOption[] = []; + + /** "Add product" panel state. A new product needs a name and a GL before it can be added. */ + addingProduct = false; + newProductName = ''; + newProductGl: number | null = null; + addProductError = ''; + + /** Index of the row whose GL is being changed inline, or null. */ + glEditRow: number | null = null; + ngOnInit(): void { + this.service.getGlOptions().subscribe((options) => (this.glOptions = options)); const idParam = this.route.snapshot.queryParamMap.get('id'); const id = idParam ? Number(idParam) : null; this.service.list().subscribe((all) => { - this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + // e.g. a reload on an unsaved local draft: nothing to edit, go back to the list. + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.register = found; this.active = this.service.getActive(); this.slabs = this.register.slabs; this.rows = this.register.rows; @@ -173,22 +209,66 @@ export class EditRegisterComponent implements OnInit { this.editing = null; } - /** Append a new empty product row at the bottom of the grid. */ + glLabel(row: RateChartRow): string | null { + return this.service.glLabel(row.glAccountId); + } + + unmappedCount(): number { + return this.rows.filter((r) => r.glAccountId == null).length; + } + + /** Submit is blocked until every product has a GL. */ + get canSubmit(): boolean { + return this.unmappedCount() === 0; + } + + /** Opens the Add product panel. */ addProduct(): void { if (!this.canEdit) { return; } - const name = window.prompt('New product name', 'New product'); + this.addingProduct = true; + this.newProductName = ''; + this.newProductGl = null; + this.addProductError = ''; + } + + cancelAddProduct(): void { + this.addingProduct = false; + this.addProductError = ''; + } + + /** Append the new product row at the bottom of the grid. Name and GL are both mandatory. */ + confirmAddProduct(): void { + const name = this.newProductName.trim(); if (!name) { + this.addProductError = 'Enter the product name.'; + return; + } + if (this.rows.some((r) => r.particulars.trim().toLowerCase() === name.toLowerCase())) { + this.addProductError = 'A product with this name already exists.'; + return; + } + if (this.newProductGl == null) { + this.addProductError = 'Select the GL account for this product.'; return; } const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1; this.rows.push({ srNo: next, - particulars: name.trim(), + particulars: name, + glAccountId: this.newProductGl, rates: Array(this.slabs.length).fill(null), isFormula: false }); + this.cancelAddProduct(); + } + + /** Toggle the inline GL selector on an existing row (Draft only). */ + editGl(rowIdx: number): void { + if (this.canEdit) { + this.glEditRow = this.glEditRow === rowIdx ? null : rowIdx; + } } /** Append a new amount slab column; every existing row grows by one blank cell. */ diff --git a/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts b/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts new file mode 100644 index 000000000..c58959fb2 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts @@ -0,0 +1,135 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { + AfterViewInit, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelect, MatSelectModule } from '@angular/material/select'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; +import { Subject, takeUntil } from 'rxjs'; +import { GlOption } from '../models/rate-register.model'; + +/** + * Compact searchable GL account dropdown. Typing in the search box filters by GL code or name. + * Emits the chosen GL id; with allowClear a "GL not mapped" entry emits null. + */ +@Component({ + selector: 'mifosx-gl-picker', + imports: [ + CommonModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + NgxMatSelectSearchModule + ], + template: ` + + + + + + GL not mapped + {{ o.label }} + + + `, + styles: [ + ` + .gl-picker { + width: 100%; + min-width: 260px; + font-size: 12px; + } + ` + ] +}) +export class GlPickerComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { + @Input() options: GlOption[] = []; + @Input() value: number | null = null; + @Input() placeholder = 'Select GL account'; + @Input() allowClear = false; + /** Open the dropdown as soon as it is rendered (used for inline row editing). */ + @Input() openOnInit = false; + + @Output() valueChange = new EventEmitter(); + /** Fired when the dropdown panel closes, whether or not a value was picked. */ + @Output() closed = new EventEmitter(); + + @ViewChild(MatSelect) private select?: MatSelect; + + filterCtrl = new UntypedFormControl(''); + filtered: GlOption[] = []; + + private readonly destroy$ = new Subject(); + + ngOnInit(): void { + this.filterCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => this.applyFilter()); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['options'] || changes['value']) { + this.applyFilter(); + } + } + + ngAfterViewInit(): void { + if (this.openOnInit) { + setTimeout(() => this.select?.open()); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onOpenedChange(opened: boolean): void { + if (opened) { + this.filterCtrl.setValue(''); + } else { + this.closed.emit(); + } + } + + /** Filter by code or name; the currently selected option always stays in the list so its label shows. */ + private applyFilter(): void { + const q = ((this.filterCtrl.value as string) ?? '').trim().toLowerCase(); + const all = this.options ?? []; + if (!q) { + this.filtered = all.slice(); + return; + } + const matches = all.filter((o) => o.label.toLowerCase().includes(q)); + const selected = all.find((o) => o.id === this.value); + this.filtered = selected && !matches.includes(selected) ? [ + selected, + ...matches + ] : matches; + } +} diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html index ab378875c..dd57f3b14 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html @@ -54,9 +54,8 @@
No rate register versions yet
- Create the first version to publish rates for all loan products. The template comes pre-filled from the bank's - approved Excel chart (34 products × 10 amount slabs + 5 CIBIL bands) — you can edit everything before - submitting for approval. + Create the first version to publish rates for all loan products. It starts with the standard loan types (rates + blank) and 5 CIBIL bands — fill in the rates, add any bank-specific products, and submit for approval.
{{ row.srNo }}{{ row.particulars }} + {{ + row.glAccountId == null ? 'GL not mapped' : glLabel(row.glAccountId) + }} {{ display(cell) }}
- - - - - - - - - - - - -
Particulars{{ s.label }}
- {{ row.srNo }}{{ row.particulars }} - - - {{ display(baselineCell(row.srNo, j)) }} - → - {{ display(draftCell(row.srNo, j)) }} - - {{ display(draftCell(row.srNo, j)) }} -
-
- - - -
-
-
-
CIBIL slabs — inline diff
-
Changed fields shown as OLD → NEW · unchanged fields render once
-
-
- - - - - - - - - - - - - - - - - - - -
#Score fromScore toTierAdjustment
{{ i + 1 }} - - {{ baselineCibil[i]?.from ?? '—' }} - → - {{ slab.from }} - - {{ slab.from }} - - - {{ baselineCibil[i]?.to ?? '—' }} - → - {{ slab.to }} - - {{ slab.to }} - - - {{ baselineCibil[i]?.tier ?? '—' }} - → - {{ slab.tier }} - - {{ slab.tier }} - - - {{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }} - → - {{ formatPct(slab.adjustmentPct) }} - - {{ formatPct(slab.adjustmentPct) }} -
-
+