- Reports > Interest Posting Reports: per branch and product, generate/regenerate at any time, approve; branch column, HO-on-behalf marker, posted txn per account - Reports > Interest Posting Summary (branch-wise) for the head office, with Excel export - Day Close warns and disables Close Day while the branch's interest report is unapproved - Savings product accounting: Interest On Savings (expense), Savings Interest Payable and Paid (liability rows, required) - RBI Form 1 statutory and Form 9 return screens, reserve config updates - Interest posting report GETs exempted from the response cache Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
573 lines
22 KiB
TypeScript
573 lines
22 KiB
TypeScript
/**
|
|
* 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 { FormsModule } from '@angular/forms';
|
|
import { catchError, forkJoin, of } from 'rxjs';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { OrganizationService } from '../organization.service';
|
|
|
|
/**
|
|
* One GL account pinned to a role: which account, how much of its balance the role takes, and - on a
|
|
* liability head - whether that portion is a demand or a time liability.
|
|
*/
|
|
export interface ReserveMapping {
|
|
glAccountId: number;
|
|
weightPercentage: number;
|
|
liabilityClass: string | null;
|
|
}
|
|
|
|
/**
|
|
* Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL
|
|
* accounts the returns read their figures from.
|
|
*
|
|
* Both halves sit on one screen because they are one decision. A percentage applies to a base, and
|
|
* an administrator who sets the rate but never maps the accounts produces a return that looks
|
|
* configured and reports nothing — so the screen states, per role, whether it is mapped at all.
|
|
*
|
|
* The accounts cannot be shipped as defaults: this platform seeds two mutually exclusive charts of
|
|
* accounts and real tenants match neither exactly, so cash on hand is 100101 on one chart and
|
|
* 101001 on another. Picking one here would read as zero on the charts it does not match.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-rbi-reserve-config',
|
|
templateUrl: './rbi-reserve-config.component.html',
|
|
styleUrls: ['./rbi-reserve-config.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
FormsModule
|
|
]
|
|
})
|
|
export class RbiReserveConfigComponent implements OnInit {
|
|
private organizationService = inject(OrganizationService);
|
|
|
|
/**
|
|
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1
|
|
* and Form 9, 'OSS1', 'OSS2'. Null shows every role.
|
|
*
|
|
* A screen per return rather than one screen for all of them: the four roles that drive CRR and
|
|
* the thirty that do not are separate decisions, made by different people at different times,
|
|
* and a single list makes each one harder to find than it is to make.
|
|
*/
|
|
protected readonly group: string | null = 'RESERVE';
|
|
|
|
/** Whether the CRR/SLR percentages belong on this screen. Only the reserve returns have rates. */
|
|
protected readonly showRates: boolean = true;
|
|
|
|
glRoles: any[] = [];
|
|
|
|
/** The roles grouped by the return they feed, in backend order. */
|
|
roleGroups: { name: string; label: string; roles: any[] }[] = [];
|
|
|
|
/**
|
|
* Which return's configuration is on screen: Form 1, Form 9, or both together.
|
|
*
|
|
* The two returns share a base but maintain their requirement in different assets, so an
|
|
* administrator setting one up is looking at a different set of heads than one setting up the
|
|
* other. Showing all twenty-six at once buries the four that matter for the task in hand.
|
|
*/
|
|
formFilter: 'ALL' | 'FORM_1' | 'FORM_9' | 'FORM_IX' = 'ALL';
|
|
|
|
/** 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. */
|
|
onFormFilterChange(): void {
|
|
this.glRoles = this.rolesForScreen();
|
|
this.rebuildGroups();
|
|
}
|
|
|
|
/**
|
|
* The roles this screen shows: those in its own group, narrowed to the return selected above.
|
|
*
|
|
* The group scope is widened for Form IX. Its heads were seeded into their own group so they sit
|
|
* apart on the backend, and a screen fixed to 'RESERVE' would filter every one of them out before
|
|
* the return filter ever ran - leaving the Form IX option selectable and its list empty.
|
|
*/
|
|
private rolesForScreen(): any[] {
|
|
const wanted = this.formFilter === 'FORM_IX' ? 'FORM9' : this.group;
|
|
const scoped = wanted ? this.allRoles.filter((r: any) => r.group === wanted) : this.allRoles;
|
|
return scoped.filter((r: any) => this.belongsToSelectedForm(r));
|
|
}
|
|
|
|
readonly formFilters = [
|
|
{ value: 'ALL', label: 'All returns' },
|
|
{ value: 'FORM_1', label: 'Form 1 — CRR (fortnightly)' },
|
|
{ value: 'FORM_9', label: 'Form 9 — SLR (monthly)' },
|
|
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
|
|
];
|
|
|
|
/**
|
|
* Whether a head belongs to the return currently selected.
|
|
*
|
|
* Decided from how the return consumes the head rather than from its name: MAINTAINED is met in
|
|
* cash and balances and so is Form 1's, SLR_ASSET is met in securities and gold and so is Form
|
|
* 9's, and a head that builds or nets the DTL base belongs to both because both charge their rate
|
|
* on that same base. A memo head is stated on either return and so shows on both.
|
|
*/
|
|
private belongsToSelectedForm(role: any): boolean {
|
|
if (this.formFilter === 'ALL') {
|
|
return true;
|
|
}
|
|
// Form IX's heads are placed by the group they were seeded into, not by usage. Its usages -
|
|
// LIABILITY and ASSET - read the same as Form 1's but mean something different: on Form IX they
|
|
// say which SIDE OF THE BALANCE SHEET a head sits on, not how it enters a ratio. Sixty heads
|
|
// showing up under Form 1's filter would bury the eight that belong there.
|
|
const group = role.group || role.roleGroup;
|
|
if (group === 'FORM9') {
|
|
return this.formFilter === 'FORM_IX';
|
|
}
|
|
if (this.formFilter === 'FORM_IX') {
|
|
return false;
|
|
}
|
|
// The built-in SLR investments role predates the SLR_ASSET usage and still carries MAINTAINED,
|
|
// so it is placed by name; every other head is placed by how the return consumes it.
|
|
if (role.configKey === 'SLR_INVESTMENTS') {
|
|
return this.formFilter === 'FORM_9';
|
|
}
|
|
const usage = role.usage || role.roleUsage;
|
|
if (usage === 'MAINTAINED') {
|
|
return this.formFilter === 'FORM_1';
|
|
}
|
|
if (usage === 'SLR_ASSET') {
|
|
return this.formFilter === 'FORM_9';
|
|
}
|
|
// LIABILITY, ASSET_NET and MEMO all appear on both returns.
|
|
return true;
|
|
}
|
|
|
|
/** Which group's roles are on screen. Unused when the screen is scoped to one group. */
|
|
activeGroup = '';
|
|
rates: any[] = [];
|
|
glAccountOptions: any[] = [];
|
|
|
|
loading = true;
|
|
savingKey: string | null = null;
|
|
savingRate = false;
|
|
errorMsg = '';
|
|
successMsg = '';
|
|
|
|
/**
|
|
* Draft of the per-role mapping, keyed by role. Committed on Save.
|
|
*
|
|
* A mapping is an account plus the portion of it the role takes and, on a liability head, which
|
|
* head that portion falls under. It is not a plain list of account ids because one account can
|
|
* appear twice under one role: RBI treats part of a savings balance as a demand liability and the
|
|
* rest as time, which is two rows against the same account.
|
|
*/
|
|
selection: Record<string, ReserveMapping[]> = {};
|
|
|
|
/** The heads a liability can fall under, and the label each carries on screen. */
|
|
readonly liabilityClasses = [
|
|
{ value: 'DEMAND', label: 'Demand' },
|
|
{ value: 'TIME', label: 'Time' }
|
|
];
|
|
|
|
/**
|
|
* How a head an administrator adds is consumed by the return.
|
|
*
|
|
* MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is
|
|
* met in cash and balances with banks, SLR in cash, gold and approved securities. One option for
|
|
* both would let a head meant for Form 1 quietly satisfy Form 9.
|
|
*/
|
|
readonly roleUsages = [
|
|
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' },
|
|
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' },
|
|
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' },
|
|
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' },
|
|
// Form IX has no ratio and no base: its usages say which side of the balance sheet a head sits
|
|
// on. LIABILITY above serves its liability side, and this its asset side.
|
|
{ value: 'ASSET', label: 'Asset — enters Total Assets (Form IX)' },
|
|
{ value: 'MEMO', label: 'Memo — shown on the return but totals nothing' }
|
|
];
|
|
|
|
/** New-head form. */
|
|
newRole: { label: string; description: string; usage: string } = { label: '', description: '', usage: 'LIABILITY' };
|
|
addingRole = false;
|
|
showAddRole = false;
|
|
|
|
/** New-rate form. Defaults to today so the common case is one edit and Save. */
|
|
newRate: { rateType: string; percentage: number | null; effectiveFrom: string } = {
|
|
rateType: 'CRR',
|
|
percentage: null,
|
|
effectiveFrom: new Date().toISOString().slice(0, 10)
|
|
};
|
|
|
|
ngOnInit(): void {
|
|
this.load();
|
|
}
|
|
|
|
load(): void {
|
|
this.loading = true;
|
|
this.errorMsg = '';
|
|
forkJoin({
|
|
config: this.organizationService.getRbiReserveConfig().pipe(catchError(() => of(null))),
|
|
template: this.organizationService.getRbiReserveConfigTemplate().pipe(catchError(() => of(null)))
|
|
}).subscribe(({ config, template }: any) => {
|
|
this.loading = false;
|
|
if (!config) {
|
|
this.errorMsg = 'Could not load RBI reserve configuration.';
|
|
return;
|
|
}
|
|
this.applyConfig(config);
|
|
this.glAccountOptions = template?.glAccountOptions || [];
|
|
});
|
|
}
|
|
|
|
private applyConfig(config: any): void {
|
|
// Kept so the form filter can re-slice without a round trip.
|
|
this.allRoles = config.glRoles || [];
|
|
// Scoped to this screen's return before anything else, so every count, warning and Save on the
|
|
// page speaks about the roles the user can actually see.
|
|
this.glRoles = this.rolesForScreen();
|
|
this.rates = config.rates || [];
|
|
this.selection = {};
|
|
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.rebuildGroups();
|
|
}
|
|
|
|
/**
|
|
* The roles split by the return they feed, so one screen can configure four returns without
|
|
* presenting thirty-four roles as a single list where the four that drive CRR are lost among
|
|
* the thirty that do not.
|
|
*
|
|
* Built from the group the backend states rather than from a key prefix: a role's grouping is
|
|
* part of what the return means, and duplicating that rule here would let the two drift.
|
|
*/
|
|
private rebuildGroups(): void {
|
|
const byGroup = new Map<string, { label: string; roles: any[] }>();
|
|
for (const role of this.glRoles) {
|
|
const group = role.group || 'OTHER';
|
|
if (!byGroup.has(group)) {
|
|
byGroup.set(group, { label: role.groupLabel || group, roles: [] });
|
|
}
|
|
byGroup.get(group)!.roles.push(role);
|
|
}
|
|
this.roleGroups = [...byGroup.entries()].map(
|
|
([
|
|
name,
|
|
entry
|
|
]) => ({ name, label: entry.label, roles: entry.roles })
|
|
);
|
|
|
|
// Keep the open tab if it still exists, so saving a role does not throw the user back to the
|
|
// first tab with their place lost.
|
|
if (!this.roleGroups.some((g) => g.name === this.activeGroup)) {
|
|
this.activeGroup = this.roleGroups[0]?.name ?? '';
|
|
}
|
|
}
|
|
|
|
/** Tabs only earn their place when the screen shows more than one return's roles. */
|
|
get showTabs(): boolean {
|
|
return this.roleGroups.length > 1;
|
|
}
|
|
|
|
/** Heading and blurb, overridden per return by the screens scoped to one. */
|
|
get screenTitle(): string {
|
|
return 'RBI Reserve Configuration';
|
|
}
|
|
|
|
get screenSubtitle(): string {
|
|
return (
|
|
'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' +
|
|
'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' +
|
|
'than reporting a nil figure that looks real.'
|
|
);
|
|
}
|
|
|
|
/** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */
|
|
isUnmapped(role: any): boolean {
|
|
return !(this.selection[role.configKey] || []).length;
|
|
}
|
|
|
|
get unmappedCount(): number {
|
|
return this.glRoles.filter((r) => this.isUnmapped(r)).length;
|
|
}
|
|
|
|
/** Unmapped roles within one group, so each tab carries its own count. */
|
|
unmappedIn(group: { roles: any[] }): number {
|
|
return group.roles.filter((r) => this.isUnmapped(r)).length;
|
|
}
|
|
|
|
showGroup(name: string): void {
|
|
this.activeGroup = name;
|
|
}
|
|
|
|
get visibleRoles(): any[] {
|
|
return this.roleGroups.find((g) => g.name === this.activeGroup)?.roles ?? [];
|
|
}
|
|
|
|
/** True once the draft differs from what is stored, so Save is only offered when useful. */
|
|
isDirty(role: any): boolean {
|
|
const key = (m: any) => `${m.glAccountId}/${m.liabilityClass ?? ''}/${+m.weightPercentage}`;
|
|
const stored = (role.accounts || [])
|
|
.map((a: any) => key({ ...a, weightPercentage: a.weightPercentage ?? 100 }))
|
|
.sort();
|
|
const draft = (this.selection[role.configKey] || []).map(key).sort();
|
|
return stored.length !== draft.length || stored.some((k: string, i: number) => k !== draft[i]);
|
|
}
|
|
|
|
/** Adds an empty row for this role, for the administrator to pick an account into. */
|
|
addMapping(role: any): void {
|
|
// Defaults to the whole balance, and to Time on a liability head. Understating demand is the
|
|
// conservative default: demand attracts the heavier treatment, so a forgotten class cannot make
|
|
// 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 }];
|
|
}
|
|
|
|
removeMapping(role: any, index: number): void {
|
|
this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index);
|
|
}
|
|
|
|
/**
|
|
* Accounts still selectable in a given row: everything not already used by ANOTHER row under the
|
|
* same head. The same account under a different head is left available, because a demand slice and
|
|
* a time slice of one savings account are two different figures, not a duplicate.
|
|
*/
|
|
availableAccounts(role: any, index: number): any[] {
|
|
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 ?? ''}`));
|
|
}
|
|
|
|
/** A row that cannot be saved - no account chosen, or a portion outside 0-100. */
|
|
isRowInvalid(mapping: ReserveMapping): boolean {
|
|
const weight = +mapping.weightPercentage;
|
|
return !mapping.glAccountId || isNaN(weight) || weight < 0 || weight > 100;
|
|
}
|
|
|
|
/** Whether any row of this role is incomplete, so Save is withheld rather than failing server-side. */
|
|
hasInvalidRows(role: any): boolean {
|
|
return (this.selection[role.configKey] || []).some((m) => this.isRowInvalid(m));
|
|
}
|
|
|
|
/**
|
|
* The demand and time subtotals this role's rows declare, as a share of the balances behind them.
|
|
*
|
|
* Shown because a split is easy to get wrong in a way no single row looks wrong in: two rows
|
|
* against one account that together take 90% of it silently drop a tenth of the balance out of the
|
|
* base, and only the total reveals it.
|
|
*/
|
|
weightTotalFor(role: any, glAccountId: number): number {
|
|
return (this.selection[role.configKey] || [])
|
|
.filter((m) => m.glAccountId === glAccountId)
|
|
.reduce((sum, m) => sum + (+m.weightPercentage || 0), 0);
|
|
}
|
|
|
|
/** Accounts split across more than one row whose portions do not add up to the whole balance. */
|
|
unbalancedAccounts(role: any): { glAccountId: number; total: number }[] {
|
|
const rows = this.selection[role.configKey] || [];
|
|
const ids = [...new Set(rows.map((m) => m.glAccountId).filter((id) => !!id))];
|
|
return ids
|
|
.map((id) => ({ glAccountId: id, total: this.weightTotalFor(role, id) }))
|
|
.filter((e) => Math.abs(e.total - 100) > 0.0001);
|
|
}
|
|
|
|
saveRole(role: any): void {
|
|
this.savingKey = role.configKey;
|
|
this.errorMsg = '';
|
|
this.successMsg = '';
|
|
this.organizationService
|
|
.updateRbiReserveGlRole(
|
|
role.configKey,
|
|
(this.selection[role.configKey] || []).map((m) => ({
|
|
glAccountId: m.glAccountId,
|
|
weightPercentage: +m.weightPercentage,
|
|
// Sent as null off a liability head, so a class left over from a head that was retyped
|
|
// cannot travel with a figure the split does not apply to.
|
|
liabilityClass: role.classified ? m.liabilityClass : null
|
|
}))
|
|
)
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.savingKey = null;
|
|
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.savingKey = null;
|
|
if (!res) {
|
|
return;
|
|
}
|
|
this.applyConfig(res);
|
|
this.successMsg = `${this.roleLabel(role)} updated.`;
|
|
});
|
|
}
|
|
|
|
saveRate(): void {
|
|
if (this.newRate.percentage === null || !this.newRate.effectiveFrom) {
|
|
this.errorMsg = 'Enter a percentage and an effective date.';
|
|
return;
|
|
}
|
|
this.savingRate = true;
|
|
this.errorMsg = '';
|
|
this.successMsg = '';
|
|
this.organizationService
|
|
.upsertRbiReserveRate(this.newRate.rateType, this.newRate.percentage, this.newRate.effectiveFrom)
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.savingRate = false;
|
|
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this rate.';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.savingRate = false;
|
|
if (!res) {
|
|
return;
|
|
}
|
|
this.applyConfig(res);
|
|
this.successMsg = `${this.newRate.rateType} rate saved.`;
|
|
this.newRate.percentage = null;
|
|
});
|
|
}
|
|
|
|
deleteRate(rate: any): void {
|
|
this.errorMsg = '';
|
|
this.successMsg = '';
|
|
this.organizationService
|
|
.deleteRbiReserveRate(rate.id)
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this rate.';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: any) => {
|
|
if (!res) {
|
|
return;
|
|
}
|
|
this.applyConfig(res);
|
|
this.successMsg = 'Rate removed.';
|
|
});
|
|
}
|
|
|
|
/** The rate a return filed today would use — the latest one already in effect. */
|
|
currentRate(rateType: string): any | null {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const inForce = this.rates.filter((r) => r.rateType === rateType && r.effectiveFrom <= today);
|
|
return inForce.length ? inForce[0] : null;
|
|
}
|
|
|
|
ratesFor(rateType: string): any[] {
|
|
return this.rates.filter((r) => r.rateType === rateType);
|
|
}
|
|
|
|
/** A future-dated rate is on record but not yet applied — worth saying so explicitly. */
|
|
isFuture(rate: any): boolean {
|
|
return rate.effectiveFrom > new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
/**
|
|
* A role's display name and how the return uses it, both as the backend states them.
|
|
*
|
|
* Taken from the response rather than from a table here: a role only exists because code
|
|
* computes something from it, and that code is what knows what it means. A copy in the screen
|
|
* would leave a newly added role showing as a raw key.
|
|
*/
|
|
roleLabel(role: any): string {
|
|
return role?.label || role?.configKey || '';
|
|
}
|
|
|
|
/** Which return each role feeds, so the consequence of leaving one unmapped is visible. */
|
|
roleUsedBy(role: any): string {
|
|
return role?.usedBy || '';
|
|
}
|
|
|
|
glLabel(accountId: number): string {
|
|
const account = this.glAccountOptions.find((a) => a.id === accountId);
|
|
return account ? `${account.glCode} — ${account.name}` : `#${accountId}`;
|
|
}
|
|
|
|
// ── heads an administrator adds ──
|
|
|
|
/**
|
|
* Adds a head to this return. The bank's own chart decides what these are - a reserve, a tax
|
|
* payable, bills payable - so they are rows rather than a fixed list in code.
|
|
*/
|
|
addRole(): void {
|
|
if (!this.newRole.label.trim()) {
|
|
this.errorMsg = 'Give the head a name.';
|
|
return;
|
|
}
|
|
this.addingRole = true;
|
|
this.errorMsg = '';
|
|
this.successMsg = '';
|
|
this.organizationService
|
|
.createRbiReserveRole({
|
|
label: this.newRole.label.trim(),
|
|
description: this.newRole.description.trim(),
|
|
// Added to the return this screen configures, so a head cannot land on a form the
|
|
// administrator is not looking at.
|
|
group: this.group ?? this.activeGroup,
|
|
usage: this.newRole.usage
|
|
})
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.addingRole = false;
|
|
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not add this head.';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.addingRole = false;
|
|
if (!res) {
|
|
return;
|
|
}
|
|
this.applyConfig(res);
|
|
this.successMsg = `${this.newRole.label.trim()} added. Map its GL accounts below.`;
|
|
this.newRole = { label: '', description: '', usage: 'LIABILITY' };
|
|
this.showAddRole = false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Removes a head and everything mapped to it. Confirmed first: the figures it contributed leave
|
|
* the return with it, so a return filed after this will not agree with one filed before.
|
|
*/
|
|
deleteRole(role: any): void {
|
|
if (!confirm(`Remove "${this.roleLabel(role)}" and its mapped accounts? Returns will stop counting it.`)) {
|
|
return;
|
|
}
|
|
this.errorMsg = '';
|
|
this.successMsg = '';
|
|
this.organizationService
|
|
.deleteRbiReserveRole(role.roleId)
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this head.';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: any) => {
|
|
if (!res) {
|
|
return;
|
|
}
|
|
this.applyConfig(res);
|
|
this.successMsg = 'Head removed.';
|
|
});
|
|
}
|
|
|
|
/** How a head is consumed, in words, for the screen to state next to it. */
|
|
usageLabel(usage: string): string {
|
|
return this.roleUsages.find((u) => u.value === usage)?.label ?? usage;
|
|
}
|
|
}
|