feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
<!--
|
||||
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="ir">
|
||||
<!-- Header -->
|
||||
<div class="ir-head">
|
||||
<div>
|
||||
<h2 class="ir-head-t"><fa-icon icon="percentage"></fa-icon> Savings Interest Posted</h2>
|
||||
<div class="ir-head-s">
|
||||
Interest actually credited to members, grouped by the rhythm each product posts on. An account appears under its
|
||||
own product's posting period and nowhere else.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ir-head-a">
|
||||
<button class="ir-btn ir-btn--slim" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="undo"></fa-icon> Reload
|
||||
</button>
|
||||
<button class="ir-btn ir-btn--primary" (click)="exportExcel()" [disabled]="loading">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export all periods
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="ir-alert"><fa-icon icon="exclamation-triangle"></fa-icon> {{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
A product paid on more dates than its rhythm allows was paid by something other than this
|
||||
ledger — stock Fineract's savings interest job writes backdated postings a day at a time and
|
||||
ignores the product's posting period. The postings are real and stay on the report; this says
|
||||
so, so a total is not read as the rhythm the product is meant to keep.
|
||||
-->
|
||||
@if (rhythmWarnings.length > 0) {
|
||||
<div class="ir-warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Postings do not follow the product's rhythm.</strong>
|
||||
@for (w of rhythmWarnings; track w.product) {
|
||||
<div class="ir-warn-row">
|
||||
<strong>{{ w.product }}</strong> is configured {{ w.label }} ({{ w.expected }}) but has paid on
|
||||
<strong>{{ w.dates }} different dates</strong>.
|
||||
</div>
|
||||
}
|
||||
<div class="ir-warn-note">
|
||||
These were written by the platform's own savings interest job, which posts a day at a time regardless of the
|
||||
product's period. Every posting below is real money credited to a member — the figures are correct, but a
|
||||
period total will not match the rhythm the product is set to.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Filters, shared by every period -->
|
||||
<div class="ir-filters">
|
||||
<div class="ir-field">
|
||||
<label>From</label>
|
||||
<input class="ir-inp" type="date" [(ngModel)]="fromDate" (ngModelChange)="load()" />
|
||||
</div>
|
||||
<div class="ir-field">
|
||||
<label>To</label>
|
||||
<input class="ir-inp" type="date" [(ngModel)]="toDate" (ngModelChange)="load()" />
|
||||
</div>
|
||||
<div class="ir-field ir-field--wide">
|
||||
<label>Search</label>
|
||||
<input class="ir-inp" type="text" [(ngModel)]="search" placeholder="Account number, customer or product" />
|
||||
</div>
|
||||
<div class="ir-field ir-field--btn">
|
||||
<button class="ir-btn ir-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One card per posting rhythm; each is its own report -->
|
||||
<div class="ir-tabs">
|
||||
@for (p of periods; track p.enumValue) {
|
||||
<button class="ir-tab" [class.ir-tab--on]="activePeriod === p.enumValue" (click)="showPeriod(p.enumValue)">
|
||||
<div class="ir-tab-h">
|
||||
<span class="ir-tab-l">{{ p.label }}</span>
|
||||
<span class="ir-tab-n">{{ countFor(p.enumValue) }}</span>
|
||||
</div>
|
||||
<div class="ir-tab-d">{{ p.cadence }}</div>
|
||||
<div class="ir-tab-t">₹{{ totalFor(p.enumValue) | number: '1.2-2' }}</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The period control for the rhythm in view.
|
||||
|
||||
Each rhythm gets its own, at its own granularity: a day for Daily, a month for the rest. One
|
||||
shared control would make the quarterly report filter by a single date and hide the rest of the
|
||||
quarter.
|
||||
-->
|
||||
<div class="ir-pick">
|
||||
<label class="ir-pick-l">{{ activeMeta.pickLabel }}</label>
|
||||
<!--
|
||||
A dropdown of the dates this rhythm actually posted on, not a calendar: a quarterly product
|
||||
posts four times a year, and a date picker would offer every other day of it holding nothing.
|
||||
The account count rides in the option, so "how many accounts at this quarter end" is answered
|
||||
before the filter is applied.
|
||||
-->
|
||||
<select class="ir-inp ir-inp--pick" [(ngModel)]="periodPick[activePeriod]">
|
||||
<option value="">All {{ activeMeta.pickUnit }}s</option>
|
||||
@for (p of availablePicks(activePeriod); track p.date) {
|
||||
<option [value]="p.date">
|
||||
{{ formatDate(p.date) }} — {{ p.accounts }} account{{ p.accounts === 1 ? '' : 's' }}, ₹{{
|
||||
p.total | number: '1.2-2'
|
||||
}}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
@if (periodPick[activePeriod]) {
|
||||
<button class="ir-btn ir-btn--slim" (click)="clearPick(activePeriod)">
|
||||
<fa-icon icon="undo"></fa-icon> Show all
|
||||
</button>
|
||||
}
|
||||
<span class="ir-spacer"></span>
|
||||
<span class="ir-pick-hint">
|
||||
{{ availablePicks(activePeriod).length }} {{ activeMeta.pickUnit
|
||||
}}{{ availablePicks(activePeriod).length === 1 ? '' : 's' }} with postings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- What the tab in view holds -->
|
||||
<div class="ir-context">
|
||||
<div class="ir-context-i">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>{{ activeMeta.label }}.</strong> {{ activeMeta.cadence }} — every account on a product configured to
|
||||
post {{ activeMeta.label.toLowerCase() }}.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ir-context-t">
|
||||
<span class="ir-context-c">{{ visibleAccounts }}</span> accounts ·
|
||||
<span class="ir-context-c">{{ visibleRows.length }}</span> postings ·
|
||||
<span class="ir-context-c">₹{{ visibleTotal | number: '1.2-2' }}</span> paid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Postings, grouped by the day they were paid -->
|
||||
@if (loading) {
|
||||
<div class="ir-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading…</div>
|
||||
} @else if (visibleRows.length === 0) {
|
||||
<div class="ir-empty">
|
||||
No {{ activeMeta.label.toLowerCase() }} interest posted in this period.
|
||||
@if (activePeriod !== 1) {
|
||||
A cycle that has not ended yet has nothing to show.
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@for (group of groupedByDate; track group.date) {
|
||||
<div class="ir-group">
|
||||
<div class="ir-group-h">
|
||||
<span class="ir-group-d"><fa-icon icon="calendar"></fa-icon> {{ formatDate(group.date) }}</span>
|
||||
<span class="ir-group-m">
|
||||
{{ group.accounts }} account{{ group.accounts === 1 ? '' : 's' }} ·
|
||||
<strong>₹{{ group.total | number: '1.2-2' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ir-tablewrap">
|
||||
<table class="ir-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Product</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Balance Before</th>
|
||||
<th class="num">Interest Posted</th>
|
||||
<th class="num">Balance After</th>
|
||||
<th>Branch</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of group.rows; track r.transaction_id) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.account_no }}</td>
|
||||
<td>{{ r.client_name || '—' }}</td>
|
||||
<td class="ir-prod">{{ r.product_name }}</td>
|
||||
<td class="num">{{ r.interest_rate | number: '1.2-2' }}%</td>
|
||||
<td class="num">{{ r.balance_before | number: '1.2-2' }}</td>
|
||||
<td class="num strong ir-amt">{{ r.interest_amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.balance_after | number: '1.2-2' }}</td>
|
||||
<td>{{ r.office_name || '—' }}</td>
|
||||
<td>
|
||||
<button class="ir-mini" (click)="openAccount(r)" [disabled]="r.client_id == null">
|
||||
<fa-icon icon="list"></fa-icon> Transactions
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--i-navy: #1a237e;
|
||||
--i-blue: #1d4ed8;
|
||||
--i-blue-l: #eff6ff;
|
||||
--i-green: #15803d;
|
||||
--i-green-l: #dcfce7;
|
||||
--i-amber: #b45309;
|
||||
--i-amber-l: #fef3c7;
|
||||
--i-red: #c0392b;
|
||||
--i-red-l: #fdecea;
|
||||
--i-g50: #f8fafc;
|
||||
--i-g100: #f1f5f9;
|
||||
--i-g200: #e2e8f0;
|
||||
--i-g300: #cbd5e1;
|
||||
--i-g500: #64748b;
|
||||
--i-g700: #334155;
|
||||
--i-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--i-g900);
|
||||
background: var(--i-g50);
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ir-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--i-navy);
|
||||
}
|
||||
|
||||
.ir-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--i-g500);
|
||||
max-width: 760px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ir-head-a {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ir-alert {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--i-red-l);
|
||||
border: 1px solid #f5b7b1;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--i-red);
|
||||
}
|
||||
|
||||
/* ── Filters ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.ir-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 150px;
|
||||
|
||||
label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--i-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
&--wide {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
&--btn {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-inp {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--i-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--i-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--i-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--i-blue);
|
||||
color: var(--i-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--slim {
|
||||
padding: 6px 11px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--i-blue);
|
||||
border-color: var(--i-blue);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ir-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--i-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--i-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--i-blue);
|
||||
color: var(--i-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── One card per posting rhythm ────────────────────────────────────────── */
|
||||
|
||||
.ir-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ir-tab {
|
||||
text-align: left;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-top: 3px solid var(--i-g300);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.12s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
|
||||
}
|
||||
|
||||
/* The rhythm on display stays obvious while its rows are being read. */
|
||||
&--on {
|
||||
background: var(--i-blue-l);
|
||||
border-top-color: var(--i-blue);
|
||||
box-shadow: inset 0 0 0 1px #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-tab-h {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ir-tab-l {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ir-tab-n {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ir-tab-d {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
|
||||
.ir-tab-t {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--i-green);
|
||||
}
|
||||
|
||||
/* ── Context strip ──────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--i-blue-l);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--i-g700);
|
||||
}
|
||||
|
||||
.ir-context-i {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
|
||||
fa-icon {
|
||||
color: var(--i-blue);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-context-t {
|
||||
white-space: nowrap;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
|
||||
.ir-context-c {
|
||||
font-weight: 700;
|
||||
color: var(--i-g900);
|
||||
}
|
||||
|
||||
/* ── One group per posting date ─────────────────────────────────────────── */
|
||||
|
||||
.ir-group {
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The date is the unit an officer reconciles against: a run pays a whole product on one day. */
|
||||
.ir-group-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
background: var(--i-g100);
|
||||
border-bottom: 1px solid var(--i-g200);
|
||||
}
|
||||
|
||||
.ir-group-d {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--i-navy);
|
||||
}
|
||||
|
||||
.ir-group-m {
|
||||
font-size: 11.5px;
|
||||
color: var(--i-g500);
|
||||
|
||||
strong {
|
||||
color: var(--i-green);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ir-tablewrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ir-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--i-g200);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--i-g500);
|
||||
white-space: nowrap;
|
||||
|
||||
&.num {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--i-g100);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--i-g50);
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-prod {
|
||||
max-width: 210px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* The figure the report exists for. */
|
||||
.ir-amt {
|
||||
color: var(--i-green);
|
||||
}
|
||||
|
||||
.ir-empty {
|
||||
padding: 34px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--i-g500);
|
||||
background: #fff;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* ── Per-rhythm period control ──────────────────────────────────────────── */
|
||||
|
||||
.ir-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 9px 14px;
|
||||
margin-bottom: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--i-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.ir-pick-l {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
|
||||
.ir-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ir-pick-hint {
|
||||
font-size: 11px;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
|
||||
/* The dropdown carries a date, a count and a total, so it needs room. */
|
||||
.ir-inp--pick {
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
/* ── Rhythm mismatch warning ────────────────────────────────────────────── */
|
||||
|
||||
.ir-warn {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--i-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
border-left: 4px solid var(--i-amber);
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--i-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--i-amber);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ir-warn-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ir-warn-note {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--i-g500);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import {
|
||||
SavingsDailyInterestService,
|
||||
SavingsInterestPostingRow
|
||||
} from '../savings-daily-interest/savings-daily-interest.service';
|
||||
|
||||
/** Shared report helpers */
|
||||
import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export';
|
||||
|
||||
/**
|
||||
* One posting rhythm, as the product configures it.
|
||||
*
|
||||
* The enum values are the product's own {@code interest_posting_period_enum}; they are not
|
||||
* consecutive, and 2 and 3 are unused in this platform.
|
||||
*/
|
||||
interface PostingPeriod {
|
||||
enumValue: number;
|
||||
key: string;
|
||||
label: string;
|
||||
/** What "the end of the cycle" means for this rhythm, in the officer's words. */
|
||||
cadence: string;
|
||||
/** What the period control is asking for, in the terms this rhythm is read in. */
|
||||
pickLabel: string;
|
||||
/** The unit the control counts in, for the "N with postings" hint. */
|
||||
pickUnit: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-interest-report',
|
||||
templateUrl: './interest-report.component.html',
|
||||
styleUrls: ['./interest-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class InterestReportComponent implements OnInit {
|
||||
private service = inject(SavingsDailyInterestService);
|
||||
private router = inject(Router);
|
||||
|
||||
/**
|
||||
* Each rhythm is its own report.
|
||||
*
|
||||
* A monthly product's month-end posting and a quarterly product's quarter-end posting are
|
||||
* different events on different dates; listing them together reads as one uneven series and
|
||||
* makes a month's total impossible to state. So an account appears under the rhythm its own
|
||||
* product posts on, and nowhere else.
|
||||
*/
|
||||
readonly periods: PostingPeriod[] = [
|
||||
{
|
||||
enumValue: 1,
|
||||
key: 'daily',
|
||||
label: 'Daily',
|
||||
cadence: 'Posted every day',
|
||||
pickLabel: 'Posted on',
|
||||
pickUnit: 'day'
|
||||
},
|
||||
{
|
||||
enumValue: 4,
|
||||
key: 'monthly',
|
||||
label: 'Monthly',
|
||||
cadence: 'Posted at each month end',
|
||||
pickLabel: 'Month end',
|
||||
pickUnit: 'month end'
|
||||
},
|
||||
{
|
||||
enumValue: 5,
|
||||
key: 'quarterly',
|
||||
label: 'Quarterly',
|
||||
cadence: 'Posted at each quarter end',
|
||||
pickLabel: 'Quarter end',
|
||||
pickUnit: 'quarter end'
|
||||
},
|
||||
{
|
||||
enumValue: 6,
|
||||
key: 'halfYearly',
|
||||
label: 'Half-yearly',
|
||||
cadence: 'Posted every six months',
|
||||
pickLabel: 'Half-year end',
|
||||
pickUnit: 'half-year end'
|
||||
},
|
||||
{
|
||||
enumValue: 7,
|
||||
key: 'yearly',
|
||||
label: 'Yearly',
|
||||
cadence: 'Posted at each year end',
|
||||
pickLabel: 'Year end',
|
||||
pickUnit: 'year end'
|
||||
}
|
||||
];
|
||||
|
||||
activePeriod = 4;
|
||||
|
||||
/** Every posting, across all rhythms — the tabs read from this. */
|
||||
rows: SavingsInterestPostingRow[] = [];
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
|
||||
/** Filters, shared by every rhythm. */
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
search = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every posting once and splits it by rhythm in the browser.
|
||||
*
|
||||
* One request rather than five: the tab counts have to be right before a tab is opened, and
|
||||
* asking per rhythm would leave them blank until each was visited.
|
||||
*/
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.service
|
||||
.getPostings({
|
||||
fromDate: this.fromDate || undefined,
|
||||
toDate: this.toDate || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.rows = data ?? [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the interest postings.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Per-rhythm views ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The period each rhythm is narrowed to, keyed by its enum.
|
||||
*
|
||||
* Each rhythm carries its own, because the unit an officer picks differs: a daily report is read
|
||||
* one day at a time, a monthly one a month at a time. A single shared control would force the
|
||||
* quarterly report to be filtered by a day, which selects one posting date and hides the rest of
|
||||
* the quarter.
|
||||
*/
|
||||
periodPick: Record<number, string> = {};
|
||||
|
||||
private matchesPick(row: SavingsInterestPostingRow, periodEnum: number): boolean {
|
||||
const pick = this.periodPick[periodEnum];
|
||||
return !pick || row.posting_date === pick;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cycle ends a rhythm actually posted on, each with how many accounts were paid.
|
||||
*
|
||||
* A dropdown of real posting dates rather than a free date picker: a rhythm posts on a handful of
|
||||
* dates a year, and a calendar would offer three hundred and sixty-odd days that hold nothing.
|
||||
* The account count sits in the option itself, so the answer to "how many accounts were paid at
|
||||
* this quarter end" is visible before the filter is even applied.
|
||||
*/
|
||||
availablePicks(periodEnum: number): { date: string; accounts: number; total: number }[] {
|
||||
const byDate = new Map<string, SavingsInterestPostingRow[]>();
|
||||
for (const row of this.rows) {
|
||||
if (row.posting_period_enum !== periodEnum || !row.posting_date) {
|
||||
continue;
|
||||
}
|
||||
const list = byDate.get(row.posting_date);
|
||||
if (list) {
|
||||
list.push(row);
|
||||
} else {
|
||||
byDate.set(row.posting_date, [row]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byDate.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(
|
||||
([
|
||||
date,
|
||||
rows
|
||||
]) => ({
|
||||
date,
|
||||
accounts: new Set(rows.map((r) => r.savings_account_id)).size,
|
||||
total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clearPick(periodEnum: number): void {
|
||||
delete this.periodPick[periodEnum];
|
||||
}
|
||||
|
||||
/**
|
||||
* Products whose postings do not follow the rhythm they are configured for.
|
||||
*
|
||||
* A quarterly product posts four times a year. Where one shows a hundred and thirty-five posting
|
||||
* dates, something other than this ledger has paid it — stock Fineract's own savings interest job
|
||||
* writes backdated postings a day at a time and pays no attention to the product's posting
|
||||
* period. The report cannot hide those: the money reached the members and the transactions are
|
||||
* real. What it can do is say so, so a total read off this screen is not mistaken for the rhythm
|
||||
* the product is meant to keep.
|
||||
*
|
||||
* The threshold is generous — a rhythm is only called out at more than double the dates it could
|
||||
* legitimately produce across the range on display, so a backdated correction or a re-run does
|
||||
* not raise it.
|
||||
*/
|
||||
get rhythmWarnings(): { product: string; label: string; dates: number; expected: string }[] {
|
||||
const expectedMax: Record<number, number> = { 1: 100000, 4: 24, 5: 8, 6: 4, 7: 2 };
|
||||
const expectedWord: Record<number, string> = {
|
||||
1: 'daily',
|
||||
4: 'about one a month',
|
||||
5: 'about four a year',
|
||||
6: 'two a year',
|
||||
7: 'one a year'
|
||||
};
|
||||
|
||||
const byProduct = new Map<string, { label: string; period: number; dates: Set<string> }>();
|
||||
for (const row of this.rows) {
|
||||
const entry = byProduct.get(row.product_name);
|
||||
if (entry) {
|
||||
entry.dates.add(row.posting_date);
|
||||
} else {
|
||||
byProduct.set(row.product_name, {
|
||||
label: this.periods.find((p) => p.enumValue === row.posting_period_enum)?.label ?? '—',
|
||||
period: row.posting_period_enum,
|
||||
dates: new Set([row.posting_date])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...byProduct.entries()]
|
||||
.filter(
|
||||
([
|
||||
,
|
||||
v
|
||||
]) => v.dates.size > (expectedMax[v.period] ?? 100000)
|
||||
)
|
||||
.map(
|
||||
([
|
||||
product,
|
||||
v
|
||||
]) => ({
|
||||
product,
|
||||
label: v.label,
|
||||
dates: v.dates.size,
|
||||
expected: expectedWord[v.period] ?? '—'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private rowsFor(periodEnum: number): SavingsInterestPostingRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (r.posting_period_enum !== periodEnum) {
|
||||
return false;
|
||||
}
|
||||
if (!this.matchesPick(r, periodEnum)) {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get visibleRows(): SavingsInterestPostingRow[] {
|
||||
return this.rowsFor(this.activePeriod);
|
||||
}
|
||||
|
||||
countFor(periodEnum: number): number {
|
||||
return this.rowsFor(periodEnum).length;
|
||||
}
|
||||
|
||||
totalFor(periodEnum: number): number {
|
||||
return this.rowsFor(periodEnum).reduce((s, r) => s + Number(r.interest_amount ?? 0), 0);
|
||||
}
|
||||
|
||||
get visibleTotal(): number {
|
||||
return this.totalFor(this.activePeriod);
|
||||
}
|
||||
|
||||
/** Distinct accounts paid on the tab in view — a count of rows would double-count re-postings. */
|
||||
get visibleAccounts(): number {
|
||||
return new Set(this.visibleRows.map((r) => r.savings_account_id)).size;
|
||||
}
|
||||
|
||||
get activeMeta(): PostingPeriod {
|
||||
return this.periods.find((p) => p.enumValue === this.activePeriod) ?? this.periods[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* The postings on the tab in view, grouped by the day they were paid.
|
||||
*
|
||||
* A posting run pays a whole product on one date, so the date is the unit an officer reconciles
|
||||
* against — "what went out at this month end" — rather than the individual row.
|
||||
*/
|
||||
get groupedByDate(): { date: string; rows: SavingsInterestPostingRow[]; total: number; accounts: number }[] {
|
||||
const byDate = new Map<string, SavingsInterestPostingRow[]>();
|
||||
for (const row of this.visibleRows) {
|
||||
const key = row.posting_date ?? '';
|
||||
const list = byDate.get(key);
|
||||
if (list) {
|
||||
list.push(row);
|
||||
} else {
|
||||
byDate.set(key, [row]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byDate.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(
|
||||
([
|
||||
date,
|
||||
rows
|
||||
]) => ({
|
||||
date,
|
||||
rows,
|
||||
total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0),
|
||||
accounts: new Set(rows.map((r) => r.savings_account_id)).size
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
showPeriod(periodEnum: number): void {
|
||||
this.activePeriod = periodEnum;
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.fromDate = '';
|
||||
this.toDate = '';
|
||||
this.search = '';
|
||||
this.periodPick = {};
|
||||
this.load();
|
||||
}
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Posting dates arrive as ISO yyyy-MM-dd. */
|
||||
formatDate(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value.split('-').map(Number);
|
||||
if (!y || !m || !d) {
|
||||
return value;
|
||||
}
|
||||
return new Date(y, m - 1, d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
openAccount(row: SavingsInterestPostingRow): void {
|
||||
if (row.client_id == null) {
|
||||
return;
|
||||
}
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.client_id,
|
||||
'savings-accounts',
|
||||
row.savings_account_id,
|
||||
'transactions'
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One sheet per rhythm, so the workbook mirrors the screen.
|
||||
*
|
||||
* A rhythm with nothing posted still gets its sheet: an empty Quarterly sheet says the quarter
|
||||
* has not ended yet, which a missing sheet would leave the reader to guess at.
|
||||
*/
|
||||
exportExcel(): void {
|
||||
const stamp = `${this.fromDate || 'from inception'} to ${this.toDate || 'date'}`;
|
||||
|
||||
const sheets: ExportSheetSpec[] = this.periods.map((p) => {
|
||||
const rows = this.rowsFor(p.enumValue);
|
||||
return {
|
||||
sheetName: p.label,
|
||||
title: `Savings Interest Posted — ${p.label}`,
|
||||
subtitle: `${p.cadence} · ${stamp} · ${rows.length} posting(s), total ${this.totalFor(p.enumValue).toFixed(2)}`,
|
||||
headers: [
|
||||
'Posting Date',
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Product',
|
||||
'Rate %',
|
||||
'Balance Before',
|
||||
'Interest Posted',
|
||||
'Balance After',
|
||||
'Branch'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
20,
|
||||
28,
|
||||
26,
|
||||
9,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
22
|
||||
],
|
||||
rows: rows.map((r) => [
|
||||
this.formatDate(r.posting_date),
|
||||
r.account_no,
|
||||
r.client_name || '—',
|
||||
r.product_name,
|
||||
Number(r.interest_rate ?? 0),
|
||||
Number(r.balance_before ?? 0),
|
||||
Number(r.interest_amount ?? 0),
|
||||
Number(r.balance_after ?? 0),
|
||||
r.office_name || '—'
|
||||
])
|
||||
};
|
||||
});
|
||||
|
||||
exportWorkbook(sheets, `savings-interest-posted-${new Date().toISOString().slice(0, 10)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user