337 lines
14 KiB
HTML
337 lines
14 KiB
HTML
<!--
|
|
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="rrc-container">
|
|
<div class="rrc-header">
|
|
<button mat-stroked-button [routerLink]="['/organization']" class="rrc-back">
|
|
<i class="fas fa-arrow-left"></i> Organization
|
|
</button>
|
|
<h2 class="rrc-header__title">{{ screenTitle }}</h2>
|
|
</div>
|
|
<p class="rrc-sub">{{ screenSubtitle }}</p>
|
|
|
|
@if (errorMsg) {
|
|
<div class="rrc-alert rrc-alert--error">{{ errorMsg }}</div>
|
|
}
|
|
@if (successMsg) {
|
|
<div class="rrc-alert rrc-alert--success">{{ successMsg }}</div>
|
|
}
|
|
|
|
@if (loading) {
|
|
<div class="rrc-loading">Loading…</div>
|
|
} @else {
|
|
<!-- ═══ Rates — only the reserve returns are charged at a percentage ═══ -->
|
|
@if (showRates) {
|
|
<div class="rrc-card">
|
|
<h3 class="rrc-card__title">Reserve rates</h3>
|
|
<p class="rrc-card__hint">
|
|
Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the rate
|
|
that was actually in force then. Saving against a date that already has a rate edits it; a new date adds one.
|
|
</p>
|
|
|
|
<div class="rrc-rate-now">
|
|
@for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) {
|
|
<div class="rrc-rate-now__item">
|
|
<span class="rrc-rate-now__label">
|
|
{{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today
|
|
</span>
|
|
@if (currentRate(type); as rate) {
|
|
<span class="rrc-rate-now__value">{{ rate.percentage }}%</span>
|
|
@if (rate.seedDefault) {
|
|
<span class="rrc-rate-now__since rrc-rate-now__since--warn">
|
|
Placeholder — confirm the rate actually in force
|
|
</span>
|
|
} @else {
|
|
<span class="rrc-rate-now__since">since {{ rate.effectiveFrom }}</span>
|
|
}
|
|
} @else {
|
|
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
|
|
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute</span>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="rrc-rate-form">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
|
|
<mat-label>Rate</mat-label>
|
|
<mat-select [(ngModel)]="newRate.rateType">
|
|
<mat-option value="CRR">CRR — Form 1</mat-option>
|
|
<mat-option value="SLR">SLR — Form 9</mat-option>
|
|
<!-- Not a reserve ratio, but set by RBI and effective-dated the same way: the share of
|
|
savings balances that counts as a demand liability, the rest being time. -->
|
|
<mat-option value="SAVINGS_DEMAND">Savings — demand share</mat-option>
|
|
</mat-select>
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__pct">
|
|
<mat-label>Percentage (%)</mat-label>
|
|
<input matInput type="number" step="0.0001" min="0" max="100" [(ngModel)]="newRate.percentage" />
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__date">
|
|
<mat-label>Effective from</mat-label>
|
|
<input matInput type="date" [(ngModel)]="newRate.effectiveFrom" />
|
|
</mat-form-field>
|
|
|
|
<button mat-raised-button color="primary" [disabled]="savingRate" (click)="saveRate()">
|
|
{{ savingRate ? 'Saving…' : 'Save rate' }}
|
|
</button>
|
|
</div>
|
|
|
|
<table class="rrc-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Rate</th>
|
|
<th>Percentage</th>
|
|
<th>Effective from</th>
|
|
<th>Status</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for (rate of rates; track rate.id) {
|
|
<tr>
|
|
<td><span class="rrc-key">{{ rate.rateType }}</span></td>
|
|
<td class="rrc-num">{{ rate.percentage }}%</td>
|
|
<td>{{ rate.effectiveFrom }}</td>
|
|
<td>
|
|
@if (isFuture(rate)) {
|
|
<span class="rrc-badge rrc-badge--future">Future-dated</span>
|
|
} @else if (currentRate(rate.rateType)?.id === rate.id) {
|
|
<span class="rrc-badge rrc-badge--active">In force</span>
|
|
} @else {
|
|
<span class="rrc-badge rrc-badge--past">Superseded</span>
|
|
}
|
|
@if (rate.seedDefault) {
|
|
<span class="rrc-badge rrc-badge--seed">Placeholder</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
<button mat-button class="rrc-remove" (click)="deleteRate(rate)" title="Remove this rate">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
|
|
<!-- ═══ GL accounts ═══ -->
|
|
<div class="rrc-card">
|
|
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
|
|
<p class="rrc-card__hint">
|
|
Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each
|
|
one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of
|
|
its children, so mapping both would count those children twice.
|
|
</p>
|
|
|
|
@if (unmappedCount) {
|
|
<div class="rrc-alert rrc-alert--warn">
|
|
{{ unmappedCount }} of {{ glRoles.length }} roles have no accounts mapped. Returns that depend on them will
|
|
report why they cannot compute instead of showing a figure.
|
|
</div>
|
|
}
|
|
|
|
<!--
|
|
One tab per return. The roles of four returns on a single list would bury the four that
|
|
drive CRR among the thirty that do not, so each return's roles are configured on their own.
|
|
Each tab carries its own unmapped count, so a return that cannot compute is visible without
|
|
opening its tab.
|
|
-->
|
|
@if (showTabs) {
|
|
<div class="rrc-tabs" role="tablist">
|
|
@for (group of roleGroups; track group.name) {
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
class="rrc-tab"
|
|
[class.rrc-tab--active]="group.name === activeGroup"
|
|
[attr.aria-selected]="group.name === activeGroup"
|
|
(click)="showGroup(group.name)"
|
|
>
|
|
{{ group.label }}
|
|
@if (unmappedIn(group)) {
|
|
<span class="rrc-tab__badge">{{ unmappedIn(group) }} unmapped</span>
|
|
} @else {
|
|
<span class="rrc-tab__badge rrc-tab__badge--done">all mapped</span>
|
|
}
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@for (role of visibleRoles; track role.configKey) {
|
|
<div class="rrc-role" [class.rrc-role--unmapped]="isUnmapped(role)">
|
|
<div class="rrc-role__head">
|
|
<div>
|
|
<span class="rrc-role__name">{{ roleLabel(role) }}</span>
|
|
<span class="rrc-key">{{ role.configKey }}</span>
|
|
<span class="rrc-role__usedby">{{ roleUsedBy(role) }}</span>
|
|
</div>
|
|
@if (isUnmapped(role)) {
|
|
<span class="rrc-badge rrc-badge--unmapped">Not mapped</span>
|
|
} @else {
|
|
<span class="rrc-badge rrc-badge--mapped">
|
|
{{ selection[role.configKey].length }} account{{ selection[role.configKey].length === 1 ? '' : 's' }}
|
|
</span>
|
|
}
|
|
</div>
|
|
|
|
<p class="rrc-role__desc">{{ role.description }}</p>
|
|
|
|
<!--
|
|
One row per mapping rather than a multi-select, because a mapping is no longer just an
|
|
account: it carries the portion of the balance this head takes, and which head that
|
|
portion falls under. A savings account appears twice here - a demand slice and a time
|
|
slice - which a multi-select cannot express.
|
|
-->
|
|
@if (selection[role.configKey]?.length) {
|
|
<table class="rrc-map">
|
|
<thead>
|
|
<tr>
|
|
<th>GL account</th>
|
|
<th class="rrc-map__narrow">Portion (%)</th>
|
|
@if (role.classified) {
|
|
<th class="rrc-map__narrow">Head</th>
|
|
}
|
|
<th class="rrc-map__actions"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for (mapping of selection[role.configKey]; track $index; 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">
|
|
@for (o of availableAccounts(role, i); track o.id) {
|
|
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
</td>
|
|
<td>
|
|
<!-- The share of this account's balance the head takes. 100 is the whole of it,
|
|
which is what every mapping meant before splits existed. -->
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
|
|
<input
|
|
matInput
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="0.0001"
|
|
[(ngModel)]="mapping.weightPercentage"
|
|
[disabled]="savingKey === role.configKey"
|
|
/>
|
|
</mat-form-field>
|
|
</td>
|
|
@if (role.classified) {
|
|
<td>
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
|
|
<mat-select [(ngModel)]="mapping.liabilityClass" [disabled]="savingKey === role.configKey">
|
|
@for (c of liabilityClasses; track c.value) {
|
|
<mat-option [value]="c.value">{{ c.label }}</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
</td>
|
|
}
|
|
<td class="rrc-map__actions">
|
|
<button mat-button class="rrc-remove" (click)="removeMapping(role, i)" title="Remove this row">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
|
|
<!--
|
|
An account split across rows whose portions do not add to the whole balance is the one
|
|
error a split invites and no single row shows: each row looks reasonable while a share of
|
|
the balance silently leaves the base.
|
|
-->
|
|
@for (entry of unbalancedAccounts(role); track entry.glAccountId) {
|
|
<div class="rrc-alert rrc-alert--warn rrc-alert--inline">
|
|
{{ glLabel(entry.glAccountId) }} is mapped at {{ entry.total }}% in total.
|
|
@if (entry.total < 100) {
|
|
{{ 100 - entry.total }}% of its balance will not appear on the return.
|
|
} @else {
|
|
It would be counted {{ entry.total }}% — more than the account holds.
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<div class="rrc-role__controls">
|
|
<button mat-stroked-button (click)="addMapping(role)" [disabled]="savingKey === role.configKey">
|
|
<i class="fas fa-plus"></i> Add account
|
|
</button>
|
|
|
|
<button
|
|
mat-raised-button
|
|
color="primary"
|
|
[disabled]="savingKey === role.configKey || !isDirty(role) || hasInvalidRows(role)"
|
|
(click)="saveRole(role)"
|
|
>
|
|
{{ savingKey === role.configKey ? 'Saving…' : 'Save' }}
|
|
</button>
|
|
|
|
@if (!role.builtIn) {
|
|
<button mat-button class="rrc-remove" (click)="deleteRole(role)" title="Remove this head">
|
|
<i class="fas fa-trash"></i> Remove head
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- ═══ add a head ═══ -->
|
|
<div class="rrc-add">
|
|
@if (!showAddRole) {
|
|
<button mat-stroked-button (click)="showAddRole = true">
|
|
<i class="fas fa-plus"></i> Add a head to this return
|
|
</button>
|
|
<p class="rrc-card__hint">
|
|
For anything this bank carries that the heads above do not name — a reserve, a tax payable, bills payable.
|
|
The return reads it from the same chart of accounts the balance sheet is built from.
|
|
</p>
|
|
} @else {
|
|
<div class="rrc-add__form">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label>Name</mat-label>
|
|
<input matInput [(ngModel)]="newRole.label" placeholder="e.g. Reserve Fund" />
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label>How the return uses it</mat-label>
|
|
<mat-select [(ngModel)]="newRole.usage">
|
|
@for (u of roleUsages; track u.value) {
|
|
<mat-option [value]="u.value">{{ u.label }}</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-add__desc">
|
|
<mat-label>Note (optional)</mat-label>
|
|
<input matInput [(ngModel)]="newRole.description" placeholder="What this head covers" />
|
|
</mat-form-field>
|
|
|
|
<button mat-raised-button color="primary" [disabled]="addingRole" (click)="addRole()">
|
|
{{ addingRole ? 'Adding…' : 'Add head' }}
|
|
</button>
|
|
<button mat-button (click)="showAddRole = false">Cancel</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|