feat: recurring deposit enhancements, dashboard, chart, and account view updates

This commit is contained in:
2026-10-12 10:23:10 +05:30
parent 6b0f218b70
commit 2d2eac8d03
69 changed files with 3619 additions and 595 deletions
@@ -230,11 +230,29 @@
</tr>
</thead>
<tbody>
@for (mapping of selection[role.configKey]; track $index; let i = $index) {
<!-- Tracked by the row's own id, never by position. With $index, deleting a row shifted every
row after it up one, Angular reused the inputs bound to the old positions, and
ngModel wrote their values back into the shortened array - the row reappeared and
Save stayed grey. -->
@for (mapping of selection[role.configKey]; track mapping.rowId; let i = $index) {
<tr [class.rrc-map__row--invalid]="isRowInvalid(mapping)">
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<mat-select [(ngModel)]="mapping.glAccountId" [disabled]="savingKey === role.configKey">
<!-- The chart runs to a hundred accounts, so the list is typed into rather
than scrolled. Cleared as the dropdown opens, so each one opens whole. -->
<mat-select
[(ngModel)]="mapping.glAccountId"
[disabled]="savingKey === role.configKey"
(openedChange)="accountSearch = ''"
>
<mat-option>
<ngx-mat-select-search
[(ngModel)]="accountSearch"
[ngModelOptions]="{ standalone: true }"
placeholderLabel="Search by GL code or name"
noEntriesFoundLabel="No account matches"
></ngx-mat-select-search>
</mat-option>
@for (o of availableAccounts(role, i); track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
@@ -7,8 +7,9 @@
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { catchError, forkJoin, of } from 'rxjs';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
@@ -20,6 +21,15 @@ export interface ReserveMapping {
glAccountId: number;
weightPercentage: number;
liabilityClass: string | null;
/**
* Identity for the row, so the template can track rows by it rather than by position.
*
* Tracking by index made deletion impossible: removing a row shifts every row after it up one, so
* Angular reused the inputs that were bound to the old positions and ngModel wrote their values
* straight back into the shortened array. The row reappeared, the draft matched what was stored
* again, and Save stayed grey. Never sent to the backend.
*/
rowId: number;
}
/**
@@ -43,7 +53,9 @@ export interface ReserveMapping {
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
FormsModule,
ReactiveFormsModule,
NgxMatSelectSearchModule
]
})
export class RbiReserveConfigComponent implements OnInit {
@@ -80,9 +92,33 @@ export class RbiReserveConfigComponent implements OnInit {
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
/** Re-slices what is on screen. No round trip - the full set is already held. */
/** Hands each mapping row an identity the template can track it by. See ReserveMapping.rowId. */
private nextRowId = 1;
/**
* What is typed into the search box inside an open account dropdown.
*
* One field for the whole screen rather than one per row: only one dropdown is open at a time, and
* it is cleared as each one opens, so a term left behind cannot narrow the next list a user opens
* without them seeing why it is short.
*/
accountSearch = '';
/**
* Re-slices what is on screen. No round trip - the full set is already held.
*
* The rows are rebuilt for whatever the new slice brings in. They used not to be: selection was
* filled once, for the heads the first slice happened to contain, so switching the filter showed
* the newly admitted heads with no rows at all - a card with a name and an empty body, reading as
* a head that had nothing mapped when it was simply never read.
*/
onFormFilterChange(): void {
this.glRoles = this.rolesForScreen();
for (const role of this.glRoles) {
if (!this.selection[role.configKey]) {
this.reloadSelection(role);
}
}
this.rebuildGroups();
}
@@ -230,11 +266,7 @@ export class RbiReserveConfigComponent implements OnInit {
for (const role of this.glRoles) {
// Read back what is stored rather than re-derived: a weight or a class the administrator set
// deliberately would otherwise be replaced by a default on the next save.
this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({
glAccountId: a.glAccountId,
weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage,
liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null)
}));
this.reloadSelection(role);
}
this.rebuildGroups();
}
@@ -288,9 +320,16 @@ export class RbiReserveConfigComponent implements OnInit {
);
}
/** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */
/**
* A role with nothing mapped makes its return refuse to compute — surfaced, not hidden.
*
* Read from what is STORED, not from the draft on screen. The returns run off the saved mapping,
* so a row cleared but not yet saved changes nothing for them; counting it as unmapped made the
* banner announce that returns could not compute while they were still computing perfectly well.
* The unsaved edit is already shown by Save turning active.
*/
isUnmapped(role: any): boolean {
return !(this.selection[role.configKey] || []).length;
return !(role.accounts || []).length;
}
get unmappedCount(): number {
@@ -327,11 +366,29 @@ export class RbiReserveConfigComponent implements OnInit {
// a bank look compliant when it is not.
this.selection[role.configKey] = [
...(this.selection[role.configKey] || []),
{ glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }];
{
glAccountId: null as any,
weightPercentage: 100,
liabilityClass: role.classified ? 'TIME' : null,
rowId: this.nextRowId++
}];
}
/**
* Drops a row and saves the head straight away.
*
* The bin used to clear the row on screen and leave it to Save, which read as the mapping having
* been removed when nothing had reached the server - the row came back on the next refresh. A bin
* is understood to delete, so it deletes: a row with nothing chosen in it never existed on the
* server and only has to go from the screen, and anything else is written through at once.
*/
removeMapping(role: any, index: number): void {
this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index);
const rows = this.selection[role.configKey] || [];
const removed = rows[index];
this.selection[role.configKey] = rows.filter((_, i) => i !== index);
if (removed?.glAccountId) {
this.saveRole(role);
}
}
/**
@@ -343,7 +400,23 @@ export class RbiReserveConfigComponent implements OnInit {
const rows = this.selection[role.configKey] || [];
const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`));
const current = rows[index];
return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
const free = this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
// Typed into the search box above the list. Matched on code AND name, because an administrator
// looking for a head knows one or the other - the GL code off a trial balance, or the name off
// the balance sheet - and should not have to know which this list is sorted by.
const term = (this.accountSearch || '').trim().toLowerCase();
if (!term) {
return free;
}
// The chosen account stays in the list whatever is typed. Dropping it would leave mat-select
// with a value that matches no option, and the row would read as empty.
return free.filter(
(o) =>
o.id === current?.glAccountId ||
`${o.glCode}`.toLowerCase().includes(term) ||
`${o.name}`.toLowerCase().includes(term)
);
}
/** A row that cannot be saved - no account chosen, or a portion outside 0-100. */
@@ -379,6 +452,16 @@ export class RbiReserveConfigComponent implements OnInit {
.filter((e) => Math.abs(e.total - 100) > 0.0001);
}
/** Rebuilds one head's rows from what the server last returned, discarding the draft. */
private reloadSelection(role: any): void {
this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({
glAccountId: a.glAccountId,
weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage,
liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null),
rowId: this.nextRowId++
}));
}
saveRole(role: any): void {
this.savingKey = role.configKey;
this.errorMsg = '';
@@ -398,6 +481,11 @@ export class RbiReserveConfigComponent implements OnInit {
catchError((err: any) => {
this.savingKey = null;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.';
// Put the screen back to what the server holds. A failed save that left the draft showing
// would say the change had gone through while the server still had the old mapping - and
// the bin deletes immediately, so this is the only thing between a rejected delete and a
// row that looks gone but is not.
this.reloadSelection(role);
return of(null);
})
)