feat: recurring deposit enhancements, dashboard, chart, and account view updates
This commit is contained in:
@@ -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);
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user