done
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-30 11:09:14 +05:30
parent 07a4c8b8e3
commit 9f95d01196
57 changed files with 5705 additions and 681 deletions
+2
View File
@@ -62,6 +62,7 @@ import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module';
import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -174,6 +175,7 @@ export function HttpLoaderFactory(http: HttpClient) {
CfcfrmsModule,
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
DicgcModule,
FiuModule,
GlReportsModule,
+12
View File
@@ -21,6 +21,8 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
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 { VoucherViewComponent } from './voucher-view/voucher-view.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
@@ -221,6 +223,16 @@ const routes: Routes = [
component: VoucherExceptionsComponent,
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
},
{
path: 'voucher',
component: VoucherViewComponent,
data: { title: 'Voucher', breadcrumb: 'Voucher' }
},
{
path: 'voucher-report',
component: VoucherReportComponent,
data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' }
},
{
path: 'closing-report',
component: ClosingCashReportComponent,
+29
View File
@@ -186,6 +186,35 @@ export class CashierService {
return this.http.get('/vouchers/exceptions', { params });
}
/**
* The vouchers raised over a period, one row each, with what was debited and what credited.
*
* Who appears is decided by the server from the caller's own roles — a cashier sees only their
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
* way to choose whose report this is.
*/
voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable<any> {
let params = new HttpParams().set('from', from).set('to', to);
if (username) {
params = params.set('username', username);
}
if (voucherType) {
params = params.set('voucherType', voucherType);
}
return this.http.get('/vouchers/report', { params });
}
/**
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
*
* The voucher number travels as a query parameter, not a path segment: numbers are formatted
* CR/2708/0001 and the server rejects the encoded slashes in a path.
*/
voucherDetail(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/detail', { 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 });
@@ -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 vr-no-print">
<div>
<div class="pg-eyebrow">CASHIER · REPORT</div>
<h1 class="pg-title">Voucher Report</h1>
<p class="pg-sub">
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Period</span>
<select [(ngModel)]="period" (change)="load()">
<option value="DAY">Day</option>
<option value="MONTH">Month</option>
</select>
</label>
<label class="pg-field">
<span>{{ period === 'DAY' ? 'Date' : 'Month of' }}</span>
<input type="date" [(ngModel)]="anchor" (change)="load()" />
</label>
@if (report?.canSeeAllUsers) {
<label class="pg-field">
<span>User</span>
<input
type="text"
[(ngModel)]="usernameFilter"
(keyup.enter)="load()"
placeholder="All users"
autocomplete="off"
/>
</label>
}
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Run
</button>
@if (report && report.count > 0) {
<button type="button" class="pg-btn" (click)="print()">
<mat-icon>print</mat-icon> Print
</button>
}
</div>
</header>
@if (error) {
<div class="pg-alert pg-alert--error vr-no-print">{{ error }}</div>
}
@if (loading) {
<div class="pg-empty vr-no-print">Loading…</div>
}
@if (report && !loading) {
<div class="vr-period">
{{ report.from | date: 'dd MMM yyyy' }}
@if (report.from !== report.to) {
— {{ report.to | date: 'dd MMM yyyy' }}
}
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
· ₹{{ report.totalAmount | number: '1.2-2' }}
@if (report.username) {
· {{ report.username }}
}
<!-- Said plainly rather than left to be inferred from a missing filter: a cashier should know
this is their own work and not the branch's. -->
@if (!report.canSeeAllUsers) {
<span class="vr-scope-note">Your own vouchers</span>
}
</div>
@if (report.count === 0) {
<div class="pg-empty">No vouchers were raised in this period.</div>
} @else {
<div class="vr-summaries">
<div class="pg-card">
<div class="pos-section-label">By type</div>
<table class="pg-table">
<thead>
<tr>
<th>Type</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of byType; track row.type) {
<tr>
<td>{{ row.type }}</td>
<td class="num">{{ row.count }}</td>
<td class="num">{{ row.total | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Only worth showing to someone who can see more than one person's work. -->
@if (report.canSeeAllUsers) {
<div class="pg-card">
<div class="pos-section-label">By user</div>
<table class="pg-table">
<thead>
<tr>
<th>User</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of byUser; track row.username) {
<tr>
<td>{{ row.username }}</td>
<td class="num">{{ row.count }}</td>
<td class="num">{{ row.total | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="pg-card">
<div class="pos-section-label">Vouchers</div>
<table class="pg-table vr-rows">
<thead>
<tr>
<th>Voucher No</th>
<th>Type</th>
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>User</th>
<th>Time</th>
</tr>
</thead>
<tbody>
@for (row of report.vouchers; track row.voucherNo) {
<tr>
<td>
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
{{ row.voucherNo }}
</a>
@if (row.verificationStatus === 'EXCEPTION') {
<mat-icon class="vr-flag" title="An exception was raised against this voucher">error_outline</mat-icon>
}
</td>
<td>{{ row.voucherType || '—' }}</td>
<td>
<div class="vr-party">{{ row.debitParty || '—' }}</div>
@if (row.debitAccount) {
<div class="vr-acct">{{ row.debitAccount }}</div>
}
</td>
<td>
<div class="vr-party">{{ row.creditParty || '—' }}</div>
@if (row.creditAccount) {
<div class="vr-acct">{{ row.creditAccount }}</div>
}
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
@if (row.extraLines > 0) {
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
}
</td>
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<td>{{ row.username || '—' }}</td>
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
@@ -0,0 +1,80 @@
/**
* 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, cards and tables come from cashier-shared.scss. Only the register itself is here. */
.vr-period {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 13px;
color: #374151;
}
.vr-scope-note {
padding: 2px 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
color: #1e40af;
font-size: 11px;
}
/* The two summaries sit side by side where there is room and stack where there is not, so a
manager reads the totals before the rows on any screen. */
.vr-summaries {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.vr-rows {
.vr-link {
color: #1d4ed8;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.vr-flag {
margin-left: 4px;
font-size: 15px;
width: 15px;
height: 15px;
color: #b91c1c;
vertical-align: text-bottom;
}
.vr-party {
color: #111827;
}
.vr-acct,
.vr-more {
margin-top: 2px;
font-size: 11px;
color: #6b7280;
}
}
/* Printing a report should put the register on paper — the filters are how you reached it. */
@media print {
.vr-no-print {
display: none !important;
}
.vr-link {
color: inherit;
text-decoration: none;
}
}
@@ -0,0 +1,159 @@
/**
* 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 { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
userId: number | null;
username: string | null;
branchName: string | null;
verificationStatus: string | null;
debitParty: string | null;
debitAccount: string | null;
creditParty: string | null;
creditAccount: string | null;
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
extraLines: number;
}
interface VoucherReport {
from: string;
to: string;
username: string | null;
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
canSeeAllUsers: boolean;
vouchers: VoucherRow[];
count: number;
totalAmount: number;
}
/**
* The vouchers raised over a day or a month, one row each.
*
* A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to
* one user. That is decided by the server, not here — this screen only shows or hides the filter.
*/
@Component({
selector: 'mifosx-voucher-report',
templateUrl: './voucher-report.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-report.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterLink,
MatIconModule
]
})
export class VoucherReportComponent implements OnInit {
private service = inject(CashierService);
/** Day is the working default; a month is what you ask for at a close. */
period: 'DAY' | 'MONTH' = 'DAY';
/** Anchor date — the day itself, or any day inside the month wanted. */
anchor = this.todayIso();
usernameFilter = '';
typeFilter = '';
report: VoucherReport | null = null;
loading = false;
error: string | null = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.error = null;
const { from, to } = this.range();
this.service
.voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
.pipe(
catchError((err: any) => {
this.error =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'The voucher report could not be loaded.';
return of(null);
})
)
.subscribe((res: VoucherReport | null) => {
this.loading = false;
this.report = res;
});
}
/** The period the anchor date names: that day, or the whole calendar month around it. */
private range(): { from: string; to: string } {
if (this.period === 'DAY') {
return { from: this.anchor, to: this.anchor };
}
const [
year,
month
] = this.anchor.split('-').map(Number);
// Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is
// ended correctly without a table of month lengths.
const lastDay = new Date(year, month, 0).getDate();
const mm = String(month).padStart(2, '0');
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` };
}
private todayIso(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/** Totals per user, for the summary a manager reads before the rows. */
get byUser(): { username: string; count: number; total: number }[] {
const rows = this.report?.vouchers ?? [];
const tally = new Map<string, { username: string; count: number; total: number }>();
for (const row of rows) {
const key = row.username ?? '—';
const entry = tally.get(key) ?? { username: key, count: 0, total: 0 };
entry.count += 1;
entry.total += row.amount ?? 0;
tally.set(key, entry);
}
return [...tally.values()].sort((a, b) => b.total - a.total);
}
/** Totals per voucher type — deposits against withdrawals against journals. */
get byType(): { type: string; count: number; total: number }[] {
const rows = this.report?.vouchers ?? [];
const tally = new Map<string, { type: string; count: number; total: number }>();
for (const row of rows) {
const key = row.voucherType ?? '—';
const entry = tally.get(key) ?? { type: key, count: 0, total: 0 };
entry.count += 1;
entry.total += row.amount ?? 0;
tally.set(key, entry);
}
return [...tally.values()].sort((a, b) => b.total - a.total);
}
print(): void {
window.print();
}
}
@@ -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/.
-->
<div class="pg">
<header class="pg-head vv-no-print">
<div>
<div class="pg-eyebrow">CASHIER · VOUCHER</div>
<h1 class="pg-title">Voucher</h1>
<p class="pg-sub">
Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
and for how much.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Voucher No</span>
<input
type="text"
[(ngModel)]="voucherNo"
(keyup.enter)="search()"
placeholder="CR/2708/0001"
autocomplete="off"
/>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="search()" [disabled]="loading">
<mat-icon>search</mat-icon> Find
</button>
@if (voucher) {
<button type="button" class="pg-btn" (click)="print()">
<mat-icon>print</mat-icon> Print
</button>
}
</div>
</header>
@if (error) {
<div class="pg-alert pg-alert--error vv-no-print">{{ error }}</div>
}
@if (loading) {
<div class="pg-empty vv-no-print">Loading…</div>
}
@if (voucher && !loading) {
<div class="pg-card vv-slip">
<div class="vv-slip-head">
<div>
<div class="vv-no">{{ voucher.voucherNo }}</div>
<div class="vv-meta">
{{ voucher.voucherType || '—' }}
@if (voucher.branchName) {
· {{ voucher.branchName }}
}
</div>
</div>
<div class="vv-slip-head-right">
<div class="vv-amount">₹{{ voucher.amount | number: '1.2-2' }}</div>
<div class="vv-meta">
@if (voucher.createdAt) {
{{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }}
}
@if (voucher.createdBy) {
· {{ voucher.createdBy }}
}
</div>
</div>
</div>
@if (voucher.verificationStatus) {
<div class="vv-status" [class.vv-status--exception]="voucher.verificationStatus === 'EXCEPTION'">
<mat-icon>{{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }}</mat-icon>
{{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }}
</div>
}
<!-- A voucher raised before its sides were recorded: the number is valid, the posting behind it
simply was never captured, so it is shown as an older record rather than as an error. -->
@if (hasNoLines) {
<div class="pg-empty">
No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept.
</div>
} @else {
<table class="pg-table vv-lines">
<thead>
<tr>
<th>Particulars</th>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track line.lineNo) {
<tr>
<td>
<div class="vv-party">{{ line.partyName || '—' }}</div>
@if (line.description) {
<div class="vv-desc">{{ line.description }}</div>
}
</td>
<td>{{ line.accountNo || '—' }}</td>
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
<td class="num">—</td>
</tr>
}
@for (line of creditLines; track line.lineNo) {
<tr>
<td class="vv-indent">
<div class="vv-party">{{ line.partyName || '—' }}</div>
@if (line.description) {
<div class="vv-desc">{{ line.description }}</div>
}
</td>
<td>{{ line.accountNo || '—' }}</td>
<td class="num">—</td>
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2">Total</td>
<td class="num">{{ voucher.totalDebit | number: '1.2-2' }}</td>
<td class="num">{{ voucher.totalCredit | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
<!-- Stated rather than assumed: a voucher whose sides were written before the balance check
existed, or repaired by hand, should read as unbalanced rather than pass as a valid slip. -->
@if (!voucher.balanced) {
<div class="pg-alert pg-alert--error">
This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
</div>
}
}
</div>
}
</div>
@@ -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/.
*/
/* Page chrome and the table come from cashier-shared.scss. Only the slip itself is defined here. */
.vv-slip {
max-width: 860px;
}
.vv-slip-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid #e5e7eb;
}
.vv-slip-head-right {
text-align: right;
}
.vv-no {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.4px;
color: #111827;
}
.vv-amount {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.vv-meta {
margin-top: 2px;
font-size: 12px;
color: #6b7280;
}
.vv-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 14px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 8px;
color: #065f46;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
/* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */
&.vv-status--exception {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
}
.vv-lines {
/* Credits sit under their debits the way they do on a written slip, so the indent carries the
relationship that the DR/CR columns alone would leave to the reader to work out. */
.vv-indent {
padding-left: 28px;
}
.vv-party {
color: #111827;
}
.vv-desc {
margin-top: 2px;
font-size: 11px;
color: #6b7280;
}
tfoot td {
font-weight: 600;
border-top: 2px solid #e5e7eb;
}
}
/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are
how you reached it, not part of it. */
@media print {
.vv-no-print {
display: none !important;
}
.vv-slip {
max-width: none;
border: none;
box-shadow: none;
}
}
@@ -0,0 +1,143 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/** One side of a voucher: a debit or a credit against a single account. */
interface VoucherLine {
lineNo: number;
entryType: 'DR' | 'CR';
glAccountId: number | null;
savingsAccountId: number | null;
loanAccountId: number | null;
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}
interface Voucher {
voucherNo: string;
voucherType: string | null;
referenceType: string | null;
referenceId: number | null;
amount: number | null;
branchOfficeId: number | null;
branchName: string | null;
createdBy: string | null;
createdAt: string | null;
verificationStatus: string | null;
lines: VoucherLine[];
totalDebit: number;
totalCredit: number;
balanced: boolean;
}
/**
* One voucher, as a slip: which accounts were debited, which credited, and for how much.
*
* Looked up by voucher number rather than listed, because that is how a voucher is reached — from a
* paper slip in someone's hand, or from a number quoted on a statement.
*/
@Component({
selector: 'mifosx-voucher-view',
templateUrl: './voucher-view.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-view.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherViewComponent {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
private router = inject(Router);
voucherNo = '';
voucher: Voucher | null = null;
loading = false;
/** Shown in place of the slip — a bad number and a server error read differently to the user. */
error: string | null = null;
constructor() {
// A voucher number in the URL means someone arrived from a link or a statement rather than by
// typing, so the slip is fetched straight away instead of showing them an empty search box.
const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo');
if (fromUrl) {
this.voucherNo = fromUrl;
this.search();
}
}
search(): void {
const trimmed = (this.voucherNo || '').trim();
if (!trimmed) {
this.error = 'Enter a voucher number.';
this.voucher = null;
return;
}
this.loading = true;
this.error = null;
// Kept in the URL so the slip can be linked to and survives a refresh.
this.router.navigate([], {
relativeTo: this.route,
queryParams: { voucherNo: trimmed },
replaceUrl: true
});
this.service
.voucherDetail(trimmed)
.pipe(
catchError((err: any) => {
this.error =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
`Voucher ${trimmed} could not be loaded.`;
return of(null);
})
)
.subscribe((res: Voucher | null) => {
this.loading = false;
this.voucher = res;
});
}
/** Debits first, then credits — the order a slip is read in. */
get debitLines(): VoucherLine[] {
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR');
}
get creditLines(): VoucherLine[] {
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR');
}
/**
* A voucher raised before its sides were recorded. The voucher is real and its number valid — only
* the posting behind it was never captured — so this reads as an older record, not a broken one.
*/
get hasNoLines(): boolean {
return !!this.voucher && this.voucher.lines.length === 0;
}
print(): void {
window.print();
}
}
@@ -34,6 +34,12 @@ export class DividendClaimRequestService {
ddBeneficiaryName: string;
ddPayableAtBranch: string;
ddPayableAtCity: string;
/**
* The leaflet the clerk picked off the shelf. Sent so the draft is drawn on that physical
* form — without it the server takes the next available one, and the number the clerk wrote
* on the counterfoil would not be the number in the books.
*/
leafletId?: number;
}
): Observable<any> {
return this.http.post('/clerk/dividend-claim-requests', {
@@ -41,7 +47,8 @@ export class DividendClaimRequestService {
ddApplyAmount: String(claim.ddApplyAmount),
ddBeneficiaryName: claim.ddBeneficiaryName,
ddPayableAtBranch: claim.ddPayableAtBranch,
ddPayableAtCity: claim.ddPayableAtCity
ddPayableAtCity: claim.ddPayableAtCity,
leafletId: claim.leafletId ?? null
});
}
@@ -39,4 +39,5 @@ export class DividendDashboardService {
const params = status && status !== 'ALL' ? { status } : {};
return this.http.get('/clerk/dividend-dashboard/locks', { params });
}
}
@@ -46,14 +46,69 @@
</div>
}
<div class="pg-card">
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</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]="declarationsLoading" (click)="loadDeclarations()">
Refresh
<!-- One list at a time. The three answer unrelated questions and a clerk is only ever in one of
them; stacked, the one they came for was usually below the fold. Counts ride on the tabs so a
list worth opening — a blocked member, a lock nearing forfeiture — is visible without opening
it. -->
<div class="dd-tabs" role="tablist">
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'declarations'"
[attr.aria-selected]="activeTab === 'declarations'"
(click)="activeTab = 'declarations'"
>
Declarations
<span class="dd-tab__count">{{ declarations.length }}</span>
</button>
</label>
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'overdue'"
[attr.aria-selected]="activeTab === 'overdue'"
(click)="activeTab = 'overdue'"
>
Overdue EMI
<span class="dd-tab__count" [class.dd-tab__count--alert]="rows.length > 0">{{ rows.length }}</span>
</button>
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'locks'"
[attr.aria-selected]="activeTab === 'locks'"
(click)="activeTab = 'locks'"
>
Lock History
<span class="dd-tab__count">{{ locks.length }}</span>
</button>
</div>
<div class="pg-card" [hidden]="activeTab !== 'declarations'">
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
<div class="dd-toolbar">
<div class="dd-search">
<input
type="search"
class="dd-search__input"
placeholder="Search product, period or status…"
[(ngModel)]="declarationSearch"
name="declarationSearch"
/>
@if (declarationSearch) {
<button type="button" class="dd-search__clear" (click)="declarationSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<span class="dd-toolbar__count">
{{ visibleDeclarations.length }} of {{ declarations.length }} declaration{{
declarations.length === 1 ? '' : 's'
}}
</span>
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">Refresh</button>
</div>
<table class="pg-table">
<thead>
@@ -75,8 +130,12 @@
<tr>
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
</tr>
} @else if (visibleDeclarations.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No declaration matches “{{ declarationSearch }}”.</td>
</tr>
} @else {
@for (d of declarations; track d.declarationId) {
@for (d of visibleDeclarations; track d.declarationId) {
<tr>
<td>{{ d.productName }}</td>
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
@@ -95,12 +154,27 @@
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pg-card" [hidden]="activeTab !== 'overdue'">
<div class="pos-section-label">Dividend Dashboard — Locked Dividends for Members with Overdue EMI</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<div class="dd-toolbar">
<div class="dd-search">
<input
type="search"
class="dd-search__input"
placeholder="Search member name or share account no…"
[(ngModel)]="overdueSearch"
name="overdueSearch"
/>
@if (overdueSearch) {
<button type="button" class="dd-search__clear" (click)="overdueSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<span class="dd-toolbar__count">
{{ visibleOverdueRows.length }} of {{ rows.length }} member{{ rows.length === 1 ? '' : 's' }}
</span>
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
@@ -122,10 +196,19 @@
<tr>
<td colspan="6" class="pg-empty">No members with a locked dividend and an overdue EMI right now.</td>
</tr>
} @else {
@for (row of rows; track row.lockId) {
} @else if (visibleOverdueRows.length === 0) {
<tr>
<td>{{ row.clientName }}</td>
<td colspan="6" class="pg-empty">No member matches “{{ overdueSearch }}”.</td>
</tr>
} @else {
@for (row of visibleOverdueRows; track row.lockId) {
<tr>
<td>
{{ row.clientName }}
@if (row.shareAccountNo) {
<div class="dd-account-no">{{ row.shareAccountNo }}</div>
}
</td>
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
<td>{{ row.lockedDate }}</td>
<td>{{ row.releaseDueDate }}</td>
@@ -181,7 +264,7 @@
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pg-card" [hidden]="activeTab !== 'locks'">
<div class="pos-section-label">All Dividend Locks — Full 3-Year Lock History</div>
<div class="inv-form-grid" style="padding: 12px 16px; gap: 8px">
<div class="dd-filter-group">
@@ -196,9 +279,21 @@
</button>
}
</div>
<label class="inv-form-field" style="align-self: end; margin-left: auto">
<div class="dd-search" style="margin-left: auto">
<input
type="search"
class="dd-search__input"
placeholder="Search member name or share account no…"
[(ngModel)]="lockSearch"
name="lockSearch"
/>
@if (lockSearch) {
<button type="button" class="dd-search__clear" (click)="lockSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</label>
</div>
<!-- Financial-year chips, built from the locks themselves: a new declaration adds its year here
@@ -246,7 +341,7 @@
<th>Release Due</th>
<th>Status</th>
<th>Applied Loan</th>
<th></th>
<th>Demand Draft</th>
</tr>
</thead>
<tbody>
@@ -256,12 +351,23 @@
</tr>
} @else if (locksLoaded && visibleLocks.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
<td colspan="8" class="pg-empty">
@if (lockSearch) {
No dividend lock matches “{{ lockSearch }}”.
} @else {
No dividend locks match this filter.
}
</td>
</tr>
} @else {
@for (l of visibleLocks; track l.lockId) {
<tr>
<td>{{ l.clientName }}</td>
<td>
{{ l.clientName }}
@if (l.shareAccountNo) {
<div class="dd-account-no">{{ l.shareAccountNo }}</div>
}
</td>
<td>
@if (l.financialYear) {
<div class="dd-dividend-year">FY {{ l.financialYear }}</div>
@@ -279,170 +385,21 @@
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
</td>
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
<!-- Once a draft has been raised the clerk needs its number, not another Claim button:
it is what goes on the counterfoil and what the member asks about when they return.
Until then, the button hands off to the DD inventory to raise one. -->
<td>
@if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
@if (l.claimDdNumber && l.claimStatus === 'APPROVED') {
<span class="dd-dd-number">{{ l.claimDdNumber }}</span>
} @else if (l.claimStatus === 'PENDING') {
<span class="dd-badge dd-badge--pending">DD {{ l.claimDdNumber }} — awaiting approval</span>
} @else if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn dd-apply-confirm" (click)="claimAsDemandDraft(l)">
Claim by DD
</button>
}
</td>
</tr>
@if (expandedClaimLockId === l.lockId) {
<tr class="dd-apply-row">
<td colspan="8">
<div class="dd-apply-panel dd-apply-panel--wrap">
<div class="dd-apply-panel-title">
Unclaimed amount: {{ l.amount | number: '1.2-2' }}
@if (l.financialYear) {
<span> (FY {{ l.financialYear }} dividend)</span>
}
— issue as a Demand Draft
</div>
<!-- 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">
<span>DD Amount</span>
<input type="number" [(ngModel)]="claimDdAmount[l.lockId]" min="0" step="0.01" />
</label>
<label class="inv-form-field">
<span>Beneficiary Name</span>
<input type="text" [(ngModel)]="claimDdBeneficiary[l.lockId]" />
</label>
<label class="inv-form-field">
<span>Payable At — Branch</span>
<input type="text" [(ngModel)]="claimDdPayableAtBranch[l.lockId]" />
</label>
<label class="inv-form-field">
<span>Payable At — City</span>
<input type="text" [(ngModel)]="claimDdPayableAtCity[l.lockId]" />
</label>
<div class="dd-apply-panel-total">
Claiming: {{ claimDdAmount[l.lockId] || 0 | number: '1.2-2' }} /
{{ l.amount | number: '1.2-2' }}
</div>
@if (claimError) {
<div class="dd-apply-error">{{ claimError }}</div>
}
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="claimSubmitting"
(click)="submitClaim(l)"
>
{{ claimSubmitting ? 'Submitting…' : 'Submit Claim Request' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<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">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
Refresh
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<!-- A dividend is collected only as a Demand Draft, so what the checker needs to see is the
draft about to be drawn: for how much, in whose favour, payable where. -->
<th class="num">DD Amount</th>
<th>Beneficiary</th>
<th>Payable At</th>
<th class="num">Unclaimed Balance</th>
<th>Requested By</th>
<th></th>
</tr>
</thead>
<tbody>
@if (claimRequestsLoading) {
<tr>
<td colspan="7" class="pg-empty">Loading…</td>
</tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr>
<td colspan="7" class="pg-empty">No dividend claim requests pending review.</td>
</tr>
} @else {
@for (req of claimRequests; track req.id) {
<tr>
<td>{{ req.client_name }}</td>
<td class="num">{{ req.dd_apply_amount | number: '1.2-2' }}</td>
<td>{{ req.dd_beneficiary_name || '—' }}</td>
<td>
@if (req.dd_payable_at_branch || req.dd_payable_at_city) {
{{ req.dd_payable_at_branch }}
@if (req.dd_payable_at_branch && req.dd_payable_at_city) {
,
}
{{ req.dd_payable_at_city }}
} @else {
—
}
</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>{{ req.clerk_name }}</td>
<td>
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
</button>
<button type="button" class="pg-btn" (click)="toggleRejectClaim(req)">
{{ expandedRejectClaimId === req.id ? 'Cancel' : 'Reject' }}
</button>
@if (claimActionError[req.id]) {
<div class="dd-retry-error">{{ claimActionError[req.id] }}</div>
}
</td>
</tr>
@if (expandedRejectClaimId === req.id) {
<tr class="dd-apply-row">
<td colspan="7">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Rejection Reason</span>
<input
type="text"
[(ngModel)]="rejectReason[req.id]"
placeholder="Why is this claim being rejected?"
/>
</label>
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="claimActioningId === req.id"
(click)="rejectClaim(req)"
>
{{ claimActioningId === req.id ? 'Rejecting…' : 'Confirm Reject' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
@@ -244,3 +244,160 @@
}
}
}
/* One toolbar per table: search on the left where the eye lands first, the row count next to it so
a narrowed table says how much it is hiding, and Refresh pushed to the far right. */
.dd-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
&__count {
margin-left: auto;
font-size: 12.5px;
color: #475569;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.dd-search {
position: relative;
flex: 1 1 260px;
max-width: 360px;
&__input {
width: 100%;
padding: 7px 30px 7px 11px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 13px;
color: #0f172a;
background: #fff;
&::placeholder {
color: #94a3b8;
}
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
/* The browser's own clear affordance would sit under ours. */
&::-webkit-search-cancel-button {
appearance: none;
}
}
/* Only rendered while there is something to clear, so it never reads as a dead control. */
&__clear {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
font-size: 17px;
line-height: 1;
cursor: pointer;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
}
/* The share account number under the member's name — the thing a clerk matches against the
passbook in front of them, so it has to be readable but must not outweigh the name. */
.dd-account-no {
margin-top: 2px;
font-size: 11.5px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
/* Horizontal tab strip: one list on screen at a time, chosen here.
Scrolls rather than wraps on a narrow window, so the strip stays one line and the tab positions
a clerk has learned do not move. */
.dd-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
padding-bottom: 2px;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.dd-tab {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
/* The active marker is this underline, so every tab reserves the space for it — otherwise the
strip would shift by 2px each time the selection moved. */
border-bottom: 2px solid transparent;
background: transparent;
font-size: 12.5px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
cursor: pointer;
&:hover {
color: #1e40af;
}
&--active {
color: #1e40af;
border-bottom-color: #2563eb;
}
}
.dd-tab__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
/* A member blocked by an overdue EMI, or a claim waiting on a manager, is work outstanding —
coloured so it reads as something to open rather than a neutral tally. */
&--alert {
background: #fef3c7;
color: #92400e;
}
}
/* The draft number a member's dividend was paid out on. Monospaced-by-figure so a clerk reading it
against the counterfoil can compare digit by digit. */
.dd-dd-number {
font-size: 12.5px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
@@ -9,17 +9,21 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { DividendDashboardService } from '../dividend-dashboard.service';
import { DividendClaimRequestService } from '../dividend-claim-request.service';
/**
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year
* lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a
* proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to
* claim their dividend directly (split across loan, savings, or cash).
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year lock
* history, and members whose unclaimed dividend sits alongside an overdue loan EMI (with a
* proactive "Apply to Loan" action).
*
* Claiming a dividend hands off to the DD inventory, because a dividend is only ever collected as a
* Demand Draft. The draft is then approved on the DD Issuance Approvals screen alongside every
* other draft, so there is no separate claim queue here — the lock history shows the DD number once
* it has been drawn.
*/
@Component({
selector: 'mifosx-dividend-dashboard',
@@ -37,7 +41,20 @@ import { DividendClaimRequestService } from '../dividend-claim-request.service';
})
export class DividendDashboardComponent implements OnInit {
private service = inject(DividendDashboardService);
private claimRequestService = inject(DividendClaimRequestService);
private router = inject(Router);
/**
* Which list the clerk is looking at.
*
* The three lists answer unrelated questions — what has been declared, who is blocked by an
* overdue EMI, and the full lock history — and a clerk is only ever in one of them. Stacked on one
* page they made the screen long enough that the section a clerk came for was usually below the
* fold, so they are tabbed instead.
*
* Every list is still loaded on init rather than on first view: the counts in the tab strip have
* to be right before a tab is opened, or the strip would be advertising numbers it does not know.
*/
activeTab: 'declarations' | 'overdue' | 'locks' = 'declarations';
rows: any[] = [];
loading = false;
@@ -56,6 +73,21 @@ export class DividendDashboardComponent implements OnInit {
declarationsLoading = false;
declarationsLoaded = false;
/**
* Free-text search, one box per table.
*
* Kept separate rather than shared because the three tables answer different questions and a
* clerk is usually in one of them: narrowing the lock history to a member should not blank out
* the declarations table above it, which has nothing to do with that member.
*
* Filtering is done here rather than server-side because every row is already loaded — these are
* a society's dividends, not a transaction ledger — so a round trip per keystroke would be slower
* than the filter itself and would blank the table while it ran.
*/
declarationSearch = '';
overdueSearch = '';
lockSearch = '';
/** Every lock the status filter returned, before the year filter narrows it. */
locks: any[] = [];
locksLoading = false;
@@ -70,164 +102,42 @@ export class DividendDashboardComponent implements OnInit {
lockYears: string[] = [];
lockYearFilter = 'ALL';
expandedClaimLockId: number | null = null;
/**
* A dividend is collected as a Demand Draft and nothing else — the savings-credit, cash and
* loan-set-off routes are retired, so the panel only gathers what a draft needs: how much, drawn
* in whose favour, and payable where.
*/
claimDdAmount: Record<number, number | null> = {};
claimDdBeneficiary: Record<number, string> = {};
claimDdPayableAtBranch: Record<number, string> = {};
claimDdPayableAtCity: Record<number, string> = {};
claimSubmitting = false;
claimError: string | null = null;
claimRequests: any[] = [];
claimRequestsLoading = false;
claimRequestsLoaded = false;
claimActioningId: number | null = null;
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;
rejectReason: Record<number, string> = {};
ngOnInit(): void {
this.loadSummary();
this.loadDeclarations();
this.load();
this.loadLocks();
this.loadClaimRequests();
}
toggleClaim(lock: any): void {
if (this.expandedClaimLockId === lock.lockId) {
this.expandedClaimLockId = null;
return;
/**
* Takes the clerk to the DD inventory to draw the draft this dividend is paid out as.
*
* A dividend is only ever collected as a Demand Draft, so the claim is really a request to issue
* one — and the place a draft is issued is the leaflet inventory, where the clerk can see which
* numbered leaflet they are about to commit. Gathering the draft's details on this dashboard
* instead meant filling a second, thinner version of the DD form and never seeing the DD number
* until after approval.
*
* The dividend travels in the query string rather than in a service, so a reloaded or
* bookmarked page still knows which lock it is drawing against instead of silently issuing an
* ordinary counter draft.
*/
claimAsDemandDraft(lock: any): void {
this.router.navigate(
[
'/sponsor-dd',
'inventory'
],
{
queryParams: {
dividendLockId: lock.lockId,
dividendAmount: Number(lock.amount),
dividendBeneficiary: lock.clientName ?? '',
dividendFinancialYear: lock.financialYear ?? ''
}
this.expandedClaimLockId = lock.lockId;
this.claimError = null;
// Defaults to the whole unclaimed balance and the member's own name, which is what a member
// asks for almost every time; the clerk overrides only when the member wants otherwise.
this.claimDdAmount[lock.lockId] = Number(lock.amount);
this.claimDdBeneficiary[lock.lockId] = lock.clientName ?? '';
this.claimDdPayableAtBranch[lock.lockId] = '';
this.claimDdPayableAtCity[lock.lockId] = '';
}
submitClaim(lock: any): void {
const ddAmount = Number(this.claimDdAmount[lock.lockId] || 0);
const beneficiary = this.claimDdBeneficiary[lock.lockId]?.trim();
const payableAtBranch = this.claimDdPayableAtBranch[lock.lockId]?.trim();
const payableAtCity = this.claimDdPayableAtCity[lock.lockId]?.trim();
if (ddAmount <= 0) {
this.claimError = 'Enter a DD amount greater than zero.';
return;
}
if (ddAmount > Number(lock.amount)) {
this.claimError = `DD amount (${ddAmount}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return;
}
if (!beneficiary) {
this.claimError = 'Enter the beneficiary name — a Demand Draft cannot be drawn without a payee.';
return;
}
if (!payableAtBranch || !payableAtCity) {
this.claimError = 'Enter both the branch and city the Demand Draft is payable at.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
.submit(lock.lockId, {
ddApplyAmount: ddAmount,
ddBeneficiaryName: beneficiary,
ddPayableAtBranch: payableAtBranch,
ddPayableAtCity: payableAtCity
})
.subscribe({
next: () => {
this.claimSubmitting = false;
this.expandedClaimLockId = null;
this.loadClaimRequests();
},
error: (err: any) => {
this.claimSubmitting = false;
this.claimError = err?.error?.message || 'Failed to submit claim request.';
}
});
}
loadClaimRequests(): void {
this.claimRequestsLoading = true;
this.claimRequestService
.list('PENDING')
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.claimRequests = res ?? [];
this.claimRequestsLoading = false;
this.claimRequestsLoaded = true;
});
}
approveClaim(req: any): void {
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.approve(req.id).subscribe({
next: (res: any) => {
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.loadLocks();
this.loadSummary();
},
error: (err: any) => {
this.claimActioningId = null;
this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.';
}
});
}
toggleRejectClaim(req: any): void {
this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id;
}
rejectClaim(req: any): void {
const reason = this.rejectReason[req.id];
if (!reason || !reason.trim()) {
this.claimActionError[req.id] = 'Rejection reason is required.';
return;
}
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.reject(req.id, reason).subscribe({
next: () => {
this.claimActioningId = null;
this.expandedRejectClaimId = null;
this.loadClaimRequests();
},
error: (err: any) => {
this.claimActioningId = null;
this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.';
}
});
);
}
loadSummary(): void {
@@ -253,6 +163,33 @@ export class DividendDashboardComponent implements OnInit {
});
}
/**
* Whether any of a row's searchable fields contains the term.
*
* Substring rather than prefix matching, because a clerk reading an account number off a passbook
* often types the distinctive tail of it rather than the branch prefix every account shares. Null
* fields are skipped — a lock with no share account still has to be findable by member name.
*/
private matches(term: string, ...fields: (string | number | null | undefined)[]): boolean {
const needle = term.trim().toLowerCase();
if (!needle) {
return true;
}
return fields.some((field) => field != null && String(field).toLowerCase().includes(needle));
}
/** Declarations the table renders. Product-level rows, so there is no member to search on. */
get visibleDeclarations(): any[] {
return this.declarations.filter((d) =>
this.matches(this.declarationSearch, d.productName, d.periodStartDate, d.periodEndDate, d.status)
);
}
/** Members with an overdue EMI, narrowed to the one the clerk is dealing with. */
get visibleOverdueRows(): any[] {
return this.rows.filter((r) => this.matches(this.overdueSearch, r.clientName, r.shareAccountNo));
}
declarationStatusLabel(status: string): string {
return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved';
}
@@ -290,11 +227,16 @@ export class DividendDashboardComponent implements OnInit {
}
}
/** Locks the table actually renders — the status filter is applied server-side, the year here. */
/**
* Locks the table actually renders — the status filter is applied server-side, the year and the
* search here. All three narrow together, so a clerk can hold a year and still search within it.
*/
get visibleLocks(): any[] {
return this.lockYearFilter === 'ALL'
? this.locks
: this.locks.filter((l) => l.financialYear === this.lockYearFilter);
return this.locks.filter(
(l) =>
(this.lockYearFilter === 'ALL' || l.financialYear === this.lockYearFilter) &&
this.matches(this.lockSearch, l.clientName, l.shareAccountNo, l.productName, l.appliedLoanAccountNo)
);
}
/** Total of what is on screen, so a year's worth of dividend can be read off directly. */
+78 -1
View File
@@ -187,6 +187,33 @@
</svg>
Exceptions
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.split('?')[0] === '/cashier/voucher'"
[routerLink]="['/cashier/voucher']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1z" />
<line x1="8" y1="9" x2="16" y2="9" />
<line x1="8" y1="13" x2="14" y2="13" />
</svg>
Voucher
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-report')"
[routerLink]="['/cashier/voucher-report']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
Voucher Report
</a>
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
} @else {
@@ -262,6 +289,20 @@
>
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="receipt"></fa-icon> Voucher
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="list"></fa-icon> Voucher Report
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
@@ -1047,6 +1088,12 @@
<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/voucher']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
@@ -1101,6 +1148,12 @@
<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/voucher']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
@@ -1144,6 +1197,9 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/savings-daily-interest']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Savings Daily Interest
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button>
@@ -1700,7 +1756,7 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="print" class="menu-item-icon" style="color: #15803d"></fa-icon> FD / Renewal
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/letter-mgmt/share-member" tabindex="0">
<fa-icon icon="coins" class="menu-item-icon" style="color: #15803d"></fa-icon> Share Member
</button>
<button mat-menu-item tabindex="0">
@@ -1742,6 +1798,17 @@
Hypothecation Certificate
</button>
</div>
<!-- Registers & Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Registers &amp; Reports</div>
<button mat-menu-item routerLink="/letter-mgmt/membership-register" tabindex="0">
<fa-icon icon="book" class="menu-item-icon" style="color: #15803d"></fa-icon> Membership Register
</button>
<button mat-menu-item routerLink="/letter-mgmt/share-allotment-report" tabindex="0">
<fa-icon icon="coins" class="menu-item-icon" style="color: #15803d"></fa-icon> Share Allotment Report
</button>
</div>
<!-- Others -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Others</div>
@@ -1984,6 +2051,16 @@
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
</a>
<!-- Named for what it reports rather than a form number, so a clerk can find it without
already knowing which return covers membership. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Members & Share Capital Return')"
routerLink="/rbi/share-capital"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members &amp; Share Capital
</a>
</div>
<!-- CIMS / XBRL Returns -->
<div class="gl-group">
@@ -19,6 +19,9 @@ import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component'
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
import { NocCertificateComponent } from './certificates/noc/noc.component';
import { DatabaseExportComponent } from './database-export/database-export.component';
import { ShareMemberComponent } from './share-member/share-member.component';
import { MembershipRegisterComponent } from './membership-register/membership-register.component';
import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component';
const routes: Routes = [
Route.withShell([
@@ -37,6 +40,21 @@ const routes: Routes = [
component: ShareCertificateComponent,
data: { title: 'Share Certificate', breadcrumb: 'Share Certificate' }
},
{
path: 'letter-mgmt/share-member',
component: ShareMemberComponent,
data: { title: 'Share Members', breadcrumb: 'Share Members' }
},
{
path: 'letter-mgmt/membership-register',
component: MembershipRegisterComponent,
data: { title: 'Membership Register', breadcrumb: 'Membership Register' }
},
{
path: 'letter-mgmt/share-allotment-report',
component: ShareAllotmentReportComponent,
data: { title: 'Share Allotment Report', breadcrumb: 'Share Allotment Report' }
},
{
path: 'letter-mgmt/balance-certificate',
component: BalanceCertificateComponent,
+7 -1
View File
@@ -24,6 +24,9 @@ import { HypothecationCertificateComponent } from './certificates/hypothecation/
import { NocCertificateComponent } from './certificates/noc/noc.component';
import { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component';
import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.component';
import { ShareMemberComponent } from './share-member/share-member.component';
import { MembershipRegisterComponent } from './membership-register/membership-register.component';
import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component';
@NgModule({
imports: [
@@ -42,7 +45,10 @@ import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.compon
HypothecationCertificateComponent,
NocCertificateComponent,
CertificatePreviewDialogComponent,
DscSignDialogComponent
DscSignDialogComponent,
ShareMemberComponent,
MembershipRegisterComponent,
ShareAllotmentReportComponent
]
})
export class LetterMgmtModule {}
@@ -0,0 +1,198 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="book" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Membership Register</h2>
<div class="cert-head-s">
Statutory register of members — one line per member, in order of admission.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="id-card" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRows.length | number }}</div>
<div class="cert-kpi-l">Members on Register</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ activeMembers() | number }}</div>
<div class="cert-kpi-l">Holding Shares</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalShares() | number }}</div>
<div class="cert-kpi-l">Total Shares Held</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Member / Membership No / Address</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or 000000012" />
</div>
</div>
<div class="cert-filter">
<label>Branch</label>
<select [(ngModel)]="branchFilter">
<option value="">All branches</option>
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Holding</label>
<select [(ngModel)]="statusFilter">
<option value="">All members</option>
<option value="active">Holding shares</option>
<option value="pending">No shares yet</option>
</select>
</div>
<div class="cert-filter">
<label>Admitted from</label>
<input type="date" [(ngModel)]="fromDate" />
</div>
<div class="cert-filter">
<label>Admitted to</label>
<input type="date" [(ngModel)]="toDate" />
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
</div>
<!-- Register -->
<div class="cert-card">
@if (loading) {
<div class="cert-empty">Loading membership register…</div>
} @else if (loadError) {
<div class="cert-empty">
Could not load the membership register.
<button class="cert-cta-btn mr-retry" (click)="loadRegister()">Retry</button>
</div>
} @else if (!filteredRows.length) {
<div class="cert-empty">
@if (rows.length) {
No member matches these filters.
} @else {
No members on the register yet.
}
</div>
} @else {
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>S.No</th>
<th>Member's Name</th>
<th>Membership No</th>
<th>Address</th>
<th>Date of Admission</th>
<th class="num">No. of Shares Held</th>
<th class="num">Share Value</th>
<th>Branch</th>
<th>Mobile</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.clientId) {
<tr
class="mr-row"
tabindex="0"
role="link"
matTooltip="Open {{ row.memberName }}'s share account"
(click)="openMember(row)"
(keydown.enter)="openMember(row)"
(keydown.space)="$event.preventDefault(); openMember(row)"
>
<td class="mr-serial">{{ row.serial }}</td>
<td class="cert-cust-name">
{{ row.memberName }}
@if (row.accountCount > 1) {
<span class="cert-pill mr-pill--multi" matTooltip="Holds {{ row.accountCount }} share accounts">
{{ row.accountCount }} a/c
</span>
}
</td>
<td class="cert-folio">{{ row.membershipNo }}</td>
<td class="mr-address" [matTooltip]="row.address">{{ row.address }}</td>
<td>{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }}</td>
<td class="num cert-shares">
{{ row.sharesHeld | number }}
@if (row.pendingShares) {
<span class="mr-pending" matTooltip="{{ row.pendingShares }} shares pending approval">
+{{ row.pendingShares | number }}
</span>
}
</td>
<td class="num">₹{{ row.shareValue | number: '1.0-0' }}</td>
<td>{{ row.branch }}</td>
<td>{{ row.mobileNo }}</td>
<td class="cert-row-actions">
<fa-icon icon="chevron-right" class="mr-chevron"></fa-icon>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="mr-total">
<td colspan="5">Total — {{ filteredRows.length | number }} members</td>
<td class="num">{{ totalShares() | number }}</td>
<td class="num">₹{{ totalValue() | number: '1.0-0' }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="cert-note">
One line per member, not per share account — a member holding several share accounts appears once with their
holdings summed. Serial numbers follow date of admission. A "+n" beside the holding is shares applied for but not
yet approved.
</div>
</div>
@@ -0,0 +1,116 @@
/**
* 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/.
*/
.mr-row {
cursor: pointer;
transition: background 0.12s ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
}
&:focus-visible {
outline: 2px solid var(--cert-blue);
outline-offset: -2px;
}
&:hover .mr-chevron,
&:focus-visible .mr-chevron {
opacity: 1;
}
}
.mr-chevron {
opacity: 0;
color: var(--cert-blue);
transition: opacity 0.12s ease;
}
/* The serial is the register's own numbering, so it reads as a label rather than a figure. */
.mr-serial {
font-weight: 700;
color: var(--cert-g500);
font-variant-numeric: tabular-nums;
}
.mr-address {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Shares still awaiting approval sit beside the holding rather than inside it — they are
not yet share capital, and adding them to the figure would overstate the register. */
.mr-pending {
margin-left: 6px;
font-size: 11px;
font-weight: 700;
color: var(--cert-amber);
}
.mr-pill--multi {
margin-left: 8px;
background: var(--cert-blue-l);
color: var(--cert-blue-d);
font-size: 10px;
padding: 2px 7px;
}
.cert-table {
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot .mr-total > td {
background: var(--cert-g50);
font-weight: 800;
color: var(--cert-g900);
border-top: 2px solid var(--cert-g200);
padding: 12px;
white-space: nowrap;
}
}
.mr-retry {
margin-left: 10px;
}
/* A register is a document that gets signed and filed, so printing drops the chrome. */
@media print {
.cert-filters,
.cert-head-r,
.cert-kpis,
.mr-chevron {
display: none !important;
}
.cert-card {
border: none;
}
.mr-address {
max-width: none;
white-space: normal;
}
.cert-table {
font-size: 10px;
}
/* Repeat the column headings on every printed page of a long register. */
.cert-table thead {
display: table-header-group;
}
.mr-row {
break-inside: avoid;
}
}
@@ -0,0 +1,349 @@
/**
* 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 { Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { forkJoin, of, Observable } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares/shares.service';
import { ClientsService } from '../../clients/clients.service';
/**
* One line of the statutory Membership Register.
*
* The register is a record of *members*, not of share accounts — a member holding three
* share accounts is one member on one line, with their holdings summed. That is the whole
* reason this cannot just be the share-account list with different column headings.
*/
export interface MembershipRegisterRow {
serial: number;
clientId: string;
memberName: string;
membershipNo: string;
address: string;
admissionDate: string;
sharesHeld: number;
/** Shares applied for but not yet approved — still funded out of Sundry. */
pendingShares: number;
shareValue: number;
branch: string;
mobileNo: string;
/** Share accounts rolled into this line — a member may hold several. */
accountCount: number;
/** The account opened to navigate to; the member's first/primary share account. */
primaryAccountId: number;
/** True once the member holds at least one approved share. */
isActive: boolean;
}
@Component({
selector: 'mifosx-membership-register',
templateUrl: './membership-register.component.html',
styleUrls: [
'../certificates/certificates.shared.scss',
'./membership-register.component.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class MembershipRegisterComponent implements OnInit {
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
private router = inject(Router);
loading = true;
loadError = false;
rows: MembershipRegisterRow[] = [];
// ── Filters ──
search = '';
branchFilter = '';
statusFilter = '';
fromDate = '';
toDate = '';
private clientDataCache = new Map<string, Observable<any>>();
private clientAddressCache = new Map<string, Observable<any>>();
ngOnInit(): void {
this.loadRegister();
}
loadRegister(): void {
this.loading = true;
this.loadError = false;
this.clientDataCache.clear();
this.clientAddressCache.clear();
this.sharesService
.getAllShareAccounts(0, 200)
.pipe(
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
switchMap((items: any[]) => {
if (!items.length) {
return of([] as any[]);
}
return forkJoin(items.map((item) => this.loadAccount(item))).pipe(
map((accounts) => accounts.filter((a) => !!a))
);
}),
catchError(() => {
this.loadError = true;
return of([] as any[]);
})
)
.subscribe((accounts) => {
this.rows = this.collapseToMembers(accounts);
this.loading = false;
});
}
private loadAccount(listItem: any): Observable<any | null> {
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
return forkJoin({
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
client: clientId ? this.getClientDataCached(clientId) : of(null),
address: clientId ? this.getClientAddressCached(clientId) : of([])
}).pipe(catchError(() => of(null)));
}
private getClientDataCached(clientId: string): Observable<any> {
if (!this.clientDataCache.has(clientId)) {
this.clientDataCache.set(
clientId,
this.clientsService.getClientData(clientId, true).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientDataCache.get(clientId)!;
}
private getClientAddressCached(clientId: string): Observable<any> {
if (!this.clientAddressCache.has(clientId)) {
this.clientAddressCache.set(
clientId,
this.clientsService.getClientAddressData(clientId, true).pipe(
catchError(() => of([])),
shareReplay(1)
)
);
}
return this.clientAddressCache.get(clientId)!;
}
/**
* Collapses share accounts into one line per member, then numbers the lines by date of
* admission — a register's serial number is the order members joined, so it has to be
* assigned after sorting, not carried over from the account list.
*/
private collapseToMembers(accounts: any[]): MembershipRegisterRow[] {
const byMember = new Map<string, MembershipRegisterRow>();
for (const entry of accounts) {
const { sa, client, address } = entry;
if (!sa) {
continue;
}
const clientId = String(sa.clientId ?? client?.id ?? '');
if (!clientId) {
continue;
}
const approved = sa.summary?.totalApprovedShares || 0;
const pending = sa.summary?.totalPendingForApprovalShares || 0;
const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0;
const existing = byMember.get(clientId);
if (existing) {
existing.sharesHeld += approved;
existing.pendingShares += pending;
existing.shareValue += approved * unitPrice;
existing.accountCount += 1;
existing.isActive = existing.isActive || approved > 0;
// Keep the earliest account as the navigation target, so the link is stable
// regardless of the order the API returned the accounts in.
existing.primaryAccountId = Math.min(existing.primaryAccountId, sa.id);
continue;
}
byMember.set(clientId, {
serial: 0,
clientId,
memberName: sa.clientName || client?.displayName || '—',
membershipNo: client?.accountNo || sa.clientAccountNo || '—',
address: this.firstAddress(address),
admissionDate:
this.toIsoDate(client?.activationDate) ||
this.toIsoDate(client?.timeline?.activatedOnDate) ||
this.toIsoDate(sa.timeline?.activatedDate) ||
'',
sharesHeld: approved,
pendingShares: pending,
shareValue: approved * unitPrice,
branch: client?.officeName || sa.officeName || '—',
mobileNo: client?.mobileNo || '—',
accountCount: 1,
primaryAccountId: sa.id,
isActive: approved > 0
});
}
// Oldest admission first: member no. 1 is the society's first member.
const ordered = Array.from(byMember.values()).sort((a, b) =>
(a.admissionDate || '').localeCompare(b.admissionDate || '')
);
ordered.forEach((row, i) => (row.serial = i + 1));
return ordered;
}
private toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
y,
m,
d
] = value;
if (!y) {
return '';
}
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
}
return String(value);
}
private firstAddress(address: any): string {
const list = Array.isArray(address) ? address : address?.pageItems || [];
if (!list.length) {
return '—';
}
const a = list[0];
const parts = [
a.addressLine1,
a.addressLine2,
a.townVillage,
a.city,
a.stateName,
a.countryName,
a.postalCode
]
.filter((p: any) => !!p && String(p).trim())
.join(', ');
return parts || '—';
}
// ── Derived list ──
get filteredRows(): MembershipRegisterRow[] {
const q = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.branch !== this.branchFilter) return false;
if (this.statusFilter === 'active' && !r.isActive) return false;
if (this.statusFilter === 'pending' && r.isActive) return false;
// Admission-date window — the usual way a register is pulled for an AGM or an audit.
if (this.fromDate && (!r.admissionDate || r.admissionDate < this.fromDate)) return false;
if (this.toDate && (!r.admissionDate || r.admissionDate > this.toDate)) return false;
if (!q) return true;
return (
r.memberName.toLowerCase().includes(q) ||
r.membershipNo.toLowerCase().includes(q) ||
r.address.toLowerCase().includes(q) ||
r.mobileNo.toLowerCase().includes(q)
);
});
}
get branchOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
}
totalShares(): number {
return this.filteredRows.reduce((s, r) => s + r.sharesHeld, 0);
}
totalValue(): number {
return this.filteredRows.reduce((s, r) => s + r.shareValue, 0);
}
activeMembers(): number {
return this.filteredRows.filter((r) => r.isActive).length;
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.statusFilter = '';
this.fromDate = '';
this.toDate = '';
}
openMember(row: MembershipRegisterRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.primaryAccountId,
'general'
]);
}
exportCsv(): void {
const header = [
'S.No',
"Member's Name",
'Membership No',
'Address',
'Date of Admission',
'No. of Shares Held',
'Share Value',
'Branch',
'Mobile'
];
const rows = this.filteredRows.map((r) => [
r.serial,
r.memberName,
r.membershipNo,
r.address,
r.admissionDate,
r.sharesHeld,
r.shareValue,
r.branch,
r.mobileNo
]);
const csv = [
header,
...rows
]
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'membership-register.csv';
link.click();
URL.revokeObjectURL(url);
}
print(): void {
window.print();
}
}
@@ -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/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="coins" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Allotment Report</h2>
<div class="cert-head-s">
Every tranche of shares issued — allotment date, number, value and payment status.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRows.length | number }}</div>
<div class="cert-kpi-l">Allotments</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ allottedShares() | number }}</div>
<div class="cert-kpi-l">Shares Allotted</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ allottedValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Value Allotted</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="sync" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ pendingShares() | number }}</div>
<div class="cert-kpi-l">Pending Approval</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Member / Membership No / A/c No / Resolution</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or BR-2026-14" />
</div>
</div>
<div class="cert-filter">
<label>Branch</label>
<select [(ngModel)]="branchFilter">
<option value="">All branches</option>
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Share product</label>
<select [(ngModel)]="productFilter">
<option value="">All products</option>
@for (p of productOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All</option>
<option value="ALLOTTED">Allotted</option>
<option value="PENDING">Pending approval</option>
<option value="REJECTED">Rejected</option>
</select>
</div>
<div class="cert-filter">
<label>Allotted from</label>
<input type="date" [(ngModel)]="fromDate" />
</div>
<div class="cert-filter">
<label>Allotted to</label>
<input type="date" [(ngModel)]="toDate" />
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
</div>
<!-- Report -->
<div class="cert-card">
@if (loading) {
<div class="cert-empty">Loading allotments…</div>
} @else if (loadError) {
<div class="cert-empty">
Could not load the allotment report.
<button class="cert-cta-btn sar-retry" (click)="loadAllotments()">Retry</button>
</div>
} @else if (!filteredRows.length) {
<div class="cert-empty">
@if (rows.length) {
No allotment matches these filters.
} @else {
No shares have been allotted yet.
}
</div>
} @else {
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>S.No</th>
<th>Member Name</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Product</th>
<th>Branch</th>
<th>Allotment Date</th>
<th class="num">No. of Shares</th>
<th class="num">Unit Price</th>
<th class="num">Value</th>
<th>Share Nos.</th>
<th>Resolution</th>
<th>Status</th>
<th>Payment</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.serial) {
<tr
class="sar-row"
tabindex="0"
role="link"
matTooltip="Open share account {{ row.shareAccountNo }}"
(click)="openAccount(row)"
(keydown.enter)="openAccount(row)"
(keydown.space)="$event.preventDefault(); openAccount(row)"
>
<td class="sar-serial">{{ row.serial }}</td>
<td class="cert-cust-name">{{ row.memberName }}</td>
<td class="cert-folio">{{ row.membershipNo }}</td>
<td class="cert-folio">{{ row.shareAccountNo }}</td>
<td>{{ row.productName }}</td>
<td>{{ row.branch }}</td>
<td>{{ row.allotmentDate ? (row.allotmentDate | date: 'd MMM y') : '—' }}</td>
<td class="num cert-shares">{{ row.numberOfShares | number }}</td>
<td class="num">₹{{ row.unitPrice | number: '1.0-2' }}</td>
<td class="num">₹{{ row.value | number: '1.0-0' }}</td>
<td class="cert-folio">
@if (row.shareNoFrom) {
{{ row.shareNoFrom }}–{{ row.shareNoTo }}
} @else {
—
}
</td>
<td>
{{ row.resolutionNumber }}
@if (row.meetingDate) {
<span class="sar-meeting">({{ row.meetingDate | date: 'd MMM y' }})</span>
}
</td>
<td>
<span class="cert-pill" [class]="'sar-pill--' + row.status.toLowerCase()">{{ row.statusLabel }}</span>
</td>
<td>
<span class="cert-pill" [class]="'sar-pay--' + row.status.toLowerCase()">{{ row.paymentStatus }}</span>
</td>
<td class="cert-row-actions">
<fa-icon icon="chevron-right" class="sar-chevron"></fa-icon>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="sar-total">
<td colspan="7">Total allotted — {{ filteredRows.length | number }} tranches</td>
<td class="num">{{ allottedShares() | number }}</td>
<td></td>
<td class="num">₹{{ allottedValue() | number: '1.0-0' }}</td>
<td colspan="5"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="cert-note">
One line per tranche, so a member who bought twice appears twice with each allotment date. Totals count allotted
shares only. "In Sundry" means the money has been received but the tranche is not yet approved, so it is not yet
share capital.
</div>
</div>
@@ -0,0 +1,125 @@
/**
* 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/.
*/
.sar-row {
cursor: pointer;
transition: background 0.12s ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
}
&:focus-visible {
outline: 2px solid var(--cert-blue);
outline-offset: -2px;
}
&:hover .sar-chevron,
&:focus-visible .sar-chevron {
opacity: 1;
}
}
.sar-chevron {
opacity: 0;
color: var(--cert-blue);
transition: opacity 0.12s ease;
}
.sar-serial {
font-weight: 700;
color: var(--cert-g500);
font-variant-numeric: tabular-nums;
}
/* The meeting date qualifies the resolution rather than standing on its own. */
.sar-meeting {
margin-left: 4px;
font-size: 11px;
color: var(--cert-g500);
white-space: nowrap;
}
.cert-table {
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot .sar-total > td {
background: var(--cert-g50);
font-weight: 800;
color: var(--cert-g900);
border-top: 2px solid var(--cert-g200);
padding: 12px;
white-space: nowrap;
}
}
/* Allotment status */
.sar-pill--allotted {
background: var(--cert-green-l);
color: var(--cert-green);
}
.sar-pill--pending {
background: var(--cert-amber-l);
color: var(--cert-amber);
}
.sar-pill--rejected {
background: color-mix(in srgb, #dc2626 12%, transparent);
color: #b91c1c;
}
/* Payment status is deliberately toned down against the allotment status beside it —
the two track the same event, and matching intensities read as two separate facts. */
.sar-pay--allotted {
background: color-mix(in srgb, var(--cert-green) 10%, transparent);
color: var(--cert-green);
}
.sar-pay--pending {
background: var(--cert-blue-l);
color: var(--cert-blue-d);
}
.sar-pay--rejected {
background: var(--cert-g100);
color: var(--cert-g700);
}
.sar-retry {
margin-left: 10px;
}
@media print {
.cert-filters,
.cert-head-r,
.cert-kpis,
.sar-chevron {
display: none !important;
}
.cert-card {
border: none;
}
.cert-table {
font-size: 10px;
}
.cert-table thead {
display: table-header-group;
}
.sar-row {
break-inside: avoid;
}
}
@@ -0,0 +1,376 @@
/**
* 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 { Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { forkJoin, of, Observable } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares/shares.service';
import { ClientsService } from '../../clients/clients.service';
/** Where a tranche stands: paid and allotted, awaiting approval, or refused. */
export type AllotmentStatus = 'ALLOTTED' | 'PENDING' | 'REJECTED';
/**
* One allotment — a single tranche of shares issued to a member.
*
* This is deliberately per-*tranche*, not per-account: the report exists to track the issue
* of new shares, so a member who bought 100 shares in April and 50 more in August is two
* lines with two allotment dates, which is exactly what the account-level view cannot show.
*/
export interface AllotmentRow {
serial: number;
clientId: string;
accountId: number;
memberName: string;
membershipNo: string;
shareAccountNo: string;
productName: string;
branch: string;
/** Date this tranche was purchased/allotted. */
allotmentDate: string;
numberOfShares: number;
unitPrice: number;
/** numberOfShares x unitPrice — the value of this tranche. */
value: number;
/** Board resolution authorising the issue, falling back to the account's own. */
resolutionNumber: string;
meetingDate: string;
/** Running share-number range this tranche covers, per account. */
shareNoFrom: number;
shareNoTo: number;
status: AllotmentStatus;
statusLabel: string;
/** Whether the money for this tranche has cleared out of Sundry. */
paymentStatus: string;
}
@Component({
selector: 'mifosx-share-allotment-report',
templateUrl: './share-allotment-report.component.html',
styleUrls: [
'../certificates/certificates.shared.scss',
'./share-allotment-report.component.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class ShareAllotmentReportComponent implements OnInit {
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
private router = inject(Router);
loading = true;
loadError = false;
rows: AllotmentRow[] = [];
// ── Filters ──
search = '';
branchFilter = '';
productFilter = '';
statusFilter = '';
fromDate = '';
toDate = '';
private clientDataCache = new Map<string, Observable<any>>();
ngOnInit(): void {
this.loadAllotments();
}
loadAllotments(): void {
this.loading = true;
this.loadError = false;
this.clientDataCache.clear();
this.sharesService
.getAllShareAccounts(0, 200)
.pipe(
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
switchMap((items: any[]) => {
if (!items.length) {
return of([] as AllotmentRow[]);
}
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(
map((perAccount) => perAccount.flat())
);
}),
catchError(() => {
this.loadError = true;
return of([] as AllotmentRow[]);
})
)
.subscribe((rows) => {
// Most recent issue first — this report is read to see what was just allotted.
const ordered = rows.sort((a, b) => (b.allotmentDate || '').localeCompare(a.allotmentDate || ''));
ordered.forEach((row, i) => (row.serial = i + 1));
this.rows = ordered;
this.loading = false;
});
}
private buildAllotments(listItem: any): Observable<AllotmentRow[]> {
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
return forkJoin({
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
client: clientId ? this.getClientDataCached(clientId) : of(null)
}).pipe(
map(({ sa, client }: any) => this.mapAccount(sa, client)),
catchError(() => of([] as AllotmentRow[]))
);
}
private getClientDataCached(clientId: string): Observable<any> {
if (!this.clientDataCache.has(clientId)) {
this.clientDataCache.set(
clientId,
this.clientsService.getClientData(clientId, true).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientDataCache.get(clientId)!;
}
/**
* Expands one account's `purchasedShares` into allotment lines.
*
* Charge payments are dropped — a fee is not a share movement. Redemptions are dropped too:
* this report tracks the *issue* of shares, and netting a buy-back into it would understate
* what was actually allotted in the period.
*/
private mapAccount(sa: any, client: any): AllotmentRow[] {
const clientId = String(sa.clientId ?? client?.id ?? '');
const memberName = sa.clientName || client?.displayName || '—';
const membershipNo = client?.accountNo || sa.clientAccountNo || '—';
const branch = client?.officeName || sa.officeName || '—';
const purchases = (sa.purchasedShares || [])
.filter((txn: any) => {
const type = txn?.type?.value;
return type !== 'Charge Payment' && type !== 'Redeem';
})
.slice()
.sort(
(a: any, b: any) =>
new Date(this.toIsoDate(a.purchasedDate)).getTime() - new Date(this.toIsoDate(b.purchasedDate)).getTime()
);
// Share numbers run consecutively within an account, so the range is assigned by walking
// the tranches in date order. Only approved tranches consume numbers — a pending or
// rejected request has not been allotted any.
let nextShareNumber = sa.id * 5000 + 201;
return purchases.map((txn: any) => {
const shares = Number(txn.numberOfShares) || 0;
const unitPrice = Number(txn.purchasedPrice) || 0;
const status = this.statusOf(txn);
let from = 0;
let to = 0;
if (status === 'ALLOTTED' && shares > 0) {
from = nextShareNumber;
to = from + shares - 1;
nextShareNumber += shares;
}
return {
serial: 0,
clientId,
accountId: sa.id,
memberName,
membershipNo,
shareAccountNo: sa.accountNo || String(sa.id),
productName: sa.productName || '—',
branch,
allotmentDate: this.toIsoDate(txn.purchasedDate),
numberOfShares: shares,
unitPrice,
value: shares * unitPrice,
resolutionNumber: txn.resolutionNumber || sa.resolutionNumber || '—',
meetingDate: this.toIsoDate(txn.meetingDate) || this.toIsoDate(sa.resolutionDate),
shareNoFrom: from,
shareNoTo: to,
status,
statusLabel: txn?.status?.value || status,
paymentStatus: this.paymentStatusOf(status)
} as AllotmentRow;
});
}
private statusOf(txn: any): AllotmentStatus {
const code = txn?.status?.code || '';
if (code === 'purchasedSharesStatusType.rejected') {
return 'REJECTED';
}
if (code === 'purchasedSharesStatusType.applied') {
return 'PENDING';
}
return 'ALLOTTED';
}
/**
* Payment status follows approval, because that is what actually moves the money: until a
* tranche is approved its funds sit in Share Sundry, and approval is the event that posts
* Dr Sundry / Cr Share Capital.
*/
private paymentStatusOf(status: AllotmentStatus): string {
switch (status) {
case 'ALLOTTED':
return 'Paid';
case 'PENDING':
return 'In Sundry';
default:
return 'Refunded';
}
}
private toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
y,
m,
d
] = value;
if (!y) {
return '';
}
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
}
return String(value);
}
// ── Derived list ──
get filteredRows(): AllotmentRow[] {
const q = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.branch !== this.branchFilter) return false;
if (this.productFilter && r.productName !== this.productFilter) return false;
if (this.statusFilter && r.status !== this.statusFilter) return false;
if (this.fromDate && (!r.allotmentDate || r.allotmentDate < this.fromDate)) return false;
if (this.toDate && (!r.allotmentDate || r.allotmentDate > this.toDate)) return false;
if (!q) return true;
return (
r.memberName.toLowerCase().includes(q) ||
r.membershipNo.toLowerCase().includes(q) ||
r.shareAccountNo.toLowerCase().includes(q) ||
r.resolutionNumber.toLowerCase().includes(q)
);
});
}
get branchOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
}
get productOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort();
}
/** Shares actually issued — pending and rejected tranches are not allotments. */
allottedShares(): number {
return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.numberOfShares, 0);
}
allottedValue(): number {
return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.value, 0);
}
pendingShares(): number {
return this.filteredRows.filter((r) => r.status === 'PENDING').reduce((s, r) => s + r.numberOfShares, 0);
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
this.fromDate = '';
this.toDate = '';
}
openAccount(row: AllotmentRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.accountId,
'general'
]);
}
exportCsv(): void {
const header = [
'S.No',
'Member Name',
'Membership No',
'Share A/c No',
'Product',
'Branch',
'Allotment Date',
'No. of Shares',
'Unit Price',
'Value',
'Share No. From',
'Share No. To',
'Resolution No',
'Meeting Date',
'Status',
'Payment Status'
];
const rows = this.filteredRows.map((r) => [
r.serial,
r.memberName,
r.membershipNo,
r.shareAccountNo,
r.productName,
r.branch,
r.allotmentDate,
r.numberOfShares,
r.unitPrice,
r.value,
r.shareNoFrom || '',
r.shareNoTo || '',
r.resolutionNumber,
r.meetingDate,
r.statusLabel,
r.paymentStatus
]);
const csv = [
header,
...rows
]
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'share-allotment-report.csv';
link.click();
URL.revokeObjectURL(url);
}
print(): void {
window.print();
}
}
@@ -0,0 +1,191 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="coins" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Members</h2>
<div class="cert-head-s">
Every share member on the register with their holding. Select a row to open that share account.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="id-card" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalMembers() | number }}</div>
<div class="cert-kpi-l">Members</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="book" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRows.length | number }}</div>
<div class="cert-kpi-l">Share Accounts</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalShares() | number }}</div>
<div class="cert-kpi-l">Shares Held</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Member / Membership No / Folio / A/c No</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. 000000012 or member name" />
</div>
</div>
<div class="cert-filter">
<label>Branch</label>
<select [(ngModel)]="branchFilter">
<option value="">All branches</option>
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Share product</label>
<select [(ngModel)]="productFilter">
<option value="">All products</option>
@for (p of productOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="closed">Closed</option>
<option value="rejected">Rejected</option>
</select>
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
</div>
<!-- Register -->
<div class="cert-card">
@if (loading) {
<div class="cert-empty">Loading share members…</div>
} @else if (loadError) {
<div class="cert-empty">
Could not load share members.
<button class="cert-cta-btn sm-retry" (click)="loadMembers()">Retry</button>
</div>
} @else if (!filteredRows.length) {
<div class="cert-empty">
@if (rows.length) {
No share member matches these filters.
} @else {
No share members on the register yet.
}
</div>
} @else {
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>S.No</th>
<th>Membership No</th>
<th>Member Name</th>
<th>Folio No</th>
<th>Share A/c No</th>
<th>Product</th>
<th>Branch</th>
<th>Address</th>
<th>Mobile</th>
<th>Date of Admission</th>
<th class="num">Shares Held</th>
<th class="num">Pending</th>
<th class="num">Unit Price</th>
<th class="num">Share Value</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.accountId; let i = $index) {
<tr
class="sm-row"
tabindex="0"
role="link"
matTooltip="Open share account {{ row.shareAccountNo }}"
(click)="openAccount(row)"
(keydown.enter)="openAccount(row)"
(keydown.space)="$event.preventDefault(); openAccount(row)"
>
<td>{{ i + 1 }}</td>
<td class="cert-folio">{{ row.membershipNo }}</td>
<td class="cert-cust-name">{{ row.memberName }}</td>
<td class="cert-folio">{{ row.folioNo }}</td>
<td class="cert-folio">{{ row.shareAccountNo }}</td>
<td>{{ row.productName }}</td>
<td>{{ row.branch }}</td>
<td class="sm-address" [matTooltip]="row.address">{{ row.address }}</td>
<td>{{ row.mobileNo }}</td>
<td>{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }}</td>
<td class="num cert-shares">{{ row.approvedShares | number }}</td>
<td class="num">{{ row.pendingShares ? (row.pendingShares | number) : '—' }}</td>
<td class="num">₹{{ row.unitPrice | number: '1.0-2' }}</td>
<td class="num">₹{{ row.shareValue | number: '1.0-0' }}</td>
<td>
<span class="cert-pill" [class]="'sm-pill--' + row.statusTone">{{ row.statusLabel }}</span>
</td>
<td class="cert-row-actions">
<fa-icon icon="chevron-right" class="sm-chevron"></fa-icon>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="cert-note">
Share Value counts approved shares only — shares still pending approval are held in the Share Sundry GL and are not
yet part of share capital.
</div>
</div>
@@ -0,0 +1,99 @@
/**
* 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/.
*/
/* Every row navigates, so the whole row reads as the target rather than a lone link cell. */
.sm-row {
cursor: pointer;
transition: background 0.12s ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
}
&:focus-visible {
outline: 2px solid var(--cert-blue);
outline-offset: -2px;
}
/* The chevron only earns its space once the row is the one being pointed at. */
&:hover .sm-chevron,
&:focus-visible .sm-chevron {
opacity: 1;
}
}
.sm-chevron {
opacity: 0;
color: var(--cert-blue);
transition: opacity 0.12s ease;
}
/* Addresses are long and would otherwise set the table's width on their own; the
full text stays reachable through the row tooltip. */
.sm-address {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cert-table {
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
/* Status tones — mapped from the account status rather than typed per row. */
.sm-pill--active {
background: var(--cert-green-l);
color: var(--cert-green);
}
.sm-pill--pending {
background: var(--cert-amber-l);
color: var(--cert-amber);
}
.sm-pill--closed {
background: var(--cert-g100);
color: var(--cert-g700);
}
.sm-pill--rejected {
background: color-mix(in srgb, #dc2626 12%, transparent);
color: #b91c1c;
}
.sm-retry {
margin-left: 10px;
}
/* Printing the register: drop the chrome and let the table use the full page. */
@media print {
.cert-filters,
.cert-head-r,
.cert-kpis,
.sm-chevron {
display: none !important;
}
.cert-card {
border: none;
}
.sm-address {
max-width: none;
white-space: normal;
}
.cert-table {
font-size: 10px;
}
}
@@ -0,0 +1,359 @@
/**
* 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 { Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { forkJoin, of, Observable } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares/shares.service';
import { ClientsService } from '../../clients/clients.service';
/**
* One share member as this register shows them: the member's identity on the left,
* their holding on the right. Kept flat (rather than nesting the raw payload) so the
* template can filter on any column without reaching through optional chains.
*/
export interface ShareMemberRow {
/** Share account id — the navigation target, never shown as a column. */
accountId: number;
clientId: string;
/** Client account no, which is what the branch calls the membership number. */
membershipNo: string;
memberName: string;
folioNo: string;
shareAccountNo: string;
productName: string;
branch: string;
address: string;
mobileNo: string;
/** Date of admission — when the member was activated, not when this account opened. */
admissionDate: string;
approvedShares: number;
pendingShares: number;
unitPrice: number;
shareValue: number;
statusLabel: string;
/** Drives the status pill's colour class. */
statusTone: 'active' | 'pending' | 'closed' | 'rejected';
}
@Component({
selector: 'mifosx-share-member',
templateUrl: './share-member.component.html',
styleUrls: [
'../certificates/certificates.shared.scss',
'./share-member.component.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class ShareMemberComponent implements OnInit {
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
private router = inject(Router);
loading = true;
loadError = false;
rows: ShareMemberRow[] = [];
// ── Filters ──
search = '';
branchFilter = '';
productFilter = '';
statusFilter = '';
/**
* One member can hold several share accounts, so both client lookups are cached per
* client id — otherwise a member with four accounts costs four identical round trips.
*/
private clientDataCache = new Map<string, Observable<any>>();
private clientAddressCache = new Map<string, Observable<any>>();
ngOnInit(): void {
this.loadMembers();
}
loadMembers(): void {
this.loading = true;
this.loadError = false;
this.clientDataCache.clear();
this.clientAddressCache.clear();
this.sharesService
.getAllShareAccounts(0, 200)
.pipe(
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
switchMap((items: any[]) => {
if (!items.length) {
return of([] as ShareMemberRow[]);
}
return forkJoin(items.map((item) => this.buildRow(item))).pipe(
map((rows) => rows.filter((r): r is ShareMemberRow => !!r))
);
}),
catchError(() => {
this.loadError = true;
return of([] as ShareMemberRow[]);
})
)
.subscribe((rows) => {
// Newest admissions first: a register is read from the most recent entry back.
this.rows = rows.sort((a, b) => (b.admissionDate || '').localeCompare(a.admissionDate || ''));
this.loading = false;
});
}
/**
* The list payload already carries clientId, so the account read and both client reads
* fire together rather than in sequence. A row whose account read fails is dropped
* instead of failing the whole register.
*/
private buildRow(listItem: any): Observable<ShareMemberRow | null> {
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
return forkJoin({
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
client: clientId ? this.getClientDataCached(clientId) : of(null),
address: clientId ? this.getClientAddressCached(clientId) : of([])
}).pipe(
map(({ sa, client, address }: any) => this.mapToRow(sa, client, address)),
catchError(() => of(null))
);
}
private getClientDataCached(clientId: string): Observable<any> {
if (!this.clientDataCache.has(clientId)) {
this.clientDataCache.set(
clientId,
this.clientsService.getClientData(clientId, true).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientDataCache.get(clientId)!;
}
private getClientAddressCached(clientId: string): Observable<any> {
if (!this.clientAddressCache.has(clientId)) {
this.clientAddressCache.set(
clientId,
this.clientsService.getClientAddressData(clientId, true).pipe(
catchError(() => of([])),
shareReplay(1)
)
);
}
return this.clientAddressCache.get(clientId)!;
}
private mapToRow(sa: any, client: any, address: any): ShareMemberRow {
const approvedShares = sa.summary?.totalApprovedShares || 0;
const pendingShares = sa.summary?.totalPendingForApprovalShares || 0;
const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0;
const clientId = String(sa.clientId ?? client?.id ?? '');
const statusValue = sa.status?.value || '';
return {
accountId: sa.id,
clientId,
membershipNo: client?.accountNo || sa.clientAccountNo || '—',
memberName: sa.clientName || client?.displayName || '—',
folioNo: `F-${client?.accountNo || String(client?.id ?? sa.clientId ?? '').padStart(5, '0')}`,
shareAccountNo: sa.accountNo || String(sa.id),
productName: sa.productName || '—',
branch: client?.officeName || sa.officeName || '—',
address: this.firstAddress(address),
mobileNo: client?.mobileNo || '—',
// Admission is the member's activation with the society, which is what the
// Membership Register records — the share account may be opened much later.
admissionDate:
this.toIsoDate(client?.activationDate) ||
this.toIsoDate(client?.timeline?.activatedOnDate) ||
this.toIsoDate(sa.timeline?.activatedDate) ||
'',
approvedShares,
pendingShares,
unitPrice,
// Only approved shares carry value: pending ones are still sitting in Sundry.
shareValue: approvedShares * unitPrice,
statusLabel: statusValue || '—',
statusTone: this.toneFor(sa.status)
};
}
private toneFor(status: any): ShareMemberRow['statusTone'] {
if (status?.rejected) return 'rejected';
if (status?.closed) return 'closed';
if (status?.active) return 'active';
const v = String(status?.value || '').toLowerCase();
if (v.includes('reject')) return 'rejected';
if (v.includes('close')) return 'closed';
if (v.includes('active')) return 'active';
return 'pending';
}
/** Fineract returns dates as [y, m, d] tuples on most reads and ISO strings on some. */
private toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
y,
m,
d
] = value;
if (!y) {
return '';
}
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
}
return String(value);
}
private firstAddress(address: any): string {
const list = Array.isArray(address) ? address : address?.pageItems || [];
if (!list.length) {
return '—';
}
const a = list[0];
const parts = [
a.addressLine1,
a.addressLine2,
a.townVillage,
a.city,
a.stateName,
a.countryName,
a.postalCode
]
.filter((p: any) => !!p && String(p).trim())
.join(', ');
return parts || '—';
}
// ── Derived list ──
get filteredRows(): ShareMemberRow[] {
const q = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.branch !== this.branchFilter) return false;
if (this.productFilter && r.productName !== this.productFilter) return false;
if (this.statusFilter && r.statusTone !== this.statusFilter) return false;
if (!q) return true;
return (
r.memberName.toLowerCase().includes(q) ||
r.membershipNo.toLowerCase().includes(q) ||
r.shareAccountNo.toLowerCase().includes(q) ||
r.folioNo.toLowerCase().includes(q) ||
r.mobileNo.toLowerCase().includes(q)
);
});
}
get branchOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
}
get productOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort();
}
/** Distinct members, not accounts — one member holding three accounts counts once. */
totalMembers(): number {
return new Set(this.filteredRows.map((r) => r.clientId)).size;
}
totalShares(): number {
return this.filteredRows.reduce((s, r) => s + r.approvedShares, 0);
}
totalValue(): number {
return this.filteredRows.reduce((s, r) => s + r.shareValue, 0);
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
}
/** Opens the member's share account — the same view the client's own tab links to. */
openAccount(row: ShareMemberRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.accountId,
'general'
]);
}
exportCsv(): void {
const header = [
'S.No',
'Membership No',
'Member Name',
'Folio No',
'Share A/c No',
'Product',
'Branch',
'Address',
'Mobile',
'Date of Admission',
'Shares Held',
'Pending Shares',
'Unit Price',
'Share Value',
'Status'
];
const rows = this.filteredRows.map((r, i) => [
i + 1,
r.membershipNo,
r.memberName,
r.folioNo,
r.shareAccountNo,
r.productName,
r.branch,
r.address,
r.mobileNo,
r.admissionDate,
r.approvedShares,
r.pendingShares,
r.unitPrice,
r.shareValue,
r.statusLabel
]);
const csv = [
header,
...rows
]
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'share-members.csv';
link.click();
URL.revokeObjectURL(url);
}
print(): void {
window.print();
}
}
+10 -9
View File
@@ -213,19 +213,11 @@ export class ProductsService {
* @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> {
previewAllProductsDividend(fromDate: string, toDate: string, ratePercentage: number): 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 });
}
@@ -305,6 +297,15 @@ export class ProductsService {
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}/rounding-residue`);
}
/**
* The heading of a Share Dividend Distribution Report: the rate the board resolved, what it came
* to, and the date it became payable. Those live on the declaration rather than on any member
* row, so they are fetched once rather than repeated across the breakdown.
*/
getDividendReportHeader(dividendId: any): Observable<any> {
return this.http.get(`/clerk/dividend-dashboard/declaration/${dividendId}/report-header`);
}
/** 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`);
@@ -182,10 +182,28 @@ export class AllProductsDividendsComponent implements OnInit {
}
/**
* Opens a declaration in the existing per-product view, which holds the member-by-member
* allocation and the approve action.
* Opens a declaration where the officer can actually act on it.
*
* One still awaiting approval goes to the Approval Centre, because approving is what the officer
* came to do and that is where the approve action lives alongside every other pending
* declaration — landing them on a read-only page they then have to navigate away from is a
* detour. The declaration is named in the query string so the Centre can open its member-by-member
* breakdown straight away rather than making them find the row again.
*
* One already approved has nothing to act on, so it goes to the per-product view to be read.
*/
viewDividend(row: any): void {
if (!row.approved) {
this.router.navigate(
[
'/checker-inbox-and-tasks',
'dividend-requests'
],
{ queryParams: { productId: row.productId, dividendId: row.dividendId } }
);
return;
}
this.router.navigate(
[
'/products',
@@ -194,7 +212,7 @@ export class AllProductsDividendsComponent implements OnInit {
'dividends',
row.dividendId
],
{ queryParams: { status: row.approved ? 'Approved' : 'Initiated' } }
{ queryParams: { status: 'Approved' } }
);
}
}
@@ -12,7 +12,7 @@
<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>
<p>One rate for the year, applied to every member’s share investment.</p>
</div>
</div>
</div>
@@ -70,34 +70,15 @@
</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>
<mat-label>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>
<p class="dd-hint">
Applied to each member's own share investment, pro-rated for how long they held it.
</p>
</div>
@if (loadingPreview) {
@@ -109,51 +90,62 @@
}
@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. -->
<!-- The calculation itself: share investment x rate, and nothing else. Profit does not
size the dividend, so it does not appear here — it is reported below only as the
account the payout is drawn from. -->
<div class="dd-preview">
<div class="dd-preview-head">Where the money comes from</div>
<div class="dd-preview-head">How the dividend is worked out</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>
<span>Share investment (weighted for holding period)</span>
<span class="dd-amount">{{ preview.totalWeightedCapital | 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 class="dd-preview-row dd-preview-row--muted">
<span>Dividend rate</span>
<span class="dd-amount">{{ declareForm.value.ratePercentage || 0 }}%</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>
<span>Dividend payable</span>
<span class="dd-amount">{{ preview.totalAmount | number: '1.2-2' }}</span>
</div>
</div>
@if (declareForm.value.dividendBasis === 'RATE_ON_CAPITAL') {
<!-- Where the money is drawn from. A funding position, not a basis: the amount above is
already fixed by capital, and this only says whether the profit account can pay it. -->
<div class="dd-preview">
<div class="dd-preview-head">Funded from</div>
<div class="dd-preview-row">
<span>
Bank profit for {{ preview.financialYear }}
<span class="dd-gl">{{ preview.profitGlCode }}</span>
</span>
<span class="dd-amount">{{ preview.fundingAvailable | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--deduction">
<span>Less dividend payable</span>
<span class="dd-amount">− {{ preview.totalAmount | number: '1.2-2' }}</span>
</div>
@if (isUnderfunded) {
<div class="dd-note dd-note--error">
Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in
{{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first.
</div>
} @else {
<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>
<span>Left in profit (swept to Bank Profit on approval)</span>
<span class="dd-amount">{{ preview.undistributedRemainder | 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'
}}
{{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
</div>
@for (product of eligibleProducts; track product.productId) {
@@ -168,13 +160,6 @@
<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) {
@@ -186,13 +171,6 @@
</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.
@@ -47,36 +47,14 @@ form {
}
}
/* Sits directly under the rate field, where it explains what the number just typed applies to. */
.dd-hint {
flex: 1 1 100%;
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 {
@@ -16,7 +16,6 @@ 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';
@@ -31,6 +30,10 @@ import { SettingsService } from '../../../../settings/settings.service';
* 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.
*
* The rate applies to members' share investment and to nothing else. Profit does not size the
* dividend — it only funds it, so the screen leads with capital x rate and reports the profit
* account only as the source the payout is drawn from.
*/
@Component({
selector: 'mifosx-declare-dividend',
@@ -44,7 +47,6 @@ import { SettingsService } from '../../../../settings/settings.service';
MatInputModule,
MatSelectModule,
MatOptionModule,
MatRadioModule,
MatProgressBarModule,
FaIconComponent
]
@@ -77,9 +79,6 @@ export class DeclareDividendComponent implements OnInit {
'',
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: [
'',
[
@@ -131,9 +130,7 @@ export class DeclareDividendComponent implements OnInit {
this.loadingPreview = true;
this.previewError = null;
this.productsService
.previewAllProductsDividend(year.start, year.end, rate, this.declareForm.value.dividendBasis)
.subscribe({
this.productsService.previewAllProductsDividend(year.start, year.end, rate).subscribe({
next: (response: any) => {
this.preview = response;
this.loadingPreview = false;
@@ -163,8 +160,19 @@ export class DeclareDividendComponent implements OnInit {
return this.eligibleProducts.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
/**
* Whether the profit account can cover what this rate works out to.
*
* The rate decides the amount; profit only has to be able to pay it. Surfaced here so the officer
* sees the shortfall on screen rather than having the command layer reject the declaration after
* they have already committed to it — the same ceiling is enforced there either way.
*/
get isUnderfunded(): boolean {
return !!this.preview?.exceedsDistributableProfit;
}
get canSubmit(): boolean {
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0;
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded;
}
submit(): void {
@@ -179,7 +187,6 @@ export class DeclareDividendComponent implements OnInit {
.createAllProductsDividend({
dividendPeriodStartDate: year.start,
dividendPeriodEndDate: year.end,
dividendBasis: this.declareForm.value.dividendBasis,
ratePercentage: String(this.declareForm.value.ratePercentage)
})
.subscribe({
@@ -80,30 +80,6 @@
}
<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>
@@ -126,40 +102,9 @@
</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-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" />
@if (createDividendForm.controls.dividendPercentage.hasError('required')) {
@@ -170,15 +115,19 @@
}
</mat-form-field>
<p class="dividend-mode__hint">
{{
'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it'
| translate
}}.
</p>
@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>
<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 }}.
@@ -205,7 +154,6 @@
}
</div>
}
}
<mat-form-field>
<mat-label
@@ -57,28 +57,11 @@
}
}
.dividend-mode {
/* Sits under the rate field, saying what the number just typed applies to. */
.dividend-mode__hint {
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 {
@@ -10,7 +10,6 @@
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
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';
@@ -29,8 +28,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./create-dividend.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton,
DecimalPipe
]
})
@@ -53,9 +50,10 @@ export class CreateDividendComponent implements OnInit {
maxDate = new Date();
/**
* 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.
* The profit available to fund a dividend for the selected period, from the backend: the profit
* standing in the GL, less dividends already declared for other share products over an
* overlapping period. The statutory reserve and education fund are not part of this — they are
* appropriated on the profit-distribution screen before a dividend is ever declared.
*/
profitBreakdown: {
financialYear: string;
@@ -64,10 +62,6 @@ export class CreateDividendComponent implements OnInit {
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;
@@ -85,8 +79,8 @@ export class CreateDividendComponent implements OnInit {
profitLookupError = false;
/**
* Weighted paid-up capital for the period, for RATE_ON_CAPITAL mode. `requiredAmount` is what the
* entered rate actually costs to honour.
* Weighted paid-up capital for the period — the only thing the dividend is calculated from.
* `requiredAmount` is what the entered rate actually costs to honour.
*/
capitalPreview: {
weightedCapital: number;
@@ -98,13 +92,15 @@ export class CreateDividendComponent implements OnInit {
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.
* A dividend is a rate on each member's own share investment, and nothing else.
*
* This is what a 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. Sizing the pool as a share of profit
* instead was dropped — the resulting per-member rate depends on how many other members hold
* shares, so it is not the rate members are promised. Profit no longer sizes the dividend; it
* only funds it, which is what {@link ceilingError} checks.
*/
dividendMode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT' = 'RATE_ON_CAPITAL';
readonly dividendMode = 'RATE_ON_CAPITAL';
/**
* Financial years a dividend can be declared for, newest first.
@@ -302,10 +298,6 @@ export class CreateDividendComponent implements OnInit {
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,
@@ -358,22 +350,13 @@ export class CreateDividendComponent implements OnInit {
});
}
/** 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.
* Computes the dividend amount — weighted paid-up capital x rate — then checks it against both
* ceilings: the rate cap in the governing Act, and the profit still available to fund it.
*
* 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.
* Every member receives the declared rate on their own capital, pro-rated by holding period, so
* the amount follows entirely from what members invested. Profit does not enter the arithmetic;
* it only has to be large enough to pay the result.
*/
recomputeDividendAmount(): void {
const percentage = Number(this.createDividendForm.get('dividendPercentage').value);
@@ -388,36 +371,27 @@ export class CreateDividendComponent implements OnInit {
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.
// The rate cap governs the rate promised on capital, which is exactly what is entered here.
const maximumRate = this.profitBreakdown?.maximumRatePercentage ?? 0;
if (this.dividendMode === 'RATE_ON_CAPITAL' && maximumRate > 0 && percentage > maximumRate) {
if (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('');
return;
}
amount = (this.capitalPreview.weightedCapital * percentage) / 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 amount = (this.capitalPreview.weightedCapital * percentage) / 100;
const rounded = Math.round(amount * 100) / 100;
this.capitalPreview.requiredAmount = rounded;
this.createDividendForm.get('dividendAmount').setValue(rounded);
// Not a limit on the calculation — a limit on what the profit account can fund. The amount is
// already fixed by capital; this only says whether there is enough profit to pay it.
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.`;
`still available to fund it for this period. Reduce the rate, or declare for a period with more profit.`;
}
}
@@ -17,7 +17,11 @@
>
</div>
<div class="vd-header__actions">
<!-- Standalone, this is a read-only breakdown and the button hands off to the Approval
Centre, so it is labelled for where it goes and needs no approve permission to press.
Embedded, this IS the Approval Centre's breakdown, so it approves in place. -->
@if (!isdividendPosted) {
@if (embedded) {
<button
mat-raised-button
color="primary"
@@ -28,7 +32,19 @@
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Approve' | translate }}
</button>
} @else {
<button mat-raised-button color="primary" class="vd-approve-btn" (click)="postDividends()">
<fa-icon icon="arrow-right" class="m-r-10"></fa-icon>
Go to Approval Centre
</button>
}
}
<!-- The distribution report: the heading the board resolved plus the member-wise split it
produced, in one sheet so the two never have to be reconciled by hand. -->
<button mat-stroked-button class="vd-export-btn" [disabled]="exporting" (click)="exportToExcel()">
<fa-icon icon="file-excel" class="m-r-10"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
</button>
@if (embedded) {
<button type="button" class="vd-close-btn" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
@@ -38,6 +54,15 @@
</div>
<div class="vd-summary">
<!-- The rate and the payment date are what a distribution report is quoted by — an AGM
resolution says "9% for 2025-26", not "₹11,300" — so they sit alongside the total rather
than being left to be worked out from it. -->
@if (reportHeader?.ratePercentage != null) {
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Dividend Rate' | translate }}</span>
<span class="vd-summary__value">{{ reportHeader.ratePercentage | number: '1.0-2' }}%</span>
</div>
}
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Total Amount' | translate }}</span>
<span class="vd-summary__value">{{ totalAmount | number: '1.2-2' }}</span>
@@ -46,6 +71,18 @@
<span class="vd-summary__label">{{ 'labels.inputs.Members' | translate }}</span>
<span class="vd-summary__value">{{ dividendData.pageItems.length }}</span>
</div>
@if (reportHeader?.financialYear) {
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Financial Year' | translate }}</span>
<span class="vd-summary__value">FY {{ reportHeader.financialYear }}</span>
</div>
}
@if (reportHeader?.paymentDate) {
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Payment Date' | translate }}</span>
<span class="vd-summary__value">{{ reportHeader.paymentDate | slice: 0 : 10 }}</span>
</div>
}
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Status' | translate }}</span>
<span [class]="isdividendPosted ? 'vd-badge vd-badge--posted' : 'vd-badge vd-badge--pending'">
@@ -278,3 +278,15 @@ table {
}
}
}
/* Export sits beside the primary action rather than under the table: a clerk asked for the
distribution report wants the file, not the screen, so it belongs where the eye lands first. */
.vd-export-btn {
margin-right: 8px;
color: #1e7f4f;
border-color: #cfe3d8;
&:disabled {
opacity: 0.6;
}
}
@@ -84,6 +84,15 @@ export class ViewDividendComponent implements OnInit {
glTransactionId: string | null;
} | null = null;
/**
* What a distribution report has to state besides the member list: the rate the board resolved
* and the date the dividend became payable. Null for a declaration made before the rate was
* recorded — the heading simply omits what it does not know rather than guessing.
*/
reportHeader: any = null;
exporting = false;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
displayedColumns: string[] = [
@@ -100,16 +109,34 @@ export class ViewDividendComponent implements OnInit {
.getDividendData(this.overrideProductId, this.overrideDividendId)
.subscribe((dividendData: any) => this.setDividendData(dividendData));
this.loadResidue(this.overrideProductId, this.overrideDividendId);
this.loadReportHeader(this.overrideDividendId);
} else {
this.status = this.route.snapshot.queryParams['status'];
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')
);
const dividendId = this.route.snapshot.paramMap.get('dividendId');
this.loadResidue(this.route.parent?.parent?.snapshot.paramMap.get('productId'), dividendId);
this.loadReportHeader(dividendId);
}
}
/**
* The rate the board resolved and the date the dividend became payable.
*
* Neither is on the member breakdown — a rate is a property of the declaration, and the payment
* date is when it was approved — but a distribution report has to quote both, so they are pulled
* once here. Silently skipped on failure: the breakdown is still perfectly readable without a
* heading, and an error banner over a working page helps nobody.
*/
private loadReportHeader(dividendId: any): void {
if (!dividendId) {
return;
}
this.productsService
.getDividendReportHeader(dividendId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.reportHeader = res));
}
/**
* 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.
@@ -161,27 +188,118 @@ export class ViewDividendComponent implements OnInit {
return 'vd-badge vd-badge--pending';
}
/**
* Approving happens in the Approval Centre, not here.
*
* This page is a read-only breakdown reached from the product's dividend register, and a
* declaration approved from here would bypass the queue every other pending declaration goes
* through — a checker would have no single place that shows what is still outstanding. So the
* button hands off to the Approval Centre with this declaration already open, rather than
* approving in place.
*
* The embedded copy IS the Approval Centre's own breakdown, so there it approves directly.
*/
postDividends() {
const shareProductId = this.embedded
? this.overrideProductId
: this.route.parent.parent.snapshot.paramMap.get('productId');
const dividendId = this.embedded ? this.overrideDividendId : this.route.snapshot.paramMap.get('dividendId');
if (!this.embedded) {
this.router.navigate(
[
'/checker-inbox-and-tasks',
'dividend-requests'
],
{ queryParams: { productId: shareProductId, dividendId: dividendId } }
);
return;
}
// Only the embedded copy reaches here — the standalone page returned above — so this is always
// the Approval Centre approving from its own modal, which closes and refreshes its list.
this.productsService
.approveDividend(shareProductId, dividendId, { productId: shareProductId, dividendId: dividendId })
.subscribe(() => {
if (this.embedded) {
this.actionCompleted.emit();
} else {
// 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'
]);
.subscribe(() => this.actionCompleted.emit());
}
/**
* Share Dividend Distribution Report as an Excel workbook.
*
* Two parts in one sheet, because that is what the report is: a heading stating what the board
* resolved — the rate, the amount, the payment date — and beneath it the member-wise distribution
* that heading produced. Split across two files they would have to be reconciled by hand.
*
* Every member is exported, including those whose entitlement rounded to nothing: a distribution
* report that silently omits them cannot be checked against the member register.
*/
async exportToExcel(): Promise<void> {
this.exporting = true;
try {
const ExcelJS = await import('exceljs');
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Dividend Distribution');
const h = this.reportHeader;
const fy = h?.financialYear ?? '';
ws.mergeCells('A1:D1');
ws.getCell('A1').value = 'SHARE DIVIDEND DISTRIBUTION REPORT';
ws.getCell('A1').font = { bold: true, size: 14 };
ws.getCell('A1').alignment = { horizontal: 'center' };
const heading: [string, any][] = [
['Share Product', h?.productName ?? ''],
['Financial Year', fy],
['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''],
['Dividend Rate (% per annum)', h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'],
['Declared Amount', h?.declaredAmount != null ? Number(h.declaredAmount) : ''],
['Distributed to Members', h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''],
['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'],
['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'],
['Members Paid', h?.paidMembers ?? ''],
['Members Below Minimum', h?.unpaidMembers ?? '']
];
heading.forEach(([label, value], i) => {
const row = ws.getRow(i + 3);
row.getCell(1).value = label;
row.getCell(1).font = { bold: true };
row.getCell(2).value = value;
});
const tableStart = heading.length + 5;
const header = ws.getRow(tableStart);
['Client Name', 'Share Account', 'Dividend Amount', 'Status'].forEach((t, i) => {
header.getCell(i + 1).value = t;
header.getCell(i + 1).font = { bold: true };
header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
});
(this.dataSource?.data ?? []).forEach((d: any, i: number) => {
const row = ws.getRow(tableStart + 1 + i);
row.getCell(1).value = d?.accountData?.clientName ?? '';
row.getCell(2).value = d?.accountData?.accountNo ?? '';
row.getCell(3).value = Number(d?.amount) || 0;
row.getCell(3).numFmt = '#,##0.00';
row.getCell(4).value = d?.status?.value ?? '';
});
const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0));
totalRow.getCell(2).value = 'Total';
totalRow.getCell(2).font = { bold: true };
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce((s: number, d: any) => s + (Number(d?.amount) || 0), 0);
totalRow.getCell(3).numFmt = '#,##0.00';
totalRow.getCell(3).font = { bold: true };
ws.getColumn(1).width = 34;
ws.getColumn(2).width = 20;
ws.getColumn(3).width = 18;
ws.getColumn(4).width = 20;
const { downloadWorkbook } = await import('../../../rbi-circulars/oss-workbook');
await downloadWorkbook(wb, `Dividend Distribution Report${fy ? ' - FY ' + fy : ''}.xlsx`);
} finally {
this.exporting = false;
}
}
close(): void {
@@ -36,6 +36,14 @@ const routes: Routes = [
loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent),
data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' }
},
{
path: 'rbi/share-capital',
// Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not
// weigh down the eager bundle for a page most users never open.
loadComponent: () =>
import('./share-capital-return/share-capital-return.component').then((m) => m.ShareCapitalReturnComponent),
data: { title: 'Members & Share Capital Return', breadcrumb: 'Members & Share Capital' }
},
{
path: 'rbi/oss3',
loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent),
@@ -44,4 +44,20 @@ export class RbiCircularsService {
triggerFetch(): Observable<any> {
return this.http.post('/rbi-feeds/fetch', {});
}
/**
* Members and share capital per share type for one financial year, with the year before alongside.
*
* @param financialYear the year the FY opens in — 2025 means FY 2025-26.
*/
getShareCapitalReturn(financialYear: number): Observable<any> {
return this.http.get('/shareproducts/capital-return', {
params: new HttpParams().set('financialYear', String(financialYear))
});
}
/** Financial years that actually have share activity, newest first, for the year picker. */
getShareCapitalReturnYears(): Observable<any> {
return this.http.get('/shareproducts/capital-return/financial-years');
}
}
@@ -0,0 +1,197 @@
<!--
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="scr-root">
<div class="scr-header">
<div class="scr-header__text">
<h2>Return on Members &amp; Share Capital</h2>
<!-- What the return is for, in the words a supervisor would use. A page titled only with a
form number tells a clerk nothing about what they are about to file. -->
<p>
Membership and share capital as reported to the RBI, per share type — how many members hold
each type, how much capital they have subscribed, and how much of the authorised issue
remains unsold, with the previous financial year alongside for comparison.
</p>
</div>
<div class="scr-header__actions">
<label class="scr-year">
<span>Financial Year</span>
<select [ngModel]="selectedYear" (ngModelChange)="onYearChange($event)" name="financialYear">
@for (y of years; track y.startYear) {
<option [value]="y.startYear">{{ y.label }}</option>
}
</select>
</label>
<button type="button" class="scr-btn" [disabled]="loading" (click)="load()">
<fa-icon icon="sync"></fa-icon>
Refresh
</button>
<button type="button" class="scr-btn scr-btn--primary" [disabled]="exporting || !report" (click)="exportToExcel()">
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
</button>
</div>
</div>
@if (loadError) {
<div class="scr-note scr-note--error">{{ loadError }}</div>
}
@if (loading) {
<div class="scr-note">Loading the share register…</div>
} @else if (report) {
<div class="scr-period">
Reporting period <strong>{{ report.periodStartDate }}</strong> to
<strong>{{ report.periodEndDate }}</strong> · compared with FY {{ report.previousFinancialYear }}
</div>
<!-- The bottom line first: a supervisor checks the society's totals before reading any single
share type, and it answers "did membership and capital grow this year" at a glance. -->
<div class="scr-summary">
<div class="scr-stat">
<span class="scr-stat__label">Total Members</span>
<span class="scr-stat__value">{{ totals.membersClosing | number }}</span>
<span class="scr-stat__sub">
{{ report.previousFinancialYear }}: {{ totals.membersClosingPrev | number }}
@if (yoyChange(totals.membersClosing, totals.membersClosingPrev) !== null) {
<span
class="scr-delta"
[class.scr-delta--up]="totals.membersClosing >= totals.membersClosingPrev"
[class.scr-delta--down]="totals.membersClosing < totals.membersClosingPrev"
>
{{ totals.membersClosing >= totals.membersClosingPrev ? '▲' : '▼' }}
{{ yoyChange(totals.membersClosing, totals.membersClosingPrev) | number: '1.1-1' }}%
</span>
}
</span>
</div>
<div class="scr-stat">
<span class="scr-stat__label">Total Share Capital</span>
<span class="scr-stat__value">{{ totals.capitalClosing | number: '1.2-2' }}</span>
<span class="scr-stat__sub">
{{ report.previousFinancialYear }}: {{ totals.capitalClosingPrev | number: '1.2-2' }}
@if (yoyChange(totals.capitalClosing, totals.capitalClosingPrev) !== null) {
<span
class="scr-delta"
[class.scr-delta--up]="totals.capitalClosing >= totals.capitalClosingPrev"
[class.scr-delta--down]="totals.capitalClosing < totals.capitalClosingPrev"
>
{{ totals.capitalClosing >= totals.capitalClosingPrev ? '▲' : '▼' }}
{{ yoyChange(totals.capitalClosing, totals.capitalClosingPrev) | number: '1.1-1' }}%
</span>
}
</span>
</div>
<div class="scr-stat">
<span class="scr-stat__label">Shares Allotted</span>
<span class="scr-stat__value">{{ totals.allottedShares | number }}</span>
<span class="scr-stat__sub">of {{ totals.authorisedShares | number }} authorised</span>
</div>
<div class="scr-stat scr-stat--muted">
<span class="scr-stat__label">Unissued (Remaining)</span>
<span class="scr-stat__value">{{ totals.unissuedShares | number }}</span>
<span class="scr-stat__sub">still available to allot</span>
</div>
</div>
@for (t of report.shareTypes; track t.productId) {
<div class="scr-card">
<div class="scr-card__head">
<span class="scr-card__title">{{ t.shareType }}</span>
<span class="scr-card__meta">
Face value {{ t.unitPrice | number: '1.2-2' }} {{ t.currency }} · Authorised
{{ t.authorisedShares | number }} shares
</span>
</div>
<!-- Allotted against authorised. A supervisor checks a fresh issue against this headroom,
so it is shown as a bar rather than two numbers to be subtracted mentally. -->
<div class="scr-util">
<div class="scr-util__bar">
<div class="scr-util__fill" [style.width.%]="t.utilisationPercentage || 0"></div>
</div>
<span class="scr-util__text">
{{ t.allottedShares | number }} allotted ·
<strong>{{ t.unissuedShares | number }} remaining</strong>
@if (t.utilisationPercentage != null) {
({{ t.utilisationPercentage | number: '1.1-1' }}% issued)
}
</span>
</div>
<table class="scr-table">
<thead>
<tr>
<th>Particulars</th>
<th class="num">FY {{ report.financialYear }}</th>
<th class="num">FY {{ report.previousFinancialYear }}</th>
</tr>
</thead>
<tbody>
<tr class="scr-group">
<td colspan="3">Members</td>
</tr>
<tr>
<td>Opening</td>
<td class="num">{{ t.currentYear.membersOpening | number }}</td>
<td class="num">{{ t.previousYear.membersOpening | number }}</td>
</tr>
<tr>
<td>New admissions during the year</td>
<td class="num">{{ t.currentYear.membersAdmitted | number }}</td>
<td class="num">{{ t.previousYear.membersAdmitted | number }}</td>
</tr>
<tr>
<td>Ceased during the year</td>
<td class="num">{{ t.currentYear.membersCeased | number }}</td>
<td class="num">{{ t.previousYear.membersCeased | number }}</td>
</tr>
<tr class="scr-total">
<td>Closing members</td>
<td class="num">{{ t.currentYear.membersClosing | number }}</td>
<td class="num">{{ t.previousYear.membersClosing | number }}</td>
</tr>
<tr class="scr-group">
<td colspan="3">Share Capital</td>
</tr>
<tr>
<td>Opening</td>
<td class="num">{{ t.currentYear.capitalOpening | number: '1.2-2' }}</td>
<td class="num">{{ t.previousYear.capitalOpening | number: '1.2-2' }}</td>
</tr>
<tr>
<td>Issued during the year</td>
<td class="num">{{ t.currentYear.capitalIssued | number: '1.2-2' }}</td>
<td class="num">{{ t.previousYear.capitalIssued | number: '1.2-2' }}</td>
</tr>
<tr>
<td>Redeemed / withdrawn</td>
<td class="num">{{ t.currentYear.capitalRedeemed | number: '1.2-2' }}</td>
<td class="num">{{ t.previousYear.capitalRedeemed | number: '1.2-2' }}</td>
</tr>
<tr class="scr-total">
<td>Closing share capital</td>
<td class="num">{{ t.currentYear.capitalClosing | number: '1.2-2' }}</td>
<td class="num">{{ t.previousYear.capitalClosing | number: '1.2-2' }}</td>
</tr>
<tr>
<td>Shares issued during the year</td>
<td class="num">{{ t.currentYear.sharesIssued | number }}</td>
<td class="num">{{ t.previousYear.sharesIssued | number }}</td>
</tr>
</tbody>
</table>
</div>
}
@if (!report.shareTypes?.length) {
<div class="scr-note">No share types are configured, so there is nothing to report.</div>
}
}
</div>
@@ -0,0 +1,282 @@
/**
* 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/.
*/
.scr-root {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.scr-header {
display: flex;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 20px;
&__text {
flex: 1 1 420px;
h2 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 600;
color: #1e293b;
}
p {
margin: 0;
max-width: 720px;
font-size: 13px;
line-height: 1.55;
color: #64748b;
}
}
&__actions {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
}
.scr-year {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: #475569;
select {
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 13px;
color: #0f172a;
background: #fff;
}
}
.scr-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #334155;
cursor: pointer;
&:hover:not(:disabled) {
background: #f8fafc;
}
&:disabled {
opacity: 0.55;
cursor: default;
}
&--primary {
border-color: #1e7f4f;
background: #1e7f4f;
color: #fff;
&:hover:not(:disabled) {
background: #19693f;
}
}
}
.scr-period {
margin-bottom: 16px;
font-size: 12.5px;
color: #475569;
}
.scr-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
margin-bottom: 22px;
}
.scr-stat {
display: flex;
flex-direction: column;
gap: 3px;
padding: 13px 15px;
border: 1px solid #e2e8f0;
border-left: 3px solid #4f46e5;
border-radius: 10px;
background: #fff;
/* Unissued stock is headroom, not performance — muted so it does not read as a result. */
&--muted {
border-left-color: #94a3b8;
}
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
}
&__value {
font-size: 22px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
&__sub {
font-size: 11.5px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
}
.scr-delta {
margin-left: 5px;
font-weight: 700;
&--up {
color: #15803d;
}
&--down {
color: #b91c1c;
}
}
.scr-card {
margin-bottom: 18px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
overflow: hidden;
&__head {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
padding: 13px 16px;
border-bottom: 1px solid #eef2f6;
background: #f8fafc;
}
&__title {
font-size: 14.5px;
font-weight: 700;
color: #0f172a;
}
&__meta {
font-size: 12px;
color: #64748b;
}
}
/* Allotted against authorised, as a bar: the remaining headroom is the figure a supervisor checks a
fresh issue against, and a bar shows it without the reader subtracting two numbers. */
.scr-util {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
&__bar {
flex: 1 1 220px;
height: 8px;
border-radius: 9999px;
background: #eef2f6;
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: 9999px;
background: #4f46e5;
}
&__text {
font-size: 12.5px;
color: #475569;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.scr-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
padding: 9px 16px;
text-align: left;
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
border-bottom: 1px solid #eef2f6;
}
td {
padding: 8px 16px;
color: #334155;
border-bottom: 1px solid #f4f7fa;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
/* Members and Share Capital are two separate statements inside one table — labelled so a reader
never has to guess which block a row belongs to. */
.scr-group td {
padding-top: 13px;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #4f46e5;
background: #fbfcfe;
}
.scr-total td {
font-weight: 700;
color: #0f172a;
background: #fbfcfe;
}
.scr-note {
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
font-size: 13px;
color: #475569;
&--error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
}
@@ -0,0 +1,233 @@
/**
* 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { RbiCircularsService } from '../rbi-circulars.service';
import { downloadWorkbook } from '../oss-workbook';
/**
* Return on Members &amp; Share Capital — the membership and share-capital position reported to the
* RBI, broken down by share type, with the previous year alongside.
*
* A supervisor reading this asks three things of every share type the society offers: how many
* members hold it, how much capital they have put in, and how much of the authorised issue is still
* unsold. Movement matters as much as position — a society whose membership fell while its capital
* rose has done something quite different from one where both grew — so each figure is shown as
* opening, movement, and closing, against the same three for the year before.
*
* Every figure comes from the share register itself rather than a stored summary, so the return
* cannot drift from the accounts it describes.
*/
@Component({
selector: 'mifosx-share-capital-return',
standalone: true,
imports: [
CommonModule,
FormsModule,
FaIconComponent
],
templateUrl: './share-capital-return.component.html',
styleUrls: ['./share-capital-return.component.scss']
})
export class ShareCapitalReturnComponent implements OnInit {
private service = inject(RbiCircularsService);
years: { startYear: number; label: string }[] = [];
selectedYear: number | null = null;
report: any = null;
loading = false;
loadError: string | null = null;
exporting = false;
ngOnInit(): void {
this.service
.getShareCapitalReturnYears()
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.years = res ?? [];
// Opens on the most recent year rather than an empty screen: that is the return being
// prepared almost every time this page is reached.
this.selectedYear = this.years.length ? this.years[0].startYear : null;
this.load();
});
}
load(): void {
if (this.selectedYear == null) {
return;
}
this.loading = true;
this.loadError = null;
this.service
.getShareCapitalReturn(this.selectedYear)
.pipe(
catchError((err: any) => {
this.loadError = err?.error?.defaultUserMessage ?? 'Could not load the share capital position.';
return of(null);
})
)
.subscribe((res: any) => {
this.report = res;
this.loading = false;
});
}
onYearChange(startYear: any): void {
this.selectedYear = Number(startYear);
this.load();
}
/** Net movement in members over the year — admitted less ceased. */
netMembers(position: any): number {
return (Number(position?.membersAdmitted) || 0) - (Number(position?.membersCeased) || 0);
}
/** Net movement in capital — issued less redeemed. */
netCapital(position: any): number {
return (Number(position?.capitalIssued) || 0) - (Number(position?.capitalRedeemed) || 0);
}
/**
* Change against the prior year, as a percentage.
*
* Null rather than zero when the prior year was nil: a society going from no members to fifty has
* not grown by 0%, and showing a number there would be worse than showing nothing.
*/
yoyChange(current: any, previous: any): number | null {
const prev = Number(previous) || 0;
const curr = Number(current) || 0;
if (prev === 0) {
return null;
}
return ((curr - prev) / prev) * 100;
}
/** Society-wide totals, so the return has a bottom line and not only per-type rows. */
get totals(): any {
const types = this.report?.shareTypes ?? [];
const sum = (pick: (t: any) => number) => types.reduce((s: number, t: any) => s + (pick(t) || 0), 0);
return {
membersClosing: sum((t) => Number(t.currentYear?.membersClosing)),
membersClosingPrev: sum((t) => Number(t.previousYear?.membersClosing)),
capitalClosing: sum((t) => Number(t.currentYear?.capitalClosing)),
capitalClosingPrev: sum((t) => Number(t.previousYear?.capitalClosing)),
authorisedShares: sum((t) => Number(t.authorisedShares)),
allottedShares: sum((t) => Number(t.allottedShares)),
unissuedShares: sum((t) => Number(t.unissuedShares))
};
}
/**
* The return as an Excel workbook, laid out the way it is read on screen.
*
* One row per share type with both years side by side, rather than a sheet per year: the whole
* point of the return is the comparison, and splitting it would leave the reader to do the
* matching by hand.
*/
async exportToExcel(): Promise<void> {
if (!this.report) {
return;
}
this.exporting = true;
try {
const ExcelJS = await import('exceljs');
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Members & Share Capital');
const fy = this.report.financialYear;
const prev = this.report.previousFinancialYear;
ws.mergeCells('A1:L1');
ws.getCell('A1').value = 'RETURN ON MEMBERS AND SHARE CAPITAL';
ws.getCell('A1').font = { bold: true, size: 14 };
ws.getCell('A1').alignment = { horizontal: 'center' };
ws.mergeCells('A2:L2');
ws.getCell('A2').value = `Financial Year ${fy} (compared with ${prev}) · Period ${this.report.periodStartDate} to ${this.report.periodEndDate}`;
ws.getCell('A2').alignment = { horizontal: 'center' };
const headers = [
'Share Type',
'Authorised Shares',
'Allotted Shares',
'Unissued (Remaining)',
`Members Opening (${fy})`,
'New Admissions',
'Ceased',
`Members Closing (${fy})`,
`Members Closing (${prev})`,
`Capital Opening (${fy})`,
'Capital Issued',
`Capital Closing (${fy})`,
`Capital Closing (${prev})`
];
const headerRow = ws.getRow(4);
headers.forEach((h, i) => {
headerRow.getCell(i + 1).value = h;
headerRow.getCell(i + 1).font = { bold: true };
headerRow.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
headerRow.getCell(i + 1).alignment = { wrapText: true, vertical: 'middle' };
});
(this.report.shareTypes ?? []).forEach((t: any, i: number) => {
const row = ws.getRow(5 + i);
const values = [
t.shareType,
Number(t.authorisedShares) || 0,
Number(t.allottedShares) || 0,
Number(t.unissuedShares) || 0,
Number(t.currentYear?.membersOpening) || 0,
Number(t.currentYear?.membersAdmitted) || 0,
Number(t.currentYear?.membersCeased) || 0,
Number(t.currentYear?.membersClosing) || 0,
Number(t.previousYear?.membersClosing) || 0,
Number(t.currentYear?.capitalOpening) || 0,
Number(t.currentYear?.capitalIssued) || 0,
Number(t.currentYear?.capitalClosing) || 0,
Number(t.previousYear?.capitalClosing) || 0
];
values.forEach((v, c) => {
row.getCell(c + 1).value = v;
if (c >= 9) {
row.getCell(c + 1).numFmt = '#,##0.00';
}
});
});
const totalRow = ws.getRow(5 + (this.report.shareTypes?.length ?? 0));
const t = this.totals;
totalRow.getCell(1).value = 'TOTAL';
totalRow.getCell(2).value = t.authorisedShares;
totalRow.getCell(3).value = t.allottedShares;
totalRow.getCell(4).value = t.unissuedShares;
totalRow.getCell(8).value = t.membersClosing;
totalRow.getCell(9).value = t.membersClosingPrev;
totalRow.getCell(12).value = t.capitalClosing;
totalRow.getCell(13).value = t.capitalClosingPrev;
[12, 13].forEach((c) => (totalRow.getCell(c).numFmt = '#,##0.00'));
for (let c = 1; c <= 13; c++) {
totalRow.getCell(c).font = { bold: true };
}
ws.getColumn(1).width = 26;
for (let c = 2; c <= 13; c++) {
ws.getColumn(c).width = 16;
}
await downloadWorkbook(wb as any, `Return on Members and Share Capital - FY ${fy}.xlsx`);
} finally {
this.exporting = false;
}
}
}
@@ -0,0 +1,29 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { SavingsDailyInterestComponent } from './savings-daily-interest.component';
const routes: Routes = [
Route.withShell([
{
path: 'savings-daily-interest',
component: SavingsDailyInterestComponent,
data: { title: 'Savings Daily Interest', breadcrumb: 'Savings Daily Interest' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class SavingsDailyInterestRoutingModule {}
@@ -0,0 +1,216 @@
<!--
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="sdi-container">
<div class="sdi-header">
<h2>Savings Daily Interest</h2>
<div class="sdi-actions">
<button mat-stroked-button (click)="runReconciliation()" [disabled]="loading">Check</button>
<button mat-stroked-button (click)="runBackfill()" [disabled]="loading">Backfill</button>
<button mat-flat-button color="primary" (click)="runToday()" [disabled]="loading">Run Today</button>
</div>
</div>
@if (message) {
<div class="sdi-message" [class.error]="messageIsError" (click)="dismissMessage()">
{{ message }}
</div>
}
<div class="sdi-tabs">
<button [class.active]="activeTab === 'products'" (click)="backToProducts()">Products</button>
@if (selectedProduct) {
<button [class.active]="activeTab === 'accounts'" (click)="backToAccounts()">
{{ selectedProduct.name }}
</button>
}
@if (selectedAccount) {
<button [class.active]="activeTab === 'ledger'">
{{ selectedAccount.account_no }}
</button>
}
<button [class.active]="activeTab === 'runs'" (click)="loadRuns()">Posting Runs</button>
</div>
@if (loading) {
<div class="sdi-loading">Loading…</div>
}
<!-- Products: one row per savings product, with where it stands in its timetable. -->
@if (activeTab === 'products' && !loading) {
<table class="sdi-table">
<thead>
<tr>
<th>Product</th>
<th>Rate</th>
<th>Posts</th>
<th class="num">Accounts</th>
<th class="num">Accrued This Cycle</th>
<th>Last Posted</th>
<th>Next Posting</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (product of products; track product.id) {
<tr class="clickable" (click)="openProduct(product)">
<td>
<strong>{{ product.name }}</strong>
<span class="sdi-sub">{{ product.short_name }}</span>
</td>
<td>{{ product.rate }}%</td>
<td>{{ postingPeriodLabel(product.posting_period_enum) }}</td>
<td class="num">{{ product.active_accounts }}</td>
<td class="num">{{ product.accrued_this_cycle | number: '1.2-2' }}</td>
<td>{{ formatDate(product.last_posting_date) }}</td>
<td>{{ formatDate(product.next_posting_date) }}</td>
<td>
@if (product.last_run_status) {
<span class="sdi-chip" [class.ok]="product.last_run_status === 'COMPLETED'"
[class.bad]="product.last_run_status === 'FAILED'">
{{ product.last_run_status }}
</span>
} @else {
<span class="sdi-sub">Not run</span>
}
</td>
</tr>
} @empty {
<tr><td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td></tr>
}
</tbody>
</table>
}
<!-- Accounts inside the selected product, as at the latest day recorded. -->
@if (activeTab === 'accounts' && !loading) {
<table class="sdi-table">
<thead>
<tr>
<th>Account</th>
<th>Client</th>
<th>As At</th>
<th class="num">Opening</th>
<th class="num">Closing</th>
<th class="num">Day Interest</th>
<th class="num">Sum This Cycle</th>
<th>Cycle</th>
<th>Pays On</th>
</tr>
</thead>
<tbody>
@for (account of accounts; track account.savings_account_id) {
<tr class="clickable" (click)="openAccount(account)">
<td><strong>{{ account.account_no }}</strong></td>
<td>{{ account.client_name || '—' }}</td>
<td>{{ formatDate(account.balance_date) }}</td>
<td class="num">{{ account.opening_balance | number: '1.2-2' }}</td>
<td class="num">{{ account.closing_balance | number: '1.2-2' }}</td>
<td class="num" [class.zero]="account.below_min_balance">
{{ account.daily_interest | number: '1.4-4' }}
@if (account.below_min_balance) {
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
}
</td>
<td class="num accent">{{ account.cycle_cumulative_interest | number: '1.2-2' }}</td>
<td>{{ account.cycle_number }}</td>
<td>{{ formatDate(account.cycle_end_date) }}</td>
</tr>
} @empty {
<tr><td colspan="9" class="sdi-empty">No ledger rows for this product yet.</td></tr>
}
</tbody>
</table>
}
<!-- One account, day by day. The posting row is where the sum is paid and reset. -->
@if (activeTab === 'ledger' && !loading) {
<table class="sdi-table">
<thead>
<tr>
<th>Date</th>
<th class="num">Opening</th>
<th class="num">Closing</th>
<th class="num">Day Interest</th>
<th class="num">Running Sum</th>
<th>Cycle</th>
<th class="num">Posted</th>
</tr>
</thead>
<tbody>
@for (day of ledger; track day.balance_date) {
<tr [class.posting-row]="day.is_posting_day">
<td>
{{ formatDate(day.balance_date) }}
@if (day.is_posting_day) {
<span class="sdi-flag paid">posting day</span>
}
</td>
<td class="num">{{ day.opening_balance | number: '1.2-2' }}</td>
<td class="num">{{ day.closing_balance | number: '1.2-2' }}</td>
<td class="num" [class.zero]="day.below_min_balance">
{{ day.daily_interest | number: '1.4-4' }}
@if (day.below_min_balance) {
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
}
</td>
<td class="num accent">{{ day.cycle_cumulative_interest | number: '1.4-4' }}</td>
<td>{{ day.cycle_number }}</td>
<td class="num">
@if (day.posted_amount !== null) {
<strong>{{ day.posted_amount | number: '1.2-2' }}</strong>
} @else {
—
}
</td>
</tr>
} @empty {
<tr><td colspan="7" class="sdi-empty">No days recorded for this account.</td></tr>
}
</tbody>
</table>
}
<!-- What each run pooled and paid. Residual is the paise rounding moved, and must be accounted for. -->
@if (activeTab === 'runs' && !loading) {
<table class="sdi-table">
<thead>
<tr>
<th>Product</th>
<th>Posting Date</th>
<th class="num">Accounts</th>
<th class="num">Pooled</th>
<th class="num">Distributed</th>
<th class="num">Residual</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (run of runs; track run.id) {
<tr>
<td>{{ run.product_name }}</td>
<td>{{ formatDate(run.posting_date) }}</td>
<td class="num">{{ run.account_count }}</td>
<td class="num">{{ run.pooled_amount | number: '1.2-2' }}</td>
<td class="num">{{ run.distributed_amount | number: '1.2-2' }}</td>
<td class="num">{{ run.rounding_residual | number: '1.2-2' }}</td>
<td>
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'"
[class.bad]="run.status === 'FAILED'">{{ run.status }}</span>
@if (run.failure_reason) {
<div class="sdi-sub">{{ run.failure_reason }}</div>
}
</td>
</tr>
} @empty {
<tr><td colspan="7" class="sdi-empty">No posting runs yet.</td></tr>
}
</tbody>
</table>
}
</div>
@@ -0,0 +1,173 @@
/**
* 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/.
*/
.sdi-container {
padding: 1rem;
}
.sdi-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
}
.sdi-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.sdi-message {
padding: 0.6rem 0.9rem;
margin-bottom: 1rem;
border-radius: 4px;
cursor: pointer;
background: #e8f4ea;
border-left: 4px solid #2e7d32;
font-size: 0.9rem;
&.error {
background: #fdecea;
border-left-color: #c62828;
}
}
.sdi-tabs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
margin-bottom: 1rem;
button {
background: none;
border: none;
padding: 0.6rem 1rem;
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
&.active {
border-bottom-color: #1565c0;
font-weight: 600;
}
}
}
.sdi-loading {
padding: 2rem;
text-align: center;
opacity: 0.7;
}
// Wide tables scroll inside their own box rather than pushing the page sideways.
.sdi-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
display: block;
overflow-x: auto;
white-space: nowrap;
th,
td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
th {
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
position: sticky;
top: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tr.clickable {
cursor: pointer;
&:hover {
background: rgba(21, 101, 192, 0.06);
}
}
// The day the cycle is paid out and the sum goes back to zero.
tr.posting-row {
background: rgba(46, 125, 50, 0.08);
font-weight: 600;
}
}
.accent {
color: #1565c0;
font-weight: 600;
}
// A day that earned nothing because the balance sat under the product minimum.
.zero {
opacity: 0.55;
}
.sdi-sub {
display: block;
font-size: 0.75rem;
opacity: 0.65;
}
.sdi-empty {
text-align: center;
padding: 2rem;
opacity: 0.6;
}
.sdi-chip {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
background: rgba(0, 0, 0, 0.08);
&.ok {
background: rgba(46, 125, 50, 0.15);
color: #2e7d32;
}
&.bad {
background: rgba(198, 40, 40, 0.15);
color: #c62828;
}
}
.sdi-flag {
display: inline-block;
margin-left: 0.4rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
background: rgba(0, 0, 0, 0.08);
&.paid {
background: rgba(46, 125, 50, 0.2);
color: #2e7d32;
}
}
@@ -0,0 +1,213 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
SavingsDailyInterestService,
SavingsInterestAccount,
SavingsInterestDay,
SavingsInterestProduct,
SavingsInterestRun
} from './savings-daily-interest.service';
/**
* The savings daily interest ledger.
*
* Interest has always been a single number on the account screen with nothing behind it. This
* shows the working: pick a product, see its accounts and where each stands in its cycle, then
* open one account and read it day by day - what the balance was, what that day earned, and the
* running total climbing until the posting day pays it out and resets it to zero.
*/
@Component({
selector: 'mifosx-savings-daily-interest',
templateUrl: './savings-daily-interest.component.html',
styleUrls: ['./savings-daily-interest.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class SavingsDailyInterestComponent implements OnInit {
private service = inject(SavingsDailyInterestService);
activeTab: 'products' | 'accounts' | 'ledger' | 'runs' = 'products';
products: SavingsInterestProduct[] = [];
accounts: SavingsInterestAccount[] = [];
ledger: SavingsInterestDay[] = [];
runs: SavingsInterestRun[] = [];
selectedProduct: SavingsInterestProduct | null = null;
selectedAccount: SavingsInterestAccount | null = null;
loading = false;
message = '';
messageIsError = false;
ngOnInit(): void {
this.loadProducts();
}
// -------------------- loading --------------------
loadProducts(): void {
this.loading = true;
this.service.getProducts().subscribe({
next: (data) => {
this.products = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
openProduct(product: SavingsInterestProduct): void {
this.selectedProduct = product;
this.selectedAccount = null;
this.activeTab = 'accounts';
this.loading = true;
this.service.getAccountsForProduct(product.id).subscribe({
next: (data) => {
this.accounts = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
openAccount(account: SavingsInterestAccount): void {
this.selectedAccount = account;
this.activeTab = 'ledger';
this.loading = true;
this.service.getAccountLedger(account.savings_account_id).subscribe({
next: (data) => {
this.ledger = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
loadRuns(): void {
this.activeTab = 'runs';
this.loading = true;
this.service.getRuns().subscribe({
next: (data) => {
this.runs = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
// -------------------- actions --------------------
/**
* The chain check and the unposted-total check. Nothing found is the answer you want: every day
* picks up where the last one left off, and the ledger still agrees with the accounts.
*/
runReconciliation(): void {
this.loading = true;
this.service.reconcile().subscribe({
next: (result) => {
this.loading = false;
this.messageIsError = !result.clean;
this.message = result.clean
? 'Reconciliation clean: the balance chain is unbroken and every cycle agrees with its account.'
: `${result.problemCount} problem(s) found: ${result.problems.slice(0, 3).join(' | ')}`;
},
error: (err) => this.fail(err)
});
}
runToday(): void {
this.loading = true;
this.service.runForDate().subscribe({
next: (result) => {
this.loading = false;
this.messageIsError = false;
this.message = `Run completed for ${result?.ranFor ?? 'today'}.`;
this.loadProducts();
},
error: (err) => this.fail(err)
});
}
runBackfill(): void {
this.loading = true;
this.service.backfill().subscribe({
next: (result) => {
this.loading = false;
this.messageIsError = false;
this.message = `Backfill wrote ${result?.rowsWritten ?? 0} rows up to ${result?.upTo ?? 'today'}.`;
this.loadProducts();
},
error: (err) => this.fail(err)
});
}
// -------------------- display helpers --------------------
/** Fineract returns dates as [year, month, day]; month is 1-based. */
formatDate(value: number[] | null | undefined): string {
if (!value || value.length < 3) {
return '—';
}
const [year, month, day] = value;
return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`;
}
postingPeriodLabel(value: number): string {
switch (value) {
case 1:
return 'Daily';
case 4:
return 'Monthly';
case 5:
return 'Quarterly';
case 6:
return 'Half-Yearly';
case 7:
return 'Annual';
default:
return 'Not set';
}
}
/**
* Accounts can carry a posting period of their own that differs from the product they were
* opened under, and the engine follows the account. Worth showing rather than hiding: a
* quarterly product whose accounts all post daily is almost never what was intended.
*/
productPostingMismatch(product: SavingsInterestProduct): boolean {
return this.accounts.length > 0 && this.selectedProduct?.id === product.id;
}
backToProducts(): void {
this.activeTab = 'products';
this.selectedProduct = null;
this.selectedAccount = null;
}
backToAccounts(): void {
this.activeTab = 'accounts';
this.selectedAccount = null;
}
dismissMessage(): void {
this.message = '';
}
private fail(error: any): void {
this.loading = false;
this.messageIsError = true;
this.message = error?.error?.defaultUserMessage ?? error?.message ?? 'Request failed.';
}
}
@@ -0,0 +1,22 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { SavingsDailyInterestRoutingModule } from './savings-daily-interest-routing.module';
import { SavingsDailyInterestComponent } from './savings-daily-interest.component';
@NgModule({
imports: [
SharedModule,
SavingsDailyInterestRoutingModule,
SavingsDailyInterestComponent
]
})
export class SavingsDailyInterestModule {}
@@ -0,0 +1,137 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** A savings product and where it stands in its interest posting timetable. */
export interface SavingsInterestProduct {
id: number;
name: string;
short_name: string;
rate: number;
posting_period_enum: number;
run_at_time: string;
last_posting_date: number[] | null;
next_posting_date: number[] | null;
last_run_status: string | null;
is_active: boolean;
active_accounts: number;
accrued_this_cycle: number;
}
/** One account's position in the product ledger, as at the latest day recorded. */
export interface SavingsInterestAccount {
savings_account_id: number;
account_no: string;
client_name: string;
balance_date: number[];
opening_balance: number;
closing_balance: number;
daily_interest: number;
cycle_cumulative_interest: number;
cycle_number: number;
cycle_start_date: number[];
cycle_end_date: number[];
below_min_balance: boolean;
}
/** One day of one account: the working behind that day's interest. */
export interface SavingsInterestDay {
balance_date: number[];
opening_balance: number;
closing_balance: number;
daily_interest: number;
cycle_cumulative_interest: number;
cycle_number: number;
cycle_start_date: number[];
cycle_end_date: number[];
is_posting_day: boolean;
posted_amount: number | null;
savings_transaction_id: number | null;
below_min_balance: boolean;
interest_rate: number;
days_in_year: number;
min_balance_for_interest: number | null;
last_recomputed_on: number[] | null;
}
/** What a posting run pooled and paid, and whether it finished. */
export interface SavingsInterestRun {
id: number;
savings_product_id: number;
product_name: string;
posting_date: number[];
account_count: number;
pooled_amount: number;
distributed_amount: number;
rounding_residual: number;
status: string;
failure_reason: string | null;
started_at: number[] | null;
completed_at: number[] | null;
}
export interface SavingsInterestReconciliation {
clean: boolean;
problemCount: number;
problems: string[];
}
@Injectable({
providedIn: 'root'
})
export class SavingsDailyInterestService {
private http = inject(HttpClient);
private readonly base = '/savingsdailyinterest';
getProducts(): Observable<SavingsInterestProduct[]> {
return this.http.get<SavingsInterestProduct[]>(`${this.base}/products`);
}
getAccountsForProduct(productId: number): Observable<SavingsInterestAccount[]> {
return this.http.get<SavingsInterestAccount[]>(`${this.base}/products/${productId}/accounts`);
}
getAccountLedger(accountId: number, limit = 400): Observable<SavingsInterestDay[]> {
const params = new HttpParams().set('limit', limit);
return this.http.get<SavingsInterestDay[]>(`${this.base}/accounts/${accountId}`, { params });
}
getRuns(productId?: number): Observable<SavingsInterestRun[]> {
let params = new HttpParams();
if (productId) {
params = params.set('productId', productId);
}
return this.http.get<SavingsInterestRun[]>(`${this.base}/runs`, { params });
}
reconcile(): Observable<SavingsInterestReconciliation> {
return this.http.get<SavingsInterestReconciliation>(`${this.base}/reconcile`);
}
/** Runs a day by hand: accrual for every account, then any product whose cycle ends that day. */
runForDate(date?: string): Observable<any> {
let params = new HttpParams();
if (date) {
params = params.set('date', date);
}
return this.http.post(`${this.base}/run`, {}, { params });
}
/** Rebuilds the ledger for every account from activation up to the given date. */
backfill(upTo?: string): Observable<any> {
let params = new HttpParams();
if (upTo) {
params = params.set('upTo', upTo);
}
return this.http.post(`${this.base}/backfill`, {}, { params });
}
}
@@ -14,6 +14,26 @@
<button mat-flat-button color="primary" (click)="openAddInventoryDialog()">Register DD Series</button>
</div>
<!-- Says why the clerk is here when they arrived from a dividend claim rather than from the menu.
Without it, a page listing every DD series gives no clue which member is waiting at the
counter or how much the draft has to be drawn for. -->
@if (dividendContext) {
<div class="dividend-banner">
<div class="dividend-banner__text">
<strong>Issuing a dividend Demand Draft</strong>
<span>
{{ dividendContext.beneficiary }} ·
{{ dividendContext.amount | number: '1.2-2' }}
@if (dividendContext.financialYear) {
· FY {{ dividendContext.financialYear }}
}
</span>
<span class="dividend-banner__hint">Open a series below and choose an available leaflet.</span>
</div>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
</div>
}
<mat-card>
<table mat-table [dataSource]="dataSource">
<!-- ID Column -->
@@ -80,8 +100,15 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<button mat-button class="action-btn" [routerLink]="['/sponsor-dd/leaflets', element.id]">
View Leaflets
<!-- The dividend context travels on, so the leaflets page knows this is a dividend
draft and not an ordinary counter sale. -->
<button
mat-button
class="action-btn"
[routerLink]="['/sponsor-dd/leaflets', element.id]"
[queryParams]="dividendQueryParams"
>
{{ dividendContext ? 'Choose Leaflet' : 'View Leaflets' }}
</button>
</td>
</ng-container>
@@ -8,7 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
@@ -103,6 +103,34 @@ import { SponsorDDService } from '../sponsor-dd.service';
color: #b91c1c;
font-weight: 600;
}
.dividend-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 12px 16px;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
}
.dividend-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: #312e81;
}
.dividend-banner__text strong {
font-size: 13.5px;
}
.dividend-banner__hint {
color: #4338ca;
font-size: 12px;
}
.dividend-banner button {
margin-left: auto;
}
`
]
})
@@ -110,6 +138,17 @@ export class DdInventoryListComponent implements OnInit {
private sponsorDDService = inject(SponsorDDService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private route = inject(ActivatedRoute);
private router = inject(Router);
/**
* The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim
* rather than from the menu.
*
* Null on an ordinary visit, which is what keeps this page's normal behaviour untouched — the
* banner, the relabelled action and the forwarded query params all hang off this being set.
*/
dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null;
displayedColumns: string[] = [
'id',
@@ -126,9 +165,51 @@ export class DdInventoryListComponent implements OnInit {
dataSource: any[] = [];
ngOnInit(): void {
this.readDividendContext();
this.loadInventories();
}
/**
* Picks up the dividend a claim sent us here to pay out.
*
* Read from the URL rather than handed over in a service so a refreshed or bookmarked page still
* knows which lock it is drawing against — losing it silently would issue an ordinary counter
* draft against the member instead of settling their dividend.
*/
private readDividendContext(): void {
const params = this.route.snapshot.queryParams;
const lockId = Number(params['dividendLockId']);
if (!lockId) {
return;
}
this.dividendContext = {
lockId,
amount: Number(params['dividendAmount']) || 0,
beneficiary: params['dividendBeneficiary'] ?? '',
financialYear: params['dividendFinancialYear'] ?? ''
};
}
/** The dividend context to carry onto the leaflets page; empty on an ordinary visit. */
get dividendQueryParams(): Record<string, string | number> {
return this.dividendContext
? {
dividendLockId: this.dividendContext.lockId,
dividendAmount: this.dividendContext.amount,
dividendBeneficiary: this.dividendContext.beneficiary,
dividendFinancialYear: this.dividendContext.financialYear
}
: {};
}
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
cancelDividendClaim(): void {
this.router.navigate([
'/clerk',
'dividend-dashboard'
]);
}
loadInventories(): void {
this.sponsorDDService.getInventories().subscribe({
next: (data) => {
@@ -189,6 +189,12 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
if (element.paymentMethod === 'CHEQUE') {
return 'Cheque';
}
// Nothing is collected — the society already owes the member this money, so the draft is funded
// from Dividend Payable. Named rather than left as "-" so a checker can see at a glance that no
// purchaser will be debited when they approve it.
if (element.paymentMethod === 'DIVIDEND') {
return 'Dividend payout';
}
if (element.savingsAccountId) {
return 'Savings';
}
@@ -19,6 +19,20 @@
</div>
</div>
<!-- Says why the clerk is here when they arrived from a dividend claim, and which member is
waiting. Without it a registry of DD numbers gives no clue what the next click commits to. -->
<div class="dividend-banner" *ngIf="dividendContext">
<div class="dividend-banner__text">
<strong>Issuing a dividend Demand Draft</strong>
<span>
{{ dividendContext.beneficiary }} · {{ dividendContext.amount | number: '1.2-2' }}
<ng-container *ngIf="dividendContext.financialYear"> · FY {{ dividendContext.financialYear }}</ng-container>
</span>
<span class="dividend-banner__hint">Pick an available leaflet below and press Issue.</span>
</div>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
</div>
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search by DD Leaflet No.</mat-label>
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" />
+230 -16
View File
@@ -8,7 +8,7 @@
import { Component, OnInit, AfterViewInit, ViewChild, inject } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
@@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HttpClient } from '@angular/common/http';
import { debounceTime } from 'rxjs/operators';
import { SponsorDDService } from '../sponsor-dd.service';
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
@Component({
selector: 'mifosx-dd-list',
@@ -50,6 +51,31 @@ import { SponsorDDService } from '../sponsor-dd.service';
max-width: 1200px;
margin: 0 auto;
}
.dividend-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 12px 16px;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
}
.dividend-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: #312e81;
}
.dividend-banner__hint {
color: #4338ca;
font-size: 12px;
}
.dividend-banner button {
margin-left: auto;
}
.header-row {
display: flex;
justify-content: space-between;
@@ -155,11 +181,18 @@ import { SponsorDDService } from '../sponsor-dd.service';
export class DdListComponent implements OnInit, AfterViewInit {
private sponsorDDService = inject(SponsorDDService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
@ViewChild(MatPaginator) paginator!: MatPaginator;
/**
* The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim.
* Null on an ordinary visit, which leaves every existing behaviour on this page untouched.
*/
dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null;
inventoryId!: number;
inventoryDetails: any = null;
displayedColumns: string[] = [
@@ -194,6 +227,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
ngOnInit(): void {
this.inventoryId = +this.route.snapshot.params['inventoryId'];
this.readDividendContext();
this.dataSource.filterPredicate = (data) => {
const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus;
const searchMatches =
@@ -207,6 +241,38 @@ export class DdListComponent implements OnInit, AfterViewInit {
this.loadLeaflets();
}
/**
* Picks up the dividend a claim sent the clerk here to pay out, and shows the leaflets they can
* actually use for it.
*
* The page normally opens on today's issued drafts, which is the right view for reconciling but
* exactly the wrong one here — a clerk sent to pick a blank leaflet would land on a list with
* none in it. Dividend mode opens on AVAILABLE instead.
*/
private readDividendContext(): void {
const params = this.route.snapshot.queryParams;
const lockId = Number(params['dividendLockId']);
if (!lockId) {
return;
}
this.dividendContext = {
lockId,
amount: Number(params['dividendAmount']) || 0,
beneficiary: params['dividendBeneficiary'] ?? '',
financialYear: params['dividendFinancialYear'] ?? ''
};
this.selectedStatus = 'AVAILABLE';
this.todayOnly = false;
}
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
cancelDividendClaim(): void {
this.router.navigate([
'/clerk',
'dividend-dashboard'
]);
}
ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
}
@@ -276,12 +342,25 @@ export class DdListComponent implements OnInit, AfterViewInit {
openIssueDialog(leaflet: any): void {
const dialogRef = this.dialog.open(IssueDraftDialogComponent, {
width: '560px',
data: { leaflet, inventoryId: this.inventoryId },
// The dividend, when the clerk came here from a claim. Passing it through puts the dialog in
// dividend mode: beneficiary and amount arrive filled in, there is no payment to collect
// because the society already owes the money, and submitting files the claim rather than an
// ordinary issue request.
data: { leaflet, inventoryId: this.inventoryId, dividend: this.dividendContext },
disableClose: true
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
// A dividend claim is finished the moment it is filed — the draft is drawn on approval, and
// there is nothing further for the clerk to do on this page.
if (this.dividendContext) {
this.router.navigate([
'/clerk',
'dividend-dashboard'
]);
return;
}
this.loadLeaflets();
}
});
@@ -366,14 +445,35 @@ export class DdListComponent implements OnInit, AfterViewInit {
],
providers: [DatePipe],
template: `
<h2 mat-dialog-title>Issue Demand Draft (DD No: {{ data.leaflet.ddNumber }})</h2>
<h2 mat-dialog-title>
{{ dividend ? 'Pay Dividend by Demand Draft' : 'Issue Demand Draft' }} (DD No:
{{ data.leaflet.ddNumber }})
</h2>
<mat-dialog-content>
<!-- A dividend draft is not a counter sale: the society already owes the member the money, so
there is nothing to collect and the beneficiary and amount are fixed by the entitlement.
The clerk is left with only the branch and city to fill in. -->
<ng-container *ngIf="dividend; else counterSaleHint">
<div class="dividend-callout">
<strong>Settling a dividend</strong>
<span>
{{ dividend.beneficiary }} · {{ dividend.amount | number: '1.2-2' }}
<ng-container *ngIf="dividend.financialYear"> · FY {{ dividend.financialYear }}</ng-container>
</span>
<span class="dividend-callout__hint">
Funded from Dividend Payable — no charge, no GST, nothing collected from the member. The draft is drawn
once a manager approves the claim.
</span>
</div>
</ng-container>
<ng-template #counterSaleHint>
<p class="hint-text">
This submits a request to a Bank Manager / Head Cashier for approval. Money only moves (client debited, Sponsor
DD Payable GL credited) once the request is approved.
This submits a request to a Bank Manager / Head Cashier for approval. Money only moves (client debited,
Sponsor DD Payable GL credited) once the request is approved.
</p>
</ng-template>
<form [formGroup]="issueForm" class="dialog-form">
<mat-form-field appearance="outline" class="full-width">
<mat-form-field appearance="outline" class="full-width" *ngIf="!dividend">
<mat-label>How is the customer paying?</mat-label>
<mat-select formControlName="paymentMethod">
<mat-option value="ACCOUNT_DEBIT">Debit from their Savings/Current Account</mat-option>
@@ -382,7 +482,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
</mat-select>
</mat-form-field>
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT'">
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT'">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType">
@@ -410,7 +510,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
</p>
</ng-container>
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'CHEQUE'">
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'CHEQUE'">
<div class="row">
<mat-form-field appearance="outline" class="half-width">
<mat-label>Cheque Number</mat-label>
@@ -426,7 +526,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
</div>
</ng-container>
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'CASH'">
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'CASH'">
<p class="hint-text">
Walk-in purchaser paying cash — need not be an existing bank customer. Record their details for the DD
record; the Aadhaar number is masked before it is stored.
@@ -501,7 +601,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
<mat-form-field
appearance="outline"
class="full-width"
*ngIf="issueForm.get('paymentMethod')?.value !== 'CASH'"
*ngIf="!dividend && issueForm.get('paymentMethod')?.value !== 'CASH'"
>
<mat-label>Client (Customer) ID</mat-label>
<input matInput type="number" formControlName="clientId" />
@@ -537,13 +637,14 @@ export class DdListComponent implements OnInit, AfterViewInit {
<mat-form-field appearance="outline" class="full-width">
<mat-label>DD Face Value (₹)</mat-label>
<input matInput type="number" formControlName="faceValue" />
<mat-hint *ngIf="issueForm.get('faceValue')?.disabled">
<mat-hint *ngIf="dividend">Locked — the member's unclaimed dividend for this lock.</mat-hint>
<mat-hint *ngIf="!dividend && issueForm.get('faceValue')?.disabled">
Locked — from the DD Charges &amp; GST breakdown entered at Transaction Entry.
</mat-hint>
<mat-error *ngIf="issueForm.get('faceValue')?.hasError('required')">Face Value is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-form-field appearance="outline" class="full-width" *ngIf="!dividend">
<mat-label>Charges / Commission (₹)</mat-label>
<input matInput type="number" formControlName="chargesAmount" />
<mat-hint *ngIf="issueForm.get('chargesAmount')?.disabled">
@@ -560,8 +661,8 @@ export class DdListComponent implements OnInit, AfterViewInit {
<ng-container
*ngIf="
issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' ||
issueForm.get('paymentMethod')?.value === 'CHEQUE'
!dividend && (issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' ||
issueForm.get('paymentMethod')?.value === 'CHEQUE')
"
>
<p class="hint-text" *ngIf="additionalLinesArray.length > 0">
@@ -638,16 +739,35 @@ export class DdListComponent implements OnInit, AfterViewInit {
[disabled]="
issueForm.invalid ||
loading ||
(issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' && accountLookupState !== 'found')
(!dividend && issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' && accountLookupState !== 'found')
"
(click)="onSubmit()"
>
Submit for Approval
{{ dividend ? 'Submit Claim Request' : 'Submit for Approval' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.dividend-callout {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
font-size: 13px;
color: #312e81;
}
.dividend-callout__hint {
margin-top: 4px;
color: #4338ca;
font-size: 12px;
line-height: 1.5;
}
.dialog-form {
display: flex;
flex-direction: column;
@@ -702,6 +822,7 @@ export class IssueDraftDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<IssueDraftDialogComponent>);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private claimRequestService = inject(DividendClaimRequestService);
protected data = inject(MAT_DIALOG_DATA);
issueForm!: FormGroup;
@@ -718,6 +839,53 @@ export class IssueDraftDialogComponent implements OnInit {
ddLimitAmount: number | null = null;
availableCashierDeposits: any[] = [];
/**
* The dividend this draft settles, when the dialog was opened from a dividend claim.
*
* A dividend draft is not a counter sale: the society already owes the member the money, so
* there is no purchaser to debit, no charge and no GST. The beneficiary and the amount are fixed
* by the entitlement itself, which leaves the clerk only the branch and city to fill in.
*/
get dividend(): { lockId: number; amount: number; beneficiary: string; financialYear: string } | null {
return this.data?.dividend ?? null;
}
/**
* Locks the form to what the dividend already decided and drops everything a counter sale needs.
*
* The payment-method controls are cleared of their validators rather than left required-and-empty
* — the form would otherwise never become valid and the clerk would be staring at a dead Submit
* button with no field to fix.
*/
private applyDividendMode(): void {
const dividend = this.dividend;
if (!dividend) {
return;
}
this.issueForm.patchValue({
beneficiaryName: dividend.beneficiary,
faceValue: dividend.amount,
chargesAmount: 0
});
// Fixed by the entitlement — a clerk cannot pay a different member, or a different amount, than
// the dividend the claim was raised against.
this.issueForm.get('beneficiaryName')?.disable();
this.issueForm.get('faceValue')?.disable();
for (const name of [
'clientId',
'accountNumber',
'paymentMethod',
'chargesAmount'
]) {
const control = this.issueForm.get(name);
control?.clearValidators();
control?.updateValueAndValidity();
}
}
ngOnInit(): void {
this.sponsorDDService.getGlMappings().subscribe({
next: (mappings) => {
@@ -787,6 +955,8 @@ export class IssueDraftDialogComponent implements OnInit {
additionalLines: this.fb.array([])
});
this.applyDividendMode();
this.forceUppercase([
'beneficiaryName',
'payableAtBranch',
@@ -964,9 +1134,53 @@ export class IssueDraftDialogComponent implements OnInit {
this.dialogRef.close(false);
}
/**
* Files the dividend claim rather than an ordinary issue request.
*
* The draft itself is drawn when a manager approves the claim — that approval already is the
* check on this money, so routing it through the DD maker-checker as well would put a member's
* own dividend behind two separate approvals for no added control.
*/
private submitDividendClaim(): void {
const dividend = this.dividend!;
const formVal = this.issueForm.getRawValue();
this.loading = true;
this.claimRequestService
.submit(dividend.lockId, {
ddApplyAmount: Number(formVal.faceValue),
ddBeneficiaryName: formVal.beneficiaryName,
ddPayableAtBranch: formVal.payableAtBranch,
ddPayableAtCity: formVal.payableAtCity,
// The leaflet the clerk actually opened this dialog on. The draft has to be drawn on the
// physical form in their hand, not on whichever one the server would pick next.
leafletId: this.data?.leaflet?.id
})
.subscribe({
next: (res: any) => {
const ddNumber = res?.ddNumber ? ` DD No ${res.ddNumber}.` : '';
this.snackBar.open(
`Dividend draft submitted for approval.${ddNumber} It now appears under DD Issuance Approvals.`,
'Close',
{ duration: 6000 }
);
this.dialogRef.close(true);
},
error: (err) => {
this.loading = false;
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
}
});
}
onSubmit(): void {
if (this.issueForm.invalid) return;
if (this.dividend) {
this.submitDividendClaim();
return;
}
const formVal = this.issueForm.getRawValue();
if (formVal.paymentMethod === 'ACCOUNT_DEBIT' && this.accountLookupState !== 'found') {
this.snackBar.open(
@@ -34,20 +34,23 @@
<td colspan="6" class="pg-empty">No dividend declarations are waiting for approval.</td>
</tr>
} @else {
<!-- The whole row opens the member-by-member breakdown, so a checker can get to the detail
the same way they would on the Dividends list rather than having to find the View
button. The action buttons stop propagation so Approve still approves directly. -->
@for (row of rows; track row.dividendId) {
<tr>
<tr class="dr-row" (click)="openView(row)">
<td>{{ row.productName }}</td>
<td>{{ row.dividendPeriodStartDate }} → {{ row.dividendPeriodEndDate }}</td>
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<td class="num">{{ row.memberCount }}</td>
<td>{{ row.declaredOn | slice: 0 : 10 }}</td>
<td class="dr-actions">
<button type="button" class="pg-btn" (click)="openView(row)">View</button>
<button type="button" class="pg-btn" (click)="openView(row); $event.stopPropagation()">View</button>
<button
type="button"
class="pg-btn dr-approve-btn"
[disabled]="approvingId === row.dividendId"
(click)="approve(row)"
(click)="approve(row); $event.stopPropagation()"
>
{{ approvingId === row.dividendId ? 'Approving…' : 'Approve' }}
</button>
@@ -83,3 +83,12 @@
animation: none;
}
}
/* The whole row opens the member-by-member breakdown, so it has to look like it will. */
.dr-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
@@ -8,6 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
@@ -38,6 +39,8 @@ import { ViewDividendComponent } from '../../../products/share-products/view-div
export class DividendRequestsComponent implements OnInit {
private service = inject(PendingDividendsService);
private productsService = inject(ProductsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
rows: any[] = [];
loading = false;
@@ -51,9 +54,30 @@ export class DividendRequestsComponent implements OnInit {
viewDividendId: number | null = null;
ngOnInit(): void {
this.openRequestedDeclaration();
this.load();
}
/**
* Opens the breakdown straight away when the officer arrived here from a specific declaration.
*
* The Dividends list sends them here to approve one declaration in particular, so making them
* find its row again in a list of every pending declaration would undo the point of the link.
* Read from the query string rather than a service so a refreshed or shared URL still opens the
* same declaration.
*/
private openRequestedDeclaration(): void {
const params = this.route.snapshot.queryParams;
const productId = Number(params['productId']);
const dividendId = Number(params['dividendId']);
if (!productId || !dividendId) {
return;
}
this.viewProductId = productId;
this.viewDividendId = dividendId;
this.showViewModal = true;
}
openView(row: any): void {
this.viewProductId = row.productId;
this.viewDividendId = row.dividendId;
@@ -64,6 +88,11 @@ export class DividendRequestsComponent implements OnInit {
this.showViewModal = false;
this.viewProductId = null;
this.viewDividendId = null;
// Dropped from the URL as well, or the declaration the officer just closed would reopen on the
// next refresh and there would be no way to get back to the plain list.
if (this.route.snapshot.queryParams['dividendId']) {
this.router.navigate([], { relativeTo: this.route, queryParams: {}, replaceUrl: true });
}
}
onViewActionCompleted(): void {
+3 -2
View File
@@ -97,11 +97,12 @@
}
.letter-mega-panel {
max-width: 850px !important;
max-width: 1100px !important;
}
.letter-grid {
grid-template-columns: repeat(4, 1fr) !important;
grid-template-columns: repeat(5, minmax(190px, 1fr)) !important;
min-width: 1040px;
}
.reconciliation-mega-panel {