feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks

This commit is contained in:
2026-08-25 11:20:42 +05:30
parent 7ef75c902d
commit 0b79b0b0d3
77 changed files with 7396 additions and 426 deletions
+2 -1
View File
@@ -36,7 +36,8 @@
"playwright:headed": "playwright test --headed", "playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug", "playwright:debug": "playwright test --debug",
"headers:check": "node scripts/check-file-headers.js", "headers:check": "node scripts/check-file-headers.js",
"headers:add": "node scripts/add-file-headers.js" "headers:add": "node scripts/add-file-headers.js",
"mock:dip": "node \"../cbsnexora-backend/scripts/mock-dip-lookup-service.js\""
}, },
"dependencies": { "dependencies": {
"@angular/animations": "20.3.16", "@angular/animations": "20.3.16",
+20
View File
@@ -44,6 +44,26 @@ export class AccountingService {
return this.http.get('/accounting/profitandloss', { params: httpParams }); return this.http.get('/accounting/profitandloss', { params: httpParams });
} }
/**
* How much profit a share dividend may actually be declared out of — net profit reduced by the
* statutory appropriations that legally precede a dividend (Statutory Reserve Fund, education
* fund) and by dividends already declared for other share products over an overlapping period.
* @param {string} fromDate Period start, yyyy-MM-dd.
* @param {string} toDate Period end, yyyy-MM-dd.
* @param {string} excludeProductId Leave this product's own declarations out of `alreadyDeclared`
* (its overlapping declaration is separately blocked outright, so counting it would double up).
* @returns {Observable<any>} `{ netProfit, statutoryReserveAmount, educationFundAmount,
* distributableProfit, alreadyDeclared, availableForDeclaration, maximumRatePercentage,
* existingDeclarations[] }`.
*/
getDistributableProfit(fromDate: string, toDate: string, excludeProductId?: string): Observable<any> {
let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
if (excludeProductId) {
httpParams = httpParams.set('excludeProductId', excludeProductId);
}
return this.http.get('/accounting/distributable-profit', { params: httpParams });
}
/** /**
* Total share dividend actually paid to members in a date range — derived from the Dividend * Total share dividend actually paid to members in a date range — derived from the Dividend
* Payable GL account's own ledger (no separate "Dividend Paid" account needed). * Payable GL account's own ledger (no separate "Dividend Paid" account needed).
@@ -0,0 +1,169 @@
<!--
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="pg ar-print">
<header class="pg-head">
<div>
<div class="pg-eyebrow">REPORTS · ACCOUNT REGISTER</div>
<h1 class="pg-title">Account Register</h1>
<p class="pg-sub">
Every account as it stood on the chosen date — which ones opened and closed that day, the balance each held
then, and what it holds now.
</p>
</div>
<div class="pg-toolbar ar-noprint">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>As on date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Shown only on paper, where the toolbar is hidden and the date has to be stated. -->
<p class="ar-printhead">Account Register as on {{ date }} · {{ section }}</p>
<div class="db-filters ar-noprint">
<label class="pg-field">
<span>Section</span>
<select [(ngModel)]="section" (change)="load()">
@for (s of sections; track s.value) {
<option [value]="s.value">{{ s.label }}</option>
}
</select>
</label>
<label class="pg-field ar-check">
<span>Movement</span>
<span class="ar-check__box">
<input type="checkbox" [(ngModel)]="movementOnly" />
Only accounts opened / closed on this date
</span>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Account no, client, product, status…" />
</label>
<button type="button" class="pg-btn" (click)="printRegister()" [disabled]="filteredRows.length === 0">
<mat-icon>print</mat-icon> Print
</button>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded) {
<!-- What actually happened that day, read before the list itself. -->
<div class="ar-summary">
<div class="ar-summary__item ar-summary__item--in">
<span class="ar-summary__label">Accounts opened on {{ date }}</span>
<span class="ar-summary__value">{{ openedCount }}</span>
</div>
<div class="ar-summary__item ar-summary__item--out">
<span class="ar-summary__label">Accounts closed on {{ date }}</span>
<span class="ar-summary__value">{{ closedCount }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Balance on {{ date }}</span>
<span class="ar-summary__value">₹{{ shownBalanceOnDate | number: '1.2-2' }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Current balance</span>
<span class="ar-summary__value">₹{{ shownCurrentBalance | number: '1.2-2' }}</span>
</div>
</div>
}
@if (!loading && loaded && filteredRows.length === 0) {
<div class="pg-empty">
<mat-icon>account_balance_wallet</mat-icon>
<p>
@if (movementOnly) {
No account was opened or closed on {{ date }}.
} @else {
No accounts existed on {{ date }}.
}
</p>
</div>
}
@if (filteredRows.length > 0) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Section</th>
<th>Client</th>
<th>Product</th>
<th>Status</th>
<th>Opened</th>
<th>Closed</th>
<th class="num">Balance on {{ date }}</th>
<th class="num">Current Balance</th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.section + r.accountId) {
<tr [class.ar-row--opened]="r.openedOnDate" [class.ar-row--closed]="r.closedOnDate">
<td>
<span class="pg-chip">{{ r.accountNo }}</span>
@if (r.openedOnDate) {
<span class="ar-tag ar-tag--in">OPENED</span>
}
@if (r.closedOnDate) {
<span class="ar-tag ar-tag--out">CLOSED</span>
}
</td>
<td>{{ r.section }}</td>
<td>{{ r.clientName ?? '—' }}</td>
<td>{{ r.productName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.openedOn ?? '—' }}</td>
<td>{{ r.closedOn ?? '—' }}</td>
<td class="num">₹{{ r.balanceOnDate | number: '1.2-2' }}</td>
<td class="num">
₹{{ r.currentBalance | number: '1.2-2' }}
@if (r.section === 'SHARE' && r.sharesHeld) {
<span class="db-muted">· {{ r.sharesHeld }} sh</span>
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">
{{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed
</td>
<td class="num">₹{{ shownBalanceOnDate | number: '1.2-2' }}</td>
<td class="num">₹{{ shownCurrentBalance | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,144 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss; badges, filters and muted text are shared with the
day book. Only what belongs to the account register lives here. */
.ar-summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
&__item {
flex: 1 1 180px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
&--in {
border-left: 4px solid #16a34a;
}
&--out {
border-left: 4px solid #dc2626;
}
}
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 4px;
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
}
/* Checkbox sits inside a pg-field, which is laid out for inputs — this keeps the label and box on
one line without fighting that. */
.ar-check {
&__box {
display: flex;
align-items: center;
gap: 6px;
height: 38px;
font-size: 12px;
color: #475569;
white-space: nowrap;
}
}
/* Marks the accounts that actually moved on the chosen date — the reason someone opens a register
for a past day at all. */
.ar-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.04em;
&--in {
background: #dcfce7;
color: #15803d;
}
&--out {
background: #fee2e2;
color: #b91c1c;
}
}
.ar-row--opened td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.ar-row--closed td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
/* Only shown on paper: the screen already states the date in the toolbar. */
.ar-printhead {
display: none;
}
/* A register is meant to be printed and filed, so paper gets its own treatment: the toolbar and
filters go, the table gains borders, and rows are not split across pages. */
@media print {
.ar-noprint {
display: none !important;
}
.ar-printhead {
display: block;
margin: 0 0 10px;
font-size: 13px;
font-weight: 800;
color: #000;
}
.pg {
padding: 0;
}
.ar-summary__item {
border: 1px solid #999;
}
.pg-table {
font-size: 10px;
th,
td {
border: 1px solid #999;
padding: 4px 6px;
color: #000;
}
}
tr {
page-break-inside: avoid;
}
/* Table headers repeat on every page, so a multi-page register stays readable. */
thead {
display: table-header-group;
}
}
@@ -0,0 +1,221 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface RegisterRow {
accountId: number;
accountNo: string;
section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE';
clientName: string | null;
productName: string | null;
status: string;
openedOn: string | null;
closedOn: string | null;
/** True when this account was opened (or closed) on the very date being viewed. */
openedOnDate: boolean;
closedOnDate: boolean;
/** The account's balance as it stood on the register's date, for every section. */
balanceOnDate: number | null;
currentBalance: number | null;
sharesHeld?: number;
}
/**
* Account register for one date: which accounts opened and closed that day, each account's balance
* as it stood then, and what it holds now.
*/
@Component({
selector: 'mifosx-account-register',
templateUrl: './account-register.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./account-register.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class AccountRegisterComponent implements OnInit {
private service = inject(CashierService);
date = '';
section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL';
search = '';
/** Narrows to just the accounts that opened or closed on the chosen date. */
movementOnly = false;
rows: RegisterRow[] = [];
openedCount = 0;
closedCount = 0;
totalBalanceOnDate = 0;
totalCurrentBalance = 0;
loading = false;
loaded = false;
errorMsg = '';
readonly sections = [
{ value: 'ALL', label: 'All accounts' },
{ value: 'SAVINGS', label: 'Savings' },
{ value: 'FD', label: 'Fixed Deposit' },
{ value: 'RD', label: 'Recurring Deposit' },
{ value: 'SHARE', label: 'Share' }
];
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.accountRegister(this.date, this.section)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not load the account register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.accounts ?? [];
this.openedCount = Number(res?.openedCount ?? 0);
this.closedCount = Number(res?.closedCount ?? 0);
this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0);
this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0);
});
}
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/** Free-text narrowing over the rows already fetched — no round trip per keystroke. */
get filteredRows(): RegisterRow[] {
const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.accountNo,
r.clientName,
r.productName,
r.status,
r.section
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a filter or search still reconciles. */
get shownBalanceOnDate(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0);
}
get shownCurrentBalance(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0);
}
statusClass(status: string): string {
switch (status) {
case 'ACTIVE':
return 'db-badge db-badge--ok';
case 'CLOSED':
case 'PREMATURELY CLOSED':
case 'REJECTED':
case 'WITHDRAWN':
return 'db-badge db-badge--rejected';
case 'SUBMITTED':
case 'APPROVED':
return 'db-badge db-badge--pending';
default:
return 'db-badge';
}
}
exportCsv(): void {
const header = [
'Account No',
'Section',
'Client',
'Product',
'Status',
'Opened On',
'Closed On',
`Balance on ${this.date}`,
'Current Balance'
];
const lines = this.filteredRows.map((r) =>
[
r.accountNo,
r.section,
r.clientName ?? '',
r.productName ?? '',
r.status,
r.openedOn ?? '',
r.closedOn ?? '',
r.balanceOnDate == null ? '' : String(r.balanceOnDate),
String(r.currentBalance ?? 0)
]
// Quote every field and double embedded quotes: client names routinely contain commas.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `account-register-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */
printRegister(): void {
window.print();
}
}
@@ -0,0 +1,337 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}</div>
<h1 class="pg-title">{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}</h1>
<p class="pg-sub">
@if (missedOnly) {
Business days that were opened and never closed. While a day stays open its closing balance never carries
forward, a back-dated entry can still be posted into it, and any cash difference on that date goes unnoticed.
} @else {
The branch's business day, opened each morning and closed each evening. A day left open never carries its
closing balance forward, and any cash difference on that date goes unnoticed — so the ones still hanging are
listed first.
}
</p>
</div>
<div class="pg-toolbar">
@if (canOpenToday && !missedOnly) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startOpen()">
<mat-icon>lock_open</mat-icon> Open Day
</button>
}
<button type="button" class="pg-btn" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="bd-success">{{ successMsg }}</div>
}
@if (!canOperate && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see the register but not open or close a day — a Clerk, Head Cashier or Bank Manager does that, since a
cashier works inside the day and cannot declare it closed.
</div>
}
<!-- Missed closes first: these are the ones that need action. -->
@if (missed.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>{{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed</strong>
</div>
@for (m of missed; track m.id) {
<div class="bd-missed">
<span class="pg-chip">{{ m.businessDate }}</span>
<strong class="bd-missed__age"> open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }} </strong>
<span class="db-muted">
opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }}
</span>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--warn bd-missed__act" (click)="startClose(m)">
Close this day
</button>
}
</div>
}
</div>
}
<!-- On the missed-close screen, nothing to report is itself the answer — and has to be stated,
or the page looks like it failed to load. -->
@if (missedOnly && loaded && missed.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>No missed closes. Every business day opened for this branch has been closed.</p>
</div>
}
<!-- Current day -->
@if (!missedOnly && openDay) {
<div class="bd-current">
<div class="bd-current__main">
<span class="db-badge db-badge--pending">DAY OPEN</span>
<strong class="bd-current__date">{{ openDay.businessDate }}</strong>
<span class="db-muted">
opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{
openDay.openingCash | number: '1.2-2'
}}
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
<span class="db-badge">NO DAY OPEN</span>
<span class="db-muted">Open the business day before any cash transaction is accepted.</span>
</div>
</div>
}
<!-- Open-day form -->
@if (showOpenForm) {
<div class="db-panel bd-panel">
<div class="db-panel__head">
<strong>Open business day</strong>
<button type="button" class="db-icon-btn" (click)="cancelOpen()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<label class="pg-field">
<span>Business date</span>
<input type="date" [(ngModel)]="openDate" />
</label>
<label class="pg-field">
<span>Opening cash (leave blank to carry forward)</span>
<input type="number" step="0.01" [(ngModel)]="openingCash" placeholder="Carried from last close" />
</label>
</div>
<p class="bd-hint">
Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance
from drifting.
</p>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="opening || !openDate">
Open Day
</button>
<button type="button" class="pg-btn" (click)="cancelOpen()">Cancel</button>
</div>
</div>
}
<!-- Close-day form -->
@if (closeTarget) {
<div class="db-panel bd-panel" [class.bd-panel--late]="isLateClose">
<div class="db-panel__head">
<strong>Close business day — {{ closeTarget.businessDate }}</strong>
<button type="button" class="db-icon-btn" (click)="cancelClose()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
@if (isLateClose) {
<p class="db-panel__warn">
This day is being closed after its own date. It will be recorded as a
<strong>force close</strong>, with the reason below kept permanently against it.
</p>
}
@if (previewLoading) {
<p class="db-muted">Working out the day's figure…</p>
}
<!-- The arithmetic, spelled out. Whoever closes the day can see where the expected figure
comes from instead of being asked to agree with a number they cannot check. -->
@if (preview) {
<div class="bd-calc">
<div class="bd-calc__row">
<span>Opening cash (carried forward)</span>
<span class="bd-calc__amt">₹{{ preview.openingCash | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash deposits taken (approved)</span>
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash drawn from vault</span>
<span class="bd-calc__amt">₹{{ preview.vaultIn | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash paid out (approved withdrawals)</span>
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash settled back to vault</span>
<span class="bd-calc__amt">₹{{ preview.settledOut | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--total">
<span>Cash the branch should be holding</span>
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
</div>
</div>
}
<div class="db-panel__body">
<label class="pg-field">
<span>Physical cash counted</span>
<input type="number" step="0.01" [(ngModel)]="closingCash" placeholder="0.00" />
</label>
@if (preview && liveDifference !== null) {
@if (liveDifference === 0) {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">Tallied</span>
</div>
} @else {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">
{{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{
liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2')
}}
</span>
</div>
}
}
@if (preview && liveDifference !== 0) {
<button type="button" class="pg-btn" (click)="useExpected()">Use expected figure</button>
}
</div>
<p class="bd-hint">
Pre-filled with what the books expect — change it only if the drawer counted something different, and that
difference is then recorded against the day.
</p>
@if (isLateClose) {
<label class="pg-field bd-panel__wide">
<span>Why was this day left open? (required)</span>
<input
type="text"
[(ngModel)]="forceReason"
placeholder="e.g. cashier on leave, cash not settled that evening"
/>
</label>
}
<label class="pg-field bd-panel__wide">
<span>Remarks (optional)</span>
<input type="text" [(ngModel)]="closeRemarks" placeholder="Anything worth recording about this day" />
</label>
<p class="bd-hint">
The figure above is stored against the record, so a difference stays readable later even if a transaction is
reversed afterwards.
</p>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose">
{{ isLateClose ? 'Force Close Day' : 'Close Day' }}
</button>
<button type="button" class="pg-btn" (click)="cancelClose()">Cancel</button>
</div>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && days.length === 0 && !missedOnly) {
<div class="pg-empty">
<mat-icon>event_note</mat-icon>
<p>No business day has been opened for this branch yet.</p>
</div>
}
<!-- The register -->
@if (days.length > 0 && !missedOnly) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Business Date</th>
<th>Status</th>
<th>Opened By</th>
<th class="num">Opening ₹</th>
<th>Closed By</th>
<th class="num">Closing ₹</th>
<th class="num">Book ₹</th>
<th>Difference</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (d of days; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.businessDate }}</span>
</td>
<td>
@if (d.status === 'OPEN') {
<span class="db-badge db-badge--pending">OPEN</span>
} @else if (d.forceClosed) {
<span class="db-badge db-badge--rejected" [title]="d.forceCloseReason ?? ''">FORCE CLOSED</span>
} @else {
<span class="db-badge db-badge--ok">CLOSED</span>
}
</td>
<td>
{{ d.openedByName ?? '—' }}
<span class="db-muted">· {{ timeOnly(d.openedAt) }}</span>
</td>
<td class="num">₹{{ d.openingCash | number: '1.2-2' }}</td>
<td>
{{ d.closedByName ?? '—' }}
@if (d.closedAt) {
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
}
</td>
<td class="num">{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td>
@if (d.cashDifference == null) {
—
} @else if (d.cashDifference === 0) {
<span class="db-badge db-badge--ok">Tallied</span>
} @else {
<span class="db-badge db-badge--rejected">{{ differenceLabel(d.cashDifference) }}</span>
}
</td>
<td class="bd-note">
@if (d.forceCloseReason) {
<strong>Left open:</strong> {{ d.forceCloseReason }}
}
@if (d.remarks) {
<span class="db-muted">{{ d.remarks }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,172 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared
with the day book. Only what is specific to the day register lives here. */
/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so
this component does not depend on another component's stylesheet. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.bd-success {
padding: 10px 14px;
margin-bottom: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #14532d;
font-size: 13px;
}
/* One row per never-closed day, inside the shared red exception panel. */
.bd-missed {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 7px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
&__age {
color: #b91c1c;
}
/* Pushed right so the date and age read before the action. */
&__act {
margin-left: auto;
height: 28px;
font-size: 11px;
}
}
/* The branch's current day — the one thing someone opening this screen is looking for. */
.bd-current {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #f59e0b;
border-radius: 8px;
/* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */
&--shut {
border-left-color: #94a3b8;
background: #f8fafc;
}
&__main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__date {
font-size: 15px;
color: #0d1b2a;
}
.pg-btn {
margin-left: auto;
}
}
.bd-panel {
margin-bottom: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* A late close is a different act from an ordinary one, and looks like it. */
&--late {
border-left: 4px solid #dc2626;
}
&__wide {
display: block;
margin-top: 12px;
max-width: 640px;
}
}
/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against
the screen it came from. */
.bd-calc {
max-width: 520px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
color: #475569;
&--in .bd-calc__amt {
color: #15803d;
}
&--out .bd-calc__amt {
color: #b91c1c;
}
&--total {
margin-top: 4px;
padding-top: 8px;
border-top: 2px solid #e2e8f0;
font-weight: 800;
font-size: 14px;
color: #0d1b2a;
}
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}
}
.bd-hint {
margin: 10px 0 0;
font-size: 11px;
color: #94a3b8;
}
.bd-note {
max-width: 280px;
font-size: 11px;
line-height: 1.4;
}
@@ -0,0 +1,284 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface BranchDay {
id: number;
officeId: number;
officeName: string | null;
businessDate: string;
openedByName: string | null;
openedAt: string | null;
openingCash: number | null;
closedByName: string | null;
closedAt: string | null;
closingCash: number | null;
bookCash: number | null;
cashDifference: number | null;
forceClosed: boolean;
forceCloseReason: string | null;
remarks: string | null;
status: 'OPEN' | 'CLOSED';
/** Only present on the missed-close list. */
daysOpen?: number;
}
/**
* Branch day register — opening and closing the branch's business day, and the list of days that
* were opened and never closed.
*
* A day left open is not a tidiness problem: its closing balance never carries forward, a
* back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
*/
@Component({
selector: 'mifosx-branch-day',
templateUrl: './branch-day.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./branch-day.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class BranchDayComponent implements OnInit {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
/**
* Set from the route: the missed-close register is the same data as the day register, shown with
* only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
* Close Register" looked like nothing had happened whenever the branch was up to date.
*/
missedOnly = false;
days: BranchDay[] = [];
missed: BranchDay[] = [];
openDay: any = null;
today = '';
/** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
canOperate = false;
loading = false;
loaded = false;
errorMsg = '';
successMsg = '';
// Open-day form
showOpenForm = false;
openDate = '';
openingCash: number | null = null;
opening = false;
// Close-day form. The date is fixed by which day is being closed, never typed.
closeTarget: BranchDay | null = null;
closingCash: number | null = null;
closeRemarks = '';
forceReason = '';
closing = false;
ngOnInit(): void {
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.branchDayRegister()
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.days = res?.days ?? [];
this.openDay = res?.openDay ?? null;
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
});
this.service
.branchDayMissed()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.missed = res?.missed ?? [];
});
}
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
return this.canOperate && !this.openDay;
}
startOpen(): void {
this.showOpenForm = true;
this.openDate = this.today;
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
// over it is how a carried balance drifts.
this.openingCash = null;
this.successMsg = '';
this.errorMsg = '';
}
cancelOpen(): void {
this.showOpenForm = false;
}
submitOpen(): void {
if (!this.openDate) return;
this.opening = true;
this.errorMsg = '';
this.service
.openBranchDay(this.openDate, this.openingCash ?? undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.opening = false;
if (res) {
this.showOpenForm = false;
this.successMsg = `Business day ${res.businessDate} opened.`;
this.load();
}
});
}
/** The day's expected figure and the working behind it, fetched when the close form opens. */
preview: any = null;
previewLoading = false;
startClose(day: BranchDay): void {
this.closeTarget = day;
this.closingCash = null;
this.closeRemarks = '';
this.forceReason = '';
this.successMsg = '';
this.errorMsg = '';
this.preview = null;
// Pre-fill the counted figure with what the books expect. It stays editable — the day is still
// closed on what was physically counted — but in the ordinary case where the drawer tallies
// there is nothing to type, and no chance of a slip of the keyboard inventing a difference.
this.previewLoading = true;
this.service
.branchDayClosePreview(day.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.previewLoading = false;
this.preview = res;
if (res?.expectedCash != null && this.closingCash == null) {
this.closingCash = Number(res.expectedCash);
}
});
}
/** Difference the form would record right now, so it is visible before saving. */
get liveDifference(): number | null {
if (!this.preview || this.closingCash == null) return null;
return Number(this.closingCash) - Number(this.preview.expectedCash ?? 0);
}
/** Puts the expected figure back after an edit. */
useExpected(): void {
if (this.preview?.expectedCash != null) {
this.closingCash = Number(this.preview.expectedCash);
}
}
cancelClose(): void {
this.closeTarget = null;
}
/** A date earlier than today is a late close, and has to say why it was left open. */
get isLateClose(): boolean {
return !!this.closeTarget && this.closeTarget.businessDate < this.today;
}
get canSubmitClose(): boolean {
if (!this.closeTarget || this.closingCash == null || this.closing) return false;
return !this.isLateClose || !!this.forceReason.trim();
}
submitClose(): void {
const day = this.closeTarget;
if (!day || this.closingCash == null) return;
this.closing = true;
this.errorMsg = '';
this.service
.closeBranchDay(
day.businessDate,
Number(this.closingCash),
this.closeRemarks.trim() || undefined,
this.isLateClose ? this.forceReason.trim() : undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.closing = false;
if (res) {
this.closeTarget = null;
const diff = Number(res.cashDifference ?? 0);
this.successMsg =
diff === 0
? `Business day ${res.businessDate} closed — cash tallied.`
: `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
this.load();
}
});
}
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
differenceLabel(d: number | null): string {
if (d == null) return '—';
const n = Number(d);
if (n === 0) return 'Tallied';
return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
}
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
timeOnly(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
}
+41
View File
@@ -19,6 +19,10 @@ import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-ca
import { CashierPositionComponent } from './cashier-position/cashier-position.component'; import { CashierPositionComponent } from './cashier-position/cashier-position.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component'; import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component'; import { CashHistoryComponent } from './cash-history/cash-history.component';
import { DayBookComponent } from './day-book/day-book.component';
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component'; import { ShareCollectionComponent } from './share-collection/share-collection.component';
@@ -128,6 +132,33 @@ const routes: Routes = [
} }
] ]
}, },
{
// Branch-level operations, kept out of the cashier tree on purpose: opening and closing the
// day governs the whole branch, and is not something a cashier does.
path: 'branch-ops',
data: { title: 'Branch Operations', breadcrumb: 'Branch Operations' },
children: [
{
path: 'day-register',
component: BranchDayComponent,
data: { title: 'Day Open / Day Close', breadcrumb: 'Day Register' }
},
{
// Same screen, narrowed to the days that were never closed. A separate route rather than
// a second link to the register, so choosing it visibly does something.
path: 'missed-close',
component: BranchDayComponent,
data: { title: 'Missed Close Register', breadcrumb: 'Missed Close', missedOnly: true }
},
{
// Lives here alongside the day register because both answer "what did this branch look
// like on that date"; it is linked from the Reports menu, where people go to find it.
path: 'account-register',
component: AccountRegisterComponent,
data: { title: 'Account Register', breadcrumb: 'Account Register' }
}
]
},
{ {
path: 'cashier', path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' }, data: { title: 'Cashier', breadcrumb: 'Cashier' },
@@ -172,6 +203,16 @@ const routes: Routes = [
component: CashHistoryComponent, component: CashHistoryComponent,
data: { title: 'Cash History', breadcrumb: 'Cash History' } data: { title: 'Cash History', breadcrumb: 'Cash History' }
}, },
{
path: 'day-book',
component: DayBookComponent,
data: { title: 'Day Book', breadcrumb: 'Day Book' }
},
{
path: 'voucher-exceptions',
component: VoucherExceptionsComponent,
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
},
{ {
path: 'closing-report', path: 'closing-report',
component: ClosingCashReportComponent, component: ClosingCashReportComponent,
+207
View File
@@ -113,6 +113,146 @@ export class CashierService {
const params = new HttpParams().set('date', date); const params = new HttpParams().set('date', date);
return this.http.get('/cashreports/closing', { params }); return this.http.get('/cashreports/closing', { params });
} }
/**
* Every voucher raised on one date — deposits, withdrawals, disbursements, collections and
* bank transfers in a single list, with the day's credit and debit totals.
*/
dayBook(date: string, side?: 'CR' | 'DR', status?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (side) params = params.set('side', side);
if (status) params = params.set('status', status);
return this.http.get('/cashreports/day-book', { params });
}
// ── Voucher physical verification & attachments ──
// The voucher number always travels in the body or a query parameter, never as a path segment:
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
// encoded slashes with a 400 before the request reaches the resource.
/** Records the outcome of checking a voucher against the paper slip. */
verifyVoucher(
voucherNo: string,
status: 'VERIFIED' | 'EXCEPTION',
slipAmount?: number,
remarks?: string
): Observable<any> {
return this.http.post('/vouchers/verify', {
voucherNo,
status,
slipAmount: slipAmount != null ? String(slipAmount) : null,
remarks: remarks ?? null,
locale: 'en'
});
}
uploadVoucherAttachment(
voucherNo: string,
fileName: string,
contentType: string,
fileData: string,
description?: string
): Observable<any> {
return this.http.post('/vouchers/attachments', {
voucherNo,
fileName,
contentType,
fileData,
description: description ?? null
});
}
listVoucherAttachments(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/attachments', { params });
}
deleteVoucherAttachment(id: number): Observable<any> {
return this.http.delete(`/vouchers/attachments/${id}`);
}
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
downloadVoucherAttachment(id: number): Observable<Blob> {
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
}
/**
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
* raised on Friday still needs answering on Monday.
*/
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
const params = new HttpParams().set('status', status);
return this.http.get('/vouchers/exceptions', { params });
}
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
}
/**
* Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now.
*/
accountRegister(date: string, section?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (section && section !== 'ALL') params = params.set('section', section);
return this.http.get('/accountregister', { params });
}
// ── Branch day register ──
/** The branch's day register, plus which day is currently open. Defaults to the caller's office. */
branchDayRegister(officeId?: number, limit?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
if (limit) params = params.set('limit', String(limit));
return this.http.get('/branchday', { params });
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/missed', { params });
}
/**
* What the day should close at, and the arithmetic behind it, so the closing figure does not
* have to be worked out by hand.
*/
branchDayClosePreview(businessDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (businessDate) params = params.set('businessDate', businessDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/close-preview', { params });
}
openBranchDay(businessDate: string, openingCash?: number, officeId?: number): Observable<any> {
return this.http.post('/branchday/open', {
businessDate,
openingCash: openingCash != null ? String(openingCash) : null,
officeId: officeId ?? null,
locale: 'en'
});
}
/** forceCloseReason is required when closing a date earlier than today. */
closeBranchDay(
businessDate: string,
closingCash: number,
remarks?: string,
forceCloseReason?: string,
officeId?: number
): Observable<any> {
return this.http.post('/branchday/close', {
businessDate,
closingCash: String(closingCash),
remarks: remarks ?? null,
forceCloseReason: forceCloseReason ?? null,
officeId: officeId ?? null,
locale: 'en'
});
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> { excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold)); let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate); if (fromDate) params = params.set('fromDate', fromDate);
@@ -188,4 +328,71 @@ export class CashierService {
getShareCollectionStatus(shareAccountId: number): Observable<any> { getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`); return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
} }
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted
// CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes
// (%2F) outright with a 400 before the request ever reaches the resource.
// clientId lets the backend adopt a cashier deposit made straight to the Share Sundry GL,
// which carries no member of its own.
let params = new HttpParams().set('voucherNo', voucherNo);
if (clientId) {
params = params.set('clientId', String(clientId));
}
return this.http.get('/shareadvancecollection/by-voucher', { params });
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : '';
return this.http.get(`/shareadvancecollection${suffix}`);
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
applyShareAdvance(collectionId: number, data: any): Observable<any> {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
}
/**
* Whether this share account can be settled from the member's linked savings, and for how much.
*
* Reports a shortfall before the clerk raises anything, so the member can be told at the counter
* rather than after a request has been submitted and has to be rejected.
* @returns {Observable<any>} `{ eligible, reason, shareAmount, chargeAmount, totalRequired,
* savingsAccountNo, savingsBalance }`.
*/
getShareSavingsDebitEligibility(shareAccountId: number): Observable<any> {
return this.http.get(`/sharesavingsdebit/eligibility/${shareAccountId}`);
}
/**
* Records a request to settle a share application by debiting the member's savings. Nothing moves
* until a manager approves it — unlike the cash route, where the cashier's receipt is already the
* second pair of eyes.
*/
submitShareSavingsDebit(data: any): Observable<any> {
return this.http.post('/sharesavingsdebit', data);
}
/** Savings-debit requests, PENDING by default. */
getShareSavingsDebitRequests(status?: string, shareAccountId?: number): Observable<any> {
const params: string[] = [];
if (status) {
params.push(`status=${status}`);
}
if (shareAccountId) {
params.push(`shareAccountId=${shareAccountId}`);
}
return this.http.get(`/sharesavingsdebit${params.length ? '?' + params.join('&') : ''}`);
}
/** Debits the member's savings and issues the shares. Manager only, and not the submitting clerk. */
approveShareSavingsDebit(id: number): Observable<any> {
return this.http.post(`/sharesavingsdebit/${id}/approve`, {});
}
rejectShareSavingsDebit(id: number, rejectionReason?: string): Observable<any> {
return this.http.post(`/sharesavingsdebit/${id}/reject`, { rejectionReason });
}
} }
@@ -0,0 +1,393 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · DAY BOOK</div>
<h1 class="pg-title">Day Book</h1>
<p class="pg-sub">
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank
transfers, with credit and debit totals.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>Date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Filters -->
<div class="db-filters">
<label class="pg-field">
<span>Side</span>
<select [(ngModel)]="side" (change)="load()">
<option value="">All</option>
<option value="CR">CR · Credit (money in)</option>
<option value="DR">DR · Debit (money out)</option>
</select>
</label>
<label class="pg-field">
<span>Status</span>
<select [(ngModel)]="status" (change)="load()">
<option value="">All</option>
<option value="APPROVED">Approved</option>
<option value="PENDING">Pending</option>
<option value="REJECTED">Rejected</option>
</select>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Voucher no, account, party, payment type, narration…" />
</label>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
flag raised for investigation is useless if someone has to scroll to find it. -->
@if (!loading && exceptionRows.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
</strong>
@if (resolvedExceptionCount > 0) {
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
}
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
</button>
</div>
@for (e of exceptionRows; track e.voucherNo) {
<div class="db-exceptions__item">
<span class="pg-chip">{{ e.voucherNo }}</span>
<span class="db-exceptions__amt">
System ₹{{ e.amount | number: '1.2-2' }}
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
<strong class="db-exceptions__diff"
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
>
}
</span>
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
</div>
}
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && rows.length === 0) {
<div class="pg-empty">
<mat-icon>receipt_long</mat-icon>
<p>No vouchers were raised on {{ date }}.</p>
</div>
}
@if (!loading && filteredRows.length > 0) {
<!-- Totals bar. Reflects what is on screen, so a search or side filter still reconciles. -->
<div class="closing-summary">
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Credit · CR (money in)</span>
<span class="closing-summary__value">₹{{ shownCredit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
<span class="closing-summary__label">Debit · DR (money out)</span>
<span class="closing-summary__value">₹{{ shownDebit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">=</div>
<div
class="closing-summary__item"
[class.closing-summary__item--net-pos]="shownCredit - shownDebit >= 0"
[class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
>
<span class="closing-summary__label">Net</span>
<span class="closing-summary__value">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</span>
</div>
</div>
<p class="db-note">
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
</p>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Dr/Cr</th>
<th>Type</th>
<th>Account</th>
<th>Party</th>
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>By</th>
<th>Approved By</th>
<th>Slip Check</th>
<th>Time</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
<td>
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
</td>
<td>
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
{{ r.side }}
</span>
</td>
<td>{{ r.kind }}</td>
<td>
{{ r.accountNo ?? '—' }}
@if (r.accountType) {
<span class="db-muted">· {{ r.accountType }}</span>
}
</td>
<td>{{ r.party ?? '—' }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.paymentTypeName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@if (r.approvedBy && r.actionedAt) {
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
}
</td>
<td>
@if (r.verifyStatus === 'VERIFIED') {
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
>✓ MATCHED</span
>
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
<span
class="db-badge db-badge--ok"
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
>
✓ RESOLVED
</span>
} @else if (r.verifyStatus === 'EXCEPTION') {
<span
class="db-badge db-badge--rejected"
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
>
⚠ EXCEPTION
</span>
} @else {
<span class="db-muted">Not checked</span>
}
</td>
<td>{{ timeOf(r.submittedAt) }}</td>
<td class="db-actions">
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
<mat-icon>fact_check</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
<mat-icon>attach_file</mat-icon>
@if (r.attachmentCount > 0) {
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
}
</button>
</td>
</tr>
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
enough: it cannot be printed, exported, or read without a mouse. -->
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
<td colspan="13">
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
<strong>Exception</strong>
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
}
— {{ r.verifyRemarks }}
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
@if (r.resolutionStatus) {
<div class="db-exc-row__resolution">
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
{{ r.resolutionNotes }}
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
</div>
}
</td>
</tr>
}
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
next to the row they describe. -->
@if (verifyRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Slip check — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
</div>
<label class="pg-field">
<span>Amount written on slip</span>
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
</label>
@if (verifySlipBlank) {
<div class="db-cmp">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">—</span>
</div>
} @else if (verifyMismatch) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Match</span>
<span class="db-cmp__value">Amounts agree</span>
</div>
}
</div>
<label class="pg-field db-panel__remarks">
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
<input
type="text"
[(ngModel)]="verifyRemarks"
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
/>
</label>
@if (verifyMismatch) {
<p class="db-panel__warn">
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
change the posted amount.
</p>
}
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveVerify()"
[disabled]="verifySaving || verifySlipBlank"
>
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
</button>
<!-- Raising an exception on an already-matched voucher stays available: a
slip cleared in haste, or a difference noticed later, has to be
recordable without erasing the earlier verdict. -->
@if (!verifyMismatch) {
<button
type="button"
class="pg-btn pg-btn--warn"
(click)="raiseException()"
[disabled]="verifySaving"
>
Raise Exception
</button>
}
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
</div>
</div>
</td>
</tr>
}
<!-- Attachment panel for this voucher. -->
@if (attachRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Attachments — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="db-muted">Nothing attached to this voucher yet.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
}
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
<div class="pg-empty">No voucher matches “{{ search }}”.</div>
}
</div>
@@ -0,0 +1,393 @@
/**
* 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/.
*/
/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book
lives here. */
.db-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* The search box takes the slack so the two selects keep their natural width. */
&__search {
flex: 1 1 260px;
}
}
.db-note {
margin: 8px 0 12px;
font-size: 11px;
color: #94a3b8;
}
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
.db-side {
display: inline-block;
min-width: 28px;
text-align: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
background: #f1f5f9;
color: #475569;
&--cr {
background: #dcfce7;
color: #15803d;
}
&--dr {
background: #fee2e2;
color: #b91c1c;
}
}
/* A hairline on the leading edge, tinted by side: readable without colouring the whole row,
which would fight the table's own hover state. */
.db-row--cr td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.db-row--dr td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
.db-muted {
color: #94a3b8;
font-size: 11px;
}
.db-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f1f5f9;
color: #475569;
&--ok {
background: #dcfce7;
color: #15803d;
}
&--pending {
background: #fef3c7;
color: #b45309;
}
&--rejected {
background: #fee2e2;
color: #b91c1c;
}
}
/* Not defined in cashier-shared.scss, so the day book carries its own. */
.pg-alert {
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 6px;
font-size: 13px;
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
}
/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to
hunt for a badge. */
.db-exceptions {
margin-bottom: 14px;
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: #b91c1c;
font-size: 13px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
/* Pushed to the right so the count reads first. */
.pg-btn {
margin-left: auto;
height: 30px;
font-size: 12px;
}
}
&__item {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 6px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
}
&__amt {
font-variant-numeric: tabular-nums;
color: #334155;
}
&__diff {
color: #b91c1c;
}
&__why {
color: #0d1b2a;
font-weight: 600;
}
}
/* The reason spelled out under its own flagged row. */
.db-exc-row td {
background: #fef2f2 !important;
border-bottom: 1px solid #fecaca !important;
color: #7f1d1d;
font-size: 12px;
padding: 8px 14px !important;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
vertical-align: -3px;
margin-right: 4px;
}
}
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
something anyone has to act on. */
.db-exc-row--resolved td {
background: #f0fdf4 !important;
border-bottom: 1px solid #bbf7d0 !important;
color: #14532d;
}
.db-exc-row__resolution {
margin-top: 4px;
padding-top: 4px;
border-top: 1px dotted #bbf7d0;
}
.db-actions {
white-space: nowrap;
}
/* Raising an exception is a deliberate, consequential act — styled to read that way without
looking destructive, since nothing is deleted. */
.pg-btn--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
&:hover {
background: #fef3c7;
}
}
.db-icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
color: #0d1b2a;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
/* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different
at a glance, without a separate column. */
&__count {
position: absolute;
top: -5px;
right: -5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 999px;
background: #2563eb;
color: #fff;
font-size: 9px;
font-weight: 800;
line-height: 14px;
}
}
/* Inline panel, opened under the row it belongs to. */
.db-panel-row td {
background: #f8fafc !important;
padding: 0 !important;
}
.db-panel {
padding: 14px 16px;
border-left: 3px solid #2563eb;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 13px;
color: #0d1b2a;
}
&__body {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
&__remarks {
display: block;
margin-top: 12px;
max-width: 640px;
}
&__warn {
margin: 10px 0 0;
padding: 8px 12px;
border-radius: 6px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
}
/* The two amounts being compared, side by side. */
.db-cmp {
padding: 8px 14px;
border-radius: 6px;
background: #fff;
border: 1px solid #e2e8f0;
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 2px;
font-size: 15px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
&--good {
background: #f0fdf4;
border-color: #bbf7d0;
.db-cmp__value {
color: #15803d;
}
}
&--bad {
background: #fef2f2;
border-color: #fecaca;
.db-cmp__value {
color: #b91c1c;
}
}
}
.db-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.db-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 6px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
&__name {
font-weight: 700;
color: #0d1b2a;
}
}
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
font-size: 12px;
color: #334155;
border-top: 1px solid #e2e8f0;
}
@@ -0,0 +1,519 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface DayBookRow {
voucherNo: string | null;
side: 'CR' | 'DR';
kind: string;
accountNo: string | null;
accountType: string | null;
party: string | null;
amount: number | null;
paymentTypeName: string | null;
description: string | null;
status: string;
handledBy: string | null;
approvedBy: string | null;
txnId: string | null;
submittedAt: string | null;
actionedAt: string | null;
/** null until someone has physically checked the slip. */
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
slipAmount: number | null;
verifyRemarks: string | null;
verifiedByName: string | null;
verifiedAt: string | null;
/** Set once a clerk or manager has closed the exception; null while it is still open. */
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
attachmentCount: number;
}
/**
* Day book — every voucher raised on one date in a single list: cash deposits, withdrawals,
* disbursements, collections and bank transfers, with the day's credit and debit totals.
*/
@Component({
selector: 'mifosx-day-book',
templateUrl: './day-book.component.html',
styleUrls: [
'../cashier-shared.scss',
'./day-book.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class DayBookComponent implements OnInit {
private service = inject(CashierService);
date = '';
side: '' | 'CR' | 'DR' = '';
status = '';
search = '';
rows: DayBookRow[] = [];
creditTotal = 0;
debitTotal = 0;
netTotal = 0;
loading = false;
loaded = false;
errorMsg = '';
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.dayBook(this.date, this.side || undefined, this.status || undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.rows ?? [];
this.creditTotal = Number(res?.creditTotal ?? 0);
this.debitTotal = Number(res?.debitTotal ?? 0);
this.netTotal = Number(res?.netTotal ?? 0);
});
}
/** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/** Show only the flagged vouchers — set from the exception banner. */
exceptionsOnly = false;
/**
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
* visible on its own row as history, but it is no longer something anyone has to act on.
*/
get exceptionRows(): DayBookRow[] {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
}
/** Closed exceptions, kept for the record rather than for action. */
get resolvedExceptionCount(): number {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
}
/** Human label for a resolution code. */
resolutionLabel(status: string | null): string {
switch (status) {
case 'RESOLVED_SYSTEM_ERROR':
return 'System entry was wrong';
case 'RESOLVED_SLIP_ERROR':
return 'Slip was written wrong';
case 'RESOLVED_NO_ACTION':
return 'No action needed';
default:
return status ?? '';
}
}
/** Vouchers nobody has checked against a slip yet. */
get uncheckedCount(): number {
return this.rows.filter((r) => !r.verifyStatus).length;
}
toggleExceptionsOnly(): void {
this.exceptionsOnly = !this.exceptionsOnly;
}
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
get filteredRows(): DayBookRow[] {
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.voucherNo,
r.accountNo,
r.party,
r.kind,
r.paymentTypeName,
r.handledBy,
r.description
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a search or side filter still reconciles. */
get shownCredit(): number {
return this.filteredRows
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
get shownDebit(): number {
return this.filteredRows
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
/** Only approved money is real money — a pending or rejected voucher must not move the totals. */
private countsAsMoney(r: DayBookRow): boolean {
const s = (r.status ?? '').toUpperCase();
return s === 'APPROVED' || s === 'COMPLETED';
}
statusClass(status: string): string {
switch ((status ?? '').toUpperCase()) {
case 'APPROVED':
case 'COMPLETED':
return 'db-badge db-badge--ok';
case 'PENDING':
return 'db-badge db-badge--pending';
case 'REJECTED':
return 'db-badge db-badge--rejected';
default:
return 'db-badge';
}
}
/** Time of day only — the date is already the page's subject. */
timeOf(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
// ── Physical slip verification ────────────────────────────────────────────
/** Row whose verification panel is open, or null when none is. */
verifyRow: DayBookRow | null = null;
verifySlipAmount: number | null = null;
verifyRemarks = '';
verifySaving = false;
openVerify(r: DayBookRow): void {
this.verifyRow = r;
// Pre-filled with the system's own figure: the common case is that the two agree, and when
// they don't the cashier only has to change the digits that actually differ.
this.verifySlipAmount = Number(r.amount ?? 0);
this.verifyRemarks = r.verifyRemarks ?? '';
this.attachRow = null;
}
closeVerify(): void {
this.verifyRow = null;
this.verifySlipAmount = null;
this.verifyRemarks = '';
}
/** True when the amount typed off the slip differs from what the system recorded. */
get verifyMismatch(): boolean {
if (!this.verifyRow || this.verifySlipAmount == null) return false;
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
}
/**
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
*/
get verifySlipBlank(): boolean {
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
}
get verifyDifference(): number {
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
}
/**
* Saves the verdict. The status is derived from the figures rather than picked by the user —
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
*/
saveVerify(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
const isException = this.verifyMismatch;
if (isException && !this.verifyRemarks.trim()) {
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
.verifyVoucher(
row.voucherNo,
isException ? 'EXCEPTION' : 'VERIFIED',
isException ? Number(this.verifySlipAmount) : undefined,
this.verifyRemarks.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
/**
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
* is exactly what an investigation needs.
*/
raiseException(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
if (!this.verifyRemarks.trim()) {
this.errorMsg = 'Say what the problem is before raising an exception.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
// both the slip and the system said at the time the exception was raised.
.verifyVoucher(
row.voucherNo,
'EXCEPTION',
Number(this.verifySlipAmount ?? row.amount ?? 0),
this.verifyRemarks.trim()
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
// ── Attachments ───────────────────────────────────────────────────────────
attachRow: DayBookRow | null = null;
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
openAttach(r: DayBookRow): void {
this.attachRow = r;
this.verifyRow = null;
this.attachments = [];
if (!r.voucherNo) return;
this.attachLoading = true;
this.service
.listVoucherAttachments(r.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
closeAttach(): void {
this.attachRow = null;
this.attachments = [];
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.attachRow;
if (!file || !row?.voucherNo) return;
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
// only to have it refused.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo!,
file.name,
file.type || 'application/octet-stream',
String(reader.result)
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.openAttach(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
/**
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
* then handed to the browser as a normal save.
*/
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
deleteAttachment(a: any): void {
const row = this.attachRow;
this.service
.deleteVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe(() => {
if (row) this.openAttach(row);
this.load();
});
}
exportCsv(): void {
const header = [
'Voucher No',
'Side',
'Type',
'Account',
'Party',
'Amount',
'Payment Type',
'Status',
'Handled By',
'Approved By',
'Slip Check',
'Slip Amount',
'Remarks',
'Checked By',
'Resolution',
'Resolution Notes',
'Closed By',
'Attachments',
'Time'
];
const lines = this.filteredRows.map((r) =>
[
r.voucherNo ?? '',
r.side,
r.kind,
r.accountNo ?? '',
r.party ?? '',
String(r.amount ?? 0),
r.paymentTypeName ?? '',
r.status,
r.handledBy ?? '',
r.approvedBy ?? '',
r.verifyStatus ?? 'NOT CHECKED',
r.slipAmount != null ? String(r.slipAmount) : '',
r.verifyRemarks ?? '',
r.verifiedByName ?? '',
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
r.resolutionNotes ?? '',
r.resolvedByName ?? '',
String(r.attachmentCount ?? 0),
this.timeOf(r.submittedAt)
]
// Quote every field and double any embedded quote: names and narrations routinely
// contain commas, which would otherwise split into extra columns.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `day-book-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
}
@@ -2317,8 +2317,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' }); alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' });
if (isYes(this.kycDetails?.willfulDefaulter)) if (isYes(this.kycDetails?.willfulDefaulter))
alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' }); alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' });
if (this.kycDetails?.amlCheckStatus === 'FLAGGED') // No AML-suspicion banner here by design. Sanctions and willful-defaulter above are legal
alerts.push({ type: 'danger', message: 'AML Flag — Suspicious Activity' }); // prohibitions the cashier has to act on at the counter; AML suspicion is not — showing it
// risks tipping off the customer. It stays in the AML review queue and the STR/CTR reports.
return alerts; return alerts;
} }
@@ -3220,21 +3221,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return; return;
} }
const AML_GL = 200000; // No AML prompt on the GL path either — see the note on the savings/current path below.
if (amount > AML_GL && isCash) {
this.showConfirmAlert(
'warning',
'AML Alert — Large Cash Transaction',
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly)
);
return;
}
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly); this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return; return;
} }
@@ -3314,23 +3301,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
} }
// 6. AML large cash // AML monitoring is deliberately invisible here. Every posted transaction is evaluated
const AML_THRESHOLD = 200000; // server-side against the AML rule catalog (AmlRuleEvaluationService) after it succeeds, and
if (amount > AML_THRESHOLD && selectedPt?.isCashPayment) { // anything it flags surfaces only in the AML reports — alerts, STR cases and CTR returns.
this.showConfirmAlert( // The cashier is never prompted or warned at the counter: tipping off the customer defeats
'warning', // the purpose of suspicious-transaction monitoring, and a hardcoded client-side threshold
'AML Alert — Large Cash Transaction', // duplicated none of the real rule set anyway.
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPt)
);
return;
}
this.openConfirmDialog(row, amount, isDeposit, selectedPt); this.openConfirmDialog(row, amount, isDeposit, selectedPt);
} }
@@ -0,0 +1,183 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · EXCEPTIONS</div>
<h1 class="pg-title">Voucher Exceptions</h1>
<p class="pg-sub">
Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
document.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Show</span>
<select [(ngModel)]="filter" (change)="load()">
<option value="OPEN">Open</option>
<option value="RESOLVED">Resolved</option>
<option value="ALL">All</option>
</select>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (!canResolve && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
that whoever raised the flag is not the one who rules on it.
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && exceptions.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
</div>
}
@for (e of exceptions; track e.voucherNo) {
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
<div class="ve-card__head">
<span class="pg-chip">{{ e.voucherNo }}</span>
@if (e.resolutionStatus) {
<span class="db-badge db-badge--ok">RESOLVED</span>
} @else {
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
}
<span class="db-muted">
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
</span>
@if (!e.resolutionStatus && canResolve) {
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
Review &amp; Close
</button>
}
</div>
<div class="ve-card__amts">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
</div>
@if (!amountsAgree(e)) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Written on slip</span>
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
</div>
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp">
<span class="db-cmp__label">Amounts</span>
<span class="db-cmp__value">Agree — flagged for another reason</span>
</div>
}
</div>
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
@if (e.resolutionStatus) {
<div class="ve-card__resolution">
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
</div>
}
<!-- Resolution panel, opened under the exception it closes. -->
@if (resolveRow === e) {
<div class="db-panel ve-panel">
<div class="db-panel__head">
<strong>Close exception — {{ e.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="pg-field ve-panel__field">
<span>What was wrong?</span>
<select [(ngModel)]="resolutionStatus">
<option value="">— Select —</option>
@for (o of resolutionOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="pg-field ve-panel__field">
<span>What was found, and what was done about it?</span>
<input
type="text"
[(ngModel)]="resolutionNotes"
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
/>
</label>
<div class="ve-panel__docs">
<strong>Supporting document</strong>
<p class="db-muted">
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
whatever the decision rests on.
</p>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="ve-panel__warn">No document attached yet — required before closing.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
</div>
}
</div>
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveResolution()"
[disabled]="!canSubmitResolution"
>
Close Exception
</button>
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
</div>
</div>
}
</div>
}
</div>
@@ -0,0 +1,120 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
shared with the day book. Only the exception card itself is defined here. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
reads at a glance without opening anything. */
.ve-card {
padding: 14px 16px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--resolved {
border-left-color: #16a34a;
background: #fafffb;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
/* Pushed right so the identity of the voucher reads first. */
&__act {
margin-left: auto;
height: 32px;
font-size: 12px;
}
&__amts {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
&__why {
margin: 0;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #0d1b2a;
}
&__resolution {
margin-top: 10px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
font-size: 12px;
color: #14532d;
}
}
.ve-panel {
margin-top: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
&__field {
display: block;
margin-bottom: 12px;
max-width: 640px;
}
&__docs {
padding: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
p {
margin: 4px 0 10px;
}
}
&__warn {
padding: 6px 10px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
color: #92400e;
}
}
@@ -0,0 +1,248 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface VoucherException {
voucherNo: string;
systemAmount: number | null;
slipAmount: number | null;
remarks: string | null;
raisedByName: string | null;
raisedAt: string | null;
accountNo: string | null;
party: string | null;
raisedAgainst: string | null;
attachmentCount: number;
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
}
/**
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
*
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
* or manager sits down with them, records what was actually wrong, and attaches the paper that
* supports the decision.
*/
@Component({
selector: 'mifosx-voucher-exceptions',
templateUrl: './voucher-exceptions.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./voucher-exceptions.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherExceptionsComponent implements OnInit {
private service = inject(CashierService);
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
exceptions: VoucherException[] = [];
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
canResolve = false;
loading = false;
loaded = false;
errorMsg = '';
// Resolution panel
resolveRow: VoucherException | null = null;
resolutionStatus = '';
resolutionNotes = '';
resolveSaving = false;
// Attachments for the voucher being resolved
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
readonly resolutionOptions = [
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.voucherExceptions(this.filter)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.exceptions = res?.exceptions ?? [];
this.canResolve = !!res?.canResolve;
});
}
difference(e: VoucherException): number {
if (e.slipAmount == null) return 0;
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
}
/** True when the flag was about something other than the amount. */
amountsAgree(e: VoucherException): boolean {
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
}
openResolve(e: VoucherException): void {
this.resolveRow = e;
this.resolutionStatus = '';
this.resolutionNotes = '';
this.loadAttachments(e);
}
closeResolve(): void {
this.resolveRow = null;
this.attachments = [];
}
private loadAttachments(e: VoucherException): void {
this.attachments = [];
this.attachLoading = true;
this.service
.listVoucherAttachments(e.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.resolveRow;
if (!file || !row) return;
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo,
file.name,
file.type || 'application/octet-stream',
String(reader.result),
'Supporting document for exception resolution'
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.loadAttachments(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
get canSubmitResolution(): boolean {
return (
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
);
}
saveResolution(): void {
const row = this.resolveRow;
if (!row) return;
this.resolveSaving = true;
this.errorMsg = '';
this.service
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.resolveSaving = false;
if (res) {
this.closeResolve();
this.load();
}
});
}
resolutionLabel(status: string | null): string {
if (!status) return '';
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
}
/** Date and time — an exception queue spans days, so the day matters here. */
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
}
+19 -31
View File
@@ -20,37 +20,29 @@ import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
export class DividendClaimRequestService { export class DividendClaimRequestService {
private http = inject(HttpClient); private http = inject(HttpClient);
/**
* Records a claim for an unclaimed dividend, collected as a Demand Draft.
*
* A dividend is only ever paid out as a draft — the savings-credit, cash and loan-set-off routes
* this endpoint once accepted are retired, so the payload carries just what a draft needs: how
* much, drawn in whose favour, and payable where.
*/
submit( submit(
lockId: number, lockId: number,
breakdown: { claim: {
loanId?: number; ddApplyAmount: number;
loanApplyAmount?: number; ddBeneficiaryName: string;
savingsAccountId?: number; ddPayableAtBranch: string;
savingsApplyAmount?: number; ddPayableAtCity: string;
cashApplyAmount?: number;
tokNo?: string;
} }
): Observable<any> { ): Observable<any> {
const body: any = { lockId }; return this.http.post('/clerk/dividend-claim-requests', {
if (breakdown.loanId != null) { lockId,
body.loanId = breakdown.loanId; ddApplyAmount: String(claim.ddApplyAmount),
} ddBeneficiaryName: claim.ddBeneficiaryName,
if (breakdown.loanApplyAmount != null) { ddPayableAtBranch: claim.ddPayableAtBranch,
body.loanApplyAmount = String(breakdown.loanApplyAmount); ddPayableAtCity: claim.ddPayableAtCity
} });
if (breakdown.savingsAccountId != null) {
body.savingsAccountId = breakdown.savingsAccountId;
}
if (breakdown.savingsApplyAmount != null) {
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
}
if (breakdown.cashApplyAmount != null) {
body.cashApplyAmount = String(breakdown.cashApplyAmount);
}
if (breakdown.tokNo != null) {
body.tokNo = breakdown.tokNo;
}
return this.http.post('/clerk/dividend-claim-requests', body);
} }
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request // Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
@@ -68,10 +60,6 @@ export class DividendClaimRequestService {
}); });
} }
getOverdueLoans(clientId: number): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
}
list(status = 'PENDING'): Observable<any> { list(status = 'PENDING'): Observable<any> {
return this.http.get('/clerk/dividend-claim-requests', { params: { status } }); return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
} }
@@ -200,11 +200,47 @@
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button> <button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</label> </label>
</div> </div>
<!-- Financial-year chips, built from the locks themselves: a new declaration adds its year here
on its own, and a year with nothing left to show stops being offered. Shown even for a single
year, so the filter is visibly there before a second year exists to use it on. -->
@if (lockYears.length > 0) {
<div class="dd-year-filter">
<span class="dd-year-filter__label">Financial year</span>
<div class="dd-filter-group">
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockYearFilter === 'ALL'"
(click)="setLockYearFilter('ALL')"
>
All years
</button>
@for (year of lockYears; track year) {
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockYearFilter === year"
(click)="setLockYearFilter(year)"
>
FY {{ year }}
</button>
}
</div>
<span class="dd-year-filter__total">
{{ visibleLocks.length }} lock{{ visibleLocks.length === 1 ? '' : 's' }} ·
{{ visibleLocksTotal | number: '1.2-2' }}
</span>
</div>
}
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Client</th> <th>Client</th>
<th>Savings Account</th> <!-- Which declaration this lock came from. A member can hold several unclaimed dividends at
once — this year's plus an earlier year they never collected — and amount alone cannot
tell them apart. -->
<th>Dividend</th>
<th class="num">Amount</th> <th class="num">Amount</th>
<th>Locked Date</th> <th>Locked Date</th>
<th>Release Due</th> <th>Release Due</th>
@@ -218,16 +254,23 @@
<tr> <tr>
<td colspan="8" class="pg-empty">Loading…</td> <td colspan="8" class="pg-empty">Loading…</td>
</tr> </tr>
} @else if (locksLoaded && locks.length === 0) { } @else if (locksLoaded && visibleLocks.length === 0) {
<tr> <tr>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td> <td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
</tr> </tr>
} @else { } @else {
@for (l of locks; track l.lockId) { @for (l of visibleLocks; track l.lockId) {
<tr> <tr>
<td>{{ l.clientName }}</td> <td>{{ l.clientName }}</td>
<td> <td>
<span class="pg-chip">{{ l.savingsAccountNo }}</span> @if (l.financialYear) {
<div class="dd-dividend-year">FY {{ l.financialYear }}</div>
<div class="dd-dividend-period">
{{ l.productName }} · {{ l.periodStartDate }} → {{ l.periodEndDate }}
</div>
} @else {
—
}
</td> </td>
<td class="num">{{ l.amount | number: '1.2-2' }}</td> <td class="num">{{ l.amount | number: '1.2-2' }}</td>
<td>{{ l.lockedDate }}</td> <td>{{ l.lockedDate }}</td>
@@ -249,48 +292,33 @@
<td colspan="8"> <td colspan="8">
<div class="dd-apply-panel dd-apply-panel--wrap"> <div class="dd-apply-panel dd-apply-panel--wrap">
<div class="dd-apply-panel-title"> <div class="dd-apply-panel-title">
Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash) Unclaimed amount: {{ l.amount | number: '1.2-2' }}
@if (l.financialYear) {
<span> (FY {{ l.financialYear }} dividend)</span>
}
— issue as a Demand Draft
</div> </div>
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) { <!-- A dividend is collected only as a Demand Draft. Amount defaults to the full
unclaimed balance; anything left unclaimed stays on the lock for a later visit. -->
<label class="inv-form-field"> <label class="inv-form-field">
<span>Apply to Overdue Loan</span> <span>DD Amount</span>
<select [(ngModel)]="claimLoanId[l.lockId]"> <input type="number" [(ngModel)]="claimDdAmount[l.lockId]" min="0" step="0.01" />
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label> </label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Loan Amount</span> <span>Beneficiary Name</span>
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" /> <input type="text" [(ngModel)]="claimDdBeneficiary[l.lockId]" />
</label> </label>
} @else {
<div class="pg-empty-inline">No overdue loan for this client.</div>
}
<label class="inv-form-field"> <label class="inv-form-field">
<span>Credit to Savings</span> <span>Payable At — Branch</span>
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" /> <input type="text" [(ngModel)]="claimDdPayableAtBranch[l.lockId]" />
</label> </label>
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
<label class="inv-form-field"> <label class="inv-form-field">
<span>Savings Account ID (no linked account — enter one to credit)</span> <span>Payable At — City</span>
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" /> <input type="text" [(ngModel)]="claimDdPayableAtCity[l.lockId]" />
</label> </label>
}
<label class="inv-form-field">
<span>Cash</span>
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (claimCashAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Token No. (for cashier hand-over)</span>
<input type="text" [(ngModel)]="claimTokNo[l.lockId]" />
</label>
}
<div class="dd-apply-panel-total"> <div class="dd-apply-panel-total">
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }} Claiming: {{ claimDdAmount[l.lockId] || 0 | number: '1.2-2' }} /
{{ l.amount | number: '1.2-2' }}
</div> </div>
@if (claimError) { @if (claimError) {
<div class="dd-apply-error">{{ claimError }}</div> <div class="dd-apply-error">{{ claimError }}</div>
@@ -315,6 +343,21 @@
<div class="pg-card" style="margin-top: 16px"> <div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div> <div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
<!-- The draft just drawn. Approving refreshes the list, so the row it came from is gone by the
time the clerk looks — and the DD number is exactly what they need to write on the
counterfoil and hand over. -->
@if (lastIssuedDd) {
<div class="dd-issued-banner">
<div class="dd-issued-banner__main">
Demand Draft <strong>{{ lastIssuedDd.ddNumber }}</strong> issued to {{ lastIssuedDd.clientName }} for
{{ lastIssuedDd.amount | number: '1.2-2' }}
</div>
<button type="button" class="dd-issued-banner__close" (click)="lastIssuedDd = null" aria-label="Dismiss">
&times;
</button>
</div>
}
<div class="inv-form-grid" style="padding: 12px 16px"> <div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end"> <label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()"> <button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
@@ -326,53 +369,44 @@
<thead> <thead>
<tr> <tr>
<th>Client</th> <th>Client</th>
<th>Savings Account</th> <!-- A dividend is collected only as a Demand Draft, so what the checker needs to see is the
<th class="num">To Loan</th> draft about to be drawn: for how much, in whose favour, payable where. -->
<th class="num">To Savings</th> <th class="num">DD Amount</th>
<th class="num">Cash</th> <th>Beneficiary</th>
<th>Payable At</th>
<th class="num">Unclaimed Balance</th> <th class="num">Unclaimed Balance</th>
<th>Client Loan Status</th> <th>Requested By</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (claimRequestsLoading) { @if (claimRequestsLoading) {
<tr> <tr>
<td colspan="8" class="pg-empty">Loading…</td> <td colspan="7" class="pg-empty">Loading…</td>
</tr> </tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) { } @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr> <tr>
<td colspan="8" class="pg-empty">No dividend claim requests pending review.</td> <td colspan="7" class="pg-empty">No dividend claim requests pending review.</td>
</tr> </tr>
} @else { } @else {
@for (req of claimRequests; track req.id) { @for (req of claimRequests; track req.id) {
<tr> <tr>
<td>{{ req.client_name }}</td> <td>{{ req.client_name }}</td>
<td class="num">{{ req.dd_apply_amount | number: '1.2-2' }}</td>
<td>{{ req.dd_beneficiary_name || '—' }}</td>
<td> <td>
<span class="pg-chip">{{ req.savings_account_no }}</span> @if (req.dd_payable_at_branch || req.dd_payable_at_city) {
</td> {{ req.dd_payable_at_branch }}
<td class="num"> @if (req.dd_payable_at_branch && req.dd_payable_at_city) {
{{ ,
req.loan_apply_amount > 0 }
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')' {{ req.dd_payable_at_city }}
: '—' } @else {
}} —
</td>
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">
{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
@if (req.cash_apply_amount > 0 && req.tok_no) {
<div class="pg-empty-inline">Tok: {{ req.tok_no }}</div>
} }
</td> </td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td> <td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td> <td>{{ req.clerk_name }}</td>
@if (req.has_overdue_loan) {
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
} @else {
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
}
</td>
<td> <td>
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)"> <button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }} {{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
@@ -387,7 +421,7 @@
</tr> </tr>
@if (expandedRejectClaimId === req.id) { @if (expandedRejectClaimId === req.id) {
<tr class="dd-apply-row"> <tr class="dd-apply-row">
<td colspan="8"> <td colspan="7">
<div class="dd-apply-panel"> <div class="dd-apply-panel">
<label class="inv-form-field"> <label class="inv-form-field">
<span>Rejection Reason</span> <span>Rejection Reason</span>
@@ -169,3 +169,78 @@
color: #fff; color: #fff;
} }
} }
/* Which declaration a lock belongs to. The financial year leads because that is how a member asks
for it ("my 2026-27 dividend"); the product and exact period sit under it for the clerk who needs
to be certain, without crowding the row. */
.dd-dividend-year {
font-weight: 600;
font-size: 13px;
color: #1e293b;
white-space: nowrap;
}
.dd-dividend-period {
font-size: 11.5px;
color: #64748b;
white-space: nowrap;
}
/* Financial-year chips. Sits below the status chips as a second, narrower axis over the same list —
status is fetched from the server, year filters what came back. */
.dd-year-filter {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 0 16px 12px;
&__label {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__total {
margin-left: auto;
font-size: 12.5px;
color: #475569;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
/* The DD number just drawn. Approving refreshes the pending list, so the row it came from is gone
by the time the clerk reads it — and that number is what goes on the counterfoil. */
.dd-issued-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 16px 0;
padding: 10px 14px;
border: 1px solid #86efac;
border-radius: 6px;
background: #f0fdf4;
color: #166534;
font-size: 13.5px;
&__main {
flex: 1;
}
&__close {
border: none;
background: none;
color: #166534;
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
&:hover {
color: #14532d;
}
}
}
@@ -56,19 +56,30 @@ export class DividendDashboardComponent implements OnInit {
declarationsLoading = false; declarationsLoading = false;
declarationsLoaded = false; declarationsLoaded = false;
/** Every lock the status filter returned, before the year filter narrows it. */
locks: any[] = []; locks: any[] = [];
locksLoading = false; locksLoading = false;
locksLoaded = false; locksLoaded = false;
lockStatusFilter = 'ALL'; lockStatusFilter = 'ALL';
/**
* Financial years that actually have locks, newest first — derived from the data rather than
* listed anywhere, so a new declaration adds its year on its own and a year whose locks are all
* gone stops being offered. No maintenance as years roll over.
*/
lockYears: string[] = [];
lockYearFilter = 'ALL';
expandedClaimLockId: number | null = null; expandedClaimLockId: number | null = null;
claimOverdueLoans: Record<number, any[]> = {}; /**
claimLoanId: Record<number, number | null> = {}; * A dividend is collected as a Demand Draft and nothing else — the savings-credit, cash and
claimLoanAmount: Record<number, number | null> = {}; * loan-set-off routes are retired, so the panel only gathers what a draft needs: how much, drawn
claimSavingsAmount: Record<number, number | null> = {}; * in whose favour, and payable where.
claimSavingsAccountOverride: Record<number, number | null> = {}; */
claimCashAmount: Record<number, number | null> = {}; claimDdAmount: Record<number, number | null> = {};
claimTokNo: Record<number, string> = {}; claimDdBeneficiary: Record<number, string> = {};
claimDdPayableAtBranch: Record<number, string> = {};
claimDdPayableAtCity: Record<number, string> = {};
claimSubmitting = false; claimSubmitting = false;
claimError: string | null = null; claimError: string | null = null;
@@ -77,6 +88,13 @@ export class DividendDashboardComponent implements OnInit {
claimRequestsLoaded = false; claimRequestsLoaded = false;
claimActioningId: number | null = null; claimActioningId: number | null = null;
claimActionError: Record<number, string> = {}; claimActionError: Record<number, string> = {};
/**
* The Demand Draft just drawn by approving a claim. Held so the clerk can read off the number
* after the row it came from has left the pending list — it is what goes on the counterfoil and
* into the member's hand, and there is nowhere else on this screen to find it.
*/
lastIssuedDd: { ddNumber: string; clientName: string; amount: number } | null = null;
expandedRejectClaimId: number | null = null; expandedRejectClaimId: number | null = null;
rejectReason: Record<number, string> = {}; rejectReason: Record<number, string> = {};
@@ -95,74 +113,45 @@ export class DividendDashboardComponent implements OnInit {
} }
this.expandedClaimLockId = lock.lockId; this.expandedClaimLockId = lock.lockId;
this.claimError = null; this.claimError = null;
this.claimLoanId[lock.lockId] = null; // Defaults to the whole unclaimed balance and the member's own name, which is what a member
this.claimLoanAmount[lock.lockId] = null; // asks for almost every time; the clerk overrides only when the member wants otherwise.
this.claimSavingsAmount[lock.lockId] = Number(lock.amount); this.claimDdAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null; this.claimDdBeneficiary[lock.lockId] = lock.clientName ?? '';
this.claimCashAmount[lock.lockId] = null; this.claimDdPayableAtBranch[lock.lockId] = '';
this.claimTokNo[lock.lockId] = ''; this.claimDdPayableAtCity[lock.lockId] = '';
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
.subscribe((loans: any[]) => {
this.claimOverdueLoans[lock.lockId] = loans ?? [];
if (loans && loans.length > 0) {
this.claimLoanId[lock.lockId] = loans[0].loanId;
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
this.claimLoanAmount[lock.lockId] = loanAmt;
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
}
});
}
claimTotal(lock: any): number {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
);
} }
submitClaim(lock: any): void { submitClaim(lock: any): void {
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0); const ddAmount = Number(this.claimDdAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0); const beneficiary = this.claimDdBeneficiary[lock.lockId]?.trim();
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a const payableAtBranch = this.claimDdPayableAtBranch[lock.lockId]?.trim();
// dividend share often works out to paise — floor to the nearest rupee so the denomination const payableAtCity = this.claimDdPayableAtCity[lock.lockId]?.trim();
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0)); if (ddAmount <= 0) {
const total = loanAmount + savingsAmount + cashAmount; this.claimError = 'Enter a DD amount greater than zero.';
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
return; return;
} }
if (total > Number(lock.amount)) { if (ddAmount > Number(lock.amount)) {
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`; this.claimError = `DD amount (${ddAmount}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return; return;
} }
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) { if (!beneficiary) {
this.claimError = 'Select a loan to apply the loan amount towards.'; this.claimError = 'Enter the beneficiary name — a Demand Draft cannot be drawn without a payee.';
return; return;
} }
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) { if (!payableAtBranch || !payableAtCity) {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.'; this.claimError = 'Enter both the branch and city the Demand Draft is payable at.';
return;
}
const tokNo = this.claimTokNo[lock.lockId]?.trim();
if (cashAmount > 0 && !tokNo) {
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
return; return;
} }
this.claimSubmitting = true; this.claimSubmitting = true;
this.claimError = null; this.claimError = null;
this.claimRequestService this.claimRequestService
.submit(lock.lockId, { .submit(lock.lockId, {
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined, ddApplyAmount: ddAmount,
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined, ddBeneficiaryName: beneficiary,
savingsAccountId: ddPayableAtBranch: payableAtBranch,
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined, ddPayableAtCity: payableAtCity
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
tokNo: cashAmount > 0 ? tokNo : undefined
}) })
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -193,8 +182,18 @@ export class DividendDashboardComponent implements OnInit {
this.claimActioningId = req.id; this.claimActioningId = req.id;
delete this.claimActionError[req.id]; delete this.claimActionError[req.id];
this.claimRequestService.approve(req.id).subscribe({ this.claimRequestService.approve(req.id).subscribe({
next: () => { next: (res: any) => {
this.claimActioningId = null; this.claimActioningId = null;
// The draft number is the one thing the clerk needs off this action — it is what they write
// on the counterfoil and hand to the member. Approving refreshes the list, so the row this
// came from disappears; without surfacing it here the number would be lost.
if (res?.ddNumber) {
this.lastIssuedDd = {
ddNumber: res.ddNumber,
clientName: req.client_name,
amount: Number(res.totalDisbursed) || Number(req.dd_apply_amount) || 0
};
}
this.loadClaimRequests(); this.loadClaimRequests();
this.loadLocks(); this.loadLocks();
this.loadSummary(); this.loadSummary();
@@ -269,16 +268,49 @@ export class DividendDashboardComponent implements OnInit {
.pipe(catchError(() => of([]))) .pipe(catchError(() => of([])))
.subscribe((res: any[]) => { .subscribe((res: any[]) => {
this.locks = res ?? []; this.locks = res ?? [];
this.rebuildLockYears();
this.locksLoading = false; this.locksLoading = false;
this.locksLoaded = true; this.locksLoaded = true;
}); });
} }
/**
* Rebuilds the year chips from whatever locks came back, newest first.
*
* A year the current status filter has no locks for disappears from the chips, so the selected
* year is reset to ALL when that happens — otherwise the table would show nothing with no
* indication why.
*/
private rebuildLockYears(): void {
this.lockYears = [...new Set(this.locks.map((l) => l.financialYear).filter((y) => !!y))].sort((a, b) =>
b.localeCompare(a)
);
if (this.lockYearFilter !== 'ALL' && !this.lockYears.includes(this.lockYearFilter)) {
this.lockYearFilter = 'ALL';
}
}
/** Locks the table actually renders — the status filter is applied server-side, the year here. */
get visibleLocks(): any[] {
return this.lockYearFilter === 'ALL'
? this.locks
: this.locks.filter((l) => l.financialYear === this.lockYearFilter);
}
/** Total of what is on screen, so a year's worth of dividend can be read off directly. */
get visibleLocksTotal(): number {
return this.visibleLocks.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
}
setLockStatusFilter(status: string): void { setLockStatusFilter(status: string): void {
this.lockStatusFilter = status; this.lockStatusFilter = status;
this.loadLocks(); this.loadLocks();
} }
setLockYearFilter(year: string): void {
this.lockYearFilter = year;
}
lockStatusClass(status: string): string { lockStatusClass(status: string): string {
switch (status) { switch (status) {
case 'UNCLAIMED': case 'UNCLAIMED':
@@ -263,7 +263,6 @@
<mat-form-field appearance="outline" class="name-field last-name"> <mat-form-field appearance="outline" class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span> <span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
<<<<<<< Updated upstream
<input <input
matInput matInput
required required
@@ -272,9 +271,6 @@
(blur)="capitalizeControl('lastname')" (blur)="capitalizeControl('lastname')"
style="text-transform: capitalize" style="text-transform: capitalize"
/> />
=======
<input matInput required formControlName="lastname" autocomplete="off" (input)="formatName('lastname')" />
>>>>>>> Stashed changes
@if (createClientForm.controls.lastname.hasError('required')) { @if (createClientForm.controls.lastname.hasError('required')) {
<mat-error>Last name is <strong>required</strong></mat-error> <mat-error>Last name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.lastname.hasError('pattern')) { } @else if (createClientForm.controls.lastname.hasError('pattern')) {
+15 -1
View File
@@ -47,7 +47,12 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
private handleError(response: HttpErrorResponse, request: HttpRequest<any>): Observable<HttpEvent<any>> { private handleError(response: HttpErrorResponse, request: HttpRequest<any>): Observable<HttpEvent<any>> {
const status = response.status; const status = response.status;
const errorBody = response.error; const errorBody = response.error;
let errorMessage = errorBody?.developerMessage || response.message; // defaultUserMessage first: it is the sentence written for the person reading it, while
// developerMessage is for the log. Falling back to response.message last, since that is
// Angular's own "Http failure response for <url>: 409 Conflict" — a description of the
// transport, not of what went wrong, and useless to whoever is looking at the dialog.
let errorMessage =
errorBody?.defaultUserMessage || errorBody?.developerMessage || errorBody?.message || response.message;
if (errorBody?.errors?.[0]) { if (errorBody?.errors?.[0]) {
errorMessage = errorBody.errors[0].defaultUserMessage || errorBody.errors[0].developerMessage; errorMessage = errorBody.errors[0].defaultUserMessage || errorBody.errors[0].developerMessage;
} }
@@ -104,6 +109,15 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
type: this.translate.instant('error.resource.not.found'), type: this.translate.instant('error.resource.not.found'),
message: errorMessage || 'Resource does not exist!' message: errorMessage || 'Resource does not exist!'
}; };
} else if (status === 409) {
// A 409 is not a failure of permission or of the request itself — the action is simply not
// allowed in the record's current state (already approved, maker cannot check their own work,
// a guarantee still outstanding). The server always explains which, so its message is shown
// as-is under a title that does not contradict it.
alert = {
type: 'Action Not Allowed',
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) { } else if (status === 500) {
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' }; alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
} else if (status === 501) { } else if (status === 501) {
+78 -7
View File
@@ -173,6 +173,20 @@
Today's Activity Today's Activity
</a> </a>
} }
<!-- Outside the clerkAccountLoaded block on purpose: an open exception has to be
reachable whether or not the clerk currently has an account loaded. -->
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-exceptions')"
[routerLink]="['/cashier/voucher-exceptions']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Exceptions
</a>
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span> <span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav> </nav>
} @else { } @else {
@@ -234,6 +248,20 @@
> >
<fa-icon icon="history"></fa-icon> Cash History <fa-icon icon="history"></fa-icon> Cash History
</a> </a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/day-book']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="book"></fa-icon> Day Book
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-exceptions']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
</a>
<a <a
class="cashier-nav__item" class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']" [routerLink]="['/cashier/closing-report']"
@@ -430,14 +458,13 @@
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier <fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a> </a>
<a <a class="ni" tabindex="0" [matMenuTriggerFor]="clerkMenu" *mifosxHasPermission="'MENU_CLERK'">
class="ni"
tabindex="0"
routerLink="/clerk/dashboard"
(click)="hideShellLayout = true"
*mifosxHasPermission="'MENU_CLERK'"
>
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Clerk <fa-icon icon="id-card" class="ni-ico"></fa-icon>Clerk
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="branchOpsMenu" *mifosxHasPermission="'MENU_BRANCH_OPS'">
<fa-icon icon="building" class="ni-ico"></fa-icon>Branch Ops
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a> </a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'"> <a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'">
<fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing <fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing
@@ -582,6 +609,9 @@
</mat-menu> </mat-menu>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown"> <mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0"> <button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports <i class="fa fa-th-list menu-item-icon"></i> All Reports
</button> </button>
@@ -993,6 +1023,41 @@
} }
<!-- Sub Menu Dropdowns --> <!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
</mat-menu>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> My Disbursement Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</mat-menu>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown"> <mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']"> <button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry <fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
@@ -1027,6 +1092,12 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']"> <button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History <fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button> </button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']"> <button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry <fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button> </button>
+24 -17
View File
@@ -47,11 +47,11 @@ import { ViewTaxComponentComponent } from './manage-tax-components/view-tax-comp
import { CreateTaxComponentComponent } from './manage-tax-components/create-tax-component/create-tax-component.component'; import { CreateTaxComponentComponent } from './manage-tax-components/create-tax-component/create-tax-component.component';
import { EditTaxComponentComponent } from './manage-tax-components/edit-tax-component/edit-tax-component.component'; import { EditTaxComponentComponent } from './manage-tax-components/edit-tax-component/edit-tax-component.component';
import { ViewTaxGroupComponent } from './manage-tax-groups/view-tax-group/view-tax-group.component'; import { ViewTaxGroupComponent } from './manage-tax-groups/view-tax-group/view-tax-group.component';
import { ShareProductsDividendsComponent } from './share-products/dividends-share-product/dividends.components';
import { CreateRecurringDepositProductComponent } from './recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component'; import { CreateRecurringDepositProductComponent } from './recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component';
import { CreateDividendComponent } from './share-products/create-dividend/create-dividend.component';
import { ViewFixedDepositProductComponent } from './fixed-deposit-products/view-fixed-deposit-product/view-fixed-deposit-product.component'; import { ViewFixedDepositProductComponent } from './fixed-deposit-products/view-fixed-deposit-product/view-fixed-deposit-product.component';
import { ViewDividendComponent } from './share-products/view-dividend/view-dividend.component'; import { ViewDividendComponent } from './share-products/view-dividend/view-dividend.component';
import { AllProductsDividendsComponent } from './share-products/all-products-dividends/all-products-dividends.component';
import { DeclareDividendComponent } from './share-products/all-products-dividends/declare-dividend/declare-dividend.component';
import { CreateTaxGroupComponent } from './manage-tax-groups/create-tax-group/create-tax-group.component'; import { CreateTaxGroupComponent } from './manage-tax-groups/create-tax-group/create-tax-group.component';
import { EditTaxGroupComponent } from './manage-tax-groups/edit-tax-group/edit-tax-group.component'; import { EditTaxGroupComponent } from './manage-tax-groups/edit-tax-group/edit-tax-group.component';
import { CreateProductMixComponent } from './products-mix/create-product-mix/create-product-mix.component'; import { CreateProductMixComponent } from './products-mix/create-product-mix/create-product-mix.component';
@@ -370,6 +370,23 @@ const routes: Routes = [
shareProductsTemplate: ShareProductsTemplateResolver shareProductsTemplate: ShareProductsTemplateResolver
} }
}, },
// Must stay ahead of ':productId', which would otherwise swallow 'dividends' as a
// product id and route to a product that does not exist.
{
path: 'dividends',
data: { title: 'Dividends', breadcrumb: 'Dividends' },
children: [
{
path: '',
component: AllProductsDividendsComponent
},
{
path: 'declare',
component: DeclareDividendComponent,
data: { title: 'Declare Dividend', breadcrumb: 'Declare', routeParamBreadcrumb: false }
}
]
},
{ {
path: ':productId', path: ':productId',
data: { title: 'View Share Product', breadcrumb: 'productId', routeParamBreadcrumb: 'productId' }, data: { title: 'View Share Product', breadcrumb: 'productId', routeParamBreadcrumb: 'productId' },
@@ -417,25 +434,15 @@ const routes: Routes = [
shareProductAndTemplate: ShareProductAndTemplateResolver shareProductAndTemplate: ShareProductAndTemplateResolver
} }
}, },
// A dividend is now declared for the whole society at once, at
// share-products/dividends, because the board resolves one rate for the year rather
// than one per product. What remains here is the per-product declaration view: the
// member-by-member allocation and the approve action still belong to the product,
// since share capital sits in a separate GL per product.
{ {
path: 'dividends', path: 'dividends',
data: { title: 'Share Products Dividends', breadcrumb: 'Dividends', routeParamBreadcrumb: false }, data: { title: 'Share Products Dividends', breadcrumb: 'Dividends', routeParamBreadcrumb: false },
children: [ children: [
{
path: '',
component: ShareProductsDividendsComponent,
resolve: {
dividends: DividendsResolver
}
},
{
path: 'create',
component: CreateDividendComponent,
data: { title: 'Create Dividend', breadcrumb: 'Create', routeParamBreadcrumb: false },
resolve: {
shareProduct: ShareProductResolver
}
},
{ {
path: ':dividendId', path: ':dividendId',
component: ViewDividendComponent, component: ViewDividendComponent,
+104 -3
View File
@@ -190,6 +190,58 @@ export class ProductsService {
return this.http.get(`/shareproduct/${shareProductId}/dividend`); return this.http.get(`/shareproduct/${shareProductId}/dividend`);
} }
/**
* Every dividend declaration, across all share products.
*
* The board resolves one rate for the society, not one per share product, so the register that
* matters is the society-wide one. Each product still carries its own declaration row underneath,
* because share capital sits in a separate GL per product.
* @returns {Observable<any>} Declarations, newest period first.
*/
getAllProductsDividends(): Observable<any> {
return this.http.get('/shareproducts/dividends');
}
/**
* What a rate would cost, broken down per share product, before anything is declared.
*
* A rate is a promise about each member's own paid-up capital, so what it costs depends on how
* much capital each product's members held and for how long. Showing that split first is what
* makes one cross-product decision safe to take.
* @param fromDate Period start, yyyy-MM-dd.
* @param toDate Period end, yyyy-MM-dd.
* @param ratePercentage Rate per annum.
* @returns {Observable<any>} `{ totalAmount, products: [{ productName, amount, eligible, reason }] }`.
*/
previewAllProductsDividend(
fromDate: string,
toDate: string,
ratePercentage: number,
dividendBasis: string
): Observable<any> {
const params: any = { fromDate, toDate };
if (ratePercentage != null) {
params.ratePercentage = ratePercentage;
}
if (dividendBasis) {
params.dividendBasis = dividendBasis;
}
return this.http.get('/shareproducts/dividends/preview', { params });
}
/**
* Declares one dividend across every eligible share product at once.
*
* Products with no eligible members, or one already declared for this period, are skipped and
* reported rather than failing the run — one ineligible product is not a reason to leave the
* rest undeclared.
* @param dividendData `{ dividendPeriodStartDate, dividendPeriodEndDate, ratePercentage }`.
* @returns {Observable<any>} `{ declarationsCreated, created, skipped }`.
*/
createAllProductsDividend(dividendData: any): Observable<any> {
return this.http.post('/shareproducts/dividends', dividendData);
}
/** /**
* Create Dividend. * Create Dividend.
* @param shareProductId Share Product Id. * @param shareProductId Share Product Id.
@@ -200,15 +252,64 @@ export class ProductsService {
return this.http.post(`/shareproduct/${shareProductId}/dividend`, dividendData); return this.http.post(`/shareproduct/${shareProductId}/dividend`, dividendData);
} }
/**
* Weighted paid-up share capital for a period, and what a given rate per annum on it costs.
*
* This is what makes a declared "9%" mean 9% on each member's own capital — the promise an AGM
* resolution actually makes — rather than 9% of the society's profit split across however many
* members happen to hold shares that year.
* @param shareProductId Share Product Id.
* @param fromDate Period start, yyyy-MM-dd.
* @param toDate Period end, yyyy-MM-dd.
* @param ratePercentage Rate per annum; omit to get the weighted capital alone.
* @returns {Observable<any>} `{ weightedCapital, requiredAmount, eligibleAccounts,
* minimumActivePeriodDays }`.
*/
getDividendCapitalPreview(
shareProductId: string,
fromDate: string,
toDate: string,
ratePercentage?: number
): Observable<any> {
let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
if (ratePercentage != null) {
httpParams = httpParams.set('ratePercentage', String(ratePercentage));
}
return this.http.get(`/shareproduct/${shareProductId}/dividend-capital-preview`, { params: httpParams });
}
/**
* Every member's share of one dividend declaration.
*
* Unpaginated on purpose. The screen this feeds totals the rows it receives and shows that as the
* declaration's amount, so a page-sized slice made the total wrong — a 22-member declaration of
* ₹22,190 read as ₹0.00 because the first ten members all happened to fall below the minimum. An
* approval decision cannot be taken against a figure that reflects only part of the register.
*/
getDividendData(shareProductId: any, dividendId: any): Observable<any> { getDividendData(shareProductId: any, dividendId: any): Observable<any> {
const httpParams = new HttpParams() const httpParams = new HttpParams()
.set('dateFormat', this.settingsService.dateFormat) .set('dateFormat', this.settingsService.dateFormat)
.set('limit', '10') .set('locale', this.settingsService.language.code);
.set('locale', this.settingsService.language.code)
.set('offset', '0');
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}`, { params: httpParams }); return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}`, { params: httpParams });
} }
/**
* What a declaration did NOT pay out to members: paise shaved off by rounding each entitlement
* down to a whole rupee, plus entitlements dropped for falling under the minimum worth drawing a
* Demand Draft for. Both are swept to Bank Profit at declaration time.
* @returns {Observable<any>} `{ declaredAmount, allocatedAmount, residueAmount, memberCount,
* belowMinimumCount, belowMinimumAmount, sweptToProfitDate, glTransactionId }`, or null for
* declarations made before this was tracked.
*/
getDividendRoundingResidue(shareProductId: any, dividendId: any): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}/rounding-residue`);
}
/** Every declaration's rounding residue for one product, newest first, with a running total. */
getDividendRoundingResidueHistory(shareProductId: any): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend/rounding-residue`);
}
approveDividend(shareProductId: any, dividendId: any, data: any): Observable<any> { approveDividend(shareProductId: any, dividendId: any, data: any): Observable<any> {
const httpParams = new HttpParams().set('command', 'approve'); const httpParams = new HttpParams().set('command', 'approve');
return this.http.put(`/shareproduct/${shareProductId}/dividend/${dividendId}`, data, { params: httpParams }); return this.http.put(`/shareproduct/${shareProductId}/dividend/${dividendId}`, data, { params: httpParams });
@@ -0,0 +1,182 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div class="pl-header-text">
<h2>Dividends</h2>
<p>One rate per financial year, applied across every share product.</p>
</div>
</div>
<div class="pl-header-right">
<div class="pl-count-chip" matTooltip="Declarations on record">
<fa-icon icon="layer-group"></fa-icon>
<strong>{{ dividends.length }}</strong>
<span>{{ dividends.length === 1 ? 'declaration' : 'declarations' }}</span>
</div>
<button
class="pl-create-btn"
type="button"
[routerLink]="['declare']"
*mifosxHasPermission="'CREATE_SHAREDIVIDEND'"
>
<fa-icon icon="plus"></fa-icon>
<span>Declare Dividend</span>
</button>
</div>
</div>
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--blue">
<div class="pl-kpi-ic"><fa-icon icon="rupee-sign"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalDeclared | number: '1.2-2' }}</div>
<div class="pl-kpi-l">
Total Declared
@if (pendingCount > 0) {
<span class="apd-pending">· {{ pendingCount }} awaiting approval</span>
}
</div>
</div>
</div>
<div class="pl-kpi pl-kpi--teal">
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ memberCount | number }}</div>
<div class="pl-kpi-l">Members Receiving</div>
</div>
</div>
<div class="pl-kpi pl-kpi--purple">
<div class="pl-kpi-ic"><fa-icon icon="coins"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ productCount | number }}</div>
<div class="pl-kpi-l">Share Products Covered</div>
</div>
</div>
</div>
@if (financialYears.length > 1) {
<div class="apd-years">
<span class="apd-years-label">Financial Year</span>
@for (year of financialYears; track year) {
<button
type="button"
class="apd-year-chip"
[class.apd-year-chip--on]="selectedYear === year"
(click)="selectYear(year)"
>
{{ year }}
</button>
}
</div>
}
<div class="pl-card">
<div class="pl-toolbar">
<div class="pl-search">
<span class="pl-search-ic"><fa-icon icon="search"></fa-icon></span>
<input type="text" placeholder="Search by product or year…" (keyup)="applyFilter($any($event.target).value)" />
</div>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (loadFailed) {
<div class="apd-empty apd-empty--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<p>Could not load dividends. This is a connection problem, not an empty register.</p>
<button type="button" class="pl-secondary-btn" (click)="loadDividends()">Retry</button>
</div>
} @else if (!loading && dataSource.data.length === 0) {
<div class="apd-empty">
<fa-icon icon="hand-holding-usd"></fa-icon>
<p>No dividend has been declared yet.</p>
</div>
}
<!-- @if, not [hidden]: the wrapper sets overflow-x, which gives it a display value that wins
over the hidden attribute, so [hidden] left the table in the layout without showing it. -->
@if (!loadFailed && dataSource.data.length > 0) {
<div class="pl-table-wrap">
<table mat-table class="pl-table" [dataSource]="dataSource" matSort>
<ng-container matColumnDef="financialYear">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Financial Year</th>
<td mat-cell *matCellDef="let row">
<span class="pl-tag">{{ row.financialYear }}</span>
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Share Product</th>
<td mat-cell *matCellDef="let row">
<div class="pl-product-cell">
<div class="pl-product-icon"><fa-icon icon="coins"></fa-icon></div>
<div class="pl-product-meta">
<div class="pl-product-name">{{ row.productName }}</div>
<div class="pl-product-sub">Declaration #{{ row.dividendId }}</div>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="period">
<th mat-header-cell *matHeaderCellDef>Period</th>
<td mat-cell *matCellDef="let row">
{{ row.periodStartDate | dateFormat }} – {{ row.periodEndDate | dateFormat }}
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Amount</th>
<td mat-cell *matCellDef="let row">
<strong>{{ row.amount | number: '1.2-2' }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="memberCount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Members</th>
<td mat-cell *matCellDef="let row">{{ row.memberCount | number }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<span class="pl-status-pill" [class.apd-status--pending]="!row.approved">
{{ row.approved ? 'Approved' : 'Awaiting Approval' }}
</span>
</td>
</ng-container>
<!-- The approve action lives on the declaration's own page, alongside the member-by-member
allocation. Naming it here rather than relying on the row click makes it findable —
an officer should not have to guess that a row opens somewhere they can approve. -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row">
<button type="button" class="apd-action" (click)="viewDividend(row); $event.stopPropagation()">
{{ row.approved ? 'View' : 'Review & Approve' }}
<fa-icon icon="chevron-right"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row class="pl-row" *matRowDef="let row; columns: displayedColumns" (click)="viewDividend(row)"></tr>
</table>
</div>
}
<!-- pageSize is required, not optional: without it the paginator starts at zero and pages every
row out, leaving a table that looks empty while the data source is full. -->
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -0,0 +1,121 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* Same pink accent as Share Products — this page is part of that area, not a separate one. */
:host {
--pl-accent: #be185d;
--pl-accent-l: #fce7f3;
}
/* Financial-year filter chips. A dividend decision belongs to a year, so the year is the primary
way an officer narrows this register. */
.apd-years {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.apd-years-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-right: 4px;
}
.apd-year-chip {
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: 999px;
padding: 5px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--pl-accent);
color: var(--pl-accent);
}
&--on {
background: var(--pl-accent);
border-color: var(--pl-accent);
color: #fff;
}
}
/* Amber rather than the default pill colour: a declaration awaiting approval has not reached any
member yet, and should not read the same as one that has. */
.apd-status--pending {
background: #fef3c7;
color: #b45309;
}
.apd-pending {
color: #b45309;
font-weight: 500;
}
/* Right-aligned so it reads as the row's action rather than another data column. */
.apd-action {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #e2e8f0;
background: #fff;
color: var(--pl-accent);
border-radius: 6px;
padding: 5px 12px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--pl-accent);
background: var(--pl-accent-l);
}
fa-icon {
font-size: 11px;
}
}
.apd-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 20px;
color: #64748b;
text-align: center;
fa-icon {
font-size: 32px;
color: #cbd5e1;
}
p {
margin: 0;
font-size: 14px;
}
&--error fa-icon {
color: #f59e0b;
}
}
@@ -0,0 +1,200 @@
/**
* 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, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortHeader, MatSortModule } from '@angular/material/sort';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Imports */
import { ProductsService } from '../../products.service';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
/**
* The society's dividend register, across every share product.
*
* A co-operative's board resolves one dividend rate for a financial year — not one rate per share
* product — so this is the level the decision is actually taken and recorded at. Each product still
* carries its own declaration row underneath, because share capital sits in a separate GL per
* product and the per-member allocation runs per product; what is shared is the rate and the period.
*
* Declarations for the same financial year are grouped, so a year that touched two products reads
* as one decision with a split rather than as two unrelated entries.
*/
@Component({
selector: 'mifosx-all-products-dividends',
templateUrl: './all-products-dividends.component.html',
styleUrls: ['./all-products-dividends.component.scss'],
imports: [
CommonModule,
FormsModule,
RouterLink,
FaIconComponent,
// Individual table directives rather than MatTableModule, matching the Share Products list this
// page sits beside — the module form leaves the header/row defs unresolved here and the table
// renders nothing at all, not even headers.
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatSort,
MatSortHeader,
MatSortModule,
MatPaginatorModule,
MatTooltipModule,
MatProgressBarModule,
DateFormatPipe,
HasPermissionDirective
]
})
export class AllProductsDividendsComponent implements OnInit {
private productsService = inject(ProductsService);
private router = inject(Router);
/** Every declaration across all products, newest period first. */
dividends: any[] = [];
dataSource = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'financialYear',
'productName',
'period',
'amount',
'memberCount',
'status',
'actions'
];
loading = true;
loadFailed = false;
/** Distinct financial years present, for the filter chips. */
financialYears: string[] = [];
selectedYear = '';
// The paginator sits outside the table's @if so it resolves statically; the sort lives on the
// table inside it, so it only exists once there are rows to show — applyYearFilter re-attaches
// both every time the rows change, which is when the sort first becomes available.
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
ngOnInit(): void {
this.loadDividends();
}
loadDividends(): void {
this.loading = true;
this.loadFailed = false;
this.productsService.getAllProductsDividends().subscribe({
next: (response: any[]) => {
this.dividends = response || [];
this.financialYears = Array.from(new Set(this.dividends.map((d) => d.financialYear)))
.sort()
.reverse();
this.applyYearFilter();
this.loading = false;
},
error: () => {
// Left empty with a retry rather than showing zeroes, so an unreachable server is never
// mistaken for a year in which no dividend was declared.
this.dividends = [];
this.loadFailed = true;
this.loading = false;
}
});
}
/** Total declared across the rows currently in view. */
get totalDeclared(): number {
return this.visibleRows.reduce((sum, d) => sum + Number(d.amount || 0), 0);
}
/** Declarations still awaiting approval — these have not reached members yet. */
get pendingCount(): number {
return this.visibleRows.filter((d) => !d.approved).length;
}
/** Members receiving a dividend across the rows in view. */
get memberCount(): number {
return this.visibleRows.reduce((sum, d) => sum + Number(d.memberCount || 0), 0);
}
/** Products covered by the rows in view. */
get productCount(): number {
return new Set(this.visibleRows.map((d) => d.productId)).size;
}
private get visibleRows(): any[] {
return this.selectedYear ? this.dividends.filter((d) => d.financialYear === this.selectedYear) : this.dividends;
}
selectYear(year: string): void {
this.selectedYear = this.selectedYear === year ? '' : year;
this.applyYearFilter();
}
/**
* Swaps the rows in place rather than rebuilding the data source, so the paginator and sort stay
* attached — a fresh MatTableDataSource loses both unless they are re-wired every time.
*/
private applyYearFilter(): void {
this.dataSource.data = this.visibleRows;
this.dataSource.paginator = this.paginator;
// Deferred a tick: the sort directive is inside the table's @if, so it does not exist until
// this change detection pass has rendered the rows we just set.
setTimeout(() => {
this.dataSource.sort = this.sort;
});
}
applyFilter(filterValue: string): void {
this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
/**
* Opens a declaration in the existing per-product view, which holds the member-by-member
* allocation and the approve action.
*/
viewDividend(row: any): void {
this.router.navigate(
[
'/products',
'share-products',
row.productId,
'dividends',
row.dividendId
],
{ queryParams: { status: row.approved ? 'Approved' : 'Initiated' } }
);
}
}
@@ -0,0 +1,217 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div class="pl-header-text">
<h2>Declare Dividend</h2>
<p>One rate for the year, applied to every share product at once.</p>
</div>
</div>
</div>
@if (result) {
<!-- The outcome, product by product. Which products were skipped and why matters as much as
which succeeded, so both are shown rather than just a success count. -->
<div class="pl-card dd-panel">
<div class="dd-result-head">
<fa-icon icon="check-circle" class="dd-result-ic"></fa-icon>
<div>
<h3>Dividend declared for {{ result.financialYear }}</h3>
<p>
{{ result.declarationsCreated }}
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
before members can claim.
</p>
</div>
</div>
<table class="dd-table">
<tbody>
@for (row of result.created; track row.productId) {
<tr>
<td>{{ row.productName }}</td>
<td class="dd-amount">{{ row.amount | number: '1.2-2' }}</td>
<td class="dd-ok">Declared</td>
</tr>
}
@for (row of result.skipped; track row.productId) {
<tr class="dd-skipped">
<td>{{ row.productName }}</td>
<td class="dd-amount">—</td>
<td class="dd-reason">{{ row.reason }}</td>
</tr>
}
</tbody>
</table>
<div class="dd-actions">
<button mat-raised-button color="primary" type="button" (click)="done()">View Dividends</button>
</div>
</div>
} @else {
<form [formGroup]="declareForm" (ngSubmit)="submit()">
<div class="pl-card dd-panel">
<div class="dd-fields">
<mat-form-field appearance="outline">
<mat-label>Financial Year</mat-label>
<mat-select formControlName="financialYear">
@for (year of financialYears; track year.label) {
<mat-option [value]="year.label">{{ year.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="dd-basis">
<span class="dd-basis-label">Dividend Basis</span>
<mat-radio-group formControlName="dividendBasis">
<mat-radio-button value="RATE_ON_CAPITAL">Rate on share capital</mat-radio-button>
<mat-radio-button value="PERCENT_OF_PROFIT">Percentage of distributable profit</mat-radio-button>
</mat-radio-group>
<p class="dd-hint">
@if (declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT') {
A share of the year's distributable profit, split across products in proportion to the capital each
carries.
} @else {
Each member receives this rate on their own paid-up capital, pro-rated for how long they held it.
}
</p>
</div>
<div class="dd-fields">
<mat-form-field appearance="outline">
<mat-label>
{{
declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT'
? 'Percentage of Profit'
: 'Dividend Rate (% per annum)'
}}
</mat-label>
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
<span matSuffix>&nbsp;%&nbsp;</span>
</mat-form-field>
</div>
@if (loadingPreview) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (previewError) {
<div class="dd-note dd-note--error">{{ previewError }}</div>
}
@if (preview && !loadingPreview) {
<!-- The appropriation waterfall. Shown in full because the officer is approving a
division of the society's profit, not a single number: ₹2,22,000 on its own gives no
way to tell a small profit from a large one already heavily appropriated, and the
reserve and education rates are configurable so their amounts cannot be inferred. -->
<div class="dd-preview">
<div class="dd-preview-head">Where the money comes from</div>
<div class="dd-preview-row">
<span>
Net profit for {{ preview.financialYear }}
<span class="dd-gl">{{ preview.profitGlCode }}</span>
</span>
<span class="dd-amount">{{ preview.netProfit | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--deduction">
<span>Less statutory reserve ({{ preview.statutoryReservePercentage | number: '1.0-2' }}%)</span>
<span class="dd-amount">− {{ preview.statutoryReserveAmount | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--deduction">
<span>Less education fund ({{ preview.educationFundPercentage | number: '1.0-2' }}%)</span>
<span class="dd-amount">− {{ preview.educationFundAmount | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--subtotal">
<span>Distributable profit</span>
<span class="dd-amount">{{ preview.distributableProfit | number: '1.2-2' }}</span>
</div>
@if (declareForm.value.dividendBasis === 'RATE_ON_CAPITAL') {
<div class="dd-preview-row dd-preview-row--muted">
<span>Weighted share capital</span>
<span class="dd-amount">{{ preview.totalWeightedCapital | number: '1.2-2' }}</span>
</div>
}
</div>
<div class="dd-preview">
<div class="dd-preview-head">
{{
declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT'
? (declareForm.value.ratePercentage || 0) + '% of distributable profit, split by capital'
: (declareForm.value.ratePercentage || 0) + '% on each member’s capital'
}}
</div>
@for (product of eligibleProducts; track product.productId) {
<div class="dd-preview-row">
<span>{{ product.productName }}</span>
<span class="dd-amount">{{ product.amount | number: '1.2-2' }}</span>
</div>
}
@if (eligibleProducts.length > 0) {
<div class="dd-preview-row dd-preview-row--total">
<span>Total to declare</span>
<span class="dd-amount">{{ payableTotal | number: '1.2-2' }}</span>
</div>
<!-- What stays behind. Called out because on approval it leaves the distributable
pool for good — no later year's dividend can draw on it. -->
<div class="dd-preview-row dd-preview-row--muted">
<span>Stays in profit (swept to Bank Profit on approval)</span>
<span class="dd-amount">{{ preview.undistributedRemainder | number: '1.2-2' }}</span>
</div>
}
@for (product of skippedProducts; track product.productId) {
<div class="dd-preview-row dd-preview-row--skipped">
<span>{{ product.productName }}</span>
<span class="dd-reason">
{{ product.alreadyDeclared ? 'Already declared for this year' : product.reason }}
</span>
</div>
}
@if (preview.exceedsDistributableProfit) {
<div class="dd-note dd-note--error">
This is more than the distributable profit for {{ preview.financialYear }}. The declaration will be
rejected.
</div>
}
@if (eligibleProducts.length === 0) {
<div class="dd-note dd-note--error">
No share product is eligible for this year, so there is nothing to declare.
</div>
}
</div>
}
@if (submitError) {
<div class="dd-note dd-note--error">{{ submitError }}</div>
}
<div class="dd-actions">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" type="submit" [disabled]="!canSubmit">
{{ submitting ? 'Declaring…' : 'Declare Dividend' }}
</button>
</div>
</div>
</form>
}
</div>
@@ -0,0 +1,250 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
:host {
--pl-accent: #be185d;
--pl-accent-l: #fce7f3;
}
/* A single-column form reads better centred than pinned to the left edge of a 1400px page — the
eye has nowhere else to go, and a wide empty right margin makes the form look unfinished. */
.dd-panel {
max-width: 720px;
margin: 0 auto;
padding: 22px 24px;
}
/* The header belongs over the form, not over the full page width, or it detaches from it. */
.pl-header {
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
/* The panel is wrapped in a <form>, so centring it needs the form to be the full-width block that
does the centring — margin:auto on the panel alone has no effect inside a default-width form. */
form {
display: block;
width: 100%;
}
.dd-fields {
display: flex;
flex-wrap: wrap;
gap: 16px;
mat-form-field {
flex: 1 1 240px;
}
}
.dd-hint {
margin: 0 0 16px;
font-size: 13px;
color: #64748b;
}
/* Which basis is chosen changes what the percentage means, so it sits above the rate field rather
than below it — the officer picks the meaning before typing the number. */
.dd-basis {
margin: 4px 0 8px;
mat-radio-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.dd-hint {
margin: 6px 0 0;
}
}
.dd-basis-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
/* The cost of the rate, per product. The officer is committing to a rate, but what leaves the
society is an amount — so the amount is shown before the commit, not after. */
.dd-preview {
margin-top: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 16px;
}
.dd-preview-head {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 10px;
}
.dd-preview-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 5px 0;
font-size: 14px;
/* Red, and signed, so an appropriation reads as money leaving the pool rather than another
figure to add up. */
&--deduction {
color: #b42318;
}
/* The line the deductions arrive at — separated from them, but lighter than the final total,
since it is a step in the arithmetic rather than the answer. */
&--subtotal {
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid #cbd5e1;
font-weight: 600;
color: #0f172a;
}
/* Context rather than part of the sum. */
&--muted {
color: #64748b;
font-size: 13px;
}
&--total {
margin-top: 6px;
padding-top: 10px;
border-top: 2px solid #cbd5e1;
font-weight: 700;
color: var(--pl-accent);
}
/* Muted, not hidden: a product that is being skipped still needs to be visibly accounted for. */
&--skipped {
color: #94a3b8;
font-size: 13px;
}
}
.dd-amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Names the account the profit is actually read from, so the figure can be traced to the ledger
rather than taken on trust. */
.dd-gl {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
/* Two stacked panels: where the money comes from, then how it is split. */
.dd-preview + .dd-preview {
margin-top: 12px;
}
.dd-reason {
font-size: 12.5px;
color: #94a3b8;
text-align: right;
}
.dd-note {
margin-top: 12px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
background: #f1f5f9;
color: #334155;
&--error {
background: #fef2f2;
color: #b42318;
}
}
.dd-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
/* Outcome view */
.dd-result-head {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 18px;
h3 {
margin: 0 0 4px;
font-size: 17px;
font-weight: 700;
color: #0f172a;
}
p {
margin: 0;
font-size: 13.5px;
color: #64748b;
}
}
.dd-result-ic {
font-size: 26px;
color: #16a34a;
margin-top: 2px;
}
.dd-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
td {
padding: 9px 8px;
border-bottom: 1px solid #f1f5f9;
}
td:nth-child(2) {
text-align: right;
width: 140px;
}
td:nth-child(3) {
text-align: right;
width: 220px;
}
}
.dd-ok {
color: #16a34a;
font-weight: 600;
font-size: 13px;
}
.dd-skipped {
color: #94a3b8;
}
@@ -0,0 +1,208 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatOptionModule } from '@angular/material/core';
import { MatRadioModule } from '@angular/material/radio';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Imports */
import { ProductsService } from '../../../products.service';
import { SettingsService } from '../../../../settings/settings.service';
/**
* Declares one dividend across every share product for a financial year.
*
* The board resolves a rate, not an amount — "9% for 2025-26" — and what that costs depends on how
* much capital each product's members held and for how long. So the officer enters the rate and the
* year, and the preview shows what it works out to per product before anything is committed. That
* preview is the point of the screen: a rate looks harmless until you see the amount it implies.
*/
@Component({
selector: 'mifosx-declare-dividend',
templateUrl: './declare-dividend.component.html',
styleUrls: ['./declare-dividend.component.scss'],
imports: [
CommonModule,
ReactiveFormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatOptionModule,
MatRadioModule,
MatProgressBarModule,
FaIconComponent
]
})
export class DeclareDividendComponent implements OnInit {
private formBuilder = inject(FormBuilder);
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private router = inject(Router);
declareForm: FormGroup;
/** The financial years a dividend may be declared for. */
financialYears: { label: string; start: string; end: string }[] = [];
/** What the entered rate works out to, per product. Null until a rate and year are both chosen. */
preview: any = null;
loadingPreview = false;
previewError: string | null = null;
submitting = false;
submitError: string | null = null;
/** Set once the declaration has gone through, so the outcome per product can be shown. */
result: any = null;
ngOnInit(): void {
this.declareForm = this.formBuilder.group({
financialYear: [
'',
Validators.required
],
// Rate on capital is the default because it is what a dividend rate means in an AGM
// resolution: each member receives that rate on their own capital, not a slice of a pool.
dividendBasis: ['RATE_ON_CAPITAL'],
ratePercentage: [
'',
[
Validators.required,
Validators.min(0.01),
Validators.max(100)
]
]
});
this.buildFinancialYears();
this.declareForm.valueChanges.subscribe(() => this.loadPreview());
}
/**
* The financial years a dividend can be declared for: the current one and the five before it.
*
* The current year is included because a society may declare an interim dividend part-way through,
* and five back covers a declaration delayed by an AGM that ran late. Built from the business date
* rather than the browser's clock, so the list matches the year the branch is operating in.
*/
private buildFinancialYears(): void {
const businessDate: Date = this.settingsService.businessDate ?? new Date();
// April starts the Indian financial year, so anything before it still belongs to the prior year.
const currentStartYear = businessDate.getMonth() >= 3 ? businessDate.getFullYear() : businessDate.getFullYear() - 1;
this.financialYears = [];
for (let startYear = currentStartYear; startYear > currentStartYear - 6; startYear--) {
this.financialYears.push({
label: `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`,
start: `${startYear}-04-01`,
end: `${startYear + 1}-03-31`
});
}
}
private selectedYear(): { label: string; start: string; end: string } | undefined {
return this.financialYears.find((y) => y.label === this.declareForm.value.financialYear);
}
/** What this rate costs, per product, before anything is created. */
loadPreview(): void {
const year = this.selectedYear();
const rate = Number(this.declareForm.value.ratePercentage);
if (!year || !rate || rate <= 0) {
this.preview = null;
return;
}
this.loadingPreview = true;
this.previewError = null;
this.productsService
.previewAllProductsDividend(year.start, year.end, rate, this.declareForm.value.dividendBasis)
.subscribe({
next: (response: any) => {
this.preview = response;
this.loadingPreview = false;
},
error: (error: any) => {
// Cleared rather than left stale, so a failed refresh can never be read as the cost of the
// rate now on screen.
this.preview = null;
this.previewError = error?.error?.defaultUserMessage ?? 'Could not work out what this rate would cost.';
this.loadingPreview = false;
}
});
}
/** Products that will actually receive a declaration. */
get eligibleProducts(): any[] {
return (this.preview?.products ?? []).filter((p: any) => p.eligible && !p.alreadyDeclared);
}
/** Products that will be skipped, with the reason shown alongside. */
get skippedProducts(): any[] {
return (this.preview?.products ?? []).filter((p: any) => !p.eligible || p.alreadyDeclared);
}
/** Total across only the products that will actually be declared. */
get payableTotal(): number {
return this.eligibleProducts.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
get canSubmit(): boolean {
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0;
}
submit(): void {
const year = this.selectedYear();
if (!year || !this.canSubmit) {
return;
}
this.submitting = true;
this.submitError = null;
this.productsService
.createAllProductsDividend({
dividendPeriodStartDate: year.start,
dividendPeriodEndDate: year.end,
dividendBasis: this.declareForm.value.dividendBasis,
ratePercentage: String(this.declareForm.value.ratePercentage)
})
.subscribe({
next: (response: any) => {
this.result = response;
this.submitting = false;
},
error: (error: any) => {
this.submitError = error?.error?.defaultUserMessage ?? 'The dividend could not be declared.';
this.submitting = false;
}
});
}
done(): void {
this.router.navigate([
'/products',
'share-products',
'dividends'
]);
}
cancel(): void {
this.done();
}
}
@@ -11,44 +11,23 @@
<form [formGroup]="createDividendForm" (ngSubmit)="submit()"> <form [formGroup]="createDividendForm" (ngSubmit)="submit()">
<mat-card-content> <mat-card-content>
<div class="layout-column"> <div class="layout-column">
<mat-form-field (click)="startDatePicker.open()"> <!-- A dividend is declared for a financial year, and the whole flow depends on that: the
<mat-label>{{ 'labels.inputs.Dividend Period Start Date' | translate }}</mat-label> profit it is measured against is the year's closed profit, and the residue and
<input undistributed remainder are filed under the year the period ends in. Picking the year
matInput sets both dates, so a part-year period cannot be entered by mistake. -->
[min]="minDate" <mat-form-field>
[max]="maxDate" <mat-label>{{ 'labels.inputs.Financial Year' | translate }}</mat-label>
[matDatepicker]="startDatePicker" <mat-select
[value]="selectedFinancialYear"
(selectionChange)="onFinancialYearChange($event.value)"
required required
formControlName="dividendPeriodStartDate" >
/> @for (fy of financialYears; track fy.label) {
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle> <mat-option [value]="fy.label"
<mat-datepicker #startDatePicker></mat-datepicker> >FY {{ fy.label }} ({{ fy.startLabel }} &ndash; {{ fy.endLabel }})</mat-option
@if (createDividendForm.controls.dividendPeriodStartDate.hasError('required')) { >
<mat-error>
{{ 'labels.inputs.Starting Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field (click)="endDatePicker.open()">
<mat-label>{{ 'labels.inputs.Dividend Period End Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="endDatePicker"
required
formControlName="dividendPeriodEndDate"
/>
<mat-datepicker-toggle matSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<mat-datepicker #endDatePicker></mat-datepicker>
@if (createDividendForm.controls.dividendPeriodEndDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.End Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
} }
</mat-select>
</mat-form-field> </mat-form-field>
@if (overlapError) { @if (overlapError) {
@@ -63,15 +42,125 @@
<p class="dividend-profit-note dividend-profit-note--error"> <p class="dividend-profit-note dividend-profit-note--error">
{{ 'labels.inputs.Could not fetch bank profit for this period' | translate }}. {{ 'labels.inputs.Could not fetch bank profit for this period' | translate }}.
</p> </p>
} @else if (netProfit !== null) { } @else if (profitBreakdown !== null) {
<p class="dividend-profit-note"> <!-- The statutory appropriations a co-operative society must make before any dividend
{{ 'labels.inputs.Bank Profit for this Period' | translate }}: may be declared, then what other products have already committed, leaving the
<strong>{{ netProfit | number: '1.2-2' }}</strong> ceiling this declaration has to stay within. -->
</p> <div class="dividend-waterfall">
<div class="dividend-waterfall__row">
@if (profitBreakdown.yearClosed) {
<span
>{{ 'labels.inputs.Net Profit' | translate }} &mdash; FY {{ profitBreakdown.financialYear }} ({{
'labels.inputs.closed books' | translate
}})</span
>
} @else {
<span>{{ 'labels.inputs.Net Profit for this Period' | translate }}</span>
}
<span>{{ profitBreakdown.netProfit | number: '1.2-2' }}</span>
</div>
<!-- Where the profit figure came from. A year that has been closed gives the audited
number out of the Bank Profit GL; an open year can only offer a P&L that is still
moving, which makes any declaration against it an interim one. -->
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
@if (profitBreakdown.yearClosed) {
<span>{{ 'labels.inputs.From the Bank Profit GL after year-end closing' | translate }}</span>
} @else if (profitBreakdown.coversWholeFinancialYear) {
<span
>{{ 'labels.inputs.FY' | translate }} {{ profitBreakdown.financialYear }}
{{
'labels.inputs.has not been closed yet — this is the open profit and loss, not an audited figure'
| translate
}}</span
>
} @else {
<span>{{
'labels.inputs.Interim period — from the open profit and loss, not an audited figure' | translate
}}</span>
}
<span></span>
</div>
@if (profitBreakdown.statutoryReservePercentage > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span
>&minus; {{ 'labels.inputs.Statutory Reserve Fund' | translate }} ({{
profitBreakdown.statutoryReservePercentage
}}%)</span
>
<span>{{ profitBreakdown.statutoryReserveAmount | number: '1.2-2' }}</span>
</div>
}
@if (profitBreakdown.educationFundPercentage > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span
>&minus; {{ 'labels.inputs.Education Fund' | translate }} ({{
profitBreakdown.educationFundPercentage
}}%)</span
>
<span>{{ profitBreakdown.educationFundAmount | number: '1.2-2' }}</span>
</div>
}
<div class="dividend-waterfall__row dividend-waterfall__row--subtotal">
<span>{{ 'labels.inputs.Distributable Profit' | translate }}</span>
<span>{{ profitBreakdown.distributableProfit | number: '1.2-2' }}</span>
</div>
@if (profitBreakdown.alreadyDeclared > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span>&minus; {{ 'labels.inputs.Already declared for other products' | translate }}</span>
<span>{{ profitBreakdown.alreadyDeclared | number: '1.2-2' }}</span>
</div>
@for (declaration of profitBreakdown.existingDeclarations; track declaration.declarationId) {
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span
>{{ declaration.productName }} ({{ declaration.periodStartDate }} &ndash;
{{ declaration.periodEndDate }})</span
>
<span>{{ declaration.amount | number: '1.2-2' }}</span>
</div>
}
}
<div class="dividend-waterfall__row dividend-waterfall__row--total">
<span>{{ 'labels.inputs.Available for this declaration' | translate }}</span>
<span>{{ profitBreakdown.availableForDeclaration | number: '1.2-2' }}</span>
</div>
</div>
} }
<!-- Which question the percentage below answers. RATE_ON_CAPITAL is the default because
it is what a rate in an AGM resolution promises each member. -->
<div class="dividend-mode">
<span class="dividend-mode__label">{{ 'labels.inputs.Dividend Basis' | translate }}</span>
<mat-radio-group [value]="dividendMode" (change)="onModeChange($event.value)">
<mat-radio-button value="RATE_ON_CAPITAL">
{{ 'labels.inputs.Rate on share capital' | translate }}
</mat-radio-button>
<mat-radio-button value="PERCENT_OF_PROFIT">
{{ 'labels.inputs.Percentage of distributable profit' | translate }}
</mat-radio-button>
</mat-radio-group>
<p class="dividend-mode__hint">
@if (dividendMode === 'RATE_ON_CAPITAL') {
{{
'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it'
| translate
}}.
} @else {
{{
'labels.inputs.This share of distributable profit is split across members by holding period — it is not the rate any individual member receives'
| translate
}}.
}
</p>
</div>
<mat-form-field> <mat-form-field>
<mat-label>{{ 'labels.inputs.Dividend Percentage' | translate }} (%)</mat-label> <mat-label>
@if (dividendMode === 'RATE_ON_CAPITAL') {
{{ 'labels.inputs.Dividend Rate' | translate }} (% {{ 'labels.inputs.per annum' | translate }})
} @else {
{{ 'labels.inputs.Dividend Percentage' | translate }} (%)
}
</mat-label>
<input matInput type="number" min="0" max="100" step="0.01" required formControlName="dividendPercentage" /> <input matInput type="number" min="0" max="100" step="0.01" required formControlName="dividendPercentage" />
@if (createDividendForm.controls.dividendPercentage.hasError('required')) { @if (createDividendForm.controls.dividendPercentage.hasError('required')) {
<mat-error> <mat-error>
@@ -81,6 +170,43 @@
} }
</mat-form-field> </mat-form-field>
@if (rateCeilingError) {
<p class="dividend-profit-note dividend-profit-note--error">{{ rateCeilingError }}</p>
}
@if (dividendMode === 'RATE_ON_CAPITAL') {
@if (loadingCapital) {
<p class="dividend-profit-note">
{{ 'labels.inputs.Calculating eligible share capital' | translate }}...
</p>
} @else if (capitalLookupError) {
<p class="dividend-profit-note dividend-profit-note--error">
{{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}.
</p>
} @else if (capitalPreview !== null) {
<div class="dividend-waterfall">
<div class="dividend-waterfall__row">
<span>{{ 'labels.inputs.Eligible share capital (weighted)' | translate }}</span>
<span>{{ capitalPreview.weightedCapital | number: '1.2-2' }}</span>
</div>
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span>{{ 'labels.inputs.Eligible accounts' | translate }}</span>
<span>{{ capitalPreview.eligibleAccounts }}</span>
</div>
@if (capitalPreview.eligibleAccounts === 0 && capitalPreview.minimumActivePeriodDays > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span
>{{ 'labels.inputs.No member has held shares for the minimum active period of' | translate }}
{{ capitalPreview.minimumActivePeriodDays }}
{{ 'labels.inputs.days' | translate }}</span
>
<span></span>
</div>
}
</div>
}
}
<mat-form-field> <mat-form-field>
<mat-label <mat-label
>{{ 'labels.inputs.Dividend Amount' | translate }} ({{ >{{ 'labels.inputs.Dividend Amount' | translate }} ({{
@@ -95,6 +221,10 @@
</mat-error> </mat-error>
} }
</mat-form-field> </mat-form-field>
@if (ceilingError) {
<p class="dividend-profit-note dividend-profit-note--error">{{ ceilingError }}</p>
}
</div> </div>
</mat-card-content> </mat-card-content>
@@ -102,7 +232,7 @@
<button type="button" mat-raised-button [routerLink]="['../']"> <button type="button" mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }} {{ 'labels.buttons.Cancel' | translate }}
</button> </button>
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid || !!overlapError"> <button mat-raised-button color="primary" [disabled]="!createDividendForm.valid || hasBlockingError">
{{ 'labels.buttons.Submit' | translate }} {{ 'labels.buttons.Submit' | translate }}
</button> </button>
</mat-card-actions> </mat-card-actions>
@@ -10,6 +10,77 @@
max-width: 37rem; max-width: 37rem;
} }
/* The appropriation waterfall: net profit reduced step by step to the ceiling this declaration
must stay within. Laid out as label/amount pairs so the arithmetic reads down the column. */
.dividend-waterfall {
margin: 0 0 12px;
padding: 10px 12px;
border: 1px solid #d8dee4;
border-radius: 6px;
font-size: 13px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 3px 0;
> span:last-child {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&--deduction {
color: #b42318;
}
&--detail {
padding-left: 14px;
color: #667085;
font-size: 12px;
}
&--subtotal {
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid #d8dee4;
font-weight: 500;
}
&--total {
margin-top: 4px;
padding-top: 6px;
border-top: 2px solid #98a2b3;
font-weight: 600;
color: #1a7f37;
}
}
}
.dividend-mode {
margin: 0 0 12px;
&__label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: #344054;
}
mat-radio-group {
display: flex;
flex-direction: column;
gap: 2px;
}
&__hint {
margin: 6px 0 0;
font-size: 12px;
color: #667085;
}
}
.dividend-profit-note { .dividend-profit-note {
margin: 0 0 12px; margin: 0 0 12px;
padding: 8px 12px; padding: 8px 12px;
@@ -10,6 +10,8 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { DecimalPipe } from '@angular/common';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
/** Custom Services. */ /** Custom Services. */
@@ -26,7 +28,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './create-dividend.component.html', templateUrl: './create-dividend.component.html',
styleUrls: ['./create-dividend.component.scss'], styleUrls: ['./create-dividend.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS ...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton,
DecimalPipe
] ]
}) })
export class CreateDividendComponent implements OnInit { export class CreateDividendComponent implements OnInit {
@@ -47,13 +52,76 @@ export class CreateDividendComponent implements OnInit {
/** Maximum Date allowed. */ /** Maximum Date allowed. */
maxDate = new Date(); maxDate = new Date();
/** Real bank profit for the selected period (fetched from posted GL journal entries). */ /**
netProfit: number | null = null; * The full appropriation waterfall for the selected period, from the backend: net profit (posted
* GL journal entries), less the statutory appropriations that legally precede a dividend, less
* dividends already declared for other share products over an overlapping period.
*/
profitBreakdown: {
financialYear: string;
coversWholeFinancialYear: boolean;
/** True once the year's P&L has been closed into the Bank Profit GL — the audited figure. */
yearClosed: boolean;
profitSource: 'BANK_PROFIT_GL' | 'OPEN_PROFIT_AND_LOSS';
netProfit: number;
statutoryReservePercentage: number;
statutoryReserveAmount: number;
educationFundPercentage: number;
educationFundAmount: number;
distributableProfit: number;
alreadyDeclared: number;
availableForDeclaration: number;
maximumRatePercentage: number;
existingDeclarations: {
productName: string;
amount: number;
periodStartDate: string;
periodEndDate: string;
}[];
} | null = null;
/** True while the profit figure for the selected period is being fetched. */ /** True while the profit figure for the selected period is being fetched. */
loadingProfit = false; loadingProfit = false;
/** Set if the profit lookup fails, so the officer knows the amount below isn't auto-computed. */ /** Set if the profit lookup fails, so the officer knows the amount below isn't auto-computed. */
profitLookupError = false; profitLookupError = false;
/**
* Weighted paid-up capital for the period, for RATE_ON_CAPITAL mode. `requiredAmount` is what the
* entered rate actually costs to honour.
*/
capitalPreview: {
weightedCapital: number;
requiredAmount: number | null;
eligibleAccounts: number;
minimumActivePeriodDays: number;
} | null = null;
loadingCapital = false;
capitalLookupError = false;
/**
* RATE_ON_CAPITAL is the default because it is what a dividend rate means in an AGM resolution:
* each member receives the declared rate on their own paid-up capital, pro-rated for how long
* they held it. PERCENT_OF_PROFIT sizes the pool as a share of profit instead — legitimate when
* the committee decides "distribute this much of the profit", but the resulting per-member rate
* then depends on how many other members hold shares, so it is not the rate members are promised.
*/
dividendMode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT' = 'RATE_ON_CAPITAL';
/**
* Financial years a dividend can be declared for, newest first.
*
* A dividend belongs to a financial year, and everything downstream depends on that: the profit it is measured
* against is the year's closed profit, and both the rounding residue and the undistributed remainder are filed
* under the year the period ends in. Choosing the year rather than two loose dates makes a part-year period —
* which none of that arithmetic would hold for — impossible to enter.
*/
financialYears: { label: string; startLabel: string; endLabel: string; start: Date; end: Date }[] = [];
selectedFinancialYear: string | null = null;
/** Set when the computed amount exceeds what may still be distributed for this period. */
ceilingError: string | null = null;
/** Set when the entered rate exceeds the ceiling in the governing Co-operative Societies Act. */
rateCeilingError: string | null = null;
/** Every dividend already declared for this product, used to block overlapping periods. */ /** Every dividend already declared for this product, used to block overlapping periods. */
private existingDividendPeriods: { start: Date; end: Date }[] = []; private existingDividendPeriods: { start: Date; end: Date }[] = [];
/** Set when the currently selected period overlaps an existing declaration for this product. */ /** Set when the currently selected period overlaps an existing declaration for this product. */
@@ -77,17 +145,57 @@ export class CreateDividendComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.maxDate = this.settingsService.businessDate; this.maxDate = this.settingsService.businessDate;
this.setDividendForm(); this.setDividendForm();
this.buildFinancialYears();
this.loadExistingDividendPeriods(); this.loadExistingDividendPeriods();
const onPeriodChange = () => { // Both dates now move together as one financial year is chosen, so watching the end date alone
// is enough — subscribing to both would fire the profit and capital lookups twice per change.
this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(() => {
this.fetchProfitForSelectedPeriod(); this.fetchProfitForSelectedPeriod();
this.checkOverlap(); this.checkOverlap();
}; });
this.createDividendForm.get('dividendPeriodStartDate').valueChanges.subscribe(onPeriodChange);
this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(onPeriodChange);
this.createDividendForm.get('dividendPercentage').valueChanges.subscribe(() => this.recomputeDividendAmount()); this.createDividendForm.get('dividendPercentage').valueChanges.subscribe(() => this.recomputeDividendAmount());
} }
/**
* The financial years a dividend can be declared for: the current one and the five before it.
*
* The current year is included because a society may declare an interim dividend part-way through, and five back
* covers a declaration delayed by an AGM that ran late. Built from the business date rather than the browser's
* clock, so the list matches the year the branch is actually operating in.
*/
private buildFinancialYears(): void {
const businessDate: Date = this.settingsService.businessDate ?? new Date();
// April starts the Indian financial year, so anything before it still belongs to the prior year.
const currentStartYear = businessDate.getMonth() >= 3 ? businessDate.getFullYear() : businessDate.getFullYear() - 1;
this.financialYears = [];
for (let startYear = currentStartYear; startYear > currentStartYear - 6; startYear--) {
const start = new Date(startYear, 3, 1);
const end = new Date(startYear + 1, 2, 31);
this.financialYears.push({
label: `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`,
startLabel: this.toIsoDate(start),
endLabel: this.toIsoDate(end),
start,
end
});
}
}
/** Sets both period dates from the chosen year, which is what drives the profit lookup. */
onFinancialYearChange(label: string): void {
const fy = this.financialYears.find((y) => y.label === label);
if (!fy) {
return;
}
this.selectedFinancialYear = label;
this.createDividendForm.patchValue({
dividendPeriodStartDate: fy.start,
dividendPeriodEndDate: fy.end
});
}
/** Parses a Fineract date field, which may arrive as `[yyyy, MM, dd]` or an ISO string. */ /** Parses a Fineract date field, which may arrive as `[yyyy, MM, dd]` or an ISO string. */
private parseFineractDate(value: any): Date | null { private parseFineractDate(value: any): Date | null {
if (!value) { if (!value) {
@@ -172,8 +280,9 @@ export class CreateDividendComponent implements OnInit {
} }
/** /**
* Looks up the bank's real net profit for the selected dividend period, once both dates are * Looks up how much profit is actually available to distribute for the selected period, once both
* set, so the officer can base the dividend on actual profit instead of guessing an amount. * dates are set — net profit from posted GL entries, less the statutory appropriations that
* legally precede a dividend, less what other products have already been declared.
*/ */
fetchProfitForSelectedPeriod(): void { fetchProfitForSelectedPeriod(): void {
const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value; const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value;
@@ -183,36 +292,145 @@ export class CreateDividendComponent implements OnInit {
} }
this.loadingProfit = true; this.loadingProfit = true;
this.profitLookupError = false; this.profitLookupError = false;
this.accountingService.getProfitAndLoss(this.toIsoDate(start), this.toIsoDate(end)).subscribe({ this.accountingService
.getDistributableProfit(this.toIsoDate(start), this.toIsoDate(end), this.shareProductData.id)
.subscribe({
next: (response: any) => { next: (response: any) => {
this.netProfit = Number(response.netProfit) || 0; this.profitBreakdown = {
financialYear: response.financialYear,
coversWholeFinancialYear: !!response.coversWholeFinancialYear,
yearClosed: !!response.yearClosed,
profitSource: response.profitSource,
netProfit: Number(response.netProfit) || 0,
statutoryReservePercentage: Number(response.statutoryReservePercentage) || 0,
statutoryReserveAmount: Number(response.statutoryReserveAmount) || 0,
educationFundPercentage: Number(response.educationFundPercentage) || 0,
educationFundAmount: Number(response.educationFundAmount) || 0,
distributableProfit: Number(response.distributableProfit) || 0,
alreadyDeclared: Number(response.alreadyDeclared) || 0,
availableForDeclaration: Number(response.availableForDeclaration) || 0,
maximumRatePercentage: Number(response.maximumRatePercentage) || 0,
existingDeclarations: response.existingDeclarations || []
};
this.loadingProfit = false; this.loadingProfit = false;
this.recomputeDividendAmount(); this.recomputeDividendAmount();
}, },
error: () => { error: () => {
this.netProfit = null; this.profitBreakdown = null;
this.loadingProfit = false; this.loadingProfit = false;
this.profitLookupError = true; this.profitLookupError = true;
} }
}); });
this.fetchCapitalPreview();
} }
/** Dividend Amount = Net Profit x Dividend Percentage / 100 — no manual entry needed. */ /**
* Weighted paid-up capital for the period. Fetched independently of the profit lookup because it
* answers a different question — what the members actually hold, rather than what the society
* earned — and RATE_ON_CAPITAL needs both.
*/
fetchCapitalPreview(): void {
const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value;
const end: Date = this.createDividendForm.get('dividendPeriodEndDate').value;
if (!(start instanceof Date) || !(end instanceof Date)) {
return;
}
this.loadingCapital = true;
this.capitalLookupError = false;
this.productService
.getDividendCapitalPreview(this.shareProductData.id, this.toIsoDate(start), this.toIsoDate(end))
.subscribe({
next: (response: any) => {
this.capitalPreview = {
weightedCapital: Number(response.weightedCapital) || 0,
requiredAmount: null,
eligibleAccounts: Number(response.eligibleAccounts) || 0,
minimumActivePeriodDays: Number(response.minimumActivePeriodDays) || 0
};
this.loadingCapital = false;
this.recomputeDividendAmount();
},
error: () => {
this.capitalPreview = null;
this.loadingCapital = false;
this.capitalLookupError = true;
}
});
}
/** Switches between the two ways of sizing the pool and recomputes under the new one. */
onModeChange(mode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT'): void {
this.dividendMode = mode;
this.recomputeDividendAmount();
}
/**
* Computes the dividend amount under whichever mode is selected, then checks it against both
* ceilings — the rate cap in the governing Act, and the profit still available to distribute.
*
* RATE_ON_CAPITAL: amount = weighted paid-up capital x rate. Every member then receives that rate
* on their own capital, pro-rated by holding period.
*
* PERCENT_OF_PROFIT: amount = distributable profit x percentage. Note this is applied to the
* DISTRIBUTABLE figure, not raw net profit — the statutory appropriations come out first either
* way.
*/
recomputeDividendAmount(): void { recomputeDividendAmount(): void {
const percentage = Number(this.createDividendForm.get('dividendPercentage').value); const percentage = Number(this.createDividendForm.get('dividendPercentage').value);
if (this.netProfit === null || !percentage || percentage <= 0) { this.ceilingError = null;
this.rateCeilingError = null;
if (!percentage || percentage <= 0) {
this.createDividendForm.get('dividendAmount').setValue('');
if (this.capitalPreview) {
this.capitalPreview.requiredAmount = null;
}
return;
}
// The rate cap governs a rate promised on capital. Under PERCENT_OF_PROFIT the number entered
// is a share of profit, not a rate any member receives, so the cap does not apply to it.
const maximumRate = this.profitBreakdown?.maximumRatePercentage ?? 0;
if (this.dividendMode === 'RATE_ON_CAPITAL' && maximumRate > 0 && percentage > maximumRate) {
this.rateCeilingError = `The maximum dividend rate permitted is ${maximumRate}%. Reduce the rate to declare this dividend.`;
}
let amount: number | null = null;
if (this.dividendMode === 'RATE_ON_CAPITAL') {
if (this.capitalPreview === null) {
this.createDividendForm.get('dividendAmount').setValue(''); this.createDividendForm.get('dividendAmount').setValue('');
return; return;
} }
const amount = (this.netProfit * percentage) / 100; amount = (this.capitalPreview.weightedCapital * percentage) / 100;
this.createDividendForm.get('dividendAmount').setValue(Math.round(amount * 100) / 100); this.capitalPreview.requiredAmount = Math.round(amount * 100) / 100;
} else {
if (this.profitBreakdown === null) {
this.createDividendForm.get('dividendAmount').setValue('');
return;
}
amount = (this.profitBreakdown.distributableProfit * percentage) / 100;
}
const rounded = Math.round(amount * 100) / 100;
this.createDividendForm.get('dividendAmount').setValue(rounded);
if (this.profitBreakdown && rounded > this.profitBreakdown.availableForDeclaration) {
this.ceilingError =
`This dividend of ${rounded.toFixed(2)} exceeds the ${this.profitBreakdown.availableForDeclaration.toFixed(2)} ` +
`still available to distribute for this period. Reduce the rate, or declare for a period with more profit.`;
}
}
/** True when any ceiling or overlap rule blocks submission. */
get hasBlockingError(): boolean {
return !!this.overlapError || !!this.ceilingError || !!this.rateCeilingError;
} }
/** /**
* Submits Create Dividend form. * Submits Create Dividend form.
*/ */
submit() { submit() {
if (this.overlapError) { if (this.hasBlockingError) {
return; return;
} }
const createDividendFormData = this.createDividendForm.value; const createDividendFormData = this.createDividendForm.value;
@@ -21,6 +21,10 @@
<strong>{{ totalCount }}</strong> <strong>{{ totalCount }}</strong>
<span>{{ totalCount === 1 ? 'product' : 'products' }}</span> <span>{{ totalCount === 1 ? 'product' : 'products' }}</span>
</div> </div>
<button class="pl-secondary-btn" type="button" [routerLink]="['/products', 'share-products', 'dividends']">
<fa-icon icon="hand-holding-usd"></fa-icon>
<span>Dividends</span>
</button>
<button <button
#buttonCreateShareProduct #buttonCreateShareProduct
class="pl-create-btn" class="pl-create-btn"
@@ -56,6 +60,26 @@
<div class="pl-kpi-l">Showing after filter</div> <div class="pl-kpi-l">Showing after filter</div>
</div> </div>
</div> </div>
<!-- Hidden rather than zeroed when the figure is unavailable, so a failed fetch is never read
as "no dividend has been declared". -->
@if (dividendSummary) {
<div
class="pl-kpi sp-kpi--dividend"
[routerLink]="['/products', 'share-products', 'dividends']"
matTooltip="View all dividend declarations"
>
<div class="pl-kpi-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ dividendSummary.totalDeclared | number: '1.2-2' }}</div>
<div class="pl-kpi-l">
Dividends Declared
@if (dividendSummary.pendingCount > 0) {
<span class="sp-kpi-pending">· {{ dividendSummary.pendingCount }} awaiting approval</span>
}
</div>
</div>
</div>
}
</div> </div>
<div class="pl-card"> <div class="pl-card">
@@ -15,3 +15,31 @@
--pl-accent: #be185d; --pl-accent: #be185d;
--pl-accent-l: #fce7f3; --pl-accent-l: #fce7f3;
} }
/* Dividend card. Pink to match the share accent rather than reusing one of the three colours
already carrying a meaning in this row. */
.sp-kpi--dividend {
cursor: pointer;
.pl-kpi-ic {
background: #fce7f3;
color: #be185d;
}
&:hover {
border-color: #be185d;
}
}
/* Only appears when something is actually waiting, so it reads as a nudge rather than a permanent
part of the label. */
.sp-kpi-pending {
color: #b45309;
font-weight: 500;
}
/* The shared grid is three columns; this page has a fourth card once the dividend figure loads.
Auto-fit keeps them even at any count instead of leaving the fourth stranded on its own row. */
.pl-kpis {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
@@ -74,6 +74,14 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
filteredCount = 0; filteredCount = 0;
/**
* Dividends declared across all share products.
*
* Left null when the figure cannot be fetched, so the card hides rather than showing a 0.00 that
* would read as "no dividend was declared" when the truth is "we could not find out".
*/
dividendSummary: { totalDeclared: number; declarationCount: number; pendingCount: number } | null = null;
get totalCount(): number { get totalCount(): number {
return this.shareProductsData?.length || 0; return this.shareProductsData?.length || 0;
} }
@@ -123,6 +131,7 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
this.dataSource.paginator = this.paginator; this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.filteredCount = this.totalCount; this.filteredCount = this.totalCount;
this.loadDividendSummary();
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
} }
@@ -130,6 +139,30 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
this.dataSource.filter = (filterValue || '').trim().toLowerCase(); this.dataSource.filter = (filterValue || '').trim().toLowerCase();
} }
/**
* What has been declared in dividends across every share product.
*
* Summarised here rather than per row because a dividend is one society-wide decision — the board
* resolves a rate for the year, and it lands on whichever products have members holding shares.
*/
private loadDividendSummary(): void {
this.productsService.getAllProductsDividends().subscribe({
next: (declarations: any[]) => {
const rows = declarations || [];
this.dividendSummary = {
totalDeclared: rows.reduce((sum, d) => sum + Number(d.amount || 0), 0),
declarationCount: rows.length,
pendingCount: rows.filter((d) => !d.approved).length
};
},
// Deliberately silent: this is a summary card beside the product list, not the reason the
// page exists. A failure hides the card instead of blocking the products the officer came for.
error: () => {
this.dividendSummary = null;
}
});
}
/** /**
* To show popover. * To show popover.
*/ */
@@ -56,6 +56,45 @@
</div> </div>
</div> </div>
<!-- Why the member rows below do not sum to the declared amount: entitlements are rounded down
to whole rupees so each can be drawn as a Demand Draft, and any left under the minimum worth
drawing one for is dropped. Both go to Bank Profit at declaration time. Hidden when nothing
was held back, and for declarations made before this was recorded. -->
@if (residue && residue.residueAmount > 0) {
<div class="vd-residue">
<div class="vd-residue__title">{{ 'labels.inputs.Not paid out to members' | translate }}</div>
<div class="vd-residue__rows">
<div class="vd-residue__row">
<span>{{ 'labels.inputs.Declared amount' | translate }}</span>
<span>{{ residue.declaredAmount | number: '1.2-2' }}</span>
</div>
<div class="vd-residue__row">
<span>{{ 'labels.inputs.Allocated to members' | translate }}</span>
<span>{{ residue.allocatedAmount | number: '1.2-2' }}</span>
</div>
<div class="vd-residue__row vd-residue__row--total">
<span>{{ 'labels.inputs.Swept to Bank Profit' | translate }}</span>
<span>{{ residue.residueAmount | number: '1.2-2' }}</span>
</div>
@if (residue.belowMinimumCount > 0) {
<div class="vd-residue__row vd-residue__row--detail">
<span>
{{ 'labels.inputs.of which' | translate }} {{ residue.belowMinimumCount }}
{{ 'labels.inputs.member(s) below the minimum payable amount' | translate }}
</span>
<span>{{ residue.belowMinimumAmount | number: '1.2-2' }}</span>
</div>
}
@if (residue.glTransactionId) {
<div class="vd-residue__row vd-residue__row--detail">
<span>{{ 'labels.inputs.Journal entry' | translate }} {{ residue.glTransactionId }}</span>
<span>{{ residue.sweptToProfitDate }}</span>
</div>
}
</div>
</div>
}
<div class="vd-filter"> <div class="vd-filter">
<div class="vd-search"> <div class="vd-search">
<fa-icon icon="search" class="vd-search__icon"></fa-icon> <fa-icon icon="search" class="vd-search__icon"></fa-icon>
@@ -225,3 +225,56 @@ table {
text-align: right; text-align: right;
} }
} }
/* Why the member rows do not sum to the declared amount — rounding down to whole rupees, plus
entitlements dropped under the minimum worth drawing a draft for. Sits between the summary tiles
and the member table because that is where the question arises. */
.vd-residue {
margin: 0 0 16px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
&__title {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 8px;
}
&__rows {
display: flex;
flex-direction: column;
gap: 3px;
}
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 13px;
color: #334155;
> span:last-child {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&--total {
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid #cbd5e1;
font-weight: 600;
color: #0f172a;
}
&--detail {
padding-left: 12px;
font-size: 12px;
color: #64748b;
}
}
}
@@ -7,6 +7,7 @@
*/ */
import { Component, OnInit, Input, Output, EventEmitter, ViewChild, inject } from '@angular/core'; import { Component, OnInit, Input, Output, EventEmitter, ViewChild, inject } from '@angular/core';
import { catchError, of } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatSort, MatSortHeader } from '@angular/material/sort';
@@ -67,6 +68,22 @@ export class ViewDividendComponent implements OnInit {
isdividendPosted = false; isdividendPosted = false;
totalAmount = 0; totalAmount = 0;
/**
* What the declaration did not pay out to members. Entitlements are rounded down to whole rupees
* (a branch cannot draw a Demand Draft for 421.32) and dropped entirely below the minimum worth
* drawing one for, so the member rows below never sum to the declared figure. This explains the
* gap rather than leaving it looking like a discrepancy.
*/
residue: {
declaredAmount: number;
allocatedAmount: number;
residueAmount: number;
belowMinimumCount: number;
belowMinimumAmount: number;
sweptToProfitDate: string | null;
glTransactionId: string | null;
} | null = null;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;
displayedColumns: string[] = [ displayedColumns: string[] = [
@@ -82,24 +99,56 @@ export class ViewDividendComponent implements OnInit {
this.productsService this.productsService
.getDividendData(this.overrideProductId, this.overrideDividendId) .getDividendData(this.overrideProductId, this.overrideDividendId)
.subscribe((dividendData: any) => this.setDividendData(dividendData)); .subscribe((dividendData: any) => this.setDividendData(dividendData));
this.loadResidue(this.overrideProductId, this.overrideDividendId);
} else { } else {
this.status = this.route.snapshot.queryParams['status']; this.status = this.route.snapshot.queryParams['status'];
if (this.status && (this.status === 'Dividend Approved' || this.status === 'Dividend Posted')) {
this.isdividendPosted = true;
}
this.route.data.subscribe((data: { dividendData: any }) => this.setDividendData(data.dividendData)); this.route.data.subscribe((data: { dividendData: any }) => this.setDividendData(data.dividendData));
this.loadResidue(
this.route.parent?.parent?.snapshot.paramMap.get('productId'),
this.route.snapshot.paramMap.get('dividendId')
);
} }
} }
/**
* Declarations made before residue was tracked have no record, and the endpoint returns null for
* them — the panel simply stays hidden rather than showing zeros that would misrepresent them.
*/
private loadResidue(productId: any, dividendId: any): void {
if (!productId || !dividendId) {
return;
}
this.productsService
.getDividendRoundingResidue(productId, dividendId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.residue = res
? {
declaredAmount: Number(res.declaredAmount) || 0,
allocatedAmount: Number(res.allocatedAmount) || 0,
residueAmount: Number(res.residueAmount) || 0,
belowMinimumCount: Number(res.belowMinimumCount) || 0,
belowMinimumAmount: Number(res.belowMinimumAmount) || 0,
sweptToProfitDate: res.sweptToProfitDate ?? null,
glTransactionId: res.glTransactionId ?? null
}
: null;
});
}
private setDividendData(dividendData: any) { private setDividendData(dividendData: any) {
this.dividendData = dividendData; this.dividendData = dividendData;
this.dataSource = new MatTableDataSource(dividendData.pageItems); const rows = dividendData.pageItems ?? [];
this.dataSource = new MatTableDataSource(rows);
this.dataSource.paginator = this.paginator; this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.totalAmount = (dividendData.pageItems ?? []).reduce( this.totalAmount = rows.reduce((sum: number, item: any) => sum + Number(item.amount ?? 0), 0);
(sum: number, item: any) => sum + Number(item.amount ?? 0),
0 // Taken from the members' own rows rather than the ?status= query parameter, which is set by
); // whichever screen linked here and can be edited in the URL. A stale or wrong parameter used to
// leave the Approve button on an already-approved declaration — an approval that would then be
// rejected by the server, after the officer had been told it was theirs to make.
this.isdividendPosted = rows.length > 0 && rows.every((item: any) => item.status?.value === 'Dividend Posted');
} }
statusChipClass(status: string): string { statusChipClass(status: string): string {
@@ -123,7 +172,14 @@ export class ViewDividendComponent implements OnInit {
if (this.embedded) { if (this.embedded) {
this.actionCompleted.emit(); this.actionCompleted.emit();
} else { } else {
this.router.navigate(['../'], { relativeTo: this.route }); // Back to the society-wide dividend register. Not '../', which used to be this product's
// own dividend list — that page is gone now that a dividend is declared for every product
// at once, and relative navigation would land on a route that no longer exists.
this.router.navigate([
'/products',
'share-products',
'dividends'
]);
} }
}); });
} }
@@ -28,15 +28,9 @@
} }
</div> </div>
</div> </div>
<!-- Dividends are declared for the whole society at once, from Share Products → Dividends,
because the board resolves one rate for the year rather than one per product. -->
<div style="display: flex; gap: 10px; flex-shrink: 0"> <div style="display: flex; gap: 10px; flex-shrink: 0">
<button
class="pv-edit-btn"
type="button"
[routerLink]="['../dividends']"
*mifosxHasPermission="'UPDATE_SHAREPRODUCT'"
>
<fa-icon icon="hand-holding-usd"></fa-icon><span>{{ 'labels.buttons.Dividends' | translate }}</span>
</button>
<button class="pv-edit-btn" type="button" [routerLink]="['../edit']" *mifosxHasPermission="'UPDATE_SHAREPRODUCT'"> <button class="pv-edit-btn" type="button" [routerLink]="['../edit']" *mifosxHasPermission="'UPDATE_SHAREPRODUCT'">
<fa-icon icon="edit"></fa-icon><span>{{ 'labels.buttons.Edit' | translate }}</span> <fa-icon icon="edit"></fa-icon><span>{{ 'labels.buttons.Edit' | translate }}</span>
</button> </button>
+17 -3
View File
@@ -183,9 +183,13 @@ export class FooterComponent implements OnInit, OnDestroy {
*/ */
getConfigurations(): void { getConfigurations(): void {
if (this.authenticationService.isAuthenticated()) { if (this.authenticationService.isAuthenticated()) {
this.systemService // Reading global configuration needs the READ_CONFIGURATION permission, which teller-style
.getConfigurationByName(SettingsService.businessDateConfigName) // roles (cashier, clerk) do not carry. This runs from the footer on every authenticated
.subscribe((configurationData: any) => { // page, so an unpermitted role was greeted by an "Unauthorized Request" modal at login for
// a check it never asked for. The footer only uses this to decide whether to show a
// business date, so a denied read is not an error here — it degrades to the system date.
this.systemService.getConfigurationByName(SettingsService.businessDateConfigName, true).subscribe({
next: (configurationData: any) => {
this.isBusinessDateEnabled = configurationData.enabled; this.isBusinessDateEnabled = configurationData.enabled;
this.settingsService.setBusinessDateConfig(configurationData.enabled); this.settingsService.setBusinessDateConfig(configurationData.enabled);
if (this.isBusinessDateEnabled) { if (this.isBusinessDateEnabled) {
@@ -194,6 +198,16 @@ export class FooterComponent implements OnInit, OnDestroy {
this.getConfigurations(); this.getConfigurations();
}, 60000); }, 60000);
} }
},
error: () => {
// No visibility of the config (403) or it is unavailable — leave the business date off
// and stop polling, rather than re-checking once a minute for a read this role will
// never be allowed to make. The stored config is deliberately left untouched: a denied
// read yields no value to record, and overwriting it here would let a cashier's session
// clobber what an authorised user established.
this.isBusinessDateEnabled = false;
clearTimeout(this.timer);
}
}); });
} else { } else {
clearTimeout(this.timer); clearTimeout(this.timer);
@@ -132,6 +132,7 @@ export class CreateSharesAccountComponent {
charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })), charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })),
applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat), applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat),
submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat), submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat),
resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat),
unitPrice: this.sharesAccountTermsForm.get('unitPrice').value, unitPrice: this.sharesAccountTermsForm.get('unitPrice').value,
dateFormat, dateFormat,
locale locale
@@ -140,9 +140,13 @@ export class EditShareAccountDetailsComponent {
const payload: any = { const payload: any = {
locale, locale,
dateFormat: 'yyyy-MM-dd', dateFormat: 'yyyy-MM-dd',
savingsAccountId: terms.savingsAccountId,
allowDividendCalculationForInactiveClients: terms.allowDividendCalculationForInactiveClients allowDividendCalculationForInactiveClients: terms.allowDividendCalculationForInactiveClients
}; };
// Optional: only sent when a savings account is actually linked, so an unlinked account
// does not post a null the update validator would reject.
if (terms.savingsAccountId) {
payload.savingsAccountId = terms.savingsAccountId;
}
if (terms.minimumActivePeriod) { if (terms.minimumActivePeriod) {
payload.minimumActivePeriod = terms.minimumActivePeriod; payload.minimumActivePeriod = terms.minimumActivePeriod;
} }
@@ -138,6 +138,7 @@ export class EditSharesAccountComponent {
charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })), charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })),
applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat), applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat),
submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat), submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat),
resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat),
unitPrice: this.sharesAccountTermsForm.get('unitPrice').value, unitPrice: this.sharesAccountTermsForm.get('unitPrice').value,
dateFormat, dateFormat,
locale locale
@@ -54,6 +54,24 @@
<strong>{{ 'labels.commons.required' | translate }}</strong> <strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-48" (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
formControlName="resolutionDate"
required
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div> </div>
<div class="layout-row align-center gap-2percent margin-t responsive-column"> <div class="layout-row align-center gap-2percent margin-t responsive-column">
@@ -68,7 +86,8 @@
!( !(
sharesAccountDetailsForm.get('productId')?.value && sharesAccountDetailsForm.get('productId')?.value &&
sharesAccountDetailsForm.get('submittedDate')?.value && sharesAccountDetailsForm.get('submittedDate')?.value &&
sharesAccountDetailsForm.get('resolutionNumber')?.value sharesAccountDetailsForm.get('resolutionNumber')?.value &&
sharesAccountDetailsForm.get('resolutionDate')?.value
) )
" "
> >
@@ -76,7 +76,9 @@ export class SharesAccountDetailsStepComponent implements OnInit {
this.sharesAccountTemplate.timeline.submittedOnDate && this.sharesAccountTemplate.timeline.submittedOnDate &&
new Date(this.sharesAccountTemplate.timeline.submittedOnDate), new Date(this.sharesAccountTemplate.timeline.submittedOnDate),
externalId: this.sharesAccountTemplate.externalId, externalId: this.sharesAccountTemplate.externalId,
resolutionNumber: this.sharesAccountTemplate.resolutionNumber resolutionNumber: this.sharesAccountTemplate.resolutionNumber,
resolutionDate:
this.sharesAccountTemplate.resolutionDate && new Date(this.sharesAccountTemplate.resolutionDate)
}); });
} }
} }
@@ -102,6 +104,10 @@ export class SharesAccountDetailsStepComponent implements OnInit {
resolutionNumber: [ resolutionNumber: [
'', '',
Validators.required Validators.required
],
resolutionDate: [
'',
Validators.required
] ]
}); });
} }
@@ -41,6 +41,11 @@
<span class="flex-60">{{ sharesAccount.resolutionNumber }}</span> <span class="flex-60">{{ sharesAccount.resolutionNumber }}</span>
</div> </div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Resolution Date' | translate }}</span>
<span class="flex-60">{{ sharesAccount.resolutionDate | dateFormat }}</span>
</div>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3> <h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider> <mat-divider class="flex-fill"></mat-divider>
@@ -71,9 +76,15 @@
<div class="flex-fill"> <div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Default Savings Account' | translate }}</span> <span class="flex-40">{{ 'labels.inputs.Default Savings Account' | translate }}</span>
<span class="flex-60">{{ <span class="flex-60">
@if (sharesAccount.savingsAccountId) {
{{
sharesAccount.savingsAccountId | find: sharesAccountProductTemplate.clientSavingsAccounts : 'id' : 'accountNo' sharesAccount.savingsAccountId | find: sharesAccountProductTemplate.clientSavingsAccounts : 'id' : 'accountNo'
}}</span> }}
} @else {
{{ 'labels.inputs.Unassigned' | translate }}
}
</span>
</div> </div>
@if (sharesAccount.minimumActivePeriod) { @if (sharesAccount.minimumActivePeriod) {
@@ -30,6 +30,9 @@
<span class="flex-48">{{ calculateCurrenValue() | currency: currency.code : 'symbol-narrow' : '1.2-2' }}</span> <span class="flex-48">{{ calculateCurrenValue() | currency: currency.code : 'symbol-narrow' : '1.2-2' }}</span>
} }
<!-- No "None" option: the server rejects a share account without a linked savings account, and
offering the choice only produced a failure after the whole stepper was filled in. The link
is also where an over-payment at the counter is credited back to the member. -->
<mat-form-field class="flex-48"> <mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Default Savings Account' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Default Savings Account' | translate }}</mat-label>
<mat-select formControlName="savingsAccountId" required> <mat-select formControlName="savingsAccountId" required>
@@ -42,6 +45,12 @@
<strong>{{ 'labels.commons.required' | translate }}</strong> <strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@if (savingsAccountsData?.length === 0) {
<p class="sats-field-note flex-98">
This client has no active savings account in the share product's currency. Open one first — a share account
cannot be created without it.
</p>
}
<mat-form-field class="flex-48" (click)="applicationDatePicker.open()"> <mat-form-field class="flex-48" (click)="applicationDatePicker.open()">
<mat-label>{{ 'labels.inputs.Application Date' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Application Date' | translate }}</mat-label>
@@ -67,6 +76,15 @@
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Minimum Active Period' | translate }}</h4> <h4 class="mat-h4 flex-98">{{ 'labels.heading.Minimum Active Period' | translate }}</h4>
<!-- Spelled out because the field name does not say what it decides. This is the holding period
that qualifies a member for a dividend: shares bought fewer days before the period end than
this are skipped entirely by the dividend allocation, and the member is paid nothing on
them. Setting it wrong is silent — nobody sees it until a declaration pays out. -->
<p class="sats-field-note flex-98">
Enter the minimum number of days a member must hold these shares before they qualify for a dividend. Shares held
for fewer days than this are excluded from the dividend calculation and earn nothing for that period.
</p>
<mat-form-field class="flex-48"> <mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label>
<input type="number" matInput formControlName="minimumActivePeriod" /> <input type="number" matInput formControlName="minimumActivePeriod" />
@@ -88,6 +106,15 @@
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Lock-in Period' | translate }}</h4> <h4 class="mat-h4 flex-98">{{ 'labels.heading.Lock-in Period' | translate }}</h4>
<!-- Sits next to Minimum Active Period and is easily confused with it, so the difference is
stated outright: that one decides whether a dividend is earned, this one decides whether
shares can be sold back. Counted per purchase, not per account — a later purchase carries
its own lock-in from its own date, which is not obvious from a single "Frequency" box. -->
<p class="sats-field-note flex-98">
Enter the minimum number of days a member must wait before they can redeem (sell back) these shares. This is
counted separately from each purchase, and does not affect dividends — for that, use Minimum Active Period above.
</p>
<mat-form-field class="flex-48"> <mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label>
<input type="number" matInput formControlName="lockinPeriodFrequency" /> <input type="number" matInput formControlName="lockinPeriodFrequency" />
@@ -14,3 +14,14 @@ h4 {
.margin-t { .margin-t {
margin-top: 1em; margin-top: 1em;
} }
/* Red because both fields it marks fail silently — a wrong Minimum Active Period pays the member
nothing months later, a wrong Lock-in Period only surfaces when a redemption is refused. Neither
raises a validation error at the time. Sits under the heading and above the inputs, so it is read
before the number is typed rather than after. */
.sats-field-note {
margin: -0.5em 0 1em;
color: #b42318;
font-size: 12.5px;
line-height: 1.5;
}
@@ -128,6 +128,13 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit {
], ],
unitPrice: [{ value: '', disabled: true }], unitPrice: [{ value: '', disabled: true }],
currencyMultiple: [{ value: '', disabled: true }], currencyMultiple: [{ value: '', disabled: true }],
// Required to match the server, which has always rejected a share account without a linked
// savings account. The form allowed one through, so the failure surfaced as a validation
// error on submit after the whole stepper had been filled in.
//
// It also gives an over-payment somewhere to go: a member who hands over more cash than the
// application costs has the difference credited to this account, rather than being left in a
// suspense GL that nobody can attribute back to them.
savingsAccountId: [ savingsAccountId: [
'', '',
Validators.required Validators.required
@@ -0,0 +1,123 @@
<!--
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/.
-->
<h2 mat-dialog-title>Apply Share Advance Collection</h2>
<mat-dialog-content>
<p class="intro">
Enter the voucher number the member was given, or tick the receipts below, to settle against share account
<b>{{ data.accountNo }}</b
>. Tick more than one where the member paid in instalments. The amount is moved out of Share Sundry into share
capital, and any entry fee is posted to fee income.
</p>
<div class="voucher-lookup">
<input
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. 000123)"
[(ngModel)]="voucherNo"
[disabled]="lookingUp || submitting"
(keyup.enter)="lookupVoucher()"
/>
<button
mat-stroked-button
type="button"
[disabled]="!voucherNo.trim() || lookingUp || submitting"
(click)="lookupVoucher()"
>
{{ lookingUp ? 'Finding…' : 'Find' }}
</button>
</div>
@if (errorMsg) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
@if (loading) {
<div class="loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Loading receipts…</span>
</div>
} @else if (receipts.length === 0) {
<div class="empty">
<mat-icon>info_outline</mat-icon>
<div>
<b>No unapplied receipt for this member.</b>
<div class="empty-hint">
Collect the cash first from Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry GL. Once a
manager approves it, enter the voucher number above.
</div>
</div>
</div>
} @else {
<table class="receipt-table">
<thead>
<tr>
<th></th>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (r of receipts; track r.id) {
<tr [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
<td class="ta-center">
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
against one application, and all of them settle it together. -->
<input
type="checkbox"
[checked]="isSelected(r.id)"
(change)="toggle(r.id)"
(click)="$event.stopPropagation()"
/>
</td>
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
<td class="ta-left">{{ r.collection_date }}</td>
<td class="ta-right">
<b>{{ r.amount }}</b>
</td>
</tr>
}
</tbody>
@if (selectedIds.length > 1) {
<tfoot>
<tr class="total-row">
<td></td>
<td class="ta-left" colspan="2">{{ selectedIds.length }} receipts selected</td>
<td class="ta-right">
<b>{{ selectedTotal | number: '1.2-2' }}</b>
</td>
</tr>
</tfoot>
}
</table>
<div class="note">
Select every receipt that makes up this payment. Their total must cover the account's share value plus any entry
fee — anything paid over that is credited to the member's savings account.
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="selectedIds.length === 0 || submitting"
(click)="apply()"
>
{{ submitting ? 'Applying…' : 'Apply Collection' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,151 @@
/**
* 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/.
*/
$primary: #1a3a6b;
$border: #d8e0ec;
$muted: #6b7a90;
$danger: #b3261e;
.intro {
font-size: 13px;
color: $muted;
margin: 0 0 12px;
}
.voucher-lookup {
display: flex;
gap: 8px;
align-items: center;
margin: 0 0 14px;
}
.voucher-input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
letter-spacing: 0.4px;
color: $primary;
&:focus {
outline: none;
border-color: $primary;
}
&:disabled {
background: #f4f7fb;
color: $muted;
}
}
.loading {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 0;
font-size: 13px;
color: $muted;
}
.empty {
display: flex;
gap: 10px;
align-items: flex-start;
background: #f4f7fb;
border: 1px solid $border;
border-radius: 6px;
padding: 12px;
font-size: 13px;
}
.empty-hint {
color: $muted;
margin-top: 4px;
}
.receipt-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
background: #eef2f8;
color: $primary;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 8px;
}
td {
padding: 8px;
border-bottom: 1px solid #eceff4;
}
tbody tr {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
&.selected {
background: #e7effb;
}
}
/* Only appears once a second receipt is ticked, when adding the rows up by eye stops being
reliable — and the total is what decides whether the settlement is accepted. */
tfoot .total-row {
border-top: 2px solid #cbd5e1;
td {
padding-top: 10px;
font-size: 13px;
color: #1f3d7a;
}
}
}
.ta-left {
text-align: left;
}
.ta-right {
text-align: right;
}
.ta-center {
text-align: center;
}
.mono {
font-family: 'Courier New', monospace;
}
.note {
margin-top: 10px;
font-size: 12px;
color: $muted;
}
.alert-error {
display: flex;
align-items: center;
gap: 8px;
background: #fdecea;
color: $danger;
border: 1px solid #f5c6c2;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 12px;
}
@@ -0,0 +1,194 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.service';
/**
* Applies a Share Advance receipt to an activated share account.
*
* The member's cash is already sitting in the Share Sundry liability from the cashier's advance
* collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees
* when an entry fee applies) and reveals the purchase entry in the transactions and passbook.
*/
@Component({
selector: 'mifosx-apply-collection-dialog',
templateUrl: './apply-collection-dialog.component.html',
styleUrls: ['./apply-collection-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplyCollectionDialogComponent implements OnInit {
private cashierService = inject(CashierService);
loading = true;
submitting = false;
errorMsg = '';
receipts: any[] = [];
/**
* Receipts chosen for this settlement, in the order they were picked.
*
* More than one because a member often pays an application in instalments — ₹4,500 today, the
* ₹500 balance tomorrow — and neither receipt settles the account on its own. They are applied
* together as a single settlement rather than forcing the member back to the counter for a fresh
* receipt covering the whole amount.
*/
selectedIds: number[] = [];
/** Voucher number quoted by the member, as an alternative to picking from the list. */
voucherNo = '';
lookingUp = false;
public dialogRef = inject(MatDialogRef<ApplyCollectionDialogComponent>);
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
ngOnInit(): void {
this.cashierService
.getUnappliedShareAdvances(this.data.clientId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the member’s receipts.');
this.loading = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.receipts = res.unappliedCollections || [];
if (this.receipts.length === 1) {
this.selectedIds = [this.receipts[0].id];
}
}
this.loading = false;
});
}
isSelected(id: number): boolean {
return this.selectedIds.includes(id);
}
toggle(id: number): void {
this.selectedIds = this.isSelected(id) ? this.selectedIds.filter((x) => x !== id) : [
...this.selectedIds,
id
];
}
/**
* What the selected receipts come to.
*
* Shown live as receipts are ticked, because the settlement only goes through when the total
* covers the account — checking that by adding up the rows by eye is exactly what leads to a
* rejected submission.
*/
get selectedTotal(): number {
return this.receipts.filter((r) => this.isSelected(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0);
}
/**
* Resolves the voucher number the member quotes into a receipt. The lookup is not restricted to
* this member, so a voucher belonging to somebody else is surfaced here as a clear mismatch
* rather than failing later inside apply().
*/
lookupVoucher(): void {
const voucher = this.voucherNo.trim();
if (!voucher || this.lookingUp) {
return;
}
this.lookingUp = true;
this.errorMsg = '';
this.cashierService
.getShareAdvanceByVoucher(voucher, this.data.clientId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`);
this.lookingUp = false;
return of(null);
})
)
.subscribe((res: any) => {
this.lookingUp = false;
if (!res) {
return;
}
if (Number(res.client_id) !== Number(this.data.clientId)) {
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
return;
}
// Show it in the table (replacing any stale copy) and add it to the selection, rather than
// replacing what is already ticked — a looked-up voucher is usually the second half of a
// part-payment, not a change of mind about the first.
this.receipts = [
res,
...this.receipts.filter((r) => r.id !== res.id)
];
if (!this.isSelected(res.id)) {
this.selectedIds = [
...this.selectedIds,
res.id
];
}
});
}
apply(): void {
if (this.selectedIds.length === 0 || this.submitting) {
return;
}
this.submitting = true;
this.errorMsg = '';
// The first receipt leads on the URL; the rest ride along in the body, and the server settles
// them as one transaction.
const [
leadId,
...additionalCollectionIds
] = this.selectedIds;
this.cashierService
.applyShareAdvance(leadId, { shareAccountId: this.data.shareAccountId, additionalCollectionIds })
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Applying the collection failed.');
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.dialogRef.close(res);
}
});
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,75 @@
<!--
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/.
-->
<h2 mat-dialog-title>Debit from Savings</h2>
<mat-dialog-content class="dfs-content">
@if (loading) {
<div class="dfs-loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Checking the member's savings account…</span>
</div>
} @else if (errorMsg && !eligibility) {
<div class="dfs-error">{{ errorMsg }}</div>
} @else if (eligibility) {
<!-- What the settlement costs, split the way it will be posted: share value to Share Capital,
entry fee to fee income. -->
<div class="dfs-breakdown">
<div class="dfs-row">
<span>Share value</span>
<span>{{ eligibility.shareAmount | number: '1.2-2' }}</span>
</div>
@if (eligibility.chargeAmount > 0) {
<div class="dfs-row">
<span>Entry fee</span>
<span>{{ eligibility.chargeAmount | number: '1.2-2' }}</span>
</div>
}
<div class="dfs-row dfs-row--total">
<span>To debit from savings</span>
<span>{{ eligibility.totalRequired | number: '1.2-2' }}</span>
</div>
@if (eligibility.savingsAccountNo) {
<div class="dfs-row dfs-row--detail">
<span>Savings account {{ eligibility.savingsAccountNo }}</span>
<span>Balance {{ eligibility.savingsBalance | number: '1.2-2' }}</span>
</div>
}
</div>
@if (!eligibility.eligible) {
<div class="dfs-error">{{ eligibility.reason }}</div>
} @else {
<label class="dfs-field">
<span>Note (optional)</span>
<input type="text" [(ngModel)]="note" maxlength="500" placeholder="Anything to record against this request" />
</label>
<!-- Says plainly that submitting does not move money, so the clerk does not tell the member
their shares are settled when a manager still has to approve it. -->
<p class="dfs-hint">
Submitting records a request for a manager to approve. The member's savings are debited only once it is
approved.
</p>
@if (errorMsg) {
<div class="dfs-error">{{ errorMsg }}</div>
}
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button (click)="cancel()">Cancel</button>
<button
mat-raised-button
color="primary"
[disabled]="loading || submitting || !eligibility?.eligible"
(click)="submit()"
>
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,98 @@
/**
* 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/.
*/
.dfs-content {
min-width: 420px;
}
.dfs-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
color: #475569;
font-size: 13.5px;
}
/* What the member is being charged, laid out as label/amount pairs so the total reads down the
column the way the posting splits it. */
.dfs-breakdown {
margin: 8px 0 16px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
.dfs-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 3px 0;
font-size: 13.5px;
color: #334155;
> span:last-child {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&--total {
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid #cbd5e1;
font-weight: 600;
color: #0f172a;
}
&--detail {
margin-top: 6px;
font-size: 12px;
color: #64748b;
}
}
.dfs-field {
display: block;
margin-bottom: 10px;
> span {
display: block;
margin-bottom: 4px;
font-size: 12.5px;
color: #475569;
}
input {
width: 100%;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-size: 13.5px;
&:focus {
outline: none;
border-color: #2563eb;
}
}
}
.dfs-hint {
margin: 0 0 4px;
font-size: 12px;
color: #64748b;
}
.dfs-error {
margin: 8px 0 0;
padding: 9px 12px;
border-radius: 6px;
background: #fdecea;
color: #b42318;
font-size: 13px;
}
@@ -0,0 +1,121 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.service';
/**
* Settles a share application out of the member's own savings account.
*
* The cash route (Apply Collection) starts at the counter, with money the cashier has already
* received sitting in Share Sundry against a receipt. A member who banks with the society has no
* reason to withdraw cash and hand it straight back, so this debits their savings directly for the
* share value plus any entry fee.
*
* Nothing moves when this dialog is submitted: it records a request for a manager to approve. The
* cash route needs no checker because the cashier's receipt is already a second pair of eyes; here
* nobody has counted anything, so the approval is the first moment the member's balance changes.
*/
@Component({
selector: 'mifosx-debit-from-savings-dialog',
templateUrl: './debit-from-savings-dialog.component.html',
styleUrls: ['./debit-from-savings-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class DebitFromSavingsDialogComponent implements OnInit {
private cashierService = inject(CashierService);
loading = true;
submitting = false;
errorMsg = '';
/**
* What the settlement would cost and whether the savings account can cover it. `eligible` false
* carries a `reason` naming the blocker — no linked savings, already settled, short balance.
*/
eligibility: {
eligible: boolean;
reason: string | null;
shareAmount: number;
chargeAmount: number;
totalRequired: number;
savingsAccountNo: string | null;
savingsBalance: number | null;
} | null = null;
note = '';
public dialogRef = inject(MatDialogRef<DebitFromSavingsDialogComponent>);
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
ngOnInit(): void {
this.cashierService
.getShareSavingsDebitEligibility(this.data.shareAccountId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.loading = false;
if (!res) {
this.errorMsg = 'Could not check whether this account can be settled from savings.';
return;
}
this.eligibility = {
eligible: !!res.eligible,
reason: res.reason ?? null,
shareAmount: Number(res.shareAmount) || 0,
chargeAmount: Number(res.chargeAmount) || 0,
totalRequired: Number(res.totalRequired) || 0,
savingsAccountNo: res.savingsAccountNo ?? null,
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
};
});
}
submit(): void {
if (!this.eligibility?.eligible || this.submitting) {
return;
}
this.submitting = true;
this.errorMsg = '';
this.cashierService
.submitShareSavingsDebit({
shareAccountId: this.data.shareAccountId,
note: this.note?.trim() || undefined
})
.subscribe({
next: (res: any) => {
this.submitting = false;
this.dialogRef.close(res);
},
error: (err: any) => {
this.submitting = false;
this.errorMsg =
err?.error?.defaultUserMessage || err?.error?.message || 'Could not submit the savings-debit request.';
}
});
}
cancel(): void {
this.dialogRef.close();
}
}
@@ -51,6 +51,19 @@
</td> </td>
} }
</tr> </tr>
<tr>
<td>{{ 'labels.inputs.Resolution Number' | translate }}</td>
<td>{{ sharesAccountData.resolutionNumber || ('labels.inputs.Unassigned' | translate) }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Resolution Date' | translate }}</td>
@if (sharesAccountData.resolutionDate) {
<td>{{ sharesAccountData.resolutionDate | dateFormat }}</td>
}
@if (!sharesAccountData.resolutionDate) {
<td>{{ 'labels.inputs.Unassigned' | translate }}</td>
}
</tr>
<tr> <tr>
<td>{{ 'labels.inputs.Linked Savings Account(Dividend Posting)' | translate }}</td> <td>{{ 'labels.inputs.Linked Savings Account(Dividend Posting)' | translate }}</td>
@if (sharesAccountData.savingsAccountNumber) { @if (sharesAccountData.savingsAccountNumber) {
@@ -103,6 +103,12 @@
<div class="detail-value">{{ resolutionNumber }}</div> <div class="detail-value">{{ resolutionNumber }}</div>
</div> </div>
} }
@if (resolutionDate) {
<div class="detail-block">
<div class="detail-label">Resolution Date</div>
<div class="detail-value">{{ resolutionDate | dateFormat }}</div>
</div>
}
</div> </div>
</div> </div>
@@ -133,29 +139,30 @@
<!-- Transactions Table --> <!-- Transactions Table -->
@if (filteredTransactions.length > 0) { @if (filteredTransactions.length > 0) {
<div class="statement-table-scroll">
<table class="statement-table"> <table class="statement-table">
<thead> <thead>
<tr> <tr>
<th class="col-sr">#</th> <th class="col-sr text-center">#</th>
<th class="col-date">Date</th> <th class="col-date text-left">Date</th>
<th class="col-desc">Description</th> <th class="col-desc text-left">Description</th>
<th class="col-ref">Ref No.</th> <th class="col-ref text-center">Ref No.</th>
<th class="col-amount text-right">Shares Purchased</th> <th class="col-amount text-right">Shares Purchased</th>
<th class="col-amount text-right">Shares Redeemed</th> <th class="col-amount text-right">Shares Redeemed</th>
<th class="col-balance text-right">Share Balance</th> <th class="col-balance text-right">Share Balance</th>
<th class="col-amount text-right">Amount (&#8377;)</th> <th class="col-amount text-right">Amount (&#8377;)</th>
<th class="col-desc">Resolution No. / Meeting</th> <th class="col-resolution text-left">Resolution No. / Meeting</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (txn of filteredTransactions; track txn.id; let i = $index) { @for (txn of filteredTransactions; track txn.id; let i = $index) {
<tr [class.debit-row]="isRedeem(txn)" [class.credit-row]="!isRedeem(txn)"> <tr [class.debit-row]="isRedeem(txn)" [class.credit-row]="!isRedeem(txn)">
<td class="col-sr text-center">{{ i + 1 }}</td> <td class="col-sr text-center">{{ i + 1 }}</td>
<td class="col-date">{{ txn.purchasedDate | dateFormat }}</td> <td class="col-date text-left">{{ txn.purchasedDate | dateFormat }}</td>
<td class="col-desc"> <td class="col-desc text-left">
<span class="txn-desc">{{ txn.type.value }} ({{ txn.status.value }})</span> <span class="txn-desc">{{ txn.type.value }} ({{ txn.status.value }})</span>
</td> </td>
<td class="col-ref text-muted mono">{{ txn.id }}</td> <td class="col-ref text-center text-muted mono">{{ txn.id }}</td>
<td class="col-amount text-right credit-amount"> <td class="col-amount text-right credit-amount">
@if (!isRedeem(txn)) { @if (!isRedeem(txn)) {
{{ txn.numberOfShares }} {{ txn.numberOfShares }}
@@ -170,14 +177,19 @@
<td class="col-amount text-right"> <td class="col-amount text-right">
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} {{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</td> </td>
<td class="col-desc"> <td class="col-resolution text-left">
@if (isRedeem(txn)) { @if (isRedeem(txn)) {
{{ txn.resolutionNumber || '—' }} {{ txn.resolutionNumber || '—' }}
@if (txn.meetingDate) { @if (txn.meetingDate) {
({{ txn.meetingDate | dateFormat }}) ({{ txn.meetingDate | dateFormat }})
} }
} @else { } @else {
— <!-- Purchases are authorised by the share account's own board resolution, which is
held on the account rather than the transaction. -->
{{ resolutionNumber || '—' }}
@if (resolutionDate) {
({{ resolutionDate | dateFormat }})
}
} }
</td> </td>
</tr> </tr>
@@ -191,6 +203,7 @@
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div>
} @else { } @else {
<div class="no-data-block"> <div class="no-data-block">
<mat-icon>inbox</mat-icon> <mat-icon>inbox</mat-icon>
@@ -214,6 +214,14 @@ $row-alt: #f4f7fb;
} }
// ─── Transactions Table ─────────────────────────────────────────────────────── // ─── Transactions Table ───────────────────────────────────────────────────────
// The columns below are fixed-width so each cell stays under its own header. On a narrow
// viewport that total exceeds the card, so the table scrolls here rather than being clipped
// by the wrapper's overflow:hidden.
.statement-table-scroll {
width: 100%;
overflow-x: auto;
}
.statement-table { .statement-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -225,6 +233,10 @@ $row-alt: #f4f7fb;
border-bottom: 2px solid $bank-primary; border-bottom: 2px solid $bank-primary;
} }
// Headers default to left so each one starts exactly where its cell's text starts; the
// numeric columns opt into right alignment via .text-right below. Without this the browser
// (and Material's table styles) can centre the heading while the cell stays left, which
// reads as the whole column being out of position.
thead th { thead th {
padding: 9px 10px; padding: 9px 10px;
font-size: 11px; font-size: 11px;
@@ -233,6 +245,25 @@ $row-alt: #f4f7fb;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.4px; letter-spacing: 0.4px;
white-space: nowrap; white-space: nowrap;
text-align: left;
}
// Alignment helpers must win over the thead default above, for header and cell alike, so
// a column's heading and its values always share one alignment.
thead th,
tbody td,
tfoot td {
&.text-right {
text-align: right;
}
&.text-center {
text-align: center;
}
&.text-left {
text-align: left;
}
} }
tbody tr { tbody tr {
@@ -274,20 +305,36 @@ $row-alt: #f4f7fb;
white-space: nowrap; white-space: nowrap;
} }
// Description is the only flexible column: it absorbs the leftover width so every other
// column keeps the fixed size its header needs and stays aligned with its cells.
.col-desc { .col-desc {
min-width: 140px; width: auto;
min-width: 160px;
}
// Board Resolution No. / Meeting Date — wide enough to hold "00RK2 (14 August 2026)"
// on one line so it does not crowd the right-aligned Amount column beside it.
.col-resolution {
width: 190px;
white-space: nowrap;
padding-left: 16px;
} }
.col-ref { .col-ref {
width: 70px; width: 80px;
white-space: nowrap;
} }
// Sized to the widest header label ("Shares Purchased") so the numeric cells sit directly
// under their own heading rather than drifting toward the neighbouring column.
.col-amount { .col-amount {
width: 100px; width: 128px;
white-space: nowrap;
} }
.col-balance { .col-balance {
width: 90px; width: 112px;
white-space: nowrap;
} }
// ─── Amount colors ──────────────────────────────────────────────────────────── // ─── Amount colors ────────────────────────────────────────────────────────────
@@ -335,6 +382,10 @@ $row-alt: #f4f7fb;
text-align: center; text-align: center;
} }
.text-left {
text-align: left;
}
.text-muted { .text-muted {
color: #999; color: #999;
} }
@@ -450,6 +501,12 @@ $row-alt: #f4f7fb;
border-radius: 0; border-radius: 0;
} }
// On paper there is nothing to scroll — let the table lay out at full width so no
// column is cut off at the page edge.
.statement-table-scroll {
overflow-x: visible;
}
.bank-header { .bank-header {
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
print-color-adjust: exact; print-color-adjust: exact;
@@ -47,6 +47,7 @@ export class SharesPassbookTabComponent implements OnInit {
status = ''; status = '';
activatedOnDate: any = null; activatedOnDate: any = null;
resolutionNumber = ''; resolutionNumber = '';
resolutionDate: any = null;
savingsAccountNumber = ''; savingsAccountNumber = '';
totalApprovedShares = 0; totalApprovedShares = 0;
currentMarketPrice = 0; currentMarketPrice = 0;
@@ -75,6 +76,7 @@ export class SharesPassbookTabComponent implements OnInit {
this.status = account.status?.value || ''; this.status = account.status?.value || '';
this.activatedOnDate = account.timeline?.activatedDate || null; this.activatedOnDate = account.timeline?.activatedDate || null;
this.resolutionNumber = account.resolutionNumber || ''; this.resolutionNumber = account.resolutionNumber || '';
this.resolutionDate = account.resolutionDate || null;
this.savingsAccountNumber = account.savingsAccountNumber || ''; this.savingsAccountNumber = account.savingsAccountNumber || '';
this.totalApprovedShares = account.summary?.totalApprovedShares || 0; this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
this.currentMarketPrice = account.currentMarketPrice || 0; this.currentMarketPrice = account.currentMarketPrice || 0;
@@ -0,0 +1,61 @@
/**
* 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 { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router';
/**
* Keeps the Transactions, Passbook and Charges tabs closed until the share application is settled.
*
* Those tabs describe money that has moved. Until the member has actually been debited and the
* shares issued, nothing has: the purchase sits pending approval, and showing a purchase entry, a
* passbook line and an entry fee reads as though the member has paid and holds shares when they
* have done neither.
*
* Hiding the tab links alone would leave the pages reachable by typing the URL, which is exactly
* where someone verifying an account is likely to go. Anyone who does is sent back to General.
*
* Judged by `collectionApplied` rather than the account's status or the purchase transaction's
* status: an account is Approved and then Activated before anything is settled, and the purchase
* reads Approved from the moment the account is approved. Only this marks money actually taken.
*/
export const settledAccountGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const router = inject(Router);
// The account resolves on the parent (SharesAccountViewComponent) route; these tabs are its
// children. Angular merges resolved data down the tree, so route.data carries it too — read from
// the parent explicitly so this does not depend on that merge.
const shareAccount = route.parent?.data?.['sharesAccountData'] ?? route.data?.['sharesAccountData'];
if (shareAccount?.collectionApplied) {
return true;
}
// Redirect rather than block outright, so the officer lands somewhere useful instead of a blank
// page with no explanation. Built from the route's own params rather than a relative path,
// because a guard only has the snapshot and createUrlTree's relativeTo needs a live route.
// Walk up for the ids rather than assuming a depth: both live on ancestors of this tab route
// (shareAccountId on the ':shareAccountId' route, clientId further up under /clients).
let node: ActivatedRouteSnapshot | null = route;
let clientId: string | null = null;
let accountId: string | null = null;
while (node && !(clientId && accountId)) {
accountId = accountId ?? node.paramMap.get('shareAccountId');
clientId = clientId ?? node.paramMap.get('clientId');
node = node.parent;
}
if (clientId && accountId) {
return router.createUrlTree([
'/clients',
clientId,
'shares-accounts',
accountId,
'general'
]);
}
return false;
};
@@ -135,6 +135,10 @@
> >
{{ 'labels.inputs.General' | translate }} {{ 'labels.inputs.General' | translate }}
</a> </a>
<!-- Transactions, Passbook and Charges all describe money that has moved. Until the
application is settled nothing has been debited and no shares exist, so showing them
would read as though the member had paid and holds shares when they have done neither. -->
@if (isSettled) {
<a <a
mat-tab-link mat-tab-link
[routerLink]="['./transactions']" [routerLink]="['./transactions']"
@@ -165,6 +169,7 @@
> >
{{ 'labels.inputs.Charges' | translate }} {{ 'labels.inputs.Charges' | translate }}
</a> </a>
}
<a <a
mat-tab-link mat-tab-link
[routerLink]="['./dividends']" [routerLink]="['./dividends']"
@@ -14,6 +14,8 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Dialogs */ /** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component'; import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component';
import { ApplyCollectionDialogComponent } from './apply-collection-dialog/apply-collection-dialog.component';
import { DebitFromSavingsDialogComponent } from './debit-from-savings-dialog/debit-from-savings-dialog.component';
/** Custom Services */ /** Custom Services */
import { SharesService } from '../shares.service'; import { SharesService } from '../shares.service';
@@ -182,6 +184,12 @@ export class SharesAccountViewComponent implements OnInit {
case 'Edit Details': case 'Edit Details':
this.router.navigate(['edit-details'], { relativeTo: this.route }); this.router.navigate(['edit-details'], { relativeTo: this.route });
break; break;
case 'Apply Collection':
this.applyCollection();
break;
case 'Debit from Savings':
this.debitFromSavings();
break;
case 'Share Certificate': case 'Share Certificate':
this.viewShareCertificate(); this.viewShareCertificate();
break; break;
@@ -191,6 +199,70 @@ export class SharesAccountViewComponent implements OnInit {
} }
} }
/**
* Settles the member's Share Advance receipt against this account: the cash moves out of Share
* Sundry into share capital, and the purchase entry becomes visible in the transactions and
* passbook views.
*/
private applyCollection() {
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
width: '560px',
data: {
shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId,
accountNo: this.sharesAccountData.accountNo
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) {
// Account data comes from a route resolver, so re-running the route is what refreshes the
// newly revealed transaction and the now-paid charges.
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
this.router.navigate([
'/clients',
this.sharesAccountData.clientId,
'shares-accounts',
this.sharesAccountData.id
]);
});
}
});
}
/**
* Whether the member's money has actually been taken and the shares issued.
*
* Until then the account is only an application: nothing has been debited and the member holds
* nothing. The Transactions, Passbook and Charges tabs all describe money that has moved, so
* showing them at that point reads as though the member has paid and holds shares when they have
* done neither.
*
* Neither the account's status nor the purchase transaction's status can stand in for this: an
* account goes Approved then Activated before anything is settled, and the purchase reads
* Approved from the moment the account is approved. Only `collectionApplied` marks the point at
* which the money was actually taken — by cash collection or by debiting savings.
*/
get isSettled(): boolean {
return !!this.sharesAccountData?.collectionApplied;
}
/**
* Settles this account out of the member's linked savings instead of cash at the counter.
*
* Only records a request — a manager's approval is what debits the member and issues the shares —
* so unlike Apply Collection there is nothing to refresh here until that approval happens.
*/
private debitFromSavings() {
this.dialog.open(DebitFromSavingsDialogComponent, {
width: '560px',
data: {
shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId,
accountNo: this.sharesAccountData.accountNo
}
});
}
/** /**
* Deletes Shares Account. * Deletes Shares Account.
*/ */
@@ -36,6 +36,21 @@ export class SharesButtonsConfiguration {
switch (status) { switch (status) {
case 'Active': case 'Active':
this.buttonsArray = [ this.buttonsArray = [
{
// Settles the member's Share Advance receipt: moves the money out of Share Sundry
// into share capital and reveals the purchase in the transactions/passbook.
name: 'Apply Collection',
icon: 'hand-holding-usd',
taskPermissionName: 'CREATE_JOURNALENTRY'
},
{
// The same settlement for a member who banks here: debits their linked savings for the
// share value plus entry fee instead of taking cash at the counter. Raises a request
// rather than posting, because no cashier has counted anything to check it against.
name: 'Debit from Savings',
icon: 'money-check-alt',
taskPermissionName: 'CREATE_JOURNALENTRY'
},
{ {
name: 'Apply Additional Shares', name: 'Apply Additional Shares',
icon: 'arrow-right', icon: 'arrow-right',
@@ -68,7 +68,12 @@
({{ transaction.meetingDate | dateFormat }}) ({{ transaction.meetingDate | dateFormat }})
} }
} @else { } @else {
— <!-- Purchases are authorised by the share account's own board resolution, which is held
on the account rather than the transaction. -->
{{ shareAccountData.resolutionNumber || '—' }}
@if (shareAccountData.resolutionDate) {
({{ shareAccountData.resolutionDate | dateFormat }})
}
} }
</td> </td>
</ng-container> </ng-container>
+7
View File
@@ -27,6 +27,7 @@ import { SharesAccountTemplateResolver } from './common-resolvers/shares-account
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver'; import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component'; import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component'; import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component';
import { settledAccountGuard } from './shares-account-view/settled-account.guard';
/** Shares Routes */ /** Shares Routes */
const routes: Routes = [ const routes: Routes = [
@@ -71,19 +72,25 @@ const routes: Routes = [
sharesAccountData: SharesAccountViewResolver sharesAccountData: SharesAccountViewResolver
} }
}, },
// Guarded as well as hidden from the tab bar: these three describe money that has
// moved, and until the application is settled none has. Hiding the links alone would
// leave the pages reachable by typing the URL.
{ {
path: 'transactions', path: 'transactions',
component: TransactionsTabComponent, component: TransactionsTabComponent,
canActivate: [settledAccountGuard],
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false } data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
}, },
{ {
path: 'passbook', path: 'passbook',
component: SharesPassbookTabComponent, component: SharesPassbookTabComponent,
canActivate: [settledAccountGuard],
data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false } data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false }
}, },
{ {
path: 'charges', path: 'charges',
component: ChargesTabComponent, component: ChargesTabComponent,
canActivate: [settledAccountGuard],
data: { title: 'Shares Account Charges', breadcrumb: 'Charges', routeParamBreadcrumb: false } data: { title: 'Shares Account Charges', breadcrumb: 'Charges', routeParamBreadcrumb: false }
}, },
{ {
@@ -75,7 +75,12 @@
<!-- Client Name Column --> <!-- Client Name Column -->
<ng-container matColumnDef="issuedToClientName"> <ng-container matColumnDef="issuedToClientName">
<th mat-header-cell *matHeaderCellDef>Purchaser / Client</th> <th mat-header-cell *matHeaderCellDef>Purchaser / Client</th>
<td mat-cell *matCellDef="let element">{{ element.issuedToClientName || '-' }}</td> <!-- A dividend payout has no purchasing client: the society raised the draft out of its own
Dividend Payable to pay the member. Falling back to the walk-in purchaser name shows
that, instead of repeating the beneficiary and reading as a counter sale. -->
<td mat-cell *matCellDef="let element">
{{ element.issuedToClientName || element.walkinPurchaserName || '-' }}
</td>
</ng-container> </ng-container>
<!-- Beneficiary Column --> <!-- Beneficiary Column -->
+8 -3
View File
@@ -8,10 +8,12 @@
/** Angular Imports */ /** Angular Imports */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
/** rxjs Imports */ /** rxjs Imports */
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
import { RunJobWithParamPayloadType } from './manage-jobs/scheduler-jobs/custom-parameters-popover/custom-parameters-popover.component'; import { RunJobWithParamPayloadType } from './manage-jobs/scheduler-jobs/custom-parameters-popover/custom-parameters-popover.component';
/** /**
@@ -446,8 +448,11 @@ export class SystemService {
* @param {string} configurationId Configuration ID of configuration. * @param {string} configurationId Configuration ID of configuration.
* @returns {Observable<any>} Configuration. * @returns {Observable<any>} Configuration.
*/ */
getConfigurationByName(configurationName: string): Observable<any> { getConfigurationByName(configurationName: string, silent = false): Observable<any> {
return this.http.get(`/configurations/name/${configurationName}`); return this.http.get(
`/configurations/name/${configurationName}`,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
} }
/** /**
@@ -0,0 +1,109 @@
<!--
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="pg-card">
<div class="pos-section-label">Share Savings Debit — Awaiting Approval</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Member</th>
<th>Share Account</th>
<th>Savings Account</th>
<th class="num">Shares</th>
<th class="num">Entry Fee</th>
<th class="num">To Debit</th>
<th>Requested By</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && rows.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No savings-debit requests are waiting for approval.</td>
</tr>
} @else {
@for (row of rows; track row.id) {
<tr>
<td>{{ row.client_name }}</td>
<td>{{ row.share_account_no }}</td>
<td>
{{ row.savings_account_no }}
<!-- The balance can move between the clerk raising this and a manager reaching it,
and the server refuses a short account — better seen before clicking. -->
<div [class.ssd-warn]="isShort(row)" class="ssd-sub">
Balance {{ row.savings_balance | number: '1.2-2' }}
</div>
</td>
<td class="num">{{ row.share_amount | number: '1.2-2' }}</td>
<td class="num">{{ row.charge_amount | number: '1.2-2' }}</td>
<td class="num">
<strong>{{ row.total_amount | number: '1.2-2' }}</strong>
</td>
<td>
{{ row.clerk_name }}
<div class="ssd-sub">{{ row.submitted_at | slice: 0 : 10 }}</div>
</td>
<td class="ssd-actions">
<button
type="button"
class="pg-btn"
[disabled]="actioningId === row.id || isShort(row)"
(click)="approve(row)"
>
{{ actioningId === row.id ? 'Working…' : 'Approve' }}
</button>
<button type="button" class="pg-btn" [disabled]="actioningId === row.id" (click)="toggleReject(row)">
{{ expandedRejectId === row.id ? 'Cancel' : 'Reject' }}
</button>
</td>
</tr>
@if (isShort(row)) {
<tr>
<td colspan="8" class="ssd-warn-row">
Savings holds {{ row.savings_balance | number: '1.2-2' }} but
{{ row.total_amount | number: '1.2-2' }} is required. Take a deposit first, or reject this and settle by
cash collection.
</td>
</tr>
}
@if (actionError[row.id]) {
<tr>
<td colspan="8" class="ssd-warn-row">{{ actionError[row.id] }}</td>
</tr>
}
@if (expandedRejectId === row.id) {
<tr>
<td colspan="8">
<div class="inv-form-grid" style="padding: 8px 0">
<label class="inv-form-field">
<span>Reason for rejection</span>
<input type="text" [(ngModel)]="rejectReason[row.id]" maxlength="500" />
</label>
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="actioningId === row.id" (click)="reject(row)">
Confirm Rejection
</button>
</label>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
@@ -0,0 +1,32 @@
/**
* 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/.
*/
/* A savings account that no longer covers the request, and any error the server returned. Both
read as blocking conditions on the row, so they share the same treatment. */
.ssd-warn {
color: #b42318;
}
.ssd-warn-row {
padding: 8px 12px;
background: #fdecea;
color: #b42318;
font-size: 12.5px;
}
.ssd-sub {
font-size: 12px;
color: #64748b;
}
.ssd-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
white-space: nowrap;
}
@@ -0,0 +1,120 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from '../../../cashier/cashier.service';
/**
* Approval Center tab — share applications a clerk has asked to settle out of the member's own
* savings, awaiting a manager.
*
* Approving is the moment the member's balance actually changes: it withdraws the share value plus
* entry fee from their savings and issues the shares. Nothing has moved before that, which is why
* this step exists at all — the cash route needs no checker because the cashier's receipt is
* already a second pair of eyes, whereas here nobody has counted anything.
*
* A manager cannot approve a request they raised themselves; the server rejects that, and the
* message is surfaced on the row rather than swallowed.
*/
@Component({
selector: 'mifosx-share-savings-debit-requests',
templateUrl: './share-savings-debit-requests.component.html',
styleUrls: [
'../../../cashier/cashier-shared.scss',
'../../../clerk/clerk-shared.scss',
'./share-savings-debit-requests.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class ShareSavingsDebitRequestsComponent implements OnInit {
private cashierService = inject(CashierService);
rows: any[] = [];
loading = false;
loaded = false;
actioningId: number | null = null;
actionError: Record<number, string> = {};
/** Which row has its reject box open, and what reason has been typed into it. */
expandedRejectId: number | null = null;
rejectReason: Record<number, string> = {};
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.cashierService
.getShareSavingsDebitRequests('PENDING')
.pipe(catchError(() => of([])))
.subscribe((res: any) => {
this.rows = res ?? [];
this.loading = false;
this.loaded = true;
});
}
approve(row: any): void {
this.actioningId = row.id;
delete this.actionError[row.id];
this.cashierService.approveShareSavingsDebit(row.id).subscribe({
next: () => {
this.actioningId = null;
this.load();
},
error: (err: any) => {
this.actioningId = null;
this.actionError[row.id] =
err?.error?.defaultUserMessage || err?.error?.message || 'Could not approve this request.';
}
});
}
toggleReject(row: any): void {
this.expandedRejectId = this.expandedRejectId === row.id ? null : row.id;
}
reject(row: any): void {
this.actioningId = row.id;
delete this.actionError[row.id];
this.cashierService.rejectShareSavingsDebit(row.id, this.rejectReason[row.id]?.trim()).subscribe({
next: () => {
this.actioningId = null;
this.expandedRejectId = null;
this.load();
},
error: (err: any) => {
this.actioningId = null;
this.actionError[row.id] =
err?.error?.defaultUserMessage || err?.error?.message || 'Could not reject this request.';
}
});
}
/**
* Whether the savings account still covers what the request asks for.
*
* The balance can move between a clerk raising the request and a manager reaching it, and the
* server refuses a short account — showing it on the row means the manager sees why before
* clicking rather than after.
*/
isShort(row: any): boolean {
return Number(row.savings_balance ?? 0) < Number(row.total_amount ?? 0);
}
}
@@ -153,6 +153,15 @@
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon> <fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>Dividend Requests</span> <span>Dividend Requests</span>
</a> </a>
<a
class="ac-tab"
routerLink="./share-savings-debit-requests"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
<span>Share Savings Debit</span>
</a>
<a <a
class="ac-tab" class="ac-tab"
routerLink="./deposit-requests" routerLink="./deposit-requests"
+6
View File
@@ -19,6 +19,7 @@ import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-in
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component'; import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
import { ShareWithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component'; import { ShareWithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component';
import { DividendRequestsComponent } from './checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component'; import { DividendRequestsComponent } from './checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component';
import { ShareSavingsDebitRequestsComponent } from './checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component';
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component'; import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component'; import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component'; import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
@@ -171,6 +172,11 @@ const routes: Routes = [
component: DividendRequestsComponent, component: DividendRequestsComponent,
data: { title: 'Dividend Requests' } data: { title: 'Dividend Requests' }
}, },
{
path: 'share-savings-debit-requests',
component: ShareSavingsDebitRequestsComponent,
data: { title: 'Share Savings Debit Requests' }
},
{ {
path: 'deposit-requests', path: 'deposit-requests',
component: DepositRequestsComponent, component: DepositRequestsComponent,
+1
View File
@@ -2038,6 +2038,7 @@
"External ID lookup timed out": "External ID lookup timed out", "External ID lookup timed out": "External ID lookup timed out",
"External ID not found": "External ID not found", "External ID not found": "External ID not found",
"Resolution Number": "Resolution Number", "Resolution Number": "Resolution Number",
"Resolution Date": "Resolution Date",
"External ID verified successfully": "External ID verified successfully", "External ID verified successfully": "External ID verified successfully",
"External Id": "External Id", "External Id": "External Id",
"External Reference ID": "External Reference ID", "External Reference ID": "External Reference ID",
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260813', 'version': '260825',
'hash': '5a6fb1f9' 'hash': '7ef75c90'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };