feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes

- Add the full Share Reports surface (final balance, account statement,
  issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry,
  payment certificate, share detail, dividend statement, folio statement,
  distinctive numbers, area-wise, mailing labels/letters, share transfer) as
  standalone routed components, wired into the Share menu
- Wire the unattributed Share Collection receipt list into Apply Collection
  and Apply Additional Shares, with an Attach action independent of
  account/client
- Sync the GL Account and Voucher No fields to whichever receipt is ticked or
  found, and surface the receipt's real Dr/Cr GL entries in its detail popup
- Block Apply Additional Shares until the account's original application is
  settled

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:06:02 +05:30
co-authored by Claude Sonnet 5
parent e53dfb5a66
commit c7428d0cb2
51 changed files with 10721 additions and 429 deletions
+11
View File
@@ -390,6 +390,17 @@ export class CashierService {
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } }); return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
} }
/**
* Approved share-collection receipts that name no member yet.
*
* A receipt taken straight into a GL account carries no member of its own, so it is never
* attributed automatically. This lists them so the counter can see the voucher they just raised
* and attach it deliberately, instead of being told there is nothing to apply.
*/
getUnattributedShareCollections(): Observable<any> {
return this.http.get('/shareadvancecollection/unattributed');
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */ /** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> { getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : ''; const suffix = clientId ? `?clientId=${clientId}` : '';
+23 -19
View File
@@ -1300,72 +1300,76 @@
<!-- Share Operations --> <!-- Share Operations -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Share Operations</div> <div class="deposit-group-title">Share Operations</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/nominal-shares']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Nominal Share Report <fa-icon icon="coins" class="menu-item-icon"></fa-icon> Nominal Share Report
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-wise']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Share Wise Report <fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Share Wise Report
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/guarantor-detail']">
<fa-icon icon="user-shield" class="menu-item-icon"></fa-icon> Guarantor Detail <fa-icon icon="user-shield" class="menu-item-icon"></fa-icon> Guarantor Detail
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/member-interest']">
<fa-icon icon="calculator" class="menu-item-icon"></fa-icon> Total Int Rec <fa-icon icon="calculator" class="menu-item-icon"></fa-icon> Total Int Rec
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/enquiry']">
<fa-icon icon="search" class="menu-item-icon"></fa-icon> Share Enquiry <fa-icon icon="search" class="menu-item-icon"></fa-icon> Share Enquiry
</button> </button>
</div> </div>
<!-- Share Documents --> <!-- Share Documents -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Share Documents</div> <div class="deposit-group-title">Share Documents</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/payment-certificate']">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Share Payment Certificate <fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Share Payment Certificate
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-detail']">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Share Detail <fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Share Detail
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/dividend-statement']">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Dividend Statement <fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Dividend Statement
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/transfers']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Share Transfer <fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Share Transfer
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/folio-statement']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Folio Statement <fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Folio Statement
</button> </button>
</div> </div>
<!-- Share Reports --> <!-- Share Reports -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Share Reports</div> <div class="deposit-group-title">Share Reports</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/final-balance']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Final Balance <fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Final Balance
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/account-statement']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Account Statement <fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Account Statement
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-issued']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued <fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
</button> </button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder <fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-status']">
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status <fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status
</button> </button>
</div> </div>
<!-- Share Admin --> <!-- Share Admin -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Share Admin</div> <div class="deposit-group-title">Share Admin</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/distinctive-numbers']">
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Devede No Detail <fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Devede No Detail
</button> </button>
<button mat-menu-item tabindex="0"><fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise</button> <button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/area-wise']">
<button mat-menu-item tabindex="0"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button> <fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
<button mat-menu-item tabindex="0"> </button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/labels']">
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/letters']">
<fa-icon icon="envelope" class="menu-item-icon"></fa-icon> Share Letter <fa-icon icon="envelope" class="menu-item-icon"></fa-icon> Share Letter
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/letter-mgmt/share-member']">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details <fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
</button> </button>
</div> </div>
@@ -0,0 +1,209 @@
<!--
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="file-invoice" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Account Statement</h2>
<div class="cert-head-s">
One member's share account, movement by movement, with the holding each movement left behind.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !lines.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !lines.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
<!-- Account picker -->
<div class="cert-filters">
<div class="cert-filter" style="flex: 2 1 320px; position: relative">
<label>Share account</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input
type="text"
[ngModel]="accountSearch"
(ngModelChange)="onAccountSearch($event)"
placeholder="Search member name or share account number"
/>
</div>
@if (accountOptions.length && !selectedAccountId) {
<div class="srep-picker">
@for (opt of accountOptions; track opt.id) {
<button type="button" class="srep-picker-item" (click)="selectAccount(opt)">
<span class="srep-picker-name">{{ opt.clientName }}</span>
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
<span class="srep-picker-meta">{{ opt.productName }}</span>
</button>
}
</div>
}
</div>
<div class="cert-filter">
<label>From</label>
<input type="date" [(ngModel)]="fromDate" (change)="load()" [disabled]="!selectedAccountId" />
</div>
<div class="cert-filter">
<label>To</label>
<input type="date" [(ngModel)]="toDate" (change)="load()" [disabled]="!selectedAccountId" />
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearPeriod()" [disabled]="!selectedAccountId">Full history</button>
</div>
</div>
@if (!selectedAccountId) {
<div class="srep-state">Search for a member or share account number above to open its statement.</div>
} @else if (loading) {
<div class="srep-state">Loading statement…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (account) {
<!-- Statement header -->
<div class="srep-stmt-head">
<div class="srep-stmt-grid">
<div>
<div class="srep-stmt-l">Member</div>
<div class="srep-stmt-v">{{ account.clientName }}</div>
</div>
<div>
<div class="srep-stmt-l">Membership No</div>
<div class="srep-stmt-v srep-mono">{{ account.cifNumber || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Share A/c No</div>
<div class="srep-stmt-v srep-mono">{{ account.accountNo }}</div>
</div>
<div>
<div class="srep-stmt-l">Product</div>
<div class="srep-stmt-v">{{ account.productName }}</div>
</div>
<div>
<div class="srep-stmt-l">Branch</div>
<div class="srep-stmt-v">{{ account.officeName || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Status</div>
<div class="srep-stmt-v">
<span class="srep-pill" [ngClass]="statusClass(account.statusEnum)">{{
statusLabel(account.statusEnum)
}}</span>
</div>
</div>
<div>
<div class="srep-stmt-l">Linked Savings</div>
<div class="srep-stmt-v srep-mono">{{ account.linkedSavingsAccountNo || 'Not linked' }}</div>
</div>
<div>
<div class="srep-stmt-l">Activated</div>
<div class="srep-stmt-v">{{ account.activatedDate || '—' }}</div>
</div>
</div>
<button class="cert-cta-btn srep-no-print" (click)="openAccount()">
<fa-icon icon="external-link-alt"></fa-icon> Open account
</button>
</div>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.closingShares | number }}</div>
<div class="cert-kpi-l">Closing Shares</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">₹{{ totals.closingValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Closing Value</div>
</div>
</div>
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="arrow-up" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.sharesIn | number }}</div>
<div class="cert-kpi-l">Shares Purchased</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="arrow-down" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.sharesOut | number }}</div>
<div class="cert-kpi-l">Shares Redeemed</div>
</div>
</div>
</div>
}
@if (!lines.length) {
<div class="srep-state">
No settled movement on this account{{ fromDate || toDate ? ' in the selected period' : '' }}.
</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Date</th>
<th>Particulars</th>
<th>Board Resolution</th>
<th>Funded By</th>
<th class="srep-num">Shares In</th>
<th class="srep-num">Shares Out</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Amount</th>
<th class="srep-num">Balance Shares</th>
<th class="srep-num">Balance Value</th>
</tr>
</thead>
<tbody>
@for (line of lines; track line.transactionId) {
<tr>
<td>{{ line.transactionDate }}</td>
<td>
<span class="srep-pill" [ngClass]="typeClass(line.typeEnum)">{{ typeLabel(line.typeEnum) }}</span>
</td>
<td class="srep-mono">{{ line.resolutionNumber || '—' }}</td>
<td>{{ funding(line.fundingSource) }}</td>
<td class="srep-num">{{ line.sharesIn ? (line.sharesIn | number) : '' }}</td>
<td class="srep-num">{{ line.sharesOut ? (line.sharesOut | number) : '' }}</td>
<td class="srep-num">{{ line.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">{{ line.amount | number: '1.2-2' }}</td>
<td class="srep-num">{{ line.balanceShares | number }}</td>
<td class="srep-num">₹{{ line.balanceValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4">Closing balance</td>
<td class="srep-num">{{ totals?.sharesIn | number }}</td>
<td class="srep-num">{{ totals?.sharesOut | number }}</td>
<td class="srep-num"></td>
<td class="srep-num"></td>
<td class="srep-num">{{ totals?.closingShares | number }}</td>
<td class="srep-num">₹{{ totals?.closingValue | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
}
</div>
@@ -0,0 +1,238 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import {
SHARE_ACCOUNT_STATUS,
SHARE_TXN_TYPE,
fundingLabel,
shareReportCsv,
shareReportPrint
} from '../share-report.util';
interface StatementLine {
transactionId: number;
transactionDate: string;
typeEnum: number;
statusEnum: number;
sharesIn: number;
sharesOut: number;
unitPrice: number;
amount: number;
chargeAmount: number;
amountPaid: number;
resolutionNumber: string;
meetingDate: string;
fundingSource: string;
balanceShares: number;
balanceValue: number;
}
/**
* Share Account Statement — one member's share account, movement by movement, with the holding each
* movement left behind.
*
* <p>
* The account is chosen first because a statement is always about one account; the search box is
* server-side (the same summary endpoint the Share Holder report uses), so it finds a member across
* every branch rather than only among accounts already downloaded.
*
* <p>
* A period narrows which lines are shown but never how the running balance was reached — the server
* walks the account's whole life and filters afterwards, so a mid-year statement opens at the
* holding the member actually had, not at zero.
*/
@Component({
selector: 'mifosx-share-account-statement',
templateUrl: './share-account-statement.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareAccountStatementComponent implements OnInit {
private sharesService = inject(SharesService);
private route = inject(ActivatedRoute);
private router = inject(Router);
/** Account picker. */
accountSearch = '';
accountOptions: any[] = [];
searchingAccounts = false;
selectedAccountId: number | null = null;
/** Statement. */
loading = false;
loadError = '';
account: any = null;
lines: StatementLine[] = [];
totals: any = null;
fromDate = '';
toDate = '';
private searchTerm$ = new Subject<string>();
ngOnInit(): void {
this.searchTerm$
.pipe(
debounceTime(350),
distinctUntilChanged(),
switchMap((term) => {
this.searchingAccounts = true;
return this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])));
})
)
.subscribe((res: any) => {
this.accountOptions = Array.isArray(res) ? res : res?.pageItems || [];
this.searchingAccounts = false;
});
// Deep-linkable: the account view's own statement button, and any report that drills in here,
// can hand over ?shareAccountId= and land on a loaded statement rather than an empty picker.
const preselected = this.route.snapshot.queryParamMap.get('shareAccountId');
if (preselected) {
this.selectedAccountId = Number(preselected);
this.load();
}
this.searchTerm$.next('');
}
onAccountSearch(term: string): void {
this.accountSearch = term;
this.searchTerm$.next(term.trim());
}
selectAccount(option: any): void {
this.selectedAccountId = option.id;
this.accountOptions = [];
this.accountSearch = `${option.clientName} — ${option.accountNo}`;
this.load();
}
load(): void {
if (!this.selectedAccountId) {
return;
}
this.loading = true;
this.loadError = '';
this.sharesService
.getShareAccountStatement(this.selectedAccountId, { fromDate: this.fromDate, toDate: this.toDate })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The share account statement could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.account = res?.account || null;
this.lines = res?.lines || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
clearPeriod(): void {
this.fromDate = '';
this.toDate = '';
this.load();
}
typeLabel(typeEnum: number): string {
return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? '');
}
typeClass(typeEnum: number): string {
return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey';
}
statusLabel(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
statusClass(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
}
funding(source: string): string {
return fundingLabel(source);
}
openAccount(): void {
if (this.account) {
this.router.navigate([
'/clients',
this.account.clientId,
'shares-accounts',
this.account.shareAccountId,
'general'
]);
}
}
exportCsv(): void {
if (!this.account) {
return;
}
shareReportCsv(
`share-statement-${this.account.accountNo}`,
[
'Date',
'Particulars',
'Board Resolution',
'Funded By',
'Shares In',
'Shares Out',
'Unit Price',
'Amount',
'Entry Fee',
'Balance Shares',
'Balance Value'
],
this.lines.map((l) => [
l.transactionDate,
this.typeLabel(l.typeEnum),
l.resolutionNumber,
this.funding(l.fundingSource),
l.sharesIn || '',
l.sharesOut || '',
l.unitPrice,
l.amount,
l.chargeAmount,
l.balanceShares,
l.balanceValue
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,174 @@
<!--
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="map" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Area Wise Shareholding</h2>
<div class="cert-head-s">
Where the society's members and its share capital sit — grouped by the member's own recorded address.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredMembers.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredMembers.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="map" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.areaCount | number }}</div>
<div class="cert-kpi-l">Areas</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.memberCount | number }}</div>
<div class="cert-kpi-l">Members</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">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.shares | number }}</div>
<div class="cert-kpi-l">Shares Held</div>
</div>
</div>
</div>
}
@if (loading) {
<div class="srep-state">Loading area-wise shareholding…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!members.length) {
<div class="srep-state">No member holds share capital yet.</div>
} @else {
<!-- Area summary — click one to narrow the member list below -->
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Area</th>
<th class="srep-num">Members</th>
<th style="width: 22%">Capital</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Share Value</th>
<th class="srep-num">% of Capital</th>
</tr>
</thead>
<tbody>
@for (a of areas; track a.area) {
<tr class="srep-row--click" (click)="selectArea(a.area)">
<td>
{{ a.area }}
@if (areaFilter === a.area) {
<span class="srep-pill srep-pill--blue srep-no-print">filtered</span>
}
</td>
<td class="srep-num">{{ a.memberCount | number }}</td>
<td>
<div class="srep-bar"><div class="srep-bar-fill" [style.width.%]="barWidth(a)"></div></div>
</td>
<td class="srep-num">{{ a.shares | number }}</td>
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">{{ capitalShare(a) | number: '1.2-2' }}%</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>All areas</td>
<td class="srep-num">{{ totals?.memberCount | number }}</td>
<td></td>
<td class="srep-num">{{ totals?.shares | number }}</td>
<td class="srep-num">₹{{ totals?.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">100.00%</td>
</tr>
</tfoot>
</table>
</div>
<!-- Members -->
<div class="cert-filters" style="margin-top: 0.85rem">
<div class="cert-filter">
<label>Member / Membership No / Area</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or Bhopal" />
</div>
</div>
<div class="cert-filter">
<label>Area</label>
<select [(ngModel)]="areaFilter">
<option value="">All areas</option>
@for (a of areas; track a.area) {
<option [value]="a.area">{{ a.area }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (!filteredMembers.length) {
<div class="srep-state">No member matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Area</th>
<th>Member</th>
<th>Membership No</th>
<th>Mobile</th>
<th>Branch</th>
<th>Postal Code</th>
<th class="srep-num">Shares Held</th>
<th class="srep-num">Share Value</th>
</tr>
</thead>
<tbody>
@for (m of filteredMembers; track m.clientId) {
<tr class="srep-row--click" (click)="openMember(m)">
<td>{{ m.area }}</td>
<td>{{ m.clientName }}</td>
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
<td>{{ m.mobileNo || '—' }}</td>
<td>{{ m.officeName || '—' }}</td>
<td class="srep-mono">{{ m.postalCode || '—' }}</td>
<td class="srep-num">{{ m.sharesHeld | number }}</td>
<td class="srep-num">₹{{ m.shareValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
@@ -0,0 +1,188 @@
/**
* 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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { shareReportCsv, shareReportPrint } from '../share-report.util';
interface AreaRow {
area: string;
memberCount: number;
shares: number;
shareValue: number;
}
interface AreaMemberRow {
clientId: number;
cifNumber: string;
clientName: string;
mobileNo: string;
officeName: string;
area: string;
postalCode: string;
sharesHeld: number;
shareValue: number;
}
/**
* Area Wise — where the society's members and its share capital actually sit.
*
* <p>
* A co-operative's area of operation is part of its licence, so "how much of our capital comes from
* which area" is a question both the board and the Registrar ask. Grouped on the member's own
* recorded address rather than the branch that serves them: the two answer different questions, and
* a licence is written against the address.
*
* <p>
* Members with no address are their own group rather than dropped — an unaddressable member is a gap
* in the register worth seeing.
*/
@Component({
selector: 'mifosx-share-area-wise',
templateUrl: './share-area-wise.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareAreaWiseComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
areas: AreaRow[] = [];
members: AreaMemberRow[] = [];
totals: any = null;
search = '';
/** Set by clicking an area row; '' means every area. */
areaFilter = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareAreaWise({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The area-wise report could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.areas = res?.areas || [];
this.members = res?.members || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get filteredMembers(): AreaMemberRow[] {
const term = this.search.trim().toLowerCase();
return this.members.filter((m) => {
if (this.areaFilter && m.area !== this.areaFilter) {
return false;
}
if (!term) {
return true;
}
return [
m.clientName,
m.cifNumber,
m.area,
m.postalCode
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
/** An area's share of the society's capital — the reading this report exists for. */
capitalShare(row: AreaRow): number {
const total = Number(this.totals?.shareValue || 0);
return total ? (Number(row.shareValue || 0) * 100) / total : 0;
}
barWidth(row: AreaRow): number {
const largest = Math.max(...this.areas.map((a) => Number(a.shareValue || 0)), 1);
return (Number(row.shareValue || 0) * 100) / largest;
}
selectArea(area: string): void {
this.areaFilter = this.areaFilter === area ? '' : area;
}
openMember(row: AreaMemberRow): void {
this.router.navigate([
'/clients',
row.clientId,
'general'
]);
}
clearFilters(): void {
this.search = '';
this.areaFilter = '';
}
exportCsv(): void {
shareReportCsv(
'share-area-wise',
[
'Area',
'Member',
'Membership No',
'Mobile',
'Branch',
'Postal Code',
'Shares Held',
'Share Value'
],
this.filteredMembers.map((m) => [
m.area,
m.clientName,
m.cifNumber,
m.mobileNo,
m.officeName,
m.postalCode,
m.sharesHeld,
m.shareValue
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,192 @@
<!--
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="list-alt" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Detail</h2>
<div class="cert-head-s">
The complete share register — every account with its member, class and the terms it was opened under.
</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>
@if (totals) {
<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">{{ totals.accountCount | 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">{{ totals.sharesHeld | number }}</div>
<div class="cert-kpi-l">Shares Held</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">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
<div class="cert-kpi" [ngClass]="totals.chargeOutstanding ? 'cert-kpi--amber' : 'cert-kpi--blue'">
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.chargeOutstanding | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Charges Outstanding</div>
</div>
</div>
</div>
}
<div class="cert-filters">
<div class="cert-filter">
<label>Member / 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>Status</label>
<select [(ngModel)]="statusFilter">
<option [ngValue]="''">All statuses</option>
@for (s of statusOptions; track s) {
<option [ngValue]="s">{{ statusLabel(s) }}</option>
}
</select>
</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>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the share register…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No share account exists yet.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No share account matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Branch</th>
<th>Product</th>
<th>State</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Value</th>
<th class="srep-num">Tranches</th>
<th>Board Resolution</th>
<th>Lock-in</th>
<th>Min Active</th>
<th>Linked Savings</th>
<th class="srep-num">Charges O/S</th>
<th>Activated</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.shareAccountId) {
<tr class="srep-row--click" [ngClass]="stateRowClass(row.holdingState)" (click)="openAccount(row)">
<td>
{{ row.clientName }}
@if (row.mobileNo) {
<div class="srep-scope-note" style="margin: 0">{{ row.mobileNo }}</div>
}
</td>
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
<td class="srep-mono">{{ row.accountNo }}</td>
<td>{{ row.officeName || '—' }}</td>
<td>{{ row.productName }}</td>
<td>
<span
class="srep-pill"
[ngClass]="stateClass(row.holdingState)"
[title]="stateNote(row.holdingState)"
>{{ stateLabel(row.holdingState) }}</span
>
</td>
<td class="srep-num">
{{ row.sharesHeld | number }}
@if (row.holdingState === 'UNPAID') {
<div class="srep-sub">{{ row.unpaidShares | number }} unpaid</div>
}
</td>
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">{{ row.trancheCount | number }}</td>
<td class="srep-mono">
{{ row.resolutionNumber || '—' }}
@if (row.resolutionDate) {
<div class="srep-scope-note" style="margin: 0">{{ row.resolutionDate }}</div>
}
</td>
<td>{{ lockin(row) }}</td>
<td>{{ minimumActive(row) }}</td>
<td class="srep-mono">{{ row.linkedSavingsAccountNo || '—' }}</td>
<td class="srep-num">
{{ row.chargeOutstanding ? '₹' + (row.chargeOutstanding | number: '1.2-2') : '—' }}
</td>
<td>{{ row.activatedDate || '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="6">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts
</td>
<td class="srep-num">{{ shownShares | number }}</td>
<td class="srep-num">₹{{ shownValue | number: '1.2-2' }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,293 @@
/**
* 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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import {
SHARE_ACCOUNT_STATUS,
holdingStateClass,
holdingStateLabel,
holdingStateNote,
holdingStateRowClass,
periodLabel,
shareReportCsv,
shareReportPrint
} from '../share-report.util';
interface DetailRow {
shareAccountId: number;
accountNo: string;
externalId: string;
clientId: number;
cifNumber: string;
clientName: string;
mobileNo: string;
officeName: string;
productId: number;
productName: string;
productShortName: string;
currencyCode: string;
statusEnum: number;
holdingState: string;
unpaidShares: number;
unitPrice: number;
sharesHeld: number;
shareValue: number;
pendingShares: number;
trancheCount: number;
resolutionNumber: string;
resolutionDate: string;
lockinPeriod: number;
lockinPeriodEnum: number;
minimumActivePeriod: number;
minimumActivePeriodEnum: number;
allowDividendsInactive: boolean;
linkedSavingsAccountNo: string;
chargeTotal: number;
chargeOutstanding: number;
submittedDate: string;
approvedDate: string;
activatedDate: string;
closedDate: string;
}
/**
* Share Detail — the complete register: every account with its member, share class, and the terms it
* was opened under.
*
* <p>
* Where the Share Holder list answers "who holds shares", this answers "what exactly does this
* account say": lock-in, minimum active period, linked savings, charges, tranche count and governing
* resolution, which are otherwise spread one tab at a time across each account.
*/
@Component({
selector: 'mifosx-share-detail-report',
templateUrl: './share-detail-report.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareDetailReportComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
rows: DetailRow[] = [];
totals: any = null;
search = '';
branchFilter = '';
productFilter = '';
statusFilter: number | '' = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareDetailRegister({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The share register could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get branchOptions(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get productOptions(): string[] {
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
}
get statusOptions(): number[] {
return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
}
get filteredRows(): DetailRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.officeName !== this.branchFilter) {
return false;
}
if (this.productFilter && r.productName !== this.productFilter) {
return false;
}
if (this.statusFilter !== '' && r.statusEnum !== this.statusFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.accountNo,
r.cifNumber,
r.productName,
r.resolutionNumber,
r.externalId
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
get shownShares(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0);
}
get shownValue(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
}
get isFiltered(): boolean {
return !!(this.search.trim() || this.branchFilter || this.productFilter || this.statusFilter !== '');
}
statusLabel(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
lockin(row: DetailRow): string {
return periodLabel(row.lockinPeriod, row.lockinPeriodEnum);
}
minimumActive(row: DetailRow): string {
return periodLabel(row.minimumActivePeriod, row.minimumActivePeriodEnum);
}
stateLabel(state: string): string {
return holdingStateLabel(state);
}
stateClass(state: string): string {
return holdingStateClass(state);
}
stateRowClass(state: string): string {
return holdingStateRowClass(state);
}
stateNote(state: string): string {
return holdingStateNote(state);
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
}
openAccount(row: DetailRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.shareAccountId,
'general'
]);
}
exportCsv(): void {
shareReportCsv(
'share-detail-register',
[
'Member',
'Membership No',
'Mobile',
'Share A/c No',
'External Id',
'Branch',
'Product',
'Status',
'Holding State',
'Allotted Unpaid',
'Shares Held',
'Unit Price',
'Share Value',
'Pending Shares',
'Tranches',
'Board Resolution',
'Lock-in',
'Min Active Period',
'Dividends If Inactive',
'Linked Savings',
'Charges',
'Charges Outstanding',
'Submitted',
'Activated',
'Closed'
],
this.filteredRows.map((r) => [
r.clientName,
r.cifNumber,
r.mobileNo,
r.accountNo,
r.externalId,
r.officeName,
r.productName,
this.statusLabel(r.statusEnum),
this.stateLabel(r.holdingState),
r.unpaidShares,
r.sharesHeld,
r.unitPrice,
r.shareValue,
r.pendingShares,
r.trancheCount,
r.resolutionNumber,
this.lockin(r),
this.minimumActive(r),
r.allowDividendsInactive ? 'Yes' : 'No',
r.linkedSavingsAccountNo,
r.chargeTotal,
r.chargeOutstanding,
r.submittedDate,
r.activatedDate,
r.closedDate
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,152 @@
<!--
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="tag" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Distinctive Number Detail</h2>
<div class="cert-head-s">
Which numbered shares each member holds — the range every allotment covers, per share class.
</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>
@if (totals) {
<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">{{ totals.allotmentCount | number }}</div>
<div class="cert-kpi-l">Numbered 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">{{ totals.sharesNumbered | number }}</div>
<div class="cert-kpi-l">Shares Numbered</div>
</div>
</div>
</div>
}
<!-- The numbers are derived, not recorded. Said plainly so nobody reads them as an independent
register the society has been maintaining. -->
<div class="srep-warn">
<fa-icon icon="info-circle"></fa-icon>
<span>
Distinctive numbers are <b>derived from issue order</b> within each share class — no distinctive-number column
exists in the system, so each paid-up allotment takes the next block in sequence. Redeemed shares leave a gap
rather than renumbering everybody else, exactly as a physical register would. Only paid-up allotments are
numbered.
</span>
</div>
<div class="cert-filters">
<div class="cert-filter">
<label>Find share number</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="number" min="1" [(ngModel)]="shareNumberLookup" placeholder="e.g. 175 — who holds it?" />
</div>
</div>
<div class="cert-filter">
<label>Member / 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" />
</div>
</div>
<div class="cert-filter">
<label>Share class</label>
<select [(ngModel)]="productFilter">
<option value="">All classes</option>
@for (p of productOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the distinctive number register…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No paid-up allotment has been made yet, so no share carries a number.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">
@if (shareNumberLookup) {
Share number {{ shareNumberLookup | number }} has not been allotted in this share class.
} @else {
No allotment matches the filters applied.
}
</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Share Class</th>
<th>Distinctive Numbers</th>
<th class="srep-num">Shares</th>
<th>Member</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Branch</th>
<th>Allotment Date</th>
<th>Funded By</th>
<th>Board Resolution</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.transactionId) {
<tr class="srep-row--click" (click)="openAccount(row)">
<td>{{ row.productName }}</td>
<td class="srep-mono">
<b>{{ range(row) }}</b>
</td>
<td class="srep-num">{{ row.shares | number }}</td>
<td>{{ row.clientName }}</td>
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
<td class="srep-mono">{{ row.accountNo }}</td>
<td>{{ row.officeName || '—' }}</td>
<td>{{ row.allotmentDate }}</td>
<td>{{ funding(row.fundingSource) }}</td>
<td class="srep-mono">{{ row.resolutionNumber || '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments
</td>
<td class="srep-num">{{ shownShares | number }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,206 @@
/**
* 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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util';
interface DistinctiveRow {
transactionId: number;
allotmentDate: string;
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeName: string;
productId: number;
productName: string;
shares: number;
unitPrice: number;
amount: number;
resolutionNumber: string;
fundingSource: string;
distinctiveFrom: number;
distinctiveTo: number;
}
/**
* Share Devede (Distinctive) Number Detail — which numbered shares each member actually holds.
*
* <p>
* A share certificate names the distinctive numbers it covers, and the register has to be able to
* say who holds which. Nothing in the schema records those numbers, so they are derived the way the
* society would have allotted them: shares numbered in issue order within each share class, each
* allotment taking the next block. That is stated plainly on the screen rather than presented as an
* independently-kept number, because it is a derivation and a reader should know it.
*/
@Component({
selector: 'mifosx-share-distinctive-numbers',
templateUrl: './share-distinctive-numbers.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareDistinctiveNumbersComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
rows: DistinctiveRow[] = [];
totals: any = null;
search = '';
productFilter = '';
/** Find who holds a given share number — the question a certificate query actually arrives as. */
shareNumberLookup: number | null = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareDistinctiveNumbers({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The distinctive number register could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get productOptions(): string[] {
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
}
get filteredRows(): DistinctiveRow[] {
const term = this.search.trim().toLowerCase();
const lookup = Number(this.shareNumberLookup);
return this.rows.filter((r) => {
if (this.productFilter && r.productName !== this.productFilter) {
return false;
}
// A share number belongs to exactly one block, so the lookup is a range test rather than a
// match — which is the whole point of holding the register this way.
if (lookup > 0 && !(lookup >= Number(r.distinctiveFrom) && lookup <= Number(r.distinctiveTo))) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.accountNo,
r.cifNumber,
r.productName,
r.resolutionNumber
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
get shownShares(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0);
}
get isFiltered(): boolean {
return !!(this.search.trim() || this.productFilter || Number(this.shareNumberLookup) > 0);
}
funding(source: string): string {
return fundingLabel(source);
}
range(row: DistinctiveRow): string {
return `${Number(row.distinctiveFrom).toLocaleString('en-IN')} – ${Number(row.distinctiveTo).toLocaleString('en-IN')}`;
}
clearFilters(): void {
this.search = '';
this.productFilter = '';
this.shareNumberLookup = null;
}
openAccount(row: DistinctiveRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.shareAccountId,
'general'
]);
}
exportCsv(): void {
shareReportCsv(
'share-distinctive-numbers',
[
'Share Class',
'Distinctive From',
'Distinctive To',
'Shares',
'Member',
'Membership No',
'Share A/c No',
'Branch',
'Allotment Date',
'Funded By',
'Board Resolution'
],
this.filteredRows.map((r) => [
r.productName,
r.distinctiveFrom,
r.distinctiveTo,
r.shares,
r.clientName,
r.cifNumber,
r.accountNo,
r.officeName,
r.allotmentDate,
this.funding(r.fundingSource),
r.resolutionNumber
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,171 @@
<!--
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="dollar-sign" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Dividend Statement</h2>
<div class="cert-head-s">
Every dividend declared to a member, year by year, with what has been paid and what is still owed.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!member || !rows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="!member">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
<div class="cert-filters">
<div class="cert-filter" style="flex: 2 1 340px">
<label>Member name, membership number or share account number</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" (keyup.enter)="runSearch()" placeholder="e.g. member name" />
</div>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
{{ searching ? 'Searching…' : 'Show statement' }}
</button>
</div>
</div>
@if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (searching) {
<div class="srep-state">Loading the statement…</div>
} @else if (matches.length) {
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Membership No</th>
<th></th>
</tr>
</thead>
<tbody>
@for (m of matches; track m.clientId) {
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
<td>{{ m.clientName }}</td>
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
<td><span class="srep-pill srep-pill--blue">Open</span></td>
</tr>
}
</tbody>
</table>
</div>
} @else if (searched && !member) {
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
} @else if (member) {
<div class="srep-stmt-head">
<div class="srep-stmt-grid">
<div>
<div class="srep-stmt-l">Member</div>
<div class="srep-stmt-v">{{ member.clientName }}</div>
</div>
<div>
<div class="srep-stmt-l">Membership No</div>
<div class="srep-stmt-v srep-mono">{{ member.cifNumber || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Branch</div>
<div class="srep-stmt-v">{{ member.officeName || '—' }}</div>
</div>
</div>
</div>
@if (totals) {
<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">{{ totals.declarationCount | number }}</div>
<div class="cert-kpi-l">Declarations</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.declared | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Total Declared</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">₹{{ totals.paid | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Paid</div>
</div>
</div>
<div class="cert-kpi" [ngClass]="totals.outstanding ? 'cert-kpi--amber' : 'cert-kpi--blue'">
<fa-icon icon="sync" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.outstanding | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Still Owed</div>
</div>
</div>
</div>
}
@if (!rows.length) {
<div class="srep-state">No dividend has been declared to this member yet.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Dividend Period</th>
<th>Share A/c No</th>
<th>Product</th>
<th class="srep-num">Rate</th>
<th class="srep-num">Shares Held Now</th>
<th class="srep-num">Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.dividendDetailId) {
<tr>
<td>{{ row.periodStart }} — {{ row.periodEnd }}</td>
<td class="srep-mono">{{ row.accountNo }}</td>
<td>{{ row.productName }}</td>
<td class="srep-num">{{ row.ratePercentage ? (row.ratePercentage | number: '1.2-2') + '%' : '—' }}</td>
<td class="srep-num">{{ row.sharesHeldNow | number }}</td>
<td class="srep-num">₹{{ row.amount | number: '1.2-2' }}</td>
<td>
<span class="srep-pill" [ngClass]="statusClass(row.statusEnum)">{{
statusLabel(row.statusEnum)
}}</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5">Total declared</td>
<td class="srep-num">₹{{ totals?.declared | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
} @else {
<div class="srep-state">Search for a member above to see their dividend statement.</div>
}
</div>
@@ -0,0 +1,151 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { SHARE_DIVIDEND_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
/**
* Dividend Statement — every dividend declared to one member, with what has been paid and what is
* still owed.
*
* <p>
* Handed to a member as their record, so declared and paid stay separate columns: "declared" and
* "paid" are different answers to "have I had my dividend", and a statement showing one amount
* cannot tell them apart.
*/
@Component({
selector: 'mifosx-share-dividend-statement',
templateUrl: './share-dividend-statement.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareDividendStatementComponent implements OnInit {
private sharesService = inject(SharesService);
private route = inject(ActivatedRoute);
search = '';
searching = false;
searched = false;
loadError = '';
matches: any[] = [];
member: any = null;
rows: any[] = [];
totals: any = null;
ngOnInit(): void {
const clientId = this.route.snapshot.queryParamMap.get('clientId');
if (clientId) {
this.loadByClient(Number(clientId));
}
}
runSearch(): void {
const term = this.search.trim();
if (!term) {
return;
}
this.fetch({ search: term });
}
loadByClient(clientId: number): void {
this.fetch({ clientId });
}
private fetch(params: { search?: string; clientId?: number }): void {
this.searching = true;
this.loadError = '';
this.reset();
this.sharesService
.getShareDividendStatement(params)
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The dividend statement could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.matches = res?.matches || [];
this.member = res?.member || null;
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.searching = false;
this.searched = true;
});
}
private reset(): void {
this.matches = [];
this.member = null;
this.rows = [];
this.totals = null;
}
statusLabel(status: number): string {
return SHARE_DIVIDEND_STATUS[status]?.label || String(status ?? '');
}
statusClass(status: number): string {
return SHARE_DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey';
}
exportCsv(): void {
if (!this.member) {
return;
}
shareReportCsv(
`dividend-statement-${this.member.cifNumber || this.member.clientId}`,
[
'Period From',
'Period To',
'Share A/c No',
'Product',
'Rate %',
'Amount',
'Status'
],
this.rows.map((r) => [
r.periodStart,
r.periodEnd,
r.accountNo,
r.productName,
r.ratePercentage,
r.amount,
this.statusLabel(r.statusEnum)
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,291 @@
<!--
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="search" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Enquiry</h2>
<div class="cert-head-s">
One member's whole share position — accounts, movements, dividends and linked savings, in one answer.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!member || !movements.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="!member">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
<div class="cert-filters">
<div class="cert-filter" style="flex: 2 1 340px">
<label>Member name, membership number or share account number</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input
type="text"
[(ngModel)]="search"
(keyup.enter)="runSearch()"
placeholder="e.g. Deepak Chouhan, 000000105 or 000000004"
/>
</div>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
{{ searching ? 'Searching…' : 'Enquire' }}
</button>
</div>
</div>
@if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (searching) {
<div class="srep-state">Looking up the member…</div>
} @else if (matches.length) {
<!-- Several members answer the search — the clerk picks, rather than this guessing between them -->
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Membership No</th>
<th></th>
</tr>
</thead>
<tbody>
@for (m of matches; track m.clientId) {
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
<td>{{ m.clientName }}</td>
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
<td><span class="srep-pill srep-pill--blue">Open</span></td>
</tr>
}
</tbody>
</table>
</div>
} @else if (searched && !member) {
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
} @else if (member) {
<!-- Member identity -->
<div class="srep-stmt-head">
<div class="srep-stmt-grid">
<div>
<div class="srep-stmt-l">Member</div>
<div class="srep-stmt-v">{{ member.clientName }}</div>
</div>
<div>
<div class="srep-stmt-l">Membership No</div>
<div class="srep-stmt-v srep-mono">{{ member.cifNumber || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Branch</div>
<div class="srep-stmt-v">{{ member.officeName || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Mobile</div>
<div class="srep-stmt-v">{{ member.mobileNo || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Member Since</div>
<div class="srep-stmt-v">{{ member.memberSince || '—' }}</div>
</div>
</div>
</div>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.sharesHeld | number }}</div>
<div class="cert-kpi-l">Shares Held</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">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Value</div>
</div>
</div>
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.accountCount | number }}</div>
<div class="cert-kpi-l">Share Accounts</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.dividendTotal | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Dividend Declared</div>
</div>
</div>
</div>
}
<!-- Accounts -->
<div class="cert-filters">
<div class="cert-filter" style="flex: 1 1 100%"><label>Share accounts</label></div>
</div>
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Share A/c No</th>
<th>Product</th>
<th>State</th>
<th class="srep-num">Shares Held</th>
<th class="srep-num">Pending</th>
<th class="srep-num">Share Value</th>
<th>Linked Savings</th>
<th>Activated</th>
<th class="srep-no-print"></th>
</tr>
</thead>
<tbody>
@for (a of accounts; track a.shareAccountId) {
<tr [ngClass]="stateRowClass(a.holdingState)">
<td class="srep-mono">{{ a.accountNo }}</td>
<td>{{ a.productName }}</td>
<td>
<span class="srep-pill" [ngClass]="stateClass(a.holdingState)" [title]="stateNote(a.holdingState)">{{
stateLabel(a.holdingState)
}}</span>
</td>
<td class="srep-num">
{{ a.sharesHeld | number }}
@if (a.holdingState === 'UNPAID') {
<div class="srep-sub">{{ a.unpaidShares | number }} unpaid</div>
}
</td>
<td class="srep-num">{{ a.pendingShares | number }}</td>
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
<td class="srep-mono">
{{ a.linkedSavingsAccountNo || 'Not linked' }}
@if (a.linkedSavingsAccountNo) {
<div class="srep-scope-note" style="margin: 0">
Balance ₹{{ a.linkedSavingsBalance | number: '1.2-2' }}
</div>
}
</td>
<td>{{ a.activatedDate || '—' }}</td>
<td class="srep-no-print">
<button class="cert-cta-btn" (click)="openStatement(a)">Statement</button>
<button class="cert-cta-btn" (click)="openAccount(a)">Open</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Movements -->
<div class="cert-filters" style="margin-top: 0.85rem">
<div class="cert-filter" style="flex: 1 1 100%"><label>Share movements</label></div>
</div>
@if (!movements.length) {
<div class="srep-state">No share movement recorded for this member.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Date</th>
<th>Share A/c No</th>
<th>Particulars</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Amount</th>
<th>Funded By</th>
<th>Board Resolution</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (m of movements; track m.transactionId) {
<tr>
<td>{{ m.transactionDate }}</td>
<td class="srep-mono">{{ m.accountNo }}</td>
<td>
<span class="srep-pill" [ngClass]="typeClass(m.typeEnum)">{{ typeLabel(m.typeEnum) }}</span>
</td>
<td class="srep-num">{{ m.shares | number }}</td>
<td class="srep-num">{{ m.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ m.amount | number: '1.2-2' }}</td>
<td>{{ funding(m.fundingSource) }}</td>
<td class="srep-mono">{{ m.resolutionNumber || '—' }}</td>
<td>
<span class="srep-pill" [ngClass]="txnStatusClass(m.statusEnum)">{{
txnStatusLabel(m.statusEnum)
}}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Dividends -->
<div class="cert-filters" style="margin-top: 0.85rem">
<div class="cert-filter" style="flex: 1 1 100%"><label>Dividends</label></div>
</div>
@if (!dividends.length) {
<div class="srep-state">No dividend has been declared to this member yet.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Period</th>
<th>Share A/c No</th>
<th>Product</th>
<th class="srep-num">Rate</th>
<th class="srep-num">Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (d of dividends; track d.dividendDetailId) {
<tr>
<td>{{ d.periodStart }} — {{ d.periodEnd }}</td>
<td class="srep-mono">{{ d.accountNo }}</td>
<td>{{ d.productName }}</td>
<td class="srep-num">{{ d.ratePercentage ? (d.ratePercentage | number: '1.2-2') + '%' : '—' }}</td>
<td class="srep-num">₹{{ d.amount | number: '1.2-2' }}</td>
<td>
<span class="srep-pill" [ngClass]="dividendStatusClass(d.statusEnum)">{{
dividendStatusLabel(d.statusEnum)
}}</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4">Total declared</td>
<td class="srep-num">₹{{ totals?.dividendTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
} @else {
<div class="srep-state">Search for a member above to see their full share position.</div>
}
</div>
@@ -0,0 +1,246 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import {
SHARE_TXN_STATUS,
SHARE_TXN_TYPE,
fundingLabel,
holdingStateClass,
holdingStateLabel,
holdingStateNote,
holdingStateRowClass,
shareReportCsv,
shareReportPrint
} from '../share-report.util';
/** m_share_account_dividend_details.status — where a member's dividend has got to. */
const DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = {
100: { label: 'Declared', cls: 'srep-pill--amber' },
300: { label: 'Approved', cls: 'srep-pill--blue' },
600: { label: 'Paid', cls: 'srep-pill--green' }
};
/**
* Share Enquiry — everything about one member's share position in a single answer: their accounts,
* every movement, the dividends declared to them, and the savings account their money moves through.
*
* <p>
* Built for the counter. Someone is standing in front of the clerk asking "how many shares do I
* hold, when did I buy them, and what dividend am I owed" — three screens is three chances to read
* the wrong member's row out loud, so this is one search and one page.
*
* <p>
* A search matching several members returns the list rather than picking one: guessing between two
* members with similar names is precisely the mistake this screen must not make.
*/
@Component({
selector: 'mifosx-share-enquiry',
templateUrl: './share-enquiry.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareEnquiryComponent implements OnInit {
private sharesService = inject(SharesService);
private route = inject(ActivatedRoute);
private router = inject(Router);
search = '';
searching = false;
searched = false;
loadError = '';
/** Populated when the search matched more than one member — the caller picks. */
matches: any[] = [];
member: any = null;
accounts: any[] = [];
movements: any[] = [];
dividends: any[] = [];
totals: any = null;
ngOnInit(): void {
const clientId = this.route.snapshot.queryParamMap.get('clientId');
if (clientId) {
this.loadByClient(Number(clientId));
}
}
runSearch(): void {
const term = this.search.trim();
if (!term) {
return;
}
this.searching = true;
this.loadError = '';
this.reset();
this.sharesService
.getShareEnquiry({ search: term })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The enquiry could not be run.');
return of(null);
})
)
.subscribe((res: any) => this.apply(res));
}
loadByClient(clientId: number): void {
this.searching = true;
this.loadError = '';
this.reset();
this.sharesService
.getShareEnquiry({ clientId })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The member could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => this.apply(res));
}
private apply(res: any): void {
this.matches = res?.matches || [];
this.member = res?.member || null;
this.accounts = res?.accounts || [];
this.movements = res?.movements || [];
this.dividends = res?.dividends || [];
this.totals = res?.totals || null;
this.searching = false;
this.searched = true;
}
private reset(): void {
this.matches = [];
this.member = null;
this.accounts = [];
this.movements = [];
this.dividends = [];
this.totals = null;
}
typeLabel(typeEnum: number): string {
return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? '');
}
typeClass(typeEnum: number): string {
return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey';
}
txnStatusLabel(statusEnum: number): string {
return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
txnStatusClass(statusEnum: number): string {
return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey';
}
dividendStatusLabel(status: number): string {
return DIVIDEND_STATUS[status]?.label || String(status ?? '');
}
dividendStatusClass(status: number): string {
return DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey';
}
funding(source: string): string {
return fundingLabel(source);
}
stateLabel(state: string): string {
return holdingStateLabel(state);
}
stateClass(state: string): string {
return holdingStateClass(state);
}
stateRowClass(state: string): string {
return holdingStateRowClass(state);
}
stateNote(state: string): string {
return holdingStateNote(state);
}
openAccount(account: any): void {
this.router.navigate([
'/clients',
this.member.clientId,
'shares-accounts',
account.shareAccountId,
'general'
]);
}
openStatement(account: any): void {
this.router.navigate(['/shares/reports/account-statement'], {
queryParams: { shareAccountId: account.shareAccountId }
});
}
exportCsv(): void {
if (!this.member) {
return;
}
shareReportCsv(
`share-enquiry-${this.member.cifNumber || this.member.clientId}`,
[
'Date',
'Share A/c No',
'Particulars',
'Shares',
'Unit Price',
'Amount',
'Funded By',
'Board Resolution',
'Status'
],
this.movements.map((m) => [
m.transactionDate,
m.accountNo,
this.typeLabel(m.typeEnum),
m.shares,
m.unitPrice,
m.amount,
this.funding(m.fundingSource),
m.resolutionNumber,
this.txnStatusLabel(m.statusEnum)
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,208 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="chart-line" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Final Balance</h2>
<div class="cert-head-s">
Share capital held by each member as on a date — purchases less redemptions, settled only.
</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 — always the society-wide figures the server returned, never the filtered view -->
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.memberCount | number }}</div>
<div class="cert-kpi-l">Members Holding</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">{{ totals.sharesHeld | number }}</div>
<div class="cert-kpi-l">Shares Held</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">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="undo" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.amountRedeemed | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Redeemed To Date</div>
</div>
</div>
</div>
}
<!-- Allotted but unpaid — excluded from the capital above, said out loud so the exclusion is not
a silent one, and because an allotment sitting unpaid is itself worth chasing. -->
@if (hasUnpaidAllotments) {
<div class="srep-warn">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>
<b>{{ unpaidAllotments.accountCount | number }}</b>
{{ unpaidAllotments.accountCount === 1 ? 'account has' : 'accounts have' }} shares allotted but not yet paid for
— <b>{{ unpaidAllotments.shares | number }}</b> shares, <b>₹{{ unpaidAllotments.amount | number: '1.2-2' }}</b
>. These are excluded above: share capital is what the society has received, not what it has agreed to issue.
Apply the member's collection to bring them in.
</span>
</div>
}
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>As on date</label>
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
</div>
<div class="cert-filter">
<label>Member / Membership No / A/c No</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or 000000004" />
</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>Nil holdings</label>
<select [(ngModel)]="includeZero" (change)="load()">
<option [ngValue]="false">Hide fully redeemed</option>
<option [ngValue]="true">Include fully redeemed</option>
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading share balances…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No member held share capital as on {{ asOnDate }}.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No member matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Branch</th>
<th>Product</th>
<th>State</th>
<th class="srep-num">Shares Held</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Share Value</th>
<th class="srep-num">Purchased</th>
<th class="srep-num">Redeemed</th>
<th>Last Movement</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.shareAccountId) {
<tr class="srep-row--click" [ngClass]="stateRowClass(row.holdingState)" (click)="openAccount(row)">
<td>{{ row.clientName }}</td>
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
<td class="srep-mono">{{ row.accountNo }}</td>
<td>{{ row.officeName || '—' }}</td>
<td>{{ row.productName }}</td>
<td>
<span
class="srep-pill"
[ngClass]="stateClass(row.holdingState)"
[title]="stateNote(row.holdingState)"
>{{ stateLabel(row.holdingState) }}</span
>
</td>
<td class="srep-num">
{{ row.sharesHeld | number }}
@if (row.holdingState === 'UNPAID') {
<div class="srep-sub">{{ row.unpaidShares | number }} unpaid</div>
}
</td>
<td class="srep-num">{{ row.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">{{ row.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">{{ row.sharesPurchased | number }}</td>
<td class="srep-num">{{ row.sharesRedeemed | number }}</td>
<td>{{ row.lastMovementDate || '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="6">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members
</td>
<td class="srep-num">{{ shownShares | number }}</td>
<td class="srep-num"></td>
<td class="srep-num">₹{{ shownValue | number: '1.2-2' }}</td>
<td class="srep-num"></td>
<td class="srep-num"></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
@if (isFiltered && totals) {
<div class="srep-scope-note">
Showing a filtered view. Society-wide as on {{ asOnDate }}: {{ totals.sharesHeld | number }} shares held by
{{ totals.memberCount | number }} members, ₹{{ totals.shareValue | number: '1.2-2' }} share capital.
</div>
}
}
</div>
@@ -0,0 +1,279 @@
/**
* 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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { SettingsService } from 'app/settings/settings.service';
import {
SHARE_ACCOUNT_STATUS,
holdingStateClass,
holdingStateLabel,
holdingStateNote,
holdingStateRowClass,
shareReportCsv,
shareReportPrint
} from '../share-report.util';
/** One member's closing shareholding. Shapes the row the server sends; nothing is derived here. */
interface FinalBalanceRow {
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
currencyCode: string;
statusEnum: number;
holdingState: string;
unpaidShares: number;
unitPrice: number;
sharesHeld: number;
shareValue: number;
sharesPurchased: number;
sharesRedeemed: number;
amountSubscribed: number;
amountRedeemed: number;
firstMovementDate: string;
lastMovementDate: string;
activatedDate: string;
closedDate: string;
}
/**
* Final Balance — what each member's share capital stands at on a given date, and what the society's
* Share Capital account should therefore read.
*
* <p>
* The date is the whole point of the report, so it is asked of the server rather than filtered in the
* browser: a holding "as on 31 March" means walking that member's purchases and redemptions up to
* that date, which the rows already on screen cannot answer. Changing the date re-queries.
*
* <p>
* Branch, product and search are client-side, because they only ever narrow rows already fetched and
* a round trip for each keystroke would be worse than useless.
*/
@Component({
selector: 'mifosx-share-final-balance',
templateUrl: './share-final-balance.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareFinalBalanceComponent implements OnInit {
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
private router = inject(Router);
loading = true;
loadError = '';
rows: FinalBalanceRow[] = [];
totals: any = null;
/**
* Shares allotted to a member whose money never arrived — the account was activated but its
* collection was never applied.
*
* Deliberately kept out of the balance (share capital is what the society received, not what it
* promised to issue) and deliberately shown anyway: a total that silently drops accounts leaves a
* reader wondering which, and an allotment sitting unpaid is the thing worth chasing.
*/
unpaidAllotments: { accountCount: number; shares: number; amount: number } | null = null;
/** Server-side filter — changing it re-queries, since the balance itself depends on it. */
asOnDate = '';
/** Closed and fully-redeemed members, off by default: a closing-balance report lists holders. */
includeZero = false;
/** Client-side filters. */
search = '';
branchFilter = '';
productFilter = '';
ngOnInit(): void {
const businessDate: Date = this.settingsService.businessDate || new Date();
this.asOnDate = this.toIso(businessDate);
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareFinalBalance({ asOnDate: this.asOnDate, includeZero: this.includeZero })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The share balance report could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.unpaidAllotments = res?.unpaidAllotments || null;
this.loading = false;
});
}
get hasUnpaidAllotments(): boolean {
return !!(this.unpaidAllotments && Number(this.unpaidAllotments.accountCount) > 0);
}
stateLabel(state: string): string {
return holdingStateLabel(state);
}
stateClass(state: string): string {
return holdingStateClass(state);
}
stateRowClass(state: string): string {
return holdingStateRowClass(state);
}
stateNote(state: string): string {
return holdingStateNote(state);
}
get branchOptions(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get productOptions(): string[] {
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
}
get filteredRows(): FinalBalanceRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.officeName !== this.branchFilter) {
return false;
}
if (this.productFilter && r.productName !== this.productFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.accountNo,
r.cifNumber,
r.productName
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
/**
* Totals for what is on screen. The server's own totals cover the unfiltered report and are shown
* beside these, so a narrowed view can never be mistaken for the society's whole share capital.
*/
get shownShares(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0);
}
get shownValue(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
}
get isFiltered(): boolean {
return !!(this.search.trim() || this.branchFilter || this.productFilter);
}
statusLabel(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
}
openAccount(row: FinalBalanceRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.shareAccountId,
'general'
]);
}
exportCsv(): void {
shareReportCsv(
`share-final-balance-${this.asOnDate || 'today'}`,
[
'Member',
'Membership No',
'Share A/c No',
'Branch',
'Product',
'Status',
'Holding State',
'Allotted Unpaid',
'Shares Held',
'Unit Price',
'Share Value',
'Shares Purchased',
'Shares Redeemed',
'Last Movement'
],
this.filteredRows.map((r) => [
r.clientName,
r.cifNumber,
r.accountNo,
r.officeName,
r.productName,
this.statusLabel(r.statusEnum),
this.stateLabel(r.holdingState),
r.unpaidShares,
r.sharesHeld,
r.unitPrice,
r.shareValue,
r.sharesPurchased,
r.sharesRedeemed,
r.lastMovementDate
])
);
}
print(): void {
shareReportPrint();
}
private toIso(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,250 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-alt" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Folio Statement</h2>
<div class="cert-head-s">
A member's folio in the share register — every account they hold, with the movements running across all of
them.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!folio || !lines.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="!folio"><fa-icon icon="print"></fa-icon> Print</button>
</div>
</div>
<div class="cert-filters">
<div class="cert-filter" style="flex: 2 1 320px">
<label>Member name, membership number or share account number</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" (keyup.enter)="runSearch()" placeholder="e.g. member name" />
</div>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
{{ searching ? 'Searching…' : 'Open folio' }}
</button>
</div>
<div class="cert-filter">
<label>From</label>
<input type="date" [(ngModel)]="fromDate" (change)="reloadPeriod()" [disabled]="!folio" />
</div>
<div class="cert-filter">
<label>To</label>
<input type="date" [(ngModel)]="toDate" (change)="reloadPeriod()" [disabled]="!folio" />
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearPeriod()" [disabled]="!folio">Full history</button>
</div>
</div>
@if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (searching) {
<div class="srep-state">Loading the folio…</div>
} @else if (matches.length) {
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Folio No</th>
<th></th>
</tr>
</thead>
<tbody>
@for (m of matches; track m.clientId) {
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
<td>{{ m.clientName }}</td>
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
<td><span class="srep-pill srep-pill--blue">Open</span></td>
</tr>
}
</tbody>
</table>
</div>
} @else if (searched && !folio) {
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
} @else if (folio) {
<div class="srep-stmt-head">
<div class="srep-stmt-grid">
<div>
<div class="srep-stmt-l">Folio No</div>
<div class="srep-stmt-v srep-mono">{{ folio.folioNumber || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Member</div>
<div class="srep-stmt-v">{{ folio.clientName }}</div>
</div>
<div>
<div class="srep-stmt-l">Branch</div>
<div class="srep-stmt-v">{{ folio.officeName || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Mobile</div>
<div class="srep-stmt-v">{{ folio.mobileNo || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Member Since</div>
<div class="srep-stmt-v">{{ folio.memberSince || '—' }}</div>
</div>
</div>
</div>
@if (totals) {
<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">{{ totals.accountCount | number }}</div>
<div class="cert-kpi-l">Accounts In Folio</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">{{ totals.sharesHeld | number }}</div>
<div class="cert-kpi-l">Shares Held</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">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Folio Value</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="exchange" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.movementCount | number }}</div>
<div class="cert-kpi-l">Movements Shown</div>
</div>
</div>
</div>
}
<!-- Accounts in this folio -->
<div class="cert-filters">
<div class="cert-filter" style="flex: 1 1 100%"><label>Accounts in this folio</label></div>
</div>
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Share A/c No</th>
<th>Product</th>
<th>State</th>
<th class="srep-num">Shares Held</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Value</th>
<th>Activated</th>
<th>Closed</th>
</tr>
</thead>
<tbody>
@for (a of accounts; track a.shareAccountId) {
<tr class="srep-row--click" [ngClass]="stateRowClass(a.holdingState)" (click)="openAccount(a)">
<td class="srep-mono">{{ a.accountNo }}</td>
<td>{{ a.productName }}</td>
<td>
<span class="srep-pill" [ngClass]="stateClass(a.holdingState)" [title]="stateNote(a.holdingState)">{{
stateLabel(a.holdingState)
}}</span>
</td>
<td class="srep-num">
{{ a.sharesHeld | number }}
@if (a.holdingState === 'UNPAID') {
<div class="srep-sub">{{ a.unpaidShares | number }} unpaid</div>
}
</td>
<td class="srep-num">{{ a.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
<td>{{ a.activatedDate || '—' }}</td>
<td>{{ a.closedDate || '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">Folio total</td>
<td class="srep-num">{{ totals?.sharesHeld | number }}</td>
<td class="srep-num"></td>
<td class="srep-num">₹{{ totals?.shareValue | number: '1.2-2' }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
<!-- Movements across the whole folio -->
<div class="cert-filters" style="margin-top: 0.85rem">
<div class="cert-filter" style="flex: 1 1 100%"><label>Folio movements</label></div>
</div>
@if (!lines.length) {
<div class="srep-state">
No settled movement in this folio{{ fromDate || toDate ? ' in the selected period' : '' }}.
</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Date</th>
<th>Share A/c No</th>
<th>Product</th>
<th>Particulars</th>
<th class="srep-num">Shares In</th>
<th class="srep-num">Shares Out</th>
<th class="srep-num">Amount</th>
<th>Funded By</th>
<th class="srep-num">Folio Balance</th>
</tr>
</thead>
<tbody>
@for (line of lines; track line.transactionId) {
<tr>
<td>{{ line.transactionDate }}</td>
<td class="srep-mono">{{ line.accountNo }}</td>
<td>{{ line.productName }}</td>
<td>
<span class="srep-pill" [ngClass]="typeClass(line.typeEnum)">{{ typeLabel(line.typeEnum) }}</span>
</td>
<td class="srep-num">{{ line.sharesIn ? (line.sharesIn | number) : '' }}</td>
<td class="srep-num">{{ line.sharesOut ? (line.sharesOut | number) : '' }}</td>
<td class="srep-num">₹{{ line.amount | number: '1.2-2' }}</td>
<td>{{ funding(line.fundingSource) }}</td>
<td class="srep-num">{{ line.folioBalance | number }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="8">Closing folio balance</td>
<td class="srep-num">{{ totals?.closingFolioBalance | number }}</td>
</tr>
</tfoot>
</table>
</div>
}
} @else {
<div class="srep-state">Search for a member above to open their folio.</div>
}
</div>
@@ -0,0 +1,217 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import {
SHARE_TXN_TYPE,
fundingLabel,
holdingStateClass,
holdingStateLabel,
holdingStateNote,
holdingStateRowClass,
shareReportCsv,
shareReportPrint
} from '../share-report.util';
/**
* Folio Statement — one member's folio in the share register: every account they hold and every
* movement across all of them, in one running sequence.
*
* <p>
* Where the Account Statement is one account, a folio is the member. Someone holding two share
* classes has two accounts but one folio, and the register is kept by folio — so the running balance
* here is across accounts, which is the figure the register itself carries.
*/
@Component({
selector: 'mifosx-share-folio-statement',
templateUrl: './share-folio-statement.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareFolioStatementComponent implements OnInit {
private sharesService = inject(SharesService);
private route = inject(ActivatedRoute);
private router = inject(Router);
search = '';
searching = false;
searched = false;
loadError = '';
matches: any[] = [];
folio: any = null;
accounts: any[] = [];
lines: any[] = [];
totals: any = null;
fromDate = '';
toDate = '';
/** Held once a folio is open, so changing the period re-queries the same member. */
private resolvedClientId: number | null = null;
ngOnInit(): void {
const clientId = this.route.snapshot.queryParamMap.get('clientId');
if (clientId) {
this.resolvedClientId = Number(clientId);
this.fetch({ clientId: this.resolvedClientId });
}
}
runSearch(): void {
const term = this.search.trim();
if (!term) {
return;
}
this.resolvedClientId = null;
this.fetch({ search: term });
}
loadByClient(clientId: number): void {
this.resolvedClientId = clientId;
this.fetch({ clientId });
}
reloadPeriod(): void {
if (this.resolvedClientId) {
this.fetch({ clientId: this.resolvedClientId });
}
}
clearPeriod(): void {
this.fromDate = '';
this.toDate = '';
this.reloadPeriod();
}
private fetch(params: { search?: string; clientId?: number }): void {
this.searching = true;
this.loadError = '';
this.sharesService
.getShareFolioStatement({ ...params, fromDate: this.fromDate, toDate: this.toDate })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The folio statement could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.matches = res?.matches || [];
this.folio = res?.folio || null;
this.accounts = res?.accounts || [];
this.lines = res?.lines || [];
this.totals = res?.totals || null;
if (this.folio) {
this.resolvedClientId = this.folio.clientId;
}
this.searching = false;
this.searched = true;
});
}
typeLabel(typeEnum: number): string {
return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? '');
}
typeClass(typeEnum: number): string {
return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey';
}
funding(source: string): string {
return fundingLabel(source);
}
stateLabel(state: string): string {
return holdingStateLabel(state);
}
stateClass(state: string): string {
return holdingStateClass(state);
}
stateRowClass(state: string): string {
return holdingStateRowClass(state);
}
stateNote(state: string): string {
return holdingStateNote(state);
}
openAccount(account: any): void {
this.router.navigate([
'/clients',
this.folio.clientId,
'shares-accounts',
account.shareAccountId,
'general'
]);
}
exportCsv(): void {
if (!this.folio) {
return;
}
shareReportCsv(
`folio-statement-${this.folio.folioNumber || this.folio.clientId}`,
[
'Date',
'Share A/c No',
'Product',
'Particulars',
'Shares In',
'Shares Out',
'Unit Price',
'Amount',
'Funded By',
'Board Resolution',
'Folio Balance'
],
this.lines.map((l) => [
l.transactionDate,
l.accountNo,
l.productName,
this.typeLabel(l.typeEnum),
l.sharesIn || '',
l.sharesOut || '',
l.unitPrice,
l.amount,
this.funding(l.fundingSource),
l.resolutionNumber,
l.folioBalance
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,162 @@
<!--
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="user-shield" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Guarantor Detail</h2>
<div class="cert-head-s">
Everyone standing surety on a live loan, with the shareholding they hold in the society.
</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>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="user-shield" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.guarantorCount | number }}</div>
<div class="cert-kpi-l">Active Guarantors</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">₹{{ totals.guaranteedOutstanding | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Guaranteed Outstanding</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">₹{{ totals.guarantorShareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Guarantors' Share Value</div>
</div>
</div>
<div class="cert-kpi" [ngClass]="totals.membersWithoutShares ? 'cert-kpi--amber' : 'cert-kpi--blue'">
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.membersWithoutShares | number }}</div>
<div class="cert-kpi-l">Members Without Shares</div>
</div>
</div>
</div>
}
<div class="cert-filters">
<div class="cert-filter">
<label>Guarantor / Borrower / Loan A/c</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. guarantor or borrower name" />
</div>
</div>
<div class="cert-filter">
<label>Guarantor type</label>
<select [(ngModel)]="typeFilter">
<option value="">All</option>
<option value="MEMBER">Member</option>
<option value="EXTERNAL">External</option>
</select>
</div>
<div class="cert-filter">
<label>Shareholding</label>
<select [(ngModel)]="onlyWithoutShares">
<option [ngValue]="false">All guarantors</option>
<option [ngValue]="true">Members holding no shares</option>
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading guarantors…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No guarantor stands on a loan at present.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No guarantor matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Guarantor</th>
<th>Type</th>
<th>Membership No</th>
<th>Borrower</th>
<th>Loan A/c No</th>
<th class="srep-num">Loan Outstanding</th>
<th>Share A/c No</th>
<th class="srep-num">Shares Held</th>
<th class="srep-num">Share Value</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.guarantorId) {
<tr [class.srep-row--click]="row.shareAccountId" (click)="openGuarantorShares(row)">
<td>
{{ row.guarantorName || '—' }}
@if (row.guarantorMobile) {
<div class="srep-scope-note" style="margin: 0">{{ row.guarantorMobile }}</div>
}
</td>
<td>
<span
class="srep-pill"
[ngClass]="row.guarantorType === 'MEMBER' ? 'srep-pill--green' : 'srep-pill--grey'"
>{{ row.guarantorType === 'MEMBER' ? 'Member' : 'External' }}</span
>
</td>
<td class="srep-mono">{{ row.guarantorCif || '—' }}</td>
<td>{{ row.borrowerName }}</td>
<td class="srep-mono">{{ row.loanAccountNo }}</td>
<td class="srep-num">₹{{ row.totalOutstanding | number: '1.2-2' }}</td>
<td class="srep-mono">{{ row.shareAccountNo || '—' }}</td>
<td class="srep-num">
@if (isUncovered(row)) {
<span class="srep-pill srep-pill--amber">No shares</span>
} @else {
{{ row.sharesHeld | number }}
}
</td>
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} guarantors
</td>
<td class="srep-num">₹{{ shownOutstanding | number: '1.2-2' }}</td>
<td></td>
<td class="srep-num"></td>
<td class="srep-num">₹{{ shownShareValue | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,208 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { shareReportCsv, shareReportPrint } from '../share-report.util';
interface GuarantorRow {
guarantorId: number;
loanId: number;
loanAccountNo: string;
loanStatusId: number;
loanPrincipal: number;
principalOutstanding: number;
totalOutstanding: number;
borrowerName: string;
borrowerCif: string;
guarantorTypeEnum: number;
guarantorType: 'MEMBER' | 'EXTERNAL';
guarantorName: string;
guarantorCif: string;
guarantorClientId: number;
guarantorOfficeName: string;
guarantorMobile: string;
shareAccountId: number;
shareAccountNo: string;
shareProductName: string;
sharesHeld: number;
shareValue: number;
}
/**
* Guarantor Detail — everyone standing surety on a live loan, with the shareholding they hold in the
* society.
*
* <p>
* A co-operative accepts a member as surety on the strength of their stake in it, so the exposure
* and the stake belong on one line. The case this report exists for is the member who stood surety
* and has since redeemed their shares: nothing on the loan screen or the share screen shows it, and
* it is flagged here rather than left to be noticed.
*/
@Component({
selector: 'mifosx-share-guarantor-report',
templateUrl: './share-guarantor-report.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareGuarantorReportComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
rows: GuarantorRow[] = [];
totals: any = null;
search = '';
typeFilter = '';
/** Members standing surety with no shareholding left — the exposure worth isolating. */
onlyWithoutShares = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareGuarantorDetail({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The guarantor report could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get filteredRows(): GuarantorRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.typeFilter && r.guarantorType !== this.typeFilter) {
return false;
}
if (this.onlyWithoutShares && !(r.guarantorType === 'MEMBER' && !Number(r.sharesHeld))) {
return false;
}
if (!term) {
return true;
}
return [
r.guarantorName,
r.guarantorCif,
r.borrowerName,
r.loanAccountNo,
r.shareAccountNo
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
get shownOutstanding(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.totalOutstanding || 0), 0);
}
get shownShareValue(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
}
get isFiltered(): boolean {
return !!(this.search.trim() || this.typeFilter || this.onlyWithoutShares);
}
/** A member guarantor holding nothing is the exposure; an external one never held any. */
isUncovered(row: GuarantorRow): boolean {
return row.guarantorType === 'MEMBER' && !Number(row.sharesHeld);
}
openGuarantorShares(row: GuarantorRow): void {
if (row.guarantorClientId && row.shareAccountId) {
this.router.navigate([
'/clients',
row.guarantorClientId,
'shares-accounts',
row.shareAccountId,
'general'
]);
}
}
clearFilters(): void {
this.search = '';
this.typeFilter = '';
this.onlyWithoutShares = false;
}
exportCsv(): void {
shareReportCsv(
'share-guarantor-detail',
[
'Guarantor',
'Type',
'Membership No',
'Branch',
'Mobile',
'Borrower',
'Loan A/c No',
'Loan Outstanding',
'Share A/c No',
'Share Product',
'Shares Held',
'Share Value'
],
this.filteredRows.map((r) => [
r.guarantorName,
r.guarantorType,
r.guarantorCif,
r.guarantorOfficeName,
r.guarantorMobile,
r.borrowerName,
r.loanAccountNo,
r.totalOutstanding,
r.shareAccountNo,
r.shareProductName,
r.sharesHeld,
r.shareValue
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,204 @@
<!--
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="check-circle" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Issued Register</h2>
<div class="cert-head-s">
Every tranche of shares allotted in the period, with the board resolution behind it and how it was paid for.
</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>
@if (totals) {
<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">{{ totals.trancheCount | 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">{{ totals.shares | number }}</div>
<div class="cert-kpi-l">Shares Issued</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">₹{{ totals.amount | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Capital Raised</div>
</div>
</div>
<div class="cert-kpi" [ngClass]="missingResolutionCount ? 'cert-kpi--amber' : 'cert-kpi--blue'">
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ missingResolutionCount | number }}</div>
<div class="cert-kpi-l">Without Resolution</div>
</div>
</div>
</div>
}
<div class="cert-filters">
<div class="cert-filter">
<label>Issued from</label>
<input type="date" [(ngModel)]="fromDate" (change)="load()" />
</div>
<div class="cert-filter">
<label>Issued to</label>
<input type="date" [(ngModel)]="toDate" (change)="load()" />
</div>
<div class="cert-filter">
<label>Allotment status</label>
<select [(ngModel)]="statusFilter" (change)="load()">
<option [ngValue]="''">Settled allotments</option>
<option [ngValue]="100">Pending approval</option>
<option [ngValue]="400">Rejected</option>
</select>
</div>
<div class="cert-filter">
<label>Member / 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>Funded by</label>
<select [(ngModel)]="fundingFilter">
<option value="">All routes</option>
@for (f of fundingOptions; track f) {
<option [value]="f">{{ f }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the issue register…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No shares were allotted between {{ fromDate }} and {{ toDate }}.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No allotment matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Issue Date</th>
<th>Member</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Branch</th>
<th>Product</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Amount</th>
<th class="srep-num">Entry Fee</th>
<th>Funded By</th>
<th>Board Resolution</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.transactionId) {
<tr class="srep-row--click" (click)="openAccount(row)">
<td>{{ row.issueDate }}</td>
<td>{{ row.clientName }}</td>
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
<td class="srep-mono">{{ row.accountNo }}</td>
<td>{{ row.officeName || '—' }}</td>
<td>{{ row.productName }}</td>
<td class="srep-num">{{ row.shares | number }}</td>
<td class="srep-num">{{ row.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">{{ row.amount | number: '1.2-2' }}</td>
<td class="srep-num">{{ row.chargeAmount ? (row.chargeAmount | number: '1.2-2') : '—' }}</td>
<td>{{ funding(row.fundingSource) }}</td>
<td>
@if (row.resolutionNumber) {
<span class="srep-mono">{{ row.resolutionNumber }}</span>
@if (row.meetingDate) {
<div class="srep-scope-note" style="margin: 0">{{ row.meetingDate }}</div>
}
} @else {
<span class="srep-pill srep-pill--amber">Not recorded</span>
}
</td>
<td>
<span class="srep-pill" [ngClass]="txnStatusClass(row.statusEnum)">{{
txnStatusLabel(row.statusEnum)
}}</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="6">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments
</td>
<td class="srep-num">{{ shownShares | number }}</td>
<td class="srep-num"></td>
<td class="srep-num">₹{{ shownAmount | number: '1.2-2' }}</td>
<td class="srep-num"></td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
@if (isFiltered && totals) {
<div class="srep-scope-note">
Showing a filtered view. Whole period {{ fromDate }} to {{ toDate }}: {{ totals.shares | number }} shares issued
to {{ totals.memberCount | number }} members across {{ totals.trancheCount | number }} allotments, ₹{{
totals.amount | number: '1.2-2'
}}.
</div>
}
}
</div>
@@ -0,0 +1,264 @@
/**
* 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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { SHARE_TXN_STATUS, fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util';
interface IssuedRow {
transactionId: number;
issueDate: string;
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
currencyCode: string;
shares: number;
unitPrice: number;
amount: number;
chargeAmount: number;
statusEnum: number;
resolutionNumber: string;
meetingDate: string;
fundingSource: string;
accountStatusEnum: number;
}
/**
* Share Issued — the allotment register: every tranche of shares issued in a period, with the board
* resolution that authorised it and how the member paid.
*
* <p>
* One row per tranche, not per member: a member who bought three times was allotted three times, and
* the register records allotments. Rolling them up per member would lose the resolution each one was
* made under, which is the column an auditor actually reads.
*
* <p>
* Period and status are server-side (the register is a period document, and "what is pending" is a
* different query, not a different view of the same rows); branch, product and search are local.
*/
@Component({
selector: 'mifosx-share-issued-report',
templateUrl: './share-issued-report.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareIssuedReportComponent implements OnInit {
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
private router = inject(Router);
loading = true;
loadError = '';
rows: IssuedRow[] = [];
totals: any = null;
// ── Server-side ──
fromDate = '';
toDate = '';
/** '' = settled allotments only (the register's default); 100 = pending; 400 = rejected. */
statusFilter: number | '' = '';
// ── Client-side ──
search = '';
branchFilter = '';
productFilter = '';
fundingFilter = '';
ngOnInit(): void {
const businessDate: Date = this.settingsService.businessDate || new Date();
// Opens on the current financial year, which is the period this register is almost always run
// for. April opens the FY in this system, matching the Share Capital Return.
const fyStart = new Date(businessDate.getFullYear(), businessDate.getMonth() >= 3 ? 3 : -9, 1);
this.fromDate = this.toIso(fyStart);
this.toDate = this.toIso(businessDate);
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareIssuedReport({
fromDate: this.fromDate,
toDate: this.toDate,
status: this.statusFilter === '' ? undefined : this.statusFilter
})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The share issue register could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get branchOptions(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get productOptions(): string[] {
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
}
get fundingOptions(): string[] {
return [...new Set(this.rows.map((r) => fundingLabel(r.fundingSource)))].sort();
}
get filteredRows(): IssuedRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.officeName !== this.branchFilter) {
return false;
}
if (this.productFilter && r.productName !== this.productFilter) {
return false;
}
if (this.fundingFilter && fundingLabel(r.fundingSource) !== this.fundingFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.accountNo,
r.cifNumber,
r.productName,
r.resolutionNumber
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
get shownShares(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0);
}
get shownAmount(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.amount || 0), 0);
}
get isFiltered(): boolean {
return !!(this.search.trim() || this.branchFilter || this.productFilter || this.fundingFilter);
}
/** Tranches with no resolution recorded — the one thing an auditor opens this register to find. */
get missingResolutionCount(): number {
return this.filteredRows.filter((r) => !r.resolutionNumber).length;
}
txnStatusLabel(statusEnum: number): string {
return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
txnStatusClass(statusEnum: number): string {
return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey';
}
funding(source: string): string {
return fundingLabel(source);
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.fundingFilter = '';
}
openAccount(row: IssuedRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.shareAccountId,
'general'
]);
}
exportCsv(): void {
shareReportCsv(
`share-issued-${this.fromDate}-to-${this.toDate}`,
[
'Issue Date',
'Member',
'Membership No',
'Share A/c No',
'Branch',
'Product',
'Shares',
'Unit Price',
'Amount',
'Entry Fee',
'Funded By',
'Board Resolution',
'Meeting Date',
'Status'
],
this.filteredRows.map((r) => [
r.issueDate,
r.clientName,
r.cifNumber,
r.accountNo,
r.officeName,
r.productName,
r.shares,
r.unitPrice,
r.amount,
r.chargeAmount,
this.funding(r.fundingSource),
r.resolutionNumber,
r.meetingDate,
this.txnStatusLabel(r.statusEnum)
])
);
}
print(): void {
shareReportPrint();
}
private toIso(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,124 @@
<!--
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 srep-no-print">
<div class="cert-head-l">
<fa-icon icon="tag" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Labels</h2>
<div class="cert-head-s">
Printable address labels for shareholding members — ready for an ordinary label sheet.
</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 || !printableRows.length">
<fa-icon icon="print"></fa-icon> Print Labels
</button>
</div>
</div>
<div class="cert-filters srep-no-print">
<div class="cert-filter">
<label>Member / Folio / Area</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or Bhopal" />
</div>
</div>
<div class="cert-filter">
<label>Area</label>
<select [(ngModel)]="areaFilter">
<option value="">All areas</option>
@for (a of areaOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Labels per row</label>
<select [(ngModel)]="columns">
<option [ngValue]="2">2 across</option>
<option [ngValue]="3">3 across</option>
</select>
</div>
<div class="cert-filter">
<label>Show folio no.</label>
<select [(ngModel)]="showFolio">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
<div class="cert-filter">
<label>Show holding</label>
<select [(ngModel)]="showHolding">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the mailing list…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No shareholding member matches the filters applied.</div>
} @else {
<div class="srep-state srep-no-print">
{{ printableRows.length | number }} labels ready to print
@if (unaddressableRows.length) {
· {{ unaddressableRows.length | number }} member{{ unaddressableRows.length === 1 ? '' : 's' }} cannot be posted
to
}
</div>
<div class="slab-sheet" [style.grid-template-columns]="'repeat(' + columns + ', 1fr)'">
@for (row of printableRows; track row.clientId) {
<div class="slab">
<div class="slab-name">{{ row.clientName }}</div>
@for (line of lines(row); track line) {
<div class="slab-line">{{ line }}</div>
}
@if (showFolio && row.folioNumber) {
<div class="slab-meta">Folio {{ row.folioNumber }}</div>
}
@if (showHolding) {
<div class="slab-meta">{{ row.sharesHeld | number }} shares · ₹{{ row.shareValue | number: '1.0-0' }}</div>
}
</div>
}
</div>
<!-- Who cannot be posted to. Kept off the sheet but never out of sight: a blank label is a
wasted sheet, and a member with no address is one the society silently stops reaching. -->
@if (unaddressableRows.length) {
<div class="srep-warn srep-no-print">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>
<b>{{ unaddressableRows.length | number }}</b> shareholding member{{
unaddressableRows.length === 1 ? ' has' : 's have'
}}
no address on file, so no label is printed for them: {{ unaddressableRows[0].clientName }}
@if (unaddressableRows.length > 1) {
and {{ unaddressableRows.length - 1 | number }} other{{ unaddressableRows.length > 2 ? 's' : '' }}
}
. Record an address against the member to include them in the next run.
</span>
</div>
}
}
</div>
@@ -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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { shareReportCsv, shareReportPrint } from '../share-report.util';
export interface MailingRow {
clientId: number;
folioNumber: string;
clientName: string;
mobileNo: string;
officeName: string;
addressLine1: string;
addressLine2: string;
street: string;
city: string;
district: string;
postalCode: string;
area: string;
sharesHeld: number;
shareValue: number;
shareAccountNos: string;
addressable: boolean;
}
/** Assembles one member's address into the lines an envelope actually carries. */
export function addressLines(row: MailingRow): string[] {
return [
row.addressLine1,
row.addressLine2,
row.street,
[
row.city,
row.district
]
.filter(Boolean)
.join(', '),
row.postalCode ? `PIN ${row.postalCode}` : ''
]
.map((line) => (line || '').trim())
.filter(Boolean);
}
/**
* Share Label — printable address labels for shareholding members.
*
* <p>
* Laid out as a grid sized to ordinary label sheets and printed straight from the browser, so a
* mailing run needs no separate mail-merge tool. Members with no usable address are listed
* separately rather than printed as a blank label: a blank label is a wasted sheet and a member who
* silently never hears from the society.
*/
@Component({
selector: 'mifosx-share-label',
templateUrl: './share-label.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareLabelComponent implements OnInit {
private sharesService = inject(SharesService);
loading = true;
loadError = '';
rows: MailingRow[] = [];
totals: any = null;
search = '';
areaFilter = '';
/** Labels per row on the sheet — 2 and 3 cover the sheets a branch actually buys. */
columns = 3;
showFolio = true;
showHolding = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareMailingList({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The mailing list could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get areaOptions(): string[] {
return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
}
/** Only members who can actually be posted to — the rest are listed below, not printed. */
get printableRows(): MailingRow[] {
return this.filteredRows.filter((r) => r.addressable);
}
get unaddressableRows(): MailingRow[] {
return this.filteredRows.filter((r) => !r.addressable);
}
get filteredRows(): MailingRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.areaFilter && r.area !== this.areaFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.folioNumber,
r.area,
r.postalCode
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
lines(row: MailingRow): string[] {
return addressLines(row);
}
clearFilters(): void {
this.search = '';
this.areaFilter = '';
}
exportCsv(): void {
shareReportCsv(
'share-mailing-labels',
[
'Member',
'Folio No',
'Address',
'City',
'District',
'Postal Code',
'Mobile',
'Shares Held',
'Addressable'
],
this.filteredRows.map((r) => [
r.clientName,
r.folioNumber,
this.lines(r).join(' / '),
r.city,
r.district,
r.postalCode,
r.mobileNo,
r.sharesHeld,
r.addressable ? 'Yes' : 'No'
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,171 @@
<!--
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 srep-no-print">
<div class="cert-head-l">
<fa-icon icon="envelope" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Letter</h2>
<div class="cert-head-s">
A letter to shareholding members, merged with their own details and printed — one page each.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !recipients.length || !body.trim()">
<fa-icon icon="print"></fa-icon> Print {{ recipients.length | number }} letters
</button>
</div>
</div>
<!-- Compose -->
<div class="srep-stmt-head srep-no-print" style="display: block">
<div class="cert-filters" style="margin: 0">
<div class="cert-filter">
<label>Template</label>
<select [(ngModel)]="templateKey" (change)="applyTemplate()">
@for (t of templates; track t.key) {
<option [value]="t.key">{{ t.label }}</option>
}
</select>
</div>
<div class="cert-filter" style="flex: 2 1 320px">
<label>Subject</label>
<input type="text" [(ngModel)]="subject" placeholder="Subject line of the letter" />
</div>
</div>
<div style="margin-top: 0.7rem">
<label class="srep-stmt-l">Letter body</label>
<textarea class="slet-body-input" rows="7" [(ngModel)]="body" placeholder="Write the letter…"></textarea>
<div class="srep-scope-note">
Merged per member:
@for (p of placeholders; track p) {
<code>{{ p }}</code
>{{ $last ? '.' : ', ' }}
}
Anything else you write is sent as it stands.
</div>
</div>
</div>
<!-- Recipients -->
<div class="cert-filters srep-no-print">
<div class="cert-filter">
<label>Member / Folio / Area</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
</div>
</div>
<div class="cert-filter">
<label>Area</label>
<select [(ngModel)]="areaFilter">
<option value="">All areas</option>
@for (a of areaOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="selectAll()">Write to all shown</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the mailing list…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No shareholding member matches the filters applied.</div>
} @else {
<div class="srep-table-wrap srep-no-print">
<table class="srep-table">
<thead>
<tr>
<th class="col-checkbox"></th>
<th>Member</th>
<th>Folio No</th>
<th>Area</th>
<th class="srep-num">Shares</th>
<th>Postable</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.clientId) {
<tr class="srep-row--click" [class.selected]="isSelected(row.clientId)" (click)="toggle(row.clientId)">
<td class="ta-center" (click)="$event.stopPropagation()">
<input type="checkbox" [checked]="isSelected(row.clientId)" (change)="toggle(row.clientId)" />
</td>
<td>{{ row.clientName }}</td>
<td class="srep-mono">{{ row.folioNumber || '—' }}</td>
<td>{{ row.area }}</td>
<td class="srep-num">{{ row.sharesHeld | number }}</td>
<td>
@if (row.addressable) {
<span class="srep-pill srep-pill--green">Yes</span>
} @else {
<span class="srep-pill srep-pill--amber">No address</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="srep-state srep-no-print">
{{ recipients.length | number }} letter{{ recipients.length === 1 ? '' : 's' }} will print
@if (!selectedIds.length) {
(everyone shown — tick rows to narrow it)
}
</div>
<!-- The letters. One per member, each its own printed page. -->
@for (row of recipients; track row.clientId) {
<div class="slet-page">
<div class="spc-letterhead">
<div class="spc-org">{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}</div>
@if (institution?.addressLine1 || institution?.address) {
<div class="spc-org-sub">{{ institution?.addressLine1 || institution?.address }}</div>
}
</div>
<div class="slet-meta">
<div>
<div>{{ row.clientName }}</div>
@for (line of lines(row); track line) {
<div>{{ line }}</div>
}
@if (row.folioNumber) {
<div class="srep-scope-note" style="margin: 0">Folio {{ row.folioNumber }}</div>
}
</div>
<div class="slet-date">Date: {{ today }}</div>
</div>
@if (subject.trim()) {
<div class="slet-subject">Subject: {{ subject }}</div>
}
<div class="slet-salutation">Dear {{ row.clientName }},</div>
<div class="slet-body">{{ merged(row) }}</div>
<div class="spc-foot">
<div class="spc-foot-l">Yours faithfully,</div>
<div class="spc-sign">
<div class="spc-sign-line"></div>
<div>Authorised Signatory</div>
</div>
</div>
</div>
}
}
</div>
@@ -0,0 +1,227 @@
/**
* 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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { OrganizationService } from 'app/organization/organization.service';
import { MailingRow, addressLines } from '../share-label/share-label.component';
import { shareReportPrint } from '../share-report.util';
/** The letters a society actually sends its shareholders, as starting text. */
const TEMPLATES: { key: string; label: string; subject: string; body: string }[] = [
{
key: 'AGM',
label: 'Notice of Annual General Meeting',
subject: 'Notice of the Annual General Meeting',
body:
'You are hereby informed that the Annual General Meeting of the society will be held on {{MEETING_DATE}} at {{VENUE}}.\n\n' +
'As a shareholding member holding {{SHARES}} shares (Folio {{FOLIO}}), you are entitled to attend and vote.\n\n' +
'Your presence is requested.'
},
{
key: 'DIVIDEND',
label: 'Dividend intimation',
subject: 'Dividend declared on your shareholding',
body:
'We are pleased to inform you that the society has declared a dividend for the year {{YEAR}}.\n\n' +
'Against your holding of {{SHARES}} shares (Folio {{FOLIO}}), the dividend due to you will be credited to your ' +
'linked savings account. Please contact your branch if it is not received within fifteen days.'
},
{
key: 'SHARE_CALL',
label: 'Call to complete shareholding',
subject: 'Completion of your share subscription',
body:
'Our records show that your share account stands at {{SHARES}} shares (Folio {{FOLIO}}), which is below the ' +
'minimum subscription your share class requires.\n\n' +
'You are requested to complete your subscription at your branch at the earliest so that your membership rights ' +
'remain unaffected.'
},
{
key: 'BLANK',
label: 'Blank letter',
subject: '',
body: ''
}
];
/**
* Share Letter — a letter to shareholding members, merged with their own details and printed.
*
* <p>
* Runs off the same mailing list the labels do, so the letter and the envelope always agree on who
* is being written to. Templates are starting text rather than fixed forms: the body is editable
* before printing, because the letter a society actually sends is never quite the one a system
* imagined.
*
* <p>
* Placeholders are merged per member — {{NAME}}, {{FOLIO}}, {{SHARES}} and the rest — so one edit
* produces every letter, each addressed to the member it is going to.
*/
@Component({
selector: 'mifosx-share-letter',
templateUrl: './share-letter.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareLetterComponent implements OnInit {
private sharesService = inject(SharesService);
private organizationService = inject(OrganizationService);
loading = true;
loadError = '';
rows: MailingRow[] = [];
institution: any = null;
readonly templates = TEMPLATES;
templateKey = 'AGM';
subject = TEMPLATES[0].subject;
body = TEMPLATES[0].body;
search = '';
areaFilter = '';
/** Which members this run is for; empty means everyone shown. */
selectedIds: number[] = [];
ngOnInit(): void {
this.sharesService
.getShareMailingList({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The mailing list could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.loading = false;
});
this.organizationService
.getInstitutionProfile()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.institution = res));
}
applyTemplate(): void {
const template = TEMPLATES.find((t) => t.key === this.templateKey);
if (template) {
this.subject = template.subject;
this.body = template.body;
}
}
get areaOptions(): string[] {
return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
}
get filteredRows(): MailingRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.areaFilter && r.area !== this.areaFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.folioNumber,
r.area
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
/** Nothing ticked means the whole filtered list — the common case is "write to all of these". */
get recipients(): MailingRow[] {
if (!this.selectedIds.length) {
return this.filteredRows;
}
return this.filteredRows.filter((r) => this.selectedIds.includes(r.clientId));
}
isSelected(clientId: number): boolean {
return this.selectedIds.includes(clientId);
}
toggle(clientId: number): void {
this.selectedIds = this.isSelected(clientId) ? this.selectedIds.filter((id) => id !== clientId) : [
...this.selectedIds,
clientId
];
}
selectAll(): void {
this.selectedIds = [];
}
lines(row: MailingRow): string[] {
return addressLines(row);
}
/** What one member's letter substitutes, keyed by the token that stands for it in the body. */
private mergeFields(row: MailingRow): Record<string, string> {
return {
NAME: row.clientName || '',
FOLIO: row.folioNumber || '',
SHARES: Number(row.sharesHeld || 0).toLocaleString('en-IN'),
SHARE_VALUE: Number(row.shareValue || 0).toLocaleString('en-IN'),
SHARE_ACCOUNTS: row.shareAccountNos || '',
BRANCH: row.officeName || '',
AREA: row.area || ''
};
}
/**
* The tokens the editor's help text offers, derived from the merge itself so the two can never
* drift apart — and assembled here rather than written in the template, because a literal
* `{{NAME}}` in an Angular template is an expression, not the text a letter-writer needs to see.
*/
readonly placeholders = Object.keys(this.mergeFields({} as MailingRow)).map((key) => `{{${key}}}`);
/** The member's own details merged into the body, one letter at a time. */
merged(row: MailingRow): string {
const fields = this.mergeFields(row);
// An unrecognised token is left standing rather than blanked: a letter that visibly still says
// {{VENUE}} tells the writer to fill it in; one silently emptied goes out with a hole in it.
return (this.body || '').replace(/\{\{([A-Z_]+)\}\}/g, (token, key: string) => fields[key] ?? token);
}
get today(): string {
const now = new Date();
return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,166 @@
<!--
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="calculator" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Total Interest Received &amp; Receivable</h2>
<div class="cert-head-s">
Interest earned from the borrowings of the members who hold the society's share capital.
</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>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.interestReceived | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Interest Received</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">₹{{ totals.interestReceivable | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Interest Receivable</div>
</div>
</div>
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.borrowingMembers | number }} / {{ totals.memberCount | number }}</div>
<div class="cert-kpi-l">Borrowing Members</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">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Their Share Capital</div>
</div>
</div>
</div>
}
<div class="cert-filters">
<div class="cert-filter">
<label>Member / Membership No</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
</div>
</div>
<div class="cert-filter">
<label>Loans counted</label>
<select [(ngModel)]="includeClosedLoans" (change)="load()">
<option [ngValue]="false">Live loans only</option>
<option [ngValue]="true">Include closed loans</option>
</select>
</div>
<div class="cert-filter">
<label>Members shown</label>
<select [(ngModel)]="onlyBorrowers">
<option [ngValue]="false">All shareholding members</option>
<option [ngValue]="true">Borrowers only</option>
</select>
</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>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading interest figures…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No member holds share capital yet.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No member matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Membership No</th>
<th>Branch</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Share Value</th>
<th class="srep-num">Loans</th>
<th class="srep-num">Principal O/S</th>
<th class="srep-num">Interest Charged</th>
<th class="srep-num">Interest Received</th>
<th class="srep-num">Interest Receivable</th>
<th class="srep-num">Waived</th>
<th class="srep-num">Written Off</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.clientId) {
<tr class="srep-row--click" (click)="openMember(row)">
<td>{{ row.clientName }}</td>
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
<td>{{ row.officeName || '—' }}</td>
<td class="srep-num">{{ row.sharesHeld | number }}</td>
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">{{ row.loanCount | number }}</td>
<td class="srep-num">₹{{ row.principalOutstanding | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ row.interestCharged | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ row.interestReceived | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ row.interestReceivable | number: '1.2-2' }}</td>
<td class="srep-num">{{ row.interestWaived ? '₹' + (row.interestWaived | number: '1.2-2') : '—' }}</td>
<td class="srep-num">
{{ row.interestWrittenOff ? '₹' + (row.interestWrittenOff | number: '1.2-2') : '—' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members
</td>
<td class="srep-num">{{ sum('sharesHeld') | number }}</td>
<td class="srep-num">₹{{ sum('shareValue') | number: '1.2-2' }}</td>
<td class="srep-num">{{ sum('loanCount') | number }}</td>
<td class="srep-num">₹{{ sum('principalOutstanding') | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ sum('interestCharged') | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ sum('interestReceived') | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ sum('interestReceivable') | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ sum('interestWaived') | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ sum('interestWrittenOff') | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -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/.
*/
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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { shareReportCsv, shareReportPrint } from '../share-report.util';
interface MemberInterestRow {
clientId: number;
cifNumber: string;
clientName: string;
officeName: string;
sharesHeld: number;
shareValue: number;
loanCount: number;
principalLent: number;
principalOutstanding: number;
interestCharged: number;
interestReceived: number;
interestReceivable: number;
interestWaived: number;
interestWrittenOff: number;
}
/**
* Total Interest Received & Receivable — what the society has earned in interest from the borrowings
* of the members who hold its share capital.
*
* <p>
* Shares earn dividend, not interest; this is the other side of the member relationship. A member's
* stake and what their lending returns is the case for admitting and keeping them, and a
* co-operative reads the two together.
*
* <p>
* Received and receivable stay in separate columns rather than being summed — interest charged but
* not yet collected is not income in hand, and a single "interest" figure hides exactly that.
*/
@Component({
selector: 'mifosx-share-member-interest-report',
templateUrl: './share-member-interest-report.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareMemberInterestReportComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
rows: MemberInterestRow[] = [];
totals: any = null;
/** Server-side: a closed loan's interest is history, not a position. */
includeClosedLoans = false;
search = '';
branchFilter = '';
/** Members who hold shares but have never borrowed are noise when the question is interest. */
onlyBorrowers = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareMemberLoanInterest({ includeClosedLoans: this.includeClosedLoans })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The interest report could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get branchOptions(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get filteredRows(): MemberInterestRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.officeName !== this.branchFilter) {
return false;
}
if (this.onlyBorrowers && !Number(r.loanCount)) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.cifNumber
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
sum(field: keyof MemberInterestRow): number {
return this.filteredRows.reduce((total, row) => total + Number(row[field] || 0), 0);
}
get isFiltered(): boolean {
return !!(this.search.trim() || this.branchFilter || this.onlyBorrowers);
}
openMember(row: MemberInterestRow): void {
this.router.navigate([
'/clients',
row.clientId,
'general'
]);
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.onlyBorrowers = false;
}
exportCsv(): void {
shareReportCsv(
'share-member-interest',
[
'Member',
'Membership No',
'Branch',
'Shares Held',
'Share Value',
'Loans',
'Principal Lent',
'Principal Outstanding',
'Interest Charged',
'Interest Received',
'Interest Receivable',
'Interest Waived',
'Interest Written Off'
],
this.filteredRows.map((r) => [
r.clientName,
r.cifNumber,
r.officeName,
r.sharesHeld,
r.shareValue,
r.loanCount,
r.principalLent,
r.principalOutstanding,
r.interestCharged,
r.interestReceived,
r.interestReceivable,
r.interestWaived,
r.interestWrittenOff
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,244 @@
<!--
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">Nominal Share Report</h2>
<div class="cert-head-s">
Each member's holding at face value, measured against the share class's nominal, minimum and maximum.
</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>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.nominalValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Nominal Capital</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.regularMembers | number }}</div>
<div class="cert-kpi-l">Regular Members</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="user-shield" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.nominalMembers | number }}</div>
<div class="cert-kpi-l">Nominal Members</div>
</div>
</div>
<div
class="cert-kpi"
[ngClass]="totals.belowMinimum + totals.aboveMaximum ? 'cert-kpi--amber' : 'cert-kpi--blue'"
>
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.belowMinimum + totals.aboveMaximum | number }}</div>
<div class="cert-kpi-l">Outside Limits</div>
</div>
</div>
</div>
}
<!-- Allotted but never collected. Kept in front of the reader rather than left to be spotted as an
amber row halfway down: these are live accounts whose money is still owed. -->
@if (unpaidRows.length) {
<div class="srep-warn">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>
<b>{{ unpaidRows.length | number }}</b> {{ unpaidRows.length === 1 ? 'account has' : 'accounts have' }} shares
allotted but never collected — <b>{{ unpaidShares | number }}</b> shares in all. They are active, so they are
listed below and flagged in amber, but they hold nothing: the shares exist on paper only until the member's
collection is applied.
</span>
</div>
}
<div class="cert-filters">
<div class="cert-filter">
<label>Account state</label>
<select [(ngModel)]="stateFilter">
<option value="LIVE">Live accounts</option>
<option value="">All states</option>
@for (s of stateOrder; track s) {
<option [value]="s">{{ stateLabel(s) }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>As on date</label>
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
</div>
<div class="cert-filter">
<label>Member / Membership No / A/c No</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
</div>
</div>
<div class="cert-filter">
<label>Compliance</label>
<select [(ngModel)]="complianceFilter">
<option value="">All</option>
<option value="WITHIN_LIMITS">Within limits</option>
<option value="BELOW_MINIMUM">Below minimum</option>
<option value="ABOVE_MAXIMUM">Above maximum</option>
<option value="NO_HOLDING">No holding</option>
<option value="NOT_APPLICABLE">Not applicable</option>
</select>
</div>
<div class="cert-filter">
<label>Membership class</label>
<select [(ngModel)]="membershipFilter">
<option value="">All</option>
<option value="REGULAR">Regular</option>
<option value="NOMINAL">Nominal</option>
<option value="NONE">No holding</option>
<option value="NOT_APPLICABLE">Not applicable</option>
</select>
</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>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading nominal share positions…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No share account existed as on {{ asOnDate }}.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No member matches the filters applied.</div>
} @else {
@if (hiddenByStateCount) {
<div class="srep-scope-note">
{{ hiddenByStateCount | number }} closed, rejected or not-yet-active
{{ hiddenByStateCount === 1 ? 'account is' : 'accounts are' }} not shown. Switch <b>Account state</b> to see
them.
</div>
}
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Product</th>
<th>State</th>
<th class="srep-num">Held</th>
<th class="srep-num">Nominal</th>
<th class="srep-num">vs Nominal</th>
<th class="srep-num">Min</th>
<th class="srep-num">Max</th>
<th class="srep-num">Face Value</th>
<th class="srep-num">Nominal Value</th>
<th>Class</th>
<th>Compliance</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.shareAccountId) {
<tr class="srep-row--click" [ngClass]="stateRowClass(row.holdingState)" (click)="openAccount(row)">
<td>{{ row.clientName }}</td>
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
<td class="srep-mono">{{ row.accountNo }}</td>
<td>{{ row.productName }}</td>
<td>
<span
class="srep-pill"
[ngClass]="stateClass(row.holdingState)"
[title]="stateNote(row.holdingState)"
>{{ stateLabel(row.holdingState) }}</span
>
</td>
<td class="srep-num">
{{ row.sharesHeld | number }}
@if (row.holdingState === 'UNPAID') {
<div class="srep-sub">{{ row.unpaidShares | number }} unpaid</div>
}
</td>
<td class="srep-num">{{ row.nominalShares | number }}</td>
<td class="srep-num">{{ row.againstNominal > 0 ? '+' : '' }}{{ row.againstNominal | number }}</td>
<td class="srep-num">{{ row.minimumShares | number }}</td>
<td class="srep-num">{{ row.maximumShares | number }}</td>
<td class="srep-num">{{ row.faceValue | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ row.nominalValue | number: '1.2-2' }}</td>
<td>
<span class="srep-pill" [ngClass]="membershipClass(row.membershipClass)">{{
membershipLabel(row.membershipClass)
}}</span>
</td>
<td>
<span class="srep-pill" [ngClass]="complianceClass(row.complianceStatus)">{{
complianceLabel(row.complianceStatus)
}}</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="11">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts
</td>
<td class="srep-num">₹{{ shownNominalValue | number: '1.2-2' }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
@if (isFiltered && totals) {
<div class="srep-scope-note">
Showing a filtered view. Society-wide: ₹{{ totals.nominalValue | number: '1.2-2' }} nominal capital,
{{ totals.regularMembers | number }} regular and {{ totals.nominalMembers | number }} nominal members,
{{ totals.belowMinimum + totals.aboveMaximum | number }} outside the class's limits.
</div>
}
}
</div>
@@ -0,0 +1,337 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { SettingsService } from 'app/settings/settings.service';
import {
SHARE_ACCOUNT_STATUS,
SHARE_HOLDING_STATE_ORDER,
holdingStateClass,
holdingStateLabel,
holdingStateNote,
holdingStateRowClass,
shareReportCsv,
shareReportPrint
} from '../share-report.util';
interface NominalRow {
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeName: string;
productId: number;
productName: string;
statusEnum: number;
faceValue: number;
nominalShares: number;
minimumShares: number;
maximumShares: number;
sharesHeld: number;
nominalValue: number;
againstNominal: number;
complianceStatus: 'NO_HOLDING' | 'BELOW_MINIMUM' | 'ABOVE_MAXIMUM' | 'WITHIN_LIMITS' | 'NOT_APPLICABLE';
membershipClass: 'NONE' | 'NOMINAL' | 'REGULAR' | 'NOT_APPLICABLE';
holdingState: string;
unpaidShares: number;
activatedDate: string;
}
/** What a compliance state is called and how it is flagged. */
const COMPLIANCE: { [key: string]: { label: string; cls: string } } = {
WITHIN_LIMITS: { label: 'Within limits', cls: 'srep-pill--green' },
BELOW_MINIMUM: { label: 'Below minimum', cls: 'srep-pill--red' },
ABOVE_MAXIMUM: { label: 'Above maximum', cls: 'srep-pill--red' },
NO_HOLDING: { label: 'No holding', cls: 'srep-pill--grey' },
// A closed or rejected account is not subject to the class's minimum any more, so it is excused
// rather than reported as breaching it.
NOT_APPLICABLE: { label: 'Not applicable', cls: 'srep-pill--grey' }
};
const MEMBERSHIP: { [key: string]: { label: string; cls: string } } = {
REGULAR: { label: 'Regular', cls: 'srep-pill--green' },
NOMINAL: { label: 'Nominal', cls: 'srep-pill--amber' },
NONE: { label: '—', cls: 'srep-pill--grey' },
NOT_APPLICABLE: { label: '—', cls: 'srep-pill--grey' }
};
/**
* Nominal Share Report — each member's holding read against the three numbers the share class
* itself sets: its nominal subscription, its minimum and its maximum.
*
* <p>
* The report exists to find breaches, so the default view is every member and the breaches are
* flagged in place; the compliance filter is there for when the answer needed is only "who is
* short".
*/
@Component({
selector: 'mifosx-share-nominal-report',
templateUrl: './share-nominal-report.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareNominalReportComponent implements OnInit {
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
private router = inject(Router);
loading = true;
loadError = '';
rows: NominalRow[] = [];
totals: any = null;
stateCounts: any = null;
asOnDate = '';
search = '';
branchFilter = '';
productFilter = '';
complianceFilter = '';
membershipFilter = '';
/**
* Which accounts to show. Defaults to the live ones — a compliance report is read as "who is
* short today", and a closed member cannot be short of anything. Closed and rejected accounts are
* one dropdown away rather than gone, and the count of what the default hides is shown above the
* table so the narrowing is never silent.
*/
stateFilter = 'LIVE';
readonly stateOrder = SHARE_HOLDING_STATE_ORDER;
ngOnInit(): void {
const businessDate: Date = this.settingsService.businessDate || new Date();
this.asOnDate = this.toIso(businessDate);
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareNominalReport({ asOnDate: this.asOnDate })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The nominal share report could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.stateCounts = res?.stateCounts || null;
this.loading = false;
});
}
/** Live means the account is still a going concern, whether or not it currently holds anything. */
private isLive(row: NominalRow): boolean {
return row.holdingState === 'HOLDING' || row.holdingState === 'UNPAID' || row.holdingState === 'NIL';
}
/** How many accounts the default "live only" view is leaving out, so it can say so. */
get hiddenByStateCount(): number {
return this.stateFilter === 'LIVE' ? this.rows.filter((r) => !this.isLive(r)).length : 0;
}
get unpaidRows(): NominalRow[] {
return this.rows.filter((r) => r.holdingState === 'UNPAID');
}
get unpaidShares(): number {
return this.unpaidRows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0);
}
get branchOptions(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get productOptions(): string[] {
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
}
get filteredRows(): NominalRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.stateFilter === 'LIVE' ? !this.isLive(r) : this.stateFilter && r.holdingState !== this.stateFilter) {
return false;
}
if (this.branchFilter && r.officeName !== this.branchFilter) {
return false;
}
if (this.productFilter && r.productName !== this.productFilter) {
return false;
}
if (this.complianceFilter && r.complianceStatus !== this.complianceFilter) {
return false;
}
if (this.membershipFilter && r.membershipClass !== this.membershipFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.accountNo,
r.cifNumber,
r.productName
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
get shownNominalValue(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.nominalValue || 0), 0);
}
get isFiltered(): boolean {
return !!(
this.search.trim() ||
this.branchFilter ||
this.productFilter ||
this.complianceFilter ||
this.membershipFilter ||
this.stateFilter !== 'LIVE'
);
}
stateLabel(state: string): string {
return holdingStateLabel(state);
}
stateClass(state: string): string {
return holdingStateClass(state);
}
stateRowClass(state: string): string {
return holdingStateRowClass(state);
}
stateNote(state: string): string {
return holdingStateNote(state);
}
complianceLabel(status: string): string {
return COMPLIANCE[status]?.label || status;
}
complianceClass(status: string): string {
return COMPLIANCE[status]?.cls || 'srep-pill--grey';
}
membershipLabel(membership: string): string {
return MEMBERSHIP[membership]?.label || membership;
}
membershipClass(membership: string): string {
return MEMBERSHIP[membership]?.cls || 'srep-pill--grey';
}
statusLabel(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
statusClass(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.complianceFilter = '';
this.membershipFilter = '';
this.stateFilter = 'LIVE';
}
openAccount(row: NominalRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.shareAccountId,
'general'
]);
}
exportCsv(): void {
shareReportCsv(
`share-nominal-${this.asOnDate || 'today'}`,
[
'Member',
'Membership No',
'Share A/c No',
'Branch',
'Product',
'Account Status',
'Holding State',
'Shares Held',
'Allotted Unpaid',
'Nominal Required',
'Against Nominal',
'Minimum',
'Maximum',
'Face Value',
'Nominal Value',
'Membership Class',
'Compliance'
],
this.filteredRows.map((r) => [
r.clientName,
r.cifNumber,
r.accountNo,
r.officeName,
r.productName,
this.statusLabel(r.statusEnum),
this.stateLabel(r.holdingState),
r.sharesHeld,
r.unpaidShares,
r.nominalShares,
r.againstNominal,
r.minimumShares,
r.maximumShares,
r.faceValue,
r.nominalValue,
this.membershipLabel(r.membershipClass),
this.complianceLabel(r.complianceStatus)
])
);
}
print(): void {
shareReportPrint();
}
private toIso(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,160 @@
<!--
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 srep-no-print">
<div class="cert-head-l">
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Payment Certificate</h2>
<div class="cert-head-s">Proof of what a member paid for their shares, and against which receipt.</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="print()" [disabled]="!account">
<fa-icon icon="print"></fa-icon> Print Certificate
</button>
</div>
</div>
<div class="cert-filters srep-no-print">
<div class="cert-filter" style="flex: 2 1 340px; position: relative">
<label>Share account</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input
type="text"
[ngModel]="accountSearch"
(ngModelChange)="onAccountSearch($event)"
placeholder="Search member name or share account number"
/>
</div>
@if (accountOptions.length && !selectedAccountId) {
<div class="srep-picker">
@for (opt of accountOptions; track opt.id) {
<button type="button" class="srep-picker-item" (click)="selectAccount(opt)">
<span class="srep-picker-name">{{ opt.clientName }}</span>
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
<span class="srep-picker-meta">{{ opt.productName }}</span>
</button>
}
</div>
}
</div>
</div>
@if (!selectedAccountId) {
<div class="srep-state">Search for a member or share account above to issue a payment certificate.</div>
} @else if (loading) {
<div class="srep-state">Loading payment record…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (account) {
<!-- The certificate itself. Everything above is the picker; this is what prints. -->
<div class="spc-doc">
<div class="spc-letterhead">
<div class="spc-org">{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}</div>
@if (institution?.addressLine1 || institution?.address) {
<div class="spc-org-sub">{{ institution?.addressLine1 || institution?.address }}</div>
}
<div class="spc-title">Share Payment Certificate</div>
</div>
<div class="spc-body">
<p>
This is to certify that <b>{{ account.clientName }}</b>
@if (account.cifNumber) {
(Membership No. <b class="srep-mono">{{ account.cifNumber }}</b
>)
}
@if (account.officeName) {
of our <b>{{ account.officeName }}</b> branch
}
has paid the amounts set out below towards
<b>{{ totals?.sharesPaidFor | number }}</b> shares of <b>{{ account.productName }}</b> at ₹{{
account.unitPrice | number: '1.2-2'
}}
each, held under share account <b class="srep-mono">{{ account.accountNo }}</b
>.
</p>
</div>
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Payment Date</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Amount Paid</th>
<th>Paid By</th>
<th>Receipt / Voucher</th>
<th>Board Resolution</th>
</tr>
</thead>
<tbody>
@for (p of payments; track p.transactionId) {
<tr>
<td>{{ p.paymentDate }}</td>
<td class="srep-num">{{ p.shares | number }}</td>
<td class="srep-num">{{ p.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">₹{{ p.amount | number: '1.2-2' }}</td>
<td>{{ funding(p.fundingSource) }}</td>
<td class="srep-mono">
{{ receiptRef(p) }}
@if (p.receiptDate) {
<div class="srep-scope-note" style="margin: 0">{{ p.receiptDate }}</div>
}
</td>
<td class="srep-mono">{{ p.resolutionNumber || '—' }}</td>
</tr>
}
@for (c of charges; track c.chargeName) {
<tr>
<td>—</td>
<td class="srep-num">—</td>
<td class="srep-num">—</td>
<td class="srep-num">₹{{ c.amountPaid | number: '1.2-2' }}</td>
<td colspan="3">{{ c.chargeName }} (entry fee)</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">Total paid</td>
<td class="srep-num">₹{{ totals?.totalPaid | number: '1.2-2' }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
@if (amountInWords) {
<div class="spc-words">
Amount in words: <b>{{ amountInWords }}</b>
</div>
}
<div class="spc-foot">
<div class="spc-foot-l">
<div>
Date of issue: <b>{{ today }}</b>
</div>
<div class="srep-scope-note" style="margin-top: 4px">
Account status: {{ statusLabel(account.statusEnum) }}. Shares currently held:
{{ account.sharesHeld | number }}.
</div>
</div>
<div class="spc-sign">
<div class="spc-sign-line"></div>
<div>Authorised Signatory</div>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,236 @@
/**
* 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 { ActivatedRoute } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { OrganizationService } from 'app/organization/organization.service';
import { SHARE_ACCOUNT_STATUS, fundingLabel, shareReportPrint } from '../share-report.util';
/**
* Share Payment Certificate — proof of what a member paid for their shares, and against which
* receipt.
*
* <p>
* Distinct from a share certificate, which certifies ownership: this certifies payment, so it leads
* with amounts, dates and the voucher each one was received on. It is the document a member is asked
* for when the money, not the holding, has to be proved.
*
* <p>
* Every tranche is shown rather than one total — a member who paid in instalments paid on several
* dates against several receipts, and a certificate carrying a single figure reconciles to none of
* them.
*/
@Component({
selector: 'mifosx-share-payment-certificate',
templateUrl: './share-payment-certificate.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class SharePaymentCertificateComponent implements OnInit {
private sharesService = inject(SharesService);
private organizationService = inject(OrganizationService);
private route = inject(ActivatedRoute);
accountSearch = '';
accountOptions: any[] = [];
selectedAccountId: number | null = null;
loading = false;
loadError = '';
account: any = null;
payments: any[] = [];
charges: any[] = [];
totals: any = null;
/** The society's own name and address, so the printed certificate is on its letterhead. */
institution: any = null;
private searchTerm$ = new Subject<string>();
ngOnInit(): void {
this.searchTerm$
.pipe(
debounceTime(350),
distinctUntilChanged(),
switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))))
)
.subscribe((res: any) => {
this.accountOptions = Array.isArray(res) ? res : res?.pageItems || [];
});
this.organizationService
.getInstitutionProfile()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.institution = res));
const preselected = this.route.snapshot.queryParamMap.get('shareAccountId');
if (preselected) {
this.selectedAccountId = Number(preselected);
this.load();
}
this.searchTerm$.next('');
}
onAccountSearch(term: string): void {
this.accountSearch = term;
this.searchTerm$.next(term.trim());
}
selectAccount(option: any): void {
this.selectedAccountId = option.id;
this.accountOptions = [];
this.accountSearch = `${option.clientName} — ${option.accountNo}`;
this.load();
}
load(): void {
if (!this.selectedAccountId) {
return;
}
this.loading = true;
this.loadError = '';
this.sharesService
.getSharePaymentCertificate(this.selectedAccountId)
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The payment certificate could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.account = res?.account || null;
this.payments = res?.payments || [];
this.charges = res?.charges || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
statusLabel(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
statusClass(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
}
funding(source: string): string {
return fundingLabel(source);
}
/** Whatever identifies the money that came in: a voucher, else the receipt, else the route. */
receiptRef(payment: any): string {
return (
payment.voucherNo || payment.receiptNo || (payment.fundingSource === 'SAVINGS' ? 'Debited from savings' : '—')
);
}
/** Amount in words, because a certificate carrying a figure alone is trivially altered. */
get amountInWords(): string {
const amount = Math.round(Number(this.totals?.totalPaid || 0));
return amount ? `${this.toWords(amount)} rupees only` : '';
}
private toWords(value: number): string {
const ones = [
'',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'ten',
'eleven',
'twelve',
'thirteen',
'fourteen',
'fifteen',
'sixteen',
'seventeen',
'eighteen',
'nineteen'
];
const tens = [
'',
'',
'twenty',
'thirty',
'forty',
'fifty',
'sixty',
'seventy',
'eighty',
'ninety'
];
const twoDigits = (n: number): string =>
n < 20 ? ones[n] : `${tens[Math.floor(n / 10)]}${n % 10 ? '-' + ones[n % 10] : ''}`;
// Indian grouping — a co-operative's certificates read in lakh and crore, not million.
const parts: string[] = [];
const crore = Math.floor(value / 10000000);
const lakh = Math.floor((value % 10000000) / 100000);
const thousand = Math.floor((value % 100000) / 1000);
const hundred = Math.floor((value % 1000) / 100);
const rest = value % 100;
if (crore) {
parts.push(`${twoDigits(crore)} crore`);
}
if (lakh) {
parts.push(`${twoDigits(lakh)} lakh`);
}
if (thousand) {
parts.push(`${twoDigits(thousand)} thousand`);
}
if (hundred) {
parts.push(`${ones[hundred]} hundred`);
}
if (rest) {
parts.push(twoDigits(rest));
}
const words = parts.join(' ').trim();
return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Zero';
}
get today(): string {
const now = new Date();
return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,494 @@
/**
* 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/.
*/
/**
* Shared table, pill and print styling for the share reports.
*
* Layered on top of certificates.shared.scss, which already supplies the page frame
* (.cert-root / .cert-head / .cert-kpis / .cert-filters). Only what those do not cover lives here.
*/
$srep-border: #e2e8f0;
$srep-border-soft: #f1f5f9;
$srep-head-bg: #f8fafc;
$srep-text: #0f172a;
$srep-muted: #64748b;
.srep-table-wrap {
overflow-x: auto;
border: 1px solid $srep-border;
border-radius: 10px;
background: #fff;
}
.srep-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
th {
position: sticky;
top: 0;
z-index: 1;
background: $srep-head-bg;
color: $srep-muted;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid $srep-border;
white-space: nowrap;
}
td {
padding: 0.5rem 0.7rem;
border-bottom: 1px solid $srep-border-soft;
color: $srep-text;
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr.srep-row--click {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
}
// A row that is over — closed, rejected, never activated — still belongs on the register, but it
// must not read like a live holding. Dimmed and marked at the edge rather than hidden, because a
// closed account that silently disappears is its own kind of confusion at a counter.
tbody tr.srep-row--muted {
background: #fafbfc;
color: $srep-muted;
td {
color: $srep-muted;
}
td:first-child {
box-shadow: inset 3px 0 0 #cbd5e1;
}
}
// Allotted but never collected. Flagged, not dimmed: this one is live and wants chasing.
tbody tr.srep-row--flag {
background: #fffdf5;
td:first-child {
box-shadow: inset 3px 0 0 #f59e0b;
}
}
.srep-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.srep-mono {
font-family: 'Courier New', monospace;
}
// A second line under a figure, for the qualifier the figure alone cannot carry — "50 unpaid"
// beneath a held count of nothing.
.srep-sub {
margin-top: 1px;
font-size: 0.68rem;
font-weight: 600;
color: #b45309;
}
tfoot td {
background: $srep-head-bg;
font-weight: 700;
border-top: 2px solid $srep-border;
border-bottom: none;
}
}
.srep-pill {
display: inline-block;
padding: 1px 9px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
white-space: nowrap;
&.srep-pill--green {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
&.srep-pill--amber {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
&.srep-pill--red {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.srep-pill--blue {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
&.srep-pill--grey {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
}
}
.srep-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem 1rem;
color: $srep-muted;
font-size: 0.85rem;
text-align: center;
}
.srep-state--error {
color: #dc2626;
}
/* A narrowed view says so, right where the numbers are, so it is never read as the whole society. */
.srep-scope-note {
margin-top: 0.5rem;
font-size: 0.75rem;
color: $srep-muted;
}
/* Account picker — results drop under the search box rather than into a dialog, so choosing an
account is one click from typing rather than a modal that has to be dismissed. */
.srep-picker {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
margin-top: 2px;
max-height: 260px;
overflow-y: auto;
background: #fff;
border: 1px solid $srep-border;
border-radius: 8px;
box-shadow: 0 10px 24px -8px rgb(15 23 42 / 18%);
}
.srep-picker-item {
display: flex;
flex-direction: column;
gap: 1px;
width: 100%;
padding: 0.45rem 0.7rem;
background: none;
border: none;
border-bottom: 1px solid $srep-border-soft;
text-align: left;
cursor: pointer;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f4f7fb;
}
}
.srep-picker-name {
font-size: 0.82rem;
font-weight: 600;
color: $srep-text;
}
.srep-picker-meta {
font-size: 0.72rem;
color: $srep-muted;
}
/* Inline distribution bar — the shareholding pattern reads as a shape before it reads as numbers,
and a chart library for one bar per row would be a dependency for nothing. */
.srep-bar {
width: 100%;
height: 8px;
background: $srep-border-soft;
border-radius: 999px;
overflow: hidden;
}
.srep-bar-fill {
height: 100%;
background: linear-gradient(90deg, #2563eb, #60a5fa);
border-radius: 999px;
}
/* Statement header — the identity block a printed statement has to carry. */
.srep-stmt-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
margin-bottom: 0.85rem;
background: #f8fafc;
border: 1px solid $srep-border;
border-radius: 10px;
}
.srep-stmt-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.6rem 1.2rem;
flex: 1;
}
.srep-stmt-l {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $srep-muted;
font-weight: 700;
}
.srep-stmt-v {
font-size: 0.84rem;
color: $srep-text;
font-weight: 500;
}
/* Something the reader has to know before trusting the number above it — an exclusion, a gap, a
figure that will not reconcile until someone acts. Loud enough to be read, not an error. */
.srep-warn {
display: flex;
align-items: flex-start;
gap: 0.6rem;
margin: 0.6rem 0;
padding: 0.65rem 0.85rem;
background: #fffbeb;
border: 1px solid #fde68a;
border-left: 4px solid #d97706;
border-radius: 8px;
font-size: 0.82rem;
line-height: 1.55;
color: #92400e;
}
/* The selected tab, in a screen that has two halves rather than two pages. */
.srep-tab--active {
background: #eff6ff !important;
border-color: #2563eb !important;
color: #1e40af !important;
font-weight: 700;
}
/* Printed certificate — a document, not a screen: it carries the society's letterhead, a body in
sentences and a signature block, because that is what a member is handed and files. */
.spc-doc {
border: 1px solid $srep-border;
border-radius: 10px;
padding: 1.5rem 1.75rem;
background: #fff;
}
.spc-letterhead {
text-align: center;
border-bottom: 2px solid $srep-text;
padding-bottom: 0.75rem;
margin-bottom: 1.1rem;
}
.spc-org {
font-size: 1.15rem;
font-weight: 700;
color: $srep-text;
letter-spacing: 0.02em;
}
.spc-org-sub {
font-size: 0.78rem;
color: $srep-muted;
margin-top: 2px;
}
.spc-title {
margin-top: 0.7rem;
font-size: 0.95rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $srep-text;
}
.spc-body {
font-size: 0.88rem;
line-height: 1.7;
color: $srep-text;
margin-bottom: 1rem;
}
.spc-words {
margin-top: 0.75rem;
font-size: 0.85rem;
color: $srep-text;
}
.spc-foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
margin-top: 2.5rem;
font-size: 0.82rem;
}
.spc-sign {
text-align: center;
font-size: 0.8rem;
color: $srep-text;
}
.spc-sign-line {
width: 170px;
border-top: 1px solid $srep-text;
margin-bottom: 4px;
}
/* Label sheet — a grid sized to the label stationery a branch buys, printed straight from here. */
.slab-sheet {
display: grid;
gap: 0.5rem;
margin-top: 0.6rem;
}
.slab {
border: 1px dashed $srep-border;
border-radius: 6px;
padding: 0.65rem 0.8rem;
min-height: 92px;
font-size: 0.82rem;
line-height: 1.45;
color: $srep-text;
break-inside: avoid;
}
.slab-name {
font-weight: 700;
}
.slab-line {
color: $srep-text;
}
.slab-meta {
margin-top: 3px;
font-size: 0.7rem;
color: $srep-muted;
}
/* One letter, one page. */
.slet-page {
border: 1px solid $srep-border;
border-radius: 10px;
padding: 1.5rem 1.75rem;
margin-top: 0.85rem;
background: #fff;
break-after: page;
}
.slet-meta {
display: flex;
justify-content: space-between;
gap: 1.5rem;
font-size: 0.84rem;
line-height: 1.5;
margin-bottom: 1.1rem;
}
.slet-date {
white-space: nowrap;
color: $srep-muted;
}
.slet-subject {
font-weight: 700;
font-size: 0.86rem;
margin-bottom: 0.9rem;
}
.slet-salutation {
font-size: 0.86rem;
margin-bottom: 0.6rem;
}
.slet-body {
font-size: 0.86rem;
line-height: 1.75;
white-space: pre-wrap;
color: $srep-text;
}
.slet-body-input {
width: 100%;
padding: 0.55rem 0.7rem;
border: 1px solid $srep-border;
border-radius: 8px;
font-family: inherit;
font-size: 0.84rem;
line-height: 1.6;
color: $srep-text;
resize: vertical;
}
@media print {
.cert-head-r,
.cert-filters,
.srep-no-print {
display: none !important;
}
.slab {
border: 1px solid #999;
}
.slet-page {
border: none;
padding: 0;
margin: 0;
}
.spc-doc {
border: none;
padding: 0;
}
.srep-table-wrap {
overflow: visible;
border: none;
}
.srep-table th {
position: static;
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* 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/.
*/
/**
* Shared vocabulary and plumbing for the share reports.
*
* Kept in one place because the reports are read side by side — a member shown as "Active" on the
* balance report and "Approved" on the status report would read as two different things when it is
* one status_enum, and a CSV that quotes its fields on one screen but not the next breaks whichever
* spreadsheet opens both.
*/
/** m_share_account.status_enum → what a reader calls it, and the pill it wears. */
export const SHARE_ACCOUNT_STATUS: { [key: number]: { label: string; cls: string } } = {
100: { label: 'Pending Approval', cls: 'srep-pill--amber' },
200: { label: 'Approved', cls: 'srep-pill--blue' },
300: { label: 'Active', cls: 'srep-pill--green' },
500: { label: 'Rejected', cls: 'srep-pill--red' },
600: { label: 'Closed', cls: 'srep-pill--grey' }
};
/**
* Why a share account's holding reads the way it does — the server's own derivation, so every
* report tells the member the same story about the same account.
*
* <p>
* A nil holding is the most over-loaded figure on a share register. A closed account, an active
* account whose money was never collected, and an account redeemed down to nothing all print the
* same "0", and a reader cannot tell them apart. Each state therefore carries its own pill, its own
* row treatment, and a sentence saying what it means — closed and finished rows are dimmed, and the
* one state that still wants chasing is flagged rather than greyed out.
*/
export const SHARE_HOLDING_STATE: {
[key: string]: { label: string; cls: string; row: string; note: string };
} = {
HOLDING: {
label: 'Holding',
cls: 'srep-pill--green',
row: '',
note: 'Paid-up shares in hand.'
},
UNPAID: {
label: 'Allotted · unpaid',
cls: 'srep-pill--amber',
row: 'srep-row--flag',
note: 'Shares allotted, but the collection was never applied — so they are in no capital figure.'
},
NIL: {
label: 'Nil holding',
cls: 'srep-pill--grey',
row: 'srep-row--muted',
note: 'Active and paid for, but redeemed back down to nothing.'
},
CLOSED: {
label: 'Closed',
cls: 'srep-pill--grey',
row: 'srep-row--muted',
note: 'The account is closed — the nil holding is final and correct.'
},
NOT_ACTIVE: {
label: 'Not active',
cls: 'srep-pill--blue',
row: 'srep-row--muted',
note: 'Submitted or approved, not yet activated.'
},
REJECTED: {
label: 'Rejected',
cls: 'srep-pill--red',
row: 'srep-row--muted',
note: 'The application never became an account.'
}
};
/** The states in the order a reader thinks about them — live first, finished last. */
export const SHARE_HOLDING_STATE_ORDER = [
'HOLDING',
'UNPAID',
'NIL',
'CLOSED',
'NOT_ACTIVE',
'REJECTED'
];
export function holdingStateLabel(state: string | null | undefined): string {
return SHARE_HOLDING_STATE[state || '']?.label || state || '—';
}
export function holdingStateClass(state: string | null | undefined): string {
return SHARE_HOLDING_STATE[state || '']?.cls || 'srep-pill--grey';
}
/** The row treatment: dimmed when the account is over, flagged when it still wants chasing. */
export function holdingStateRowClass(state: string | null | undefined): string {
return SHARE_HOLDING_STATE[state || '']?.row || '';
}
export function holdingStateNote(state: string | null | undefined): string {
return SHARE_HOLDING_STATE[state || '']?.note || '';
}
/** m_share_account_transactions.type_enum — only the two that move shares. */
export const SHARE_TXN_TYPE: { [key: number]: { label: string; cls: string } } = {
500: { label: 'Purchase', cls: 'srep-pill--green' },
600: { label: 'Redemption', cls: 'srep-pill--red' },
700: { label: 'Charge Payment', cls: 'srep-pill--grey' }
};
/** m_share_account_transactions.status_enum — a tranche's own approval state. */
export const SHARE_TXN_STATUS: { [key: number]: { label: string; cls: string } } = {
100: { label: 'Pending', cls: 'srep-pill--amber' },
300: { label: 'Approved', cls: 'srep-pill--green' },
400: { label: 'Rejected', cls: 'srep-pill--red' }
};
/** How a tranche was paid for; blank on the opening tranche, which the account itself carries. */
export function fundingLabel(source: string | null | undefined): string {
if (!source) {
return 'Opening';
}
return source === 'CASH' ? 'Cash Receipt' : source === 'SAVINGS' ? 'Savings Debit' : source;
}
/** m_share_account_dividend_details.status — where a member's dividend has got to. */
export const SHARE_DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = {
100: { label: 'Declared', cls: 'srep-pill--amber' },
300: { label: 'Approved', cls: 'srep-pill--blue' },
600: { label: 'Paid', cls: 'srep-pill--green' }
};
/** Where a transfer stands in maker-checker. */
export const SHARE_TRANSFER_STATUS: { [key: string]: { label: string; cls: string } } = {
PENDING: { label: 'Pending approval', cls: 'srep-pill--amber' },
APPROVED: { label: 'Approved', cls: 'srep-pill--green' },
REJECTED: { label: 'Rejected', cls: 'srep-pill--red' }
};
/**
* Fineract's period frequency enum, as a phrase.
*
* Reads "18 months" rather than "18 / 2", which is what the raw pair means to nobody outside the
* schema. An unset period says "None" rather than showing a bare zero that looks like a real term.
*/
export function periodLabel(value: number | null | undefined, frequencyEnum: number | null | undefined): string {
if (!value) {
return 'None';
}
const unit = { 0: 'day', 1: 'week', 2: 'month', 3: 'year' }[frequencyEnum ?? -1] || 'period';
return `${value} ${unit}${Number(value) === 1 ? '' : 's'}`;
}
/**
* Downloads rows as CSV.
*
* Every field is quoted and its own quotes doubled, so a member name carrying a comma — which is
* most of them, once an address or a title creeps in — cannot shift every later column by one.
*/
export function shareReportCsv(
filename: string,
headers: string[],
rows: (string | number | null | undefined)[][]
): void {
const escape = (value: string | number | null | undefined): string => `"${String(value ?? '').replace(/"/g, '""')}"`;
const csv = [
headers.map(escape).join(','),
...rows.map((row) => row.map(escape).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 = `${filename}.csv`;
link.click();
URL.revokeObjectURL(url);
}
/** The browser's own print, against the component's print stylesheet. */
export function shareReportPrint(): void {
window.print();
}
@@ -0,0 +1,230 @@
<!--
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="info-circle" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Status Report</h2>
<div class="cert-head-s">
Where every share account stands — by status and share class, with the accounts behind each figure.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredAccounts.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredAccounts.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
@if (totals) {
<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">{{ totals.accountCount | number }}</div>
<div class="cert-kpi-l">Share Accounts</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.memberCount | number }}</div>
<div class="cert-kpi-l">Members</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">{{ totals.approvedShares | number }}</div>
<div class="cert-kpi-l">Shares Allotted</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">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
</div>
}
@if (loading) {
<div class="srep-state">Loading share status…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else {
<!-- Summary: click a status to narrow the detail below, click again to clear -->
<div class="srep-table-wrap srep-no-print-filters">
<table class="srep-table">
<thead>
<tr>
<th>Status</th>
<th class="srep-num">Accounts</th>
<th class="srep-num">Members</th>
<th class="srep-num">Shares Allotted</th>
<th class="srep-num">Share Value</th>
</tr>
</thead>
<tbody>
@for (s of statusTotals; track s.statusEnum) {
<tr class="srep-row--click" (click)="selectStatus(s.statusEnum)">
<td>
<span class="srep-pill" [ngClass]="statusClass(s.statusEnum)">{{ statusLabel(s.statusEnum) }}</span>
@if (statusFilter === s.statusEnum) {
<span class="srep-pill srep-pill--blue srep-no-print">&nbsp;filtered&nbsp;</span>
}
</td>
<td class="srep-num">{{ s.accountCount | number }}</td>
<td class="srep-num">{{ s.memberCount | number }}</td>
<td class="srep-num">{{ s.shares | number }}</td>
<td class="srep-num">₹{{ s.value | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>All statuses</td>
<td class="srep-num">{{ totals?.accountCount | number }}</td>
<td class="srep-num">{{ totals?.memberCount | number }}</td>
<td class="srep-num">{{ totals?.approvedShares | number }}</td>
<td class="srep-num">₹{{ totals?.shareValue | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
<!-- Product breakup -->
@if (groups.length > 1) {
<div class="cert-filters" style="margin-top: 0.85rem">
<div class="cert-filter" style="flex: 1 1 100%">
<label>By share class</label>
</div>
</div>
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Share Product</th>
<th>Status</th>
<th class="srep-num">Accounts</th>
<th class="srep-num">Members</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Pending Shares</th>
<th class="srep-num">Share Value</th>
</tr>
</thead>
<tbody>
@for (g of groups; track g.productId + '-' + g.statusEnum) {
<tr>
<td>{{ g.productName }}</td>
<td>
<span class="srep-pill" [ngClass]="statusClass(g.statusEnum)">{{ statusLabel(g.statusEnum) }}</span>
</td>
<td class="srep-num">{{ g.accountCount | number }}</td>
<td class="srep-num">{{ g.memberCount | number }}</td>
<td class="srep-num">{{ g.approvedShares | number }}</td>
<td class="srep-num">{{ g.pendingShares | number }}</td>
<td class="srep-num">₹{{ g.shareValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Detail -->
<div class="cert-filters" style="margin-top: 0.85rem">
<div class="cert-filter">
<label>Member / Membership No / A/c No</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
</div>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option [ngValue]="''">All statuses</option>
@for (s of statusOptions; track s) {
<option [ngValue]="s">{{ statusLabel(s) }}</option>
}
</select>
</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>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (!filteredAccounts.length) {
<div class="srep-state">No share account matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Branch</th>
<th>Product</th>
<th>Status</th>
<th>Status Date</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Pending</th>
<th class="srep-num">Share Value</th>
</tr>
</thead>
<tbody>
@for (a of filteredAccounts; track a.shareAccountId) {
<tr class="srep-row--click" (click)="openAccount(a)">
<td>{{ a.clientName }}</td>
<td class="srep-mono">{{ a.cifNumber || '—' }}</td>
<td class="srep-mono">{{ a.accountNo }}</td>
<td>{{ a.officeName || '—' }}</td>
<td>{{ a.productName }}</td>
<td>
<span class="srep-pill" [ngClass]="statusClass(a.statusEnum)">{{ statusLabel(a.statusEnum) }}</span>
</td>
<td>{{ effectiveDate(a) || '—' }}</td>
<td class="srep-num">{{ a.approvedShares | number }}</td>
<td class="srep-num">{{ a.pendingShares | number }}</td>
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
@@ -0,0 +1,268 @@
/**
* 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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { SHARE_ACCOUNT_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
interface StatusGroup {
statusEnum: number;
productId: number;
productName: string;
accountCount: number;
memberCount: number;
approvedShares: number;
pendingShares: number;
shareValue: number;
}
interface StatusAccount {
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeName: string;
productId: number;
productName: string;
currencyCode: string;
statusEnum: number;
approvedShares: number;
pendingShares: number;
shareValue: number;
submittedDate: string;
approvedDate: string;
activatedDate: string;
rejectedDate: string;
closedDate: string;
}
/**
* Share Status — where every share account stands, summarised by status and share class, with the
* accounts behind each figure on the same screen.
*
* <p>
* Summary and detail together on purpose: the question this report is opened with is almost always
* "how many are pending, and which ones", and splitting that across two screens turns one answer
* into two navigations. Clicking a summary row filters the detail below it rather than leaving.
*/
@Component({
selector: 'mifosx-share-status-report',
templateUrl: './share-status-report.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareStatusReportComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
groups: StatusGroup[] = [];
accounts: StatusAccount[] = [];
totals: any = null;
search = '';
branchFilter = '';
productFilter = '';
/** Set by clicking a summary row; '' means every status. */
statusFilter: number | '' = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareStatusSummary({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The share status report could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.groups = res?.groups || [];
this.accounts = res?.accounts || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get branchOptions(): string[] {
return [...new Set(this.accounts.map((a) => a.officeName).filter(Boolean))].sort();
}
get productOptions(): string[] {
return [...new Set(this.accounts.map((a) => a.productName).filter(Boolean))].sort();
}
/** Distinct statuses present, in the lifecycle order a reader expects rather than by count. */
get statusOptions(): number[] {
return [...new Set(this.groups.map((g) => g.statusEnum))].sort((a, b) => a - b);
}
/** One line per status, products folded together — the shape a board paper wants. */
get statusTotals(): {
statusEnum: number;
accountCount: number;
memberCount: number;
shares: number;
value: number;
}[] {
const byStatus = new Map<
number,
{ statusEnum: number; accountCount: number; memberCount: number; shares: number; value: number }
>();
this.groups.forEach((g) => {
const entry = byStatus.get(g.statusEnum) || {
statusEnum: g.statusEnum,
accountCount: 0,
memberCount: 0,
shares: 0,
value: 0
};
entry.accountCount += Number(g.accountCount || 0);
entry.memberCount += Number(g.memberCount || 0);
entry.shares += Number(g.approvedShares || 0);
entry.value += Number(g.shareValue || 0);
byStatus.set(g.statusEnum, entry);
});
return [...byStatus.values()].sort((a, b) => a.statusEnum - b.statusEnum);
}
get filteredAccounts(): StatusAccount[] {
const term = this.search.trim().toLowerCase();
return this.accounts.filter((a) => {
if (this.statusFilter !== '' && a.statusEnum !== this.statusFilter) {
return false;
}
if (this.branchFilter && a.officeName !== this.branchFilter) {
return false;
}
if (this.productFilter && a.productName !== this.productFilter) {
return false;
}
if (!term) {
return true;
}
return [
a.clientName,
a.accountNo,
a.cifNumber,
a.productName
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
selectStatus(statusEnum: number): void {
this.statusFilter = this.statusFilter === statusEnum ? '' : statusEnum;
}
statusLabel(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
statusClass(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
}
/** The date that put an account into the status it is in — otherwise every row shows a blank. */
effectiveDate(account: StatusAccount): string {
switch (account.statusEnum) {
case 600:
return account.closedDate;
case 500:
return account.rejectedDate;
case 300:
return account.activatedDate;
case 200:
return account.approvedDate;
default:
return account.submittedDate;
}
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
}
openAccount(account: StatusAccount): void {
this.router.navigate([
'/clients',
account.clientId,
'shares-accounts',
account.shareAccountId,
'general'
]);
}
exportCsv(): void {
shareReportCsv(
'share-status-report',
[
'Member',
'Membership No',
'Share A/c No',
'Branch',
'Product',
'Status',
'Status Date',
'Approved Shares',
'Pending Shares',
'Share Value'
],
this.filteredAccounts.map((a) => [
a.clientName,
a.cifNumber,
a.accountNo,
a.officeName,
a.productName,
this.statusLabel(a.statusEnum),
this.effectiveDate(a),
a.approvedShares,
a.pendingShares,
a.shareValue
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -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/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="exchange" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Transfer</h2>
<div class="cert-head-s">
Moving shares from one member to another, under board resolution — and the register of every transfer made.
</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>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="exchange" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.transferCount | number }}</div>
<div class="cert-kpi-l">Transfers</div>
</div>
</div>
<div class="cert-kpi" [ngClass]="totals.pendingCount ? 'cert-kpi--amber' : 'cert-kpi--blue'">
<fa-icon icon="sync" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.pendingCount | number }}</div>
<div class="cert-kpi-l">Awaiting Approval</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">{{ totals.sharesTransferred | number }}</div>
<div class="cert-kpi-l">Shares Transferred</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">₹{{ totals.valueTransferred | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Value Transferred</div>
</div>
</div>
</div>
}
<!-- Tabs -->
<div class="cert-filters srep-no-print">
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" [class.srep-tab--active]="tab === 'register'" (click)="tab = 'register'">
Transfer register
</button>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" [class.srep-tab--active]="tab === 'new'" (click)="tab = 'new'">New transfer</button>
</div>
</div>
@if (tab === 'new') {
<!-- ── Raise a transfer ── -->
<div class="srep-stmt-head" style="display: block">
<div class="cert-filters" style="margin: 0">
<div class="cert-filter" style="flex: 2 1 300px; position: relative">
<label>Transfer FROM (member giving up shares)</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input
type="text"
[ngModel]="fromSearch"
(ngModelChange)="onFromSearch($event)"
placeholder="Search member or share account number"
/>
</div>
@if (fromOptions.length && !fromAccount) {
<div class="srep-picker">
@for (opt of fromOptions; track opt.id) {
<button type="button" class="srep-picker-item" (click)="pickFrom(opt)">
<span class="srep-picker-name">{{ opt.clientName }}</span>
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
<span class="srep-picker-meta">
{{ opt.productName }} · holds {{ opt.totalApprovedShares | number }}
</span>
</button>
}
</div>
}
</div>
<div class="cert-filter" style="flex: 2 1 300px; position: relative">
<label>Transfer TO (member receiving shares)</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input
type="text"
[ngModel]="toSearch"
(ngModelChange)="onToSearch($event)"
placeholder="Search member or share account number"
/>
</div>
@if (toOptions.length && !toAccount) {
<div class="srep-picker">
@for (opt of toOptions; track opt.id) {
<button type="button" class="srep-picker-item" (click)="pickTo(opt)">
<span class="srep-picker-name">{{ opt.clientName }}</span>
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
<span class="srep-picker-meta">
{{ opt.productName }} · holds {{ opt.totalApprovedShares | number }}
</span>
</button>
}
</div>
}
</div>
<div class="cert-filter">
<label>Shares to transfer</label>
<input type="number" min="1" [(ngModel)]="shares" placeholder="e.g. 20" />
</div>
<div class="cert-filter">
<label>Transfer date</label>
<input type="date" [(ngModel)]="transferDate" />
</div>
<div class="cert-filter">
<label>Board resolution no.</label>
<input type="text" [(ngModel)]="resolutionNumber" placeholder="e.g. BR-2026-41" />
</div>
<div class="cert-filter">
<label>Board meeting date</label>
<input type="date" [(ngModel)]="meetingDate" />
</div>
<div class="cert-filter" style="flex: 2 1 300px">
<label>Reason (optional)</label>
<input type="text" [(ngModel)]="reason" placeholder="e.g. transfer to legal heir" />
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="submit()" [disabled]="!canSubmit">
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
</button>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="resetForm()">Clear</button>
</div>
</div>
@if (fromAccount && toAccount && shares) {
<div class="srep-scope-note">
{{ shares | number }} shares of {{ fromAccount.productName }} move from
<b>{{ fromAccount.clientName }}</b> ({{ fromAccount.accountNo }}) to <b>{{ toAccount.clientName }}</b> ({{
toAccount.accountNo
}}). Share capital does not change — only the register does. Nothing moves until a checker approves it.
</div>
}
</div>
@if (submitError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ submitError }}</div>
}
@if (submitSuccess) {
<div class="srep-state"><fa-icon icon="check-circle"></fa-icon> {{ submitSuccess }}</div>
}
}
<!-- ── Register ── -->
<div class="cert-filters" [class.srep-no-print]="tab === 'new'">
<div class="cert-filter">
<label>Member / Reference / Resolution</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or TRF-260917-0001" />
</div>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter" (change)="loadRegister()">
<option value="ALL">All</option>
<option value="PENDING">Pending approval</option>
<option value="APPROVED">Approved</option>
<option value="REJECTED">Rejected</option>
</select>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the transfer register…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No share transfer has been recorded yet.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No transfer matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Reference</th>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Product</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Value</th>
<th>Board Resolution</th>
<th>Status</th>
<th class="srep-no-print">Action</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.transferId) {
<tr>
<td class="srep-mono">{{ row.transferReference }}</td>
<td>{{ row.transferDate }}</td>
<td>
{{ row.fromClientName }}
<div class="srep-scope-note srep-mono" style="margin: 0">{{ row.fromAccountNo }}</div>
</td>
<td>
{{ row.toClientName }}
<div class="srep-scope-note srep-mono" style="margin: 0">{{ row.toAccountNo }}</div>
</td>
<td>{{ row.productName }}</td>
<td class="srep-num">{{ row.shares | number }}</td>
<td class="srep-num">₹{{ row.transferAmount | number: '1.2-2' }}</td>
<td class="srep-mono">
{{ row.resolutionNumber || '—' }}
@if (row.meetingDate) {
<div class="srep-scope-note" style="margin: 0">{{ row.meetingDate }}</div>
}
</td>
<td>
<span class="srep-pill" [ngClass]="statusClass(row.status)">{{ statusLabel(row.status) }}</span>
@if (row.status === 'REJECTED' && row.rejectionReason) {
<div class="srep-scope-note" style="margin: 0">{{ row.rejectionReason }}</div>
}
@if (row.status !== 'PENDING' && row.checkerName) {
<div class="srep-scope-note" style="margin: 0">by {{ row.checkerName }}</div>
}
</td>
<td class="srep-no-print">
@if (row.status === 'PENDING') {
<button class="cert-cta-btn" (click)="approve(row)" [disabled]="actioningId === row.transferId">
{{ actioningId === row.transferId ? '…' : 'Approve' }}
</button>
<button class="cert-cta-btn" (click)="reject(row)" [disabled]="actioningId === row.transferId">
Reject
</button>
} @else {
<span class="srep-scope-note">{{ row.actionedOn || '—' }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,339 @@
/**
* 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 { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { SHARE_TRANSFER_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
/**
* Share Transfer — moving shares from one member to another, and the register of every transfer
* made.
*
* <p>
* No money reaches the society and its share capital does not change: what changes is the register,
* one member's holding down and another's up. The society's part is to approve it, so the screen is
* a request form and a queue rather than an instant action — a transfer carries voting rights with
* it and is not one clerk's decision.
*
* <p>
* Every rule is enforced by the server at submission and again at approval. Nothing is validated
* only here: holdings move while a request waits, and a browser that checked once at 10am would
* happily submit something impossible by noon.
*/
@Component({
selector: 'mifosx-share-transfer',
templateUrl: './share-transfer.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareTransferComponent implements OnInit {
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
/** Which half of the screen is showing: raise a transfer, or work the register. */
tab: 'new' | 'register' = 'register';
// ── Register ──
loading = true;
loadError = '';
rows: any[] = [];
totals: any = null;
statusFilter = 'ALL';
search = '';
actioningId: number | null = null;
// ── New transfer ──
fromSearch = '';
toSearch = '';
fromOptions: any[] = [];
toOptions: any[] = [];
fromAccount: any = null;
toAccount: any = null;
shares: number | null = null;
transferDate = '';
resolutionNumber = '';
meetingDate = '';
reason = '';
submitting = false;
submitError = '';
submitSuccess = '';
private fromTerm$ = new Subject<string>();
private toTerm$ = new Subject<string>();
ngOnInit(): void {
const businessDate: Date = this.settingsService.businessDate || new Date();
this.transferDate = this.toIso(businessDate);
this.fromTerm$
.pipe(
debounceTime(350),
distinctUntilChanged(),
switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))))
)
.subscribe((res: any) => (this.fromOptions = Array.isArray(res) ? res : res?.pageItems || []));
this.toTerm$
.pipe(
debounceTime(350),
distinctUntilChanged(),
switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))))
)
.subscribe((res: any) => (this.toOptions = Array.isArray(res) ? res : res?.pageItems || []));
this.loadRegister();
}
// ── Register ──────────────────────────────────────────────────────────
loadRegister(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareTransfers(this.statusFilter === 'ALL' ? undefined : this.statusFilter)
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The transfer register could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get filteredRows(): any[] {
const term = this.search.trim().toLowerCase();
if (!term) {
return this.rows;
}
return this.rows.filter((r) =>
[
r.fromClientName,
r.toClientName,
r.fromAccountNo,
r.toAccountNo,
r.transferReference,
r.resolutionNumber
]
.filter(Boolean)
.some((field: any) => String(field).toLowerCase().includes(term))
);
}
approve(row: any): void {
this.actioningId = row.transferId;
this.loadError = '';
this.sharesService
.approveShareTransfer(row.transferId)
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The transfer could not be approved.');
this.actioningId = null;
return of(null);
})
)
.subscribe((res: any) => {
this.actioningId = null;
if (res) {
this.loadRegister();
}
});
}
reject(row: any): void {
this.actioningId = row.transferId;
this.loadError = '';
this.sharesService
.rejectShareTransfer(row.transferId)
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The transfer could not be rejected.');
this.actioningId = null;
return of(null);
})
)
.subscribe((res: any) => {
this.actioningId = null;
if (res) {
this.loadRegister();
}
});
}
statusLabel(status: string): string {
return SHARE_TRANSFER_STATUS[status]?.label || status;
}
statusClass(status: string): string {
return SHARE_TRANSFER_STATUS[status]?.cls || 'srep-pill--grey';
}
// ── New transfer ──────────────────────────────────────────────────────
onFromSearch(term: string): void {
this.fromSearch = term;
this.fromAccount = null;
this.fromTerm$.next(term.trim());
}
onToSearch(term: string): void {
this.toSearch = term;
this.toAccount = null;
this.toTerm$.next(term.trim());
}
pickFrom(option: any): void {
this.fromAccount = option;
this.fromOptions = [];
this.fromSearch = `${option.clientName} — ${option.accountNo}`;
}
pickTo(option: any): void {
this.toAccount = option;
this.toOptions = [];
this.toSearch = `${option.clientName} — ${option.accountNo}`;
}
/**
* Only what the browser can honestly know: both sides chosen, a positive number of shares, and the
* resolution captured. Everything about holdings and class limits is the server's to judge, since
* only it knows what the register says right now.
*/
get canSubmit(): boolean {
return !!(
this.fromAccount &&
this.toAccount &&
this.shares &&
this.shares > 0 &&
this.transferDate &&
this.resolutionNumber.trim() &&
this.meetingDate &&
!this.submitting
);
}
submit(): void {
if (!this.canSubmit) {
return;
}
this.submitting = true;
this.submitError = '';
this.submitSuccess = '';
this.sharesService
.submitShareTransfer({
fromShareAccountId: this.fromAccount.id,
toShareAccountId: this.toAccount.id,
shares: this.shares,
transferDate: this.transferDate,
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
reason: this.reason.trim() || null
})
.pipe(
catchError((err) => {
this.submitError = this.readError(err, 'The transfer could not be submitted.');
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
this.submitting = false;
if (res) {
this.submitSuccess = res.message || `Transfer ${res.transferReference} sent for approval.`;
this.resetForm();
this.loadRegister();
}
});
}
resetForm(): void {
this.fromSearch = '';
this.toSearch = '';
this.fromAccount = null;
this.toAccount = null;
this.shares = null;
this.resolutionNumber = '';
this.meetingDate = '';
this.reason = '';
this.submitError = '';
}
exportCsv(): void {
shareReportCsv(
'share-transfer-register',
[
'Reference',
'Date',
'From Member',
'From A/c No',
'To Member',
'To A/c No',
'Product',
'Shares',
'Unit Price',
'Value',
'Board Resolution',
'Meeting Date',
'Status',
'Raised By',
'Actioned By'
],
this.filteredRows.map((r) => [
r.transferReference,
r.transferDate,
r.fromClientName,
r.fromAccountNo,
r.toClientName,
r.toAccountNo,
r.productName,
r.shares,
r.unitPrice,
r.transferAmount,
r.resolutionNumber,
r.meetingDate,
this.statusLabel(r.status),
r.makerName,
r.checkerName
])
);
}
print(): void {
shareReportPrint();
}
private toIso(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,165 @@
<!--
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="file-alt" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Wise Report</h2>
<div class="cert-head-s">
How the society's share capital is spread — members per holding band, and the split by share class.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !bands.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !bands.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalMembers | number }}</div>
<div class="cert-kpi-l">Shareholding Members</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">{{ totals.shares | number }}</div>
<div class="cert-kpi-l">Shares Held</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">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="chart-pie" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ byProduct.length | number }}</div>
<div class="cert-kpi-l">Share Classes</div>
</div>
</div>
</div>
}
<div class="cert-filters">
<div class="cert-filter">
<label>As on date</label>
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the shareholding pattern…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!totals?.shares) {
<div class="srep-state">No member held share capital as on {{ asOnDate }}.</div>
} @else {
<!-- Distribution by holding band -->
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Holding Band (shares)</th>
<th class="srep-num">Members</th>
<th style="width: 22%">Distribution</th>
<th class="srep-num">Accounts</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Share Value</th>
<th class="srep-num">% of Capital</th>
</tr>
</thead>
<tbody>
@for (band of bands; track band.band) {
<tr>
<td>{{ band.band }}</td>
<td class="srep-num">{{ band.memberCount | number }}</td>
<td>
<div class="srep-bar">
<div class="srep-bar-fill" [style.width.%]="barWidth(band)"></div>
</div>
</td>
<td class="srep-num">{{ band.accountCount | number }}</td>
<td class="srep-num">{{ band.shares | number }}</td>
<td class="srep-num">₹{{ band.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">{{ band.capitalShare | number: '1.2-2' }}%</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>All bands</td>
<td class="srep-num">{{ totalMembers | number }}</td>
<td></td>
<td class="srep-num"></td>
<td class="srep-num">{{ totals.shares | number }}</td>
<td class="srep-num">₹{{ totals.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">100.00%</td>
</tr>
</tfoot>
</table>
</div>
<!-- Split by share class -->
<div class="cert-filters" style="margin-top: 0.85rem">
<div class="cert-filter" style="flex: 1 1 100%">
<label>By share class</label>
</div>
</div>
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Share Product</th>
<th class="srep-num">Members</th>
<th class="srep-num">Accounts</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Share Value</th>
<th class="srep-num">% of Capital</th>
</tr>
</thead>
<tbody>
@for (row of byProduct; track row.productId) {
<tr>
<td>{{ row.productName }}</td>
<td class="srep-num">{{ row.memberCount | number }}</td>
<td class="srep-num">{{ row.accountCount | number }}</td>
<td class="srep-num">{{ row.shares | number }}</td>
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">{{ productShare(row) | number: '1.2-2' }}%</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>All classes</td>
<td class="srep-num"></td>
<td class="srep-num"></td>
<td class="srep-num">{{ totals.shares | number }}</td>
<td class="srep-num">₹{{ totals.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">100.00%</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,167 @@
/**
* 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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { shareReportCsv, shareReportPrint } from '../share-report.util';
interface BandRow {
band: string;
bandOrder: number;
accountCount: number;
memberCount: number;
shares: number;
shareValue: number;
/** This band's percentage of total share capital, computed server-side against the true total. */
capitalShare: number;
}
interface ProductRow {
productId: number;
productName: string;
accountCount: number;
memberCount: number;
shares: number;
shareValue: number;
}
/**
* Share Wise Report — the shareholding pattern: how the society's capital is spread across its
* members, and how it splits by share class.
*
* <p>
* Concentration is what this answers. A total and a member count cannot show whether three members
* hold half the capital, and that is the number a board and the Registrar both ask for.
*
* <p>
* No client-side filters beyond the date and scope the server already applied: a distribution that
* can be narrowed in the browser stops being a distribution — the percentages would still be of the
* whole while the bands shown were of a part.
*/
@Component({
selector: 'mifosx-share-wise-report',
templateUrl: './share-wise-report.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareWiseReportComponent implements OnInit {
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
loading = true;
loadError = '';
bands: BandRow[] = [];
byProduct: ProductRow[] = [];
totals: any = null;
asOnDate = '';
ngOnInit(): void {
const businessDate: Date = this.settingsService.businessDate || new Date();
this.asOnDate = this.toIso(businessDate);
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareWiseReport({ asOnDate: this.asOnDate })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The shareholding pattern could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.bands = res?.bands || [];
this.byProduct = res?.byProduct || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get totalMembers(): number {
return this.bands.reduce((sum, b) => sum + Number(b.memberCount || 0), 0);
}
/** A product's share of total capital — the same reading the bands give, by class instead. */
productShare(row: ProductRow): number {
const total = Number(this.totals?.shareValue || 0);
return total ? (Number(row.shareValue || 0) * 100) / total : 0;
}
/** Bar width for the inline distribution graphic; the largest band fills the cell. */
barWidth(row: BandRow): number {
const largest = Math.max(...this.bands.map((b) => Number(b.memberCount || 0)), 1);
return (Number(row.memberCount || 0) * 100) / largest;
}
exportCsv(): void {
shareReportCsv(
`share-wise-${this.asOnDate || 'today'}`,
[
'Holding Band',
'Accounts',
'Members',
'Shares',
'Share Value',
'% of Capital'
],
[
...this.bands.map((b) => [
b.band,
b.accountCount,
b.memberCount,
b.shares,
b.shareValue,
b.capitalShare
]),
[
'TOTAL',
'',
this.totalMembers,
this.totals?.shares ?? '',
this.totals?.shareValue ?? '',
100
]
]
);
}
print(): void {
shareReportPrint();
}
private toIso(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -208,20 +208,20 @@
</label> </label>
<div class="lookup-input-group"> <div class="lookup-input-group">
<input <input
#voucherInput
type="text" type="text"
class="lookup-input font-mono" class="lookup-input font-mono"
placeholder="Voucher no. (e.g. 000123)" placeholder="Voucher no. (e.g. 000123)"
[(ngModel)]="voucherNo"
[disabled]="lookingUpVoucher || submitting" [disabled]="lookingUpVoucher || submitting"
(keyup.enter)="lookupVoucher(voucherInput.value)" (keyup.enter)="lookupVoucher()"
/> />
<button <button
mat-flat-button mat-flat-button
color="primary" color="primary"
type="button" type="button"
class="find-btn" class="find-btn"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting" [disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)" (click)="lookupVoucher()"
> >
@if (lookingUpVoucher) { @if (lookingUpVoucher) {
<mat-spinner diameter="14"></mat-spinner> <mat-spinner diameter="14"></mat-spinner>
@@ -234,6 +234,62 @@
</div> </div>
</div> </div>
<!-- Receipts tagged "Share Collection" that belong to nobody yet. A deposit straight into
a GL account names no member, so it is never attributed on its own -- and never
filtered by this member's client id either, since the cashier's typed description is
free text and could easily be wrong. Attaching one is still a click made against a
member on screen, exactly like typing the voucher number above. -->
@if (unattributed.length) {
<div class="unattributed-section">
<div class="section-title text-amber">
<mat-icon class="sec-icon">person_add</mat-icon>
<span>Unattached Share Collection Receipts — Attach to This Tranche</span>
</div>
<div class="table-responsive">
<table class="receipt-table unattributed-table">
<thead>
<tr>
<th class="ta-left">Voucher No</th>
<th class="ta-left">Date</th>
<th class="ta-left">Deposited To</th>
<th class="ta-left">Description</th>
<th class="ta-right">Amount</th>
<th class="col-attach ta-center">Action</th>
</tr>
</thead>
<tbody>
@for (u of unattributed; track u.id) {
<tr [class.own-client-row]="isOwnClientVoucher(u)">
<td class="ta-left">
<span class="voucher-badge font-mono">{{ u.voucherNo }}</span>
@if (isOwnClientVoucher(u)) {
<span class="own-client-badge">This member's receipt</span>
}
</td>
<td class="ta-left">{{ u.txnDate }}</td>
<td class="ta-left">{{ u.glAccountNo }} — {{ u.glAccountName }}</td>
<td class="ta-left desc-cell" [title]="u.description">{{ u.description || '—' }}</td>
<td class="ta-right amount-cell">{{ u.amount | number: '1.2-2' }}</td>
<td class="ta-center col-attach">
<button
mat-flat-button
color="accent"
type="button"
class="attach-btn"
[disabled]="lookingUpVoucher || submitting"
(click)="attachUnattributedVoucher(u)"
>
Attach
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (voucherError) { @if (voucherError) {
<div class="alert-box alert-error"> <div class="alert-box alert-error">
<mat-icon>error_outline</mat-icon> <mat-icon>error_outline</mat-icon>
@@ -481,6 +481,69 @@ mat-form-field {
overflow-x: auto; overflow-x: auto;
} }
/* Unattached share-collection receipts. Set apart in amber so it never reads as this member's own
settled list above: these belong to nobody until somebody attaches one. */
.unattributed-section {
margin: 0 0 0.75rem;
padding: 0.65rem;
border: 1px solid $warning-border;
border-radius: 8px;
background: $warning-bg;
.section-title {
border-bottom: none;
margin-bottom: 0.5rem;
padding-bottom: 0;
&.text-amber {
color: $warning;
.sec-icon {
color: $warning;
}
}
}
.unattributed-table {
background: $bg-surface;
.col-attach {
width: 96px;
}
}
.attach-btn {
height: 28px;
line-height: 28px;
font-size: 0.75rem;
padding: 0 0.65rem;
}
// A row already linked to the member whose screen this is -- the same voucher is also sitting,
// pre-loaded and tickable, in the Vouchers/Receipts table below. This is not a duplicate to
// clear up; it is the same receipt shown two ways for two different purposes (find it here,
// select it there), so the row is called out instead of removed.
.own-client-row {
background: $success-bg;
&:hover {
background: $success-bg;
}
.voucher-badge {
border-color: $success-border;
}
}
.own-client-badge {
display: block;
margin-top: 2px;
font-size: 0.65rem;
font-weight: 700;
color: $success;
}
}
.receipt-table { .receipt-table {
width: 100%; width: 100%;
border-collapse: separate; border-collapse: separate;
@@ -100,6 +100,24 @@ export class ApplySharesComponent implements OnInit {
lookingUpVoucher = false; lookingUpVoucher = false;
voucherError = ''; voucherError = '';
/** Voucher / Receipt No shown in the "Find Specific Voucher" box. Mirrors whichever receipt is
* ticked below (see {@link toggleVoucher}) so a tick is visibly confirmed up there too, and
* carries whatever the counter has typed in when they search by hand instead. */
voucherNo = '';
/**
* Share-collection receipts that are approved but belong to nobody yet.
*
* A receipt paid straight into a GL account names no member of its own — a GL account is nobody's
* account — so it is never attributed automatically, and never filtered by this member's client id
* (the cashier's typed description is free text and can easily be wrong; nothing here is trusted
* to match a member by parsing it). Listing it is not attribution: it only makes the voucher
* findable, so a counter that tagged a receipt "Share Collection" moments ago is not told there is
* nothing to fund this tranche with. Attaching it below is still a deliberate click against the
* member on screen.
*/
unattributed: any[] = [];
/** GL account the deposit was posted to — required, and always picked by hand. Never resolved /** GL account the deposit was posted to — required, and always picked by hand. Never resolved
* from a share product's accounting setup, so a broken or missing product mapping can never * from a share product's accounting setup, so a broken or missing product mapping can never
* make a genuine voucher unreachable. */ * make a genuine voucher unreachable. */
@@ -242,6 +260,7 @@ export class ApplySharesComponent implements OnInit {
this.vouchers = []; this.vouchers = [];
this.selectedVoucherIds = []; this.selectedVoucherIds = [];
this.voucherError = ''; this.voucherError = '';
this.voucherNo = '';
this.errorMsg = ''; this.errorMsg = '';
this.savingsEligibility = null; this.savingsEligibility = null;
if (mode === 'SAVINGS') { if (mode === 'SAVINGS') {
@@ -266,8 +285,9 @@ export class ApplySharesComponent implements OnInit {
/** /**
* Loads the member's unapplied Share Sundry receipts so they can be ticked off. * Loads the member's unapplied Share Sundry receipts so they can be ticked off.
* *
* A single receipt is ticked for them: with only one candidate there is nothing to choose, and * Deliberately nothing ticked, not even when a single receipt is listed — which receipt funds the
* making the clerk tick it adds a step that can only be got wrong by forgetting it. * tranche is a choice the person applying it makes, not one they inherit from a pre-ticked radio
* they never looked at.
*/ */
private loadVouchers(): void { private loadVouchers(): void {
this.loadingVouchers = true; this.loadingVouchers = true;
@@ -286,16 +306,49 @@ export class ApplySharesComponent implements OnInit {
return; return;
} }
this.vouchers = res.unappliedCollections || []; this.vouchers = res.unappliedCollections || [];
if (this.vouchers.length === 1) {
this.selectedVoucherIds = [this.vouchers[0].id];
}
}); });
this.cashierService
.getUnattributedShareCollections()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.unattributed = res?.rows || []));
}
/**
* Attaches one of the unattributed share-collection receipts to this member's tranche.
*
* Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already
* sitting in this list is either a fresh cashier deposit (which does carry its own GL account) or
* an existing, already-adopted {@code m_share_advance_collection} row (the "linked to …" ones),
* whose GL account can genuinely come back blank — its subquery reads the posting the ORIGINAL
* adoption made, which a clerk-disbursement-funded receipt does not always leave behind cleanly.
* Either way the backend needs no GL account to resolve a voucher it can already find by number, so
* a blank one here must never block the click the way it rightly blocks a hand-typed search with
* nothing chosen yet.
*/
attachUnattributedVoucher(row: any): void {
if (this.lookingUpVoucher || this.submitting) {
return;
}
this.resolveVoucher(row.voucherNo, row.glAccountNo || '');
} }
isVoucherSelected(id: number): boolean { isVoucherSelected(id: number): boolean {
return this.selectedVoucherIds.includes(id); return this.selectedVoucherIds.includes(id);
} }
/**
* Whether an unattributed-list row is already linked to the member this screen belongs to.
*
* Such a row also appears, pre-loaded and ready to tick, in the Vouchers/Receipts table below —
* that is not a bug to hide, it is the same receipt reachable two ways. This only flags it so the
* counter understands why it shows up here too, rather than reading it as an unrelated stray
* voucher that happens to look identical.
*/
isOwnClientVoucher(u: any): boolean {
return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.sharesAccountData.clientId);
}
/** /**
* One receipt funds one tranche — no combining instalments here (unlike the first-application * One receipt funds one tranche — no combining instalments here (unlike the first-application
* Apply Collection dialog, which still allows several). Picking a different receipt replaces the * Apply Collection dialog, which still allows several). Picking a different receipt replaces the
@@ -305,15 +358,18 @@ export class ApplySharesComponent implements OnInit {
const wasSelected = this.isVoucherSelected(id); const wasSelected = this.isVoucherSelected(id);
this.selectedVoucherIds = wasSelected ? [] : [id]; this.selectedVoucherIds = wasSelected ? [] : [id];
this.voucherError = ''; this.voucherError = '';
// The GL Account picker above follows whichever receipt is ticked — its own actual GL account if (wasSelected) {
// (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt this.voucherNo = '';
// that plainly does have one is already chosen. return;
if (!wasSelected) { }
// The GL Account and Find Specific Voucher fields above follow whichever receipt is ticked --
// their own actual GL account and voucher number (see gl_code / voucher_no on the row), not left
// showing an unrelated value or blank once a receipt that plainly has them is already chosen.
const voucher = this.vouchers.find((v) => v.id === id); const voucher = this.vouchers.find((v) => v.id === id);
if (voucher?.gl_code) { if (voucher?.gl_code) {
this.glAccountNo = voucher.gl_code; this.glAccountNo = voucher.gl_code;
} }
} this.voucherNo = voucher?.voucher_no || voucher?.receipt_no || '';
} }
/** /**
@@ -330,24 +386,40 @@ export class ApplySharesComponent implements OnInit {
* Resolves a voucher number the member quotes and adds it to the list. * Resolves a voucher number the member quotes and adds it to the list.
* *
* Kept alongside the tick list rather than replacing it: the member usually quotes the number on * Kept alongside the tick list rather than replacing it: the member usually quotes the number on
* the slip in their hand, which is faster than finding it among their receipts. The lookup is * the slip in their hand, which is faster than finding it among their receipts. The lookup is not
* deliberately not restricted to this member, so a voucher belonging to somebody else is * restricted to this member — any receipt found this way can be attached here, whichever member it
* surfaced here as a plain mismatch rather than silently ignored. * was originally recorded against.
*/ */
lookupVoucher(voucherNo: string): void { lookupVoucher(): void {
const trimmed = (voucherNo || '').trim(); const trimmed = this.voucherNo.trim();
const glAccountNo = this.glAccountNo.trim(); const glAccountNo = this.glAccountNo.trim();
// A hand-typed search genuinely needs both fields: an empty GL account here means nothing has
// been chosen yet, not that one isn't needed — unlike {@link attachUnattributedVoucher}, which
// already knows exactly which receipt it means and never has this ambiguity.
if (!trimmed || !glAccountNo || this.lookingUpVoucher) { if (!trimmed || !glAccountNo || this.lookingUpVoucher) {
return; return;
} }
this.resolveVoucher(trimmed, glAccountNo);
}
/**
* Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the
* receipt it finds to the list, selected.
*
* Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank
* by the Find button's own disabled state) and {@link attachUnattributedVoucher} (a click on a row
* already on screen, whose GL account can legitimately be blank) — the actual lookup and its
* bookkeeping afterwards are identical either way.
*/
private resolveVoucher(voucherNo: string, glAccountNo: string): void {
this.lookingUpVoucher = true; this.lookingUpVoucher = true;
this.voucherError = ''; this.voucherError = '';
this.cashierService this.cashierService
.getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId) .getShareAdvanceByVoucher(voucherNo, glAccountNo, this.sharesAccountData.clientId)
.pipe( .pipe(
catchError((err: any) => { catchError((err: any) => {
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`); this.voucherError = this.readError(err, `Voucher ${voucherNo} could not be found.`);
this.lookingUpVoucher = false; this.lookingUpVoucher = false;
return of(null); return of(null);
}) })
@@ -357,10 +429,8 @@ export class ApplySharesComponent implements OnInit {
if (!res) { if (!res) {
return; return;
} }
if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) { // No client-match check here by deliberate choice: any receipt can be attached to any
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`; // share account, regardless of which member it was originally recorded against.
return;
}
// Replaces the selection rather than adding to it — one receipt funds one tranche here, so // Replaces the selection rather than adding to it — one receipt funds one tranche here, so
// finding a voucher by number is a different way to pick THE receipt, not a second one. // finding a voucher by number is a different way to pick THE receipt, not a second one.
this.vouchers = [ this.vouchers = [
@@ -368,6 +438,13 @@ export class ApplySharesComponent implements OnInit {
...this.vouchers.filter((v) => v.id !== res.id) ...this.vouchers.filter((v) => v.id !== res.id)
]; ];
this.selectedVoucherIds = [res.id]; this.selectedVoucherIds = [res.id];
this.voucherNo = res.voucher_no || res.receipt_no || voucherNo;
if (res.gl_code) {
this.glAccountNo = res.gl_code;
}
// Once attached it belongs to this member, so it leaves the unattributed list rather than
// sitting there inviting a second, conflicting attachment.
this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucherNo);
}); });
} }
@@ -6,47 +6,95 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<h2 mat-dialog-title>Apply Share Advance Collection</h2> <div class="dialog-wrapper">
<!-- Dialog Header Banner -->
<mat-dialog-content> <div class="dialog-header-banner">
<p class="intro"> <div class="header-icon-box">
Tick the receipts that make up this payment to settle share account <b>{{ data.accountNo }}</b <mat-icon>account_balance_wallet</mat-icon>
>, then apply. Today's unapplied receipts for this member are listed below — tick more than one where they paid in </div>
instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income. <div class="header-text font-accent">
<h2>Apply Share Advance Collection</h2>
<p>
Settling share account <strong class="font-mono">{{ data.accountNo }}</strong>
</p> </p>
</div>
<button type="button" mat-icon-button class="close-header-btn" [mat-dialog-close]="false">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- What this account needs, up front — so the counter knows the full figure, and that it may take <mat-dialog-content class="dialog-body">
a second receipt for the entry fee, before a search ever comes up short. --> <!-- Intro Banner -->
@if (requirement) { <div class="dialog-intro-banner">
<div class="requirement-summary"> <mat-icon class="banner-icon">info</mat-icon>
<span class="requirement-line"> <span>
Required: <b>{{ requirement.shareAmount | number: '1.2-2' }}</b> (Shares) Tick the receipts that make up this payment to settle share account <strong>{{ data.accountNo }}</strong
@if (requirement.chargeAmount > 0) { >. Multiple receipts can be selected for instalment payments. Click
+ <b>{{ requirement.chargeAmount | number: '1.2-2' }}</b> (Entry Fee) = <mat-icon class="inline-icon">visibility</mat-icon> to view full receipt popup details.
<b>{{ requirement.required | number: '1.2-2' }}</b> Total
}
</span> </span>
@if (selectedIds.length > 0) { </div>
<span class="requirement-progress" [class.covered]="shortfall === 0">
@if (shortfall === 0) { <!-- Requirement Summary Stat Card -->
Share receipts covered @if (requirement) {
} @else { <div class="requirement-summary-card">
{{ selectedTotal | number: '1.2-2' }} of {{ shareRequired | number: '1.2-2' }} collected — <div class="req-header">
{{ shortfall | number: '1.2-2' }} short <mat-icon class="req-icon">calculate</mat-icon>
<span>Tranche Requirement</span>
</div>
<div class="req-metrics font-accent">
<div class="metric-item">
<span class="m-label">Shares Value</span>
<span class="m-val font-mono">{{ requirement.shareAmount | number: '1.2-2' }}</span>
</div>
@if (requirement.chargeAmount > 0) {
<span class="metric-plus">+</span>
<div class="metric-item">
<span class="m-label">Entry Fee</span>
<span class="m-val font-mono">{{ requirement.chargeAmount | number: '1.2-2' }}</span>
</div>
} }
<span class="metric-equals">=</span>
<div class="metric-item highlight-total">
<span class="m-label">Total Required</span>
<span class="m-val font-mono font-semibold">{{ requirement.required | number: '1.2-2' }}</span>
</div>
</div>
@if (selectedIds.length > 0) {
<div class="req-status-bar mt-6">
@if (shortfall === 0) {
<span class="status-badge badge-success">
<mat-icon>check_circle</mat-icon> Share receipts fully covered
</span>
} @else {
<span class="status-badge badge-warning">
<mat-icon>warning</mat-icon> {{ selectedTotal | number: '1.2-2' }} of
{{ shareRequired | number: '1.2-2' }} collected — {{ shortfall | number: '1.2-2' }} short
</span> </span>
} }
</div> </div>
} }
</div>
}
<!-- For a receipt the list does not carry — one taken on an earlier day, or a slip the member is <!-- SHARE RECEIPT LOOKUP SECTION -->
holding that is quicker to type than to find. Both the GL account the deposit was posted to <div class="search-section">
and the voucher number are required — never resolved from a share product's own accounting <div class="section-label">
setup, so a broken or missing product mapping can never make a genuine voucher unreachable. --> <mat-icon class="sec-icon">search</mat-icon>
<div class="voucher-section"> <span>Share Receipt Lookup</span>
<div class="voucher-section-label">Share Receipt</div> </div>
<div class="gl-select-field">
<select class="gl-select" [(ngModel)]="glAccountNo" [disabled]="lookingUp || submitting || loadingGlAccounts"> <div class="lookup-toolbar">
<div class="gl-select-box">
<label class="toolbar-label"><mat-icon class="mini-icon">account_tree</mat-icon> GL Account</label>
<select
class="custom-select"
[(ngModel)]="glAccountNo"
[disabled]="lookingUp || submitting || loadingGlAccounts"
>
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option> <option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
@for (gl of glAccounts; track gl.id) { @for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option> <option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
@@ -54,178 +102,476 @@
</select> </select>
</div> </div>
<div class="voucher-lookup"> <div class="voucher-lookup-box">
<label class="toolbar-label"><mat-icon class="mini-icon">receipt</mat-icon> Voucher / Receipt No</label>
<div class="lookup-input-group">
<input <input
type="text" type="text"
class="voucher-input" class="lookup-input font-mono"
placeholder="Voucher no. (e.g. CR/001/2608/0001)" placeholder="Voucher no. (e.g. CR/001/2608/0001)"
[(ngModel)]="voucherNo" [(ngModel)]="voucherNo"
[disabled]="lookingUp || submitting" [disabled]="lookingUp || submitting"
(keyup.enter)="lookupVoucher()" (keyup.enter)="lookupVoucher()"
/> />
<button <button
mat-stroked-button mat-flat-button
color="primary"
type="button" type="button"
class="find-btn"
[disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUp || submitting" [disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUp || submitting"
(click)="lookupVoucher()" (click)="lookupVoucher()"
> >
{{ lookingUp ? 'Finding…' : 'Find' }} @if (lookingUp) {
<mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
<!-- Shown only when this account actually has an outstanding fee -- a member sometimes pays it as <!-- ENTRY FEE RECEIPT SECTION (If charge exists) -->
a second receipt into a different GL account rather than folding it into the share-value
deposit. This search only ever verifies that receipt exists; it never moves its money -- that
stays exactly where the cashier already deposited it, unlike the share receipts above which
genuinely move out of Sundry. -->
@if (requirement && requirement.chargeAmount > 0) { @if (requirement && requirement.chargeAmount > 0) {
<div class="voucher-section"> <div class="search-section fee-section">
<div class="voucher-section-label">Entry Fee Receipt — verification only, no GL entry is posted for it</div> <div class="section-label">
<mat-icon class="sec-icon font-warning">assignment_turned_in</mat-icon>
<span>Entry Fee Receipt — Verification Only (No GL Entry)</span>
</div>
@if (chargeReceipt) { @if (chargeReceipt) {
<div class="charge-receipt-found" [class.short]="chargeShortfall > 0"> <div class="charge-receipt-found" [class.short]="chargeShortfall > 0">
<mat-icon>{{ chargeShortfall > 0 ? 'error_outline' : 'check_circle_outline' }}</mat-icon> <mat-icon>{{ chargeShortfall > 0 ? 'error_outline' : 'check_circle' }}</mat-icon>
<span class="charge-receipt-detail"> <span class="charge-receipt-detail font-accent">
<b class="mono">{{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }}</b> <b class="font-mono">{{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }}</b>
— {{ chargeReceipt.amount | number: '1.2-2' }} — {{ chargeReceipt.amount | number: '1.2-2' }}
@if (chargeShortfall > 0) { @if (chargeShortfall > 0) {
(short by {{ chargeShortfall | number: '1.2-2' }}) <span class="short-text">(short by {{ chargeShortfall | number: '1.2-2' }})</span>
} @else { } @else {
— entry fee verified <span class="verified-text">— Entry fee verified</span>
} }
</span> </span>
<button mat-button type="button" [disabled]="submitting" (click)="clearChargeReceipt()">Change</button> <button
mat-stroked-button
type="button"
class="change-btn"
[disabled]="submitting"
(click)="clearChargeReceipt()"
>
Change
</button>
</div> </div>
} @else { } @else {
<div class="gl-select-field"> <div class="lookup-toolbar">
<div class="gl-select-box">
<label class="toolbar-label"><mat-icon class="mini-icon">account_tree</mat-icon> Fee GL Account</label>
<select <select
class="gl-select" class="custom-select"
[(ngModel)]="chargeGlAccountNo" [(ngModel)]="chargeGlAccountNo"
[disabled]="lookingUpCharge || submitting || loadingGlAccounts" [disabled]="lookingUpCharge || submitting || loadingGlAccounts"
> >
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option> <option value="" disabled>
{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}
</option>
@for (gl of glAccounts; track gl.id) { @for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option> <option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
} }
</select> </select>
</div> </div>
<div class="voucher-lookup"> <div class="voucher-lookup-box">
<label class="toolbar-label"><mat-icon class="mini-icon">receipt</mat-icon> Fee Voucher No</label>
<div class="lookup-input-group">
<input <input
type="text" type="text"
class="voucher-input" class="lookup-input font-mono"
placeholder="Voucher no. (e.g. CR/001/2608/0002)" placeholder="Voucher no. (e.g. CR/001/2608/0002)"
[(ngModel)]="chargeVoucherNo" [(ngModel)]="chargeVoucherNo"
[disabled]="lookingUpCharge || submitting" [disabled]="lookingUpCharge || submitting"
(keyup.enter)="lookupChargeVoucher()" (keyup.enter)="lookupChargeVoucher()"
/> />
<button <button
mat-stroked-button mat-flat-button
color="primary"
type="button" type="button"
class="find-btn"
[disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting" [disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting"
(click)="lookupChargeVoucher()" (click)="lookupChargeVoucher()"
> >
{{ lookingUpCharge ? 'Finding…' : 'Find' }} @if (lookingUpCharge) {
<mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button> </button>
</div> </div>
</div>
</div>
<div class="charge-receipt-hint"> <div class="charge-receipt-hint">
Leave this blank if the fee was already paid as part of the share receipt above. Leave this blank if the entry fee was already paid as part of the main share receipt above.
</div> </div>
} }
</div> </div>
} }
<!-- UNATTRIBUTED RECEIPTS SECTION -->
@if (unattributed.length) {
<div class="unattributed-section">
<div class="section-label text-amber">
<mat-icon class="sec-icon">person_add</mat-icon>
<span
>Unattached Share Collection Receipts — Attach to <strong>{{ data.accountNo }}</strong></span
>
</div>
<div class="table-responsive">
<table class="receipt-table unattributed-table">
<thead>
<tr>
<th class="ta-left">Voucher No</th>
<th class="ta-left">Date</th>
<th class="ta-left">Deposited To</th>
<th class="ta-left">Description</th>
<th class="ta-right">Amount</th>
<th class="col-attach ta-center">Action</th>
</tr>
</thead>
<tbody>
@for (u of unattributed; track u.id) {
<tr [class.own-client-row]="isOwnClientVoucher(u)">
<td class="ta-left">
<span class="voucher-badge font-mono">{{ u.voucherNo }}</span>
@if (isOwnClientVoucher(u)) {
<span class="own-client-badge">This member's receipt</span>
}
</td>
<td class="ta-left">{{ u.txnDate }}</td>
<td class="ta-left">{{ u.glAccountNo }} — {{ u.glAccountName }}</td>
<td class="ta-left desc-cell" [title]="u.description">{{ u.description || '—' }}</td>
<td class="ta-right amount-cell">{{ u.amount | number: '1.2-2' }}</td>
<td class="ta-center col-attach">
<button
mat-flat-button
color="accent"
type="button"
class="attach-btn"
[disabled]="lookingUp || submitting"
(click)="attachUnattributed(u)"
>
Attach
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (errorMsg) { @if (errorMsg) {
<div class="alert-error"> <div class="alert-box alert-error">
<mat-icon>error_outline</mat-icon> <mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span> <span>{{ errorMsg }}</span>
</div> </div>
} }
<!-- RECEIPTS ARRAY LIST -->
<div class="vouchers-array-container mt-8">
@if (loading) { @if (loading) {
<div class="loading"> <div class="funding-loading">
<mat-spinner diameter="28"></mat-spinner> <mat-spinner diameter="24"></mat-spinner>
<span>Loading receipts…</span> <span>Loading member receipts…</span>
</div> </div>
} @else if (receipts.length === 0) { } @else if (receipts.length === 0) {
<div class="empty"> <div class="funding-empty">
<mat-icon>info_outline</mat-icon> <mat-icon class="empty-icon">receipt_long</mat-icon>
<div> <div class="empty-text">
<b>No unapplied receipt for this member today.</b> <strong>No unapplied receipt found for this member today.</strong>
<div class="empty-hint"> <p>
If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from If cash was taken on an earlier day, enter that voucher number above. Otherwise collect cash first at
Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the <em>Cashier &rsaquo; Transaction Entry</em> into Share Sundry GL.
voucher appears here. </p>
</div>
</div> </div>
</div> </div>
} @else { } @else {
<div class="vouchers-card">
<!-- Array Header Toolbar -->
<div class="vouchers-header">
<div class="vouchers-title font-accent">
<mat-icon class="v-icon">receipt</mat-icon>
<span>Member Unapplied Receipts</span>
<span class="badge-count">{{ receipts.length }}</span>
</div>
@if (receipts.length > 1) {
<div class="select-all-action">
<button type="button" mat-button class="select-all-btn" (click)="toggleAll()">
<mat-icon>{{
isAllSelected()
? 'check_box'
: isSomeSelected()
? 'indeterminate_check_box'
: 'check_box_outline_blank'
}}</mat-icon>
<span
>{{ isAllSelected() ? 'Deselect All' : 'Select All' }} ({{ selectedIds.length }}/{{
receipts.length
}})</span
>
</button>
</div>
}
</div>
<!-- Table View -->
<div class="table-responsive">
<table class="receipt-table"> <table class="receipt-table">
<thead> <thead>
<tr> <tr>
<th class="col-tick"></th> <th class="col-checkbox ta-center">
<th class="ta-left">Voucher / Receipt</th> @if (receipts.length > 1) {
<input
type="checkbox"
class="custom-checkbox"
[checked]="isAllSelected()"
[indeterminate]="isSomeSelected()"
(change)="toggleAll()"
/>
} @else {
<span>#</span>
}
</th>
<th class="ta-left">Voucher / Receipt No</th>
<th class="ta-left">Date</th> <th class="ta-left">Date</th>
<th class="ta-left">Description</th> <th class="ta-left">Description</th>
<th class="ta-left">Collected By</th> <th class="ta-left">Collected By</th>
<th class="ta-right">Amount</th> <th class="ta-right">Amount</th>
<th class="col-actions ta-center">Detail</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (r of receipts; track r.id) { @for (r of receipts; track r.id) {
<tr [class.selected]="isSelected(r.id)" (click)="toggle(r.id)"> <tr class="receipt-row" [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
<td class="ta-center col-tick"> <td class="col-checkbox ta-center" (click)="$event.stopPropagation()">
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
against one application, and all of them settle it together. -->
<input <input
type="checkbox" type="checkbox"
class="custom-checkbox"
[checked]="isSelected(r.id)" [checked]="isSelected(r.id)"
(change)="toggle(r.id)" (change)="toggle(r.id)"
(click)="$event.stopPropagation()"
/> />
</td> </td>
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td> <td class="ta-left">
<td class="ta-left">{{ r.collection_date }}</td> <span class="voucher-badge font-mono">{{ r.voucher_no || r.receipt_no }}</span>
<td class="ta-left" [title]="r.note">{{ r.note || '—' }}</td> </td>
<td class="ta-left">{{ r.collected_by_name || '—' }}</td> <td class="ta-left date-cell">{{ r.collection_date }}</td>
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td> <td class="ta-left desc-cell" [title]="r.note">{{ r.note || 'Share Collection' }}</td>
<td class="ta-left user-cell">
<span class="user-chip">
<mat-icon class="chip-icon">person_outline</mat-icon>
{{ r.collected_by_name || 'System' }}
</span>
</td>
<td class="ta-right amount-cell">
<span class="val font-semibold">{{ r.amount | number: '1.2-2' }}</span>
</td>
<td class="col-actions ta-center" (click)="$event.stopPropagation()">
<button
type="button"
mat-icon-button
class="view-detail-btn"
title="View Full Details Popup"
(click)="openReceiptDetail(r, $event)"
>
<mat-icon class="eye-icon">visibility</mat-icon>
</button>
</td>
</tr> </tr>
} }
</tbody> </tbody>
<!-- Shown from the first tick rather than the second: the total against what the account needs
is what decides whether the settlement is accepted, so it should be on screen as soon as
there is anything to total. -->
@if (selectedIds.length > 0) {
<tfoot>
<tr class="total-row">
<td class="col-tick"></td>
<td class="ta-left" colspan="4">
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
</td>
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
}
</table> </table>
</div>
<div class="note"> <!-- Summary Footer -->
Their total must cover the account's share value — plus any entry fee, unless it was verified as its own receipt <div class="vouchers-footer">
above. Anything paid over that is credited to the member's savings account. <div class="selected-summary">
<span class="summary-text font-accent">
<strong>{{ selectedIds.length }}</strong> of <strong>{{ receipts.length }}</strong> receipts selected
</span>
<div class="summary-amounts">
<span class="total-label">Selected Total:</span>
<span class="total-value font-semibold">{{ selectedTotal | number: '1.2-2' }}</span>
<span class="target-divider">/</span>
<span class="target-value">Required {{ shareRequired | number: '1.2-2' }}</span>
</div>
</div>
<div class="coverage-status">
@if (selectedIds.length === 0) {
<span class="status-badge badge-neutral"> <mat-icon>info</mat-icon> Tick at least 1 receipt </span>
} @else if (shortfall > 0) {
<span class="status-badge badge-warning">
<mat-icon>warning</mat-icon> Shortfall {{ shortfall | number: '1.2-2' }}
</span>
} @else {
<span class="status-badge badge-success"> <mat-icon>check_circle</mat-icon> Fully Covered </span>
}
</div>
</div>
</div> </div>
} }
</mat-dialog-content> </div>
</mat-dialog-content>
<mat-dialog-actions align="end"> <!-- Dialog Actions Bar -->
<button mat-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button> <mat-dialog-actions align="end" class="dialog-actions-bar">
<button <button
mat-raised-button mat-stroked-button
type="button"
class="action-btn cancel-btn"
[mat-dialog-close]="false"
[disabled]="submitting"
>
Cancel
</button>
<button
mat-flat-button
color="primary" color="primary"
type="button" type="button"
class="action-btn submit-btn"
[disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting" [disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting"
(click)="apply()" (click)="apply()"
> >
{{ submitting ? 'Applying…' : 'Apply Collection' }} @if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Collection</span>
}
</button> </button>
</mat-dialog-actions> </mat-dialog-actions>
</div>
<!-- RECEIPT DETAIL POPUP MODAL -->
@if (viewingReceiptDetail) {
<div class="modal-backdrop animate-fade-in" (click)="closeReceiptDetail()">
<div class="voucher-modal-dialog" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="header-title font-accent">
<mat-icon class="m-icon">receipt_long</mat-icon>
<span>Receipt Details</span>
<span class="modal-badge font-mono">{{
viewingReceiptDetail.voucher_no || viewingReceiptDetail.receipt_no
}}</span>
</div>
<button type="button" mat-icon-button class="close-btn" (click)="closeReceiptDetail()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="modal-body">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Voucher / Receipt No</span>
<span class="detail-val font-mono font-semibold">{{
viewingReceiptDetail.voucher_no || viewingReceiptDetail.receipt_no || '—'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Receipt Reference</span>
<span class="detail-val font-mono">{{ viewingReceiptDetail.receipt_no || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Client ID</span>
<span class="detail-val font-mono">{{ viewingReceiptDetail.client_id || data?.clientId || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collection Date</span>
<span class="detail-val">{{ viewingReceiptDetail.collection_date || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Status</span>
<span class="status-pill status-unapplied">{{ viewingReceiptDetail.status || 'UNAPPLIED' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collected By</span>
<span class="detail-val">{{ viewingReceiptDetail.collected_by_name || 'System' }}</span>
</div>
<div class="detail-item full-width">
<span class="detail-label">GL Entries — What This Receipt Actually Posted</span>
@if (loadingReceiptGlEntries) {
<div class="gl-entries-loading">
<mat-spinner diameter="16"></mat-spinner>
<span>Loading the ledger entries…</span>
</div>
} @else if (!viewingReceiptGlEntries?.length) {
<div class="gl-entries-empty">No posted GL entries found for this receipt yet.</div>
} @else {
<table class="gl-entries-table">
<thead>
<tr>
<th class="ta-left">GL Account</th>
<th class="ta-center">Dr / Cr</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (e of viewingReceiptGlEntries; track e.glCode) {
<tr>
<td class="ta-left font-mono">{{ e.glCode }} — {{ e.accountName }}</td>
<td class="ta-center">
<span class="drcr-badge" [class.dr]="e.drCr === 'Dr'" [class.cr]="e.drCr === 'Cr'">{{
e.drCr
}}</span>
</td>
<td class="ta-right font-mono">{{ e.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
</div>
<div class="detail-item full-width highlight-amount-box">
<span class="detail-label">Receipt Amount</span>
<span class="amount-val font-mono">{{ viewingReceiptDetail.amount | number: '1.2-2' }}</span>
</div>
<div class="detail-item full-width">
<span class="detail-label">Full Note / Description</span>
<div class="note-box font-accent">
{{ viewingReceiptDetail.note || 'Share collection deposit to Share Sundry GL' }}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
mat-flat-button
[color]="isSelected(viewingReceiptDetail.id) ? 'warn' : 'primary'"
class="modal-action-btn"
(click)="toggle(viewingReceiptDetail.id); closeReceiptDetail()"
>
<mat-icon>{{
isSelected(viewingReceiptDetail.id) ? 'remove_circle_outline' : 'check_circle_outline'
}}</mat-icon>
<span>{{ isSelected(viewingReceiptDetail.id) ? 'Unselect Receipt' : 'Select Receipt' }}</span>
</button>
<button type="button" mat-stroked-button (click)="closeReceiptDetail()">Close</button>
</div>
</div>
</div>
}
@@ -13,7 +13,7 @@ import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/materia
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError, map } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.service'; import { CashierService } from 'app/cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
@@ -97,6 +97,18 @@ export class ApplyCollectionDialogComponent implements OnInit {
requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null; requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null;
loadingRequirement = true; loadingRequirement = true;
/**
* Share-collection receipts that are approved but belong to nobody yet.
*
* <p>
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
* so it is never attributed on its own. Listing it here is not attribution: it only makes the
* voucher findable, so a counter that tagged a receipt "Share Collection" ten minutes ago is not
* told there is nothing to apply. Attaching it is still a click a person makes against a member
* they can see.
*/
unattributed: any[] = [];
public dialogRef = inject(MatDialogRef<ApplyCollectionDialogComponent>); public dialogRef = inject(MatDialogRef<ApplyCollectionDialogComponent>);
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
@@ -124,6 +136,11 @@ export class ApplyCollectionDialogComponent implements OnInit {
this.loading = false; this.loading = false;
}); });
this.cashierService
.getUnattributedShareCollections()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.unattributed = res?.rows || []));
this.cashierService this.cashierService
.getShareAdvanceGlAccounts() .getShareAdvanceGlAccounts()
.pipe( .pipe(
@@ -135,12 +152,17 @@ export class ApplyCollectionDialogComponent implements OnInit {
) )
.subscribe((res: any) => { .subscribe((res: any) => {
this.glAccounts = res?.glAccounts || []; this.glAccounts = res?.glAccounts || [];
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed // Auto-picked only when it is genuinely the only option. The list now carries every
// this list to accounts that actually hold an eligible receipt today, so a single option is // postable GL account again (see the backend's own comment), so picking index 0 whenever
// unambiguous. Two or more still means the counter chooses by hand. // one loads would silently select whichever account sorts first by gl_code -- Cash at
// Vault, almost always -- the exact bug this was fixed for once already. Two or more
// candidates always means the counter chooses by hand.
if (this.glAccounts.length === 1 && !this.glAccountNo) { if (this.glAccounts.length === 1 && !this.glAccountNo) {
this.glAccountNo = this.glAccounts[0].gl_code; this.glAccountNo = this.glAccounts[0].gl_code;
} }
if (this.glAccounts.length === 1 && !this.chargeGlAccountNo) {
this.chargeGlAccountNo = this.glAccounts[0].gl_code;
}
this.loadingGlAccounts = false; this.loadingGlAccounts = false;
}); });
@@ -159,6 +181,92 @@ export class ApplyCollectionDialogComponent implements OnInit {
}); });
} }
/** Receipt object currently being viewed in detail popup. */
viewingReceiptDetail: any = null;
/**
* The receipt's own real Dr/Cr GL legs, under its own {@code gl_transaction_id} — never the GL
* account currently picked in the by-voucher search box above, which is unrelated screen state
* that can name a completely different account than the one this receipt actually sits in.
*/
viewingReceiptGlEntries: any[] | null = null;
loadingReceiptGlEntries = false;
openReceiptDetail(receipt: any, event?: Event): void {
if (event) {
event.stopPropagation();
}
this.viewingReceiptDetail = receipt;
this.viewingReceiptGlEntries = null;
this.loadingReceiptGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(receipt.id)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.viewingReceiptGlEntries = res?.entries || [];
this.loadingReceiptGlEntries = false;
});
}
closeReceiptDetail(): void {
this.viewingReceiptDetail = null;
this.viewingReceiptGlEntries = null;
}
toggleAll(): void {
if (this.isAllSelected()) {
this.selectedIds = [];
} else {
this.selectedIds = this.receipts.map((r) => r.id);
}
this.syncLookupFieldsToSelection();
}
/**
* Mirrors a ticked receipt's own GL account and voucher number into the Share Receipt Lookup
* fields above, so a tick is visibly confirmed rather than leaving those fields blank while the
* table below shows the real selection. Particularly worth seeing across instalments, where two
* receipts making up one settlement can sit in two different GL accounts, exactly like this
* member's own 200902 and 400105 receipts do.
*
* Shows the most recently ticked receipt when several are selected — there is no single GL
* account to show once more than one is chosen, and the most recent tick is the one the counter
* just made. Clears both fields once nothing is ticked.
*/
private syncLookupFieldsToSelection(): void {
if (!this.selectedIds.length) {
this.glAccountNo = '';
this.voucherNo = '';
return;
}
const lastId = this.selectedIds[this.selectedIds.length - 1];
const receipt = this.receipts.find((r) => r.id === lastId);
if (receipt) {
this.glAccountNo = receipt.gl_code || this.glAccountNo;
this.voucherNo = receipt.voucher_no || receipt.receipt_no || '';
}
}
/**
* Whether an unattributed-list row is already linked to the member this dialog is settling.
*
* Such a row also appears, pre-loaded, in the Member Unapplied Receipts table below — that is not
* a duplicate to clear up, it is the same receipt reachable two ways. This only flags it so the
* counter understands why it shows up here too, rather than reading it as an unrelated stray
* voucher that happens to look identical.
*/
isOwnClientVoucher(u: any): boolean {
return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.data.clientId);
}
isAllSelected(): boolean {
return this.receipts.length > 0 && this.selectedIds.length === this.receipts.length;
}
isSomeSelected(): boolean {
return this.selectedIds.length > 0 && this.selectedIds.length < this.receipts.length;
}
/** /**
* What the share receipts still need to reach — the share value alone once the entry fee has its * What the share receipts still need to reach — the share value alone once the entry fee has its
* own verified receipt, otherwise the combined share-plus-fee total, matching exactly what the * own verified receipt, otherwise the combined share-plus-fee total, matching exactly what the
@@ -221,6 +329,7 @@ export class ApplyCollectionDialogComponent implements OnInit {
...this.selectedIds, ...this.selectedIds,
id id
]; ];
this.syncLookupFieldsToSelection();
} }
/** /**
@@ -243,12 +352,48 @@ export class ApplyCollectionDialogComponent implements OnInit {
* voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing * voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing
* later inside apply(). * later inside apply().
*/ */
/**
* Attaches one of the unattributed share-collection receipts to this member.
*
* <p>
* Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already
* on this list is either a fresh cashier deposit (which does carry its own GL account) or an
* already-adopted {@code m_share_advance_collection} row (the "linked to …" ones), whose GL account
* can legitimately come back blank — its subquery reads the posting the original adoption made,
* which a clerk-disbursement-funded receipt does not always leave behind cleanly. Either way the
* backend needs no GL account to resolve a voucher it can already find by number, so a blank one
* here must never block the click the way it rightly blocks a hand-typed search with nothing chosen
* yet. The list never decides whose money it is; the click does.
*/
attachUnattributed(row: any): void {
if (this.lookingUp || this.submitting) {
return;
}
this.resolveVoucher(row.voucherNo, row.glAccountNo || '');
}
lookupVoucher(): void { lookupVoucher(): void {
const voucher = this.voucherNo.trim(); const voucher = this.voucherNo.trim();
const glAccountNo = this.glAccountNo.trim(); const glAccountNo = this.glAccountNo.trim();
// A hand-typed search genuinely needs both fields: an empty GL account here means nothing has
// been chosen yet, not that one isn't needed — unlike {@link attachUnattributed}, which already
// knows exactly which receipt it means and never has this ambiguity.
if (!voucher || !glAccountNo || this.lookingUp) { if (!voucher || !glAccountNo || this.lookingUp) {
return; return;
} }
this.resolveVoucher(voucher, glAccountNo);
}
/**
* Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the
* receipt it finds to the table, ticked.
*
* Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank
* by the Find button's own disabled state) and {@link attachUnattributed} (a click on a row already
* on screen, whose GL account can legitimately be blank) — the actual lookup and its bookkeeping
* afterwards are identical either way.
*/
private resolveVoucher(voucher: string, glAccountNo: string): void {
this.lookingUp = true; this.lookingUp = true;
this.errorMsg = ''; this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => { this.findVoucher(voucher, glAccountNo).subscribe((res) => {
@@ -257,8 +402,8 @@ export class ApplyCollectionDialogComponent implements OnInit {
return; return;
} }
// Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which // Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which
// are left for the clerk: typing a voucher number IS the deliberate choice of that receipt, // are left for the clerk: attaching a specific voucher IS the deliberate choice of that
// so making them tick it again would be asking the same question twice. // receipt, so making them tick it again would be asking the same question twice.
this.receipts = [ this.receipts = [
res, res,
...this.receipts.filter((r) => r.id !== res.id) ...this.receipts.filter((r) => r.id !== res.id)
@@ -269,7 +414,19 @@ export class ApplyCollectionDialogComponent implements OnInit {
res.id res.id
]; ];
} }
// Once attached it belongs to this member, so it leaves the unattributed list rather than
// sitting there inviting a second, conflicting attachment.
this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucher);
// Cleared, not left showing this voucher: an instalment payment often means searching for a
// second receipt right after this one, and the box should be ready for that number, not still
// holding the one just added.
this.voucherNo = ''; this.voucherNo = '';
// The GL Account picker still follows this receipt, exactly as it does when an already-listed
// receipt is ticked — res.gl_code is this receipt's own real GL account (see findByVoucher()'s
// subquery on the server), not whatever the search box happened to hold before this search.
if (res.gl_code) {
this.glAccountNo = res.gl_code;
}
}); });
} }
@@ -311,14 +468,9 @@ export class ApplyCollectionDialogComponent implements OnInit {
* own for the entry fee — since the two are never allowed to mix into the same sum. * own for the entry fee — since the two are never allowed to mix into the same sum.
*/ */
private findVoucher(voucher: string, glAccountNo: string) { private findVoucher(voucher: string, glAccountNo: string) {
// No client-match check here by deliberate choice: any receipt can be attached to any share
// account, regardless of which member it was originally recorded against.
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe( return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
map((res: any) => {
if (Number(res.client_id) !== Number(this.data.clientId)) {
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
return null;
}
return res;
}),
catchError((err) => { catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`); this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`);
return of(null); return of(null);
@@ -149,6 +149,10 @@ export class SharesAccountViewComponent implements OnInit {
// capital that was never issued. // capital that was never issued.
if (!this.isSettled) { if (!this.isSettled) {
this.buttonConfig.removeOption('Share Certificate'); this.buttonConfig.removeOption('Share Certificate');
// A further tranche on top of an application the member never paid for makes no sense: the
// first allotment is still sitting unissued, and a second one would only grow a holding that
// does not exist yet. Settle the original collection first, then add to it.
this.buttonConfig.removeButton('Apply Additional Shares');
} else { } else {
// "Apply Collection" settles the account's ORIGINAL application funding -- a one-time // "Apply Collection" settles the account's ORIGINAL application funding -- a one-time
// step. Once collectionApplied is true there is nothing left to settle this way; any // step. Once collectionApplied is true there is nothing left to settle this way; any
@@ -202,7 +206,9 @@ export class SharesAccountViewComponent implements OnInit {
*/ */
private applyCollection() { private applyCollection() {
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, { const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
width: '560px', width: '820px',
maxWidth: '92vw',
panelClass: 'apply-collection-dialog-panel',
data: { data: {
shareAccountId: this.sharesAccountData.id, shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId, clientId: this.sharesAccountData.clientId,
+133
View File
@@ -41,6 +41,139 @@ const routes: Routes = [
data: { title: 'Share Holders', breadcrumb: 'Share Holders' }, data: { title: 'Share Holders', breadcrumb: 'Share Holders' },
component: ShareHoldersComponent component: ShareHoldersComponent
}, },
// Share reports. Lazily loaded one component at a time: a clerk opening the Share menu for a
// withdrawal should not pay for four report screens they did not ask for, and each is
// self-contained enough that there is nothing to share by bundling them together.
{
path: 'reports/final-balance',
data: { title: 'Share Final Balance', breadcrumb: 'Final Balance' },
loadComponent: () =>
import('./reports/share-final-balance/share-final-balance.component').then(
(m) => m.ShareFinalBalanceComponent
)
},
{
path: 'reports/account-statement',
data: { title: 'Share Account Statement', breadcrumb: 'Account Statement' },
loadComponent: () =>
import('./reports/share-account-statement/share-account-statement.component').then(
(m) => m.ShareAccountStatementComponent
)
},
{
path: 'reports/share-issued',
data: { title: 'Share Issued Register', breadcrumb: 'Share Issued' },
loadComponent: () =>
import('./reports/share-issued-report/share-issued-report.component').then(
(m) => m.ShareIssuedReportComponent
)
},
{
path: 'reports/share-status',
data: { title: 'Share Status Report', breadcrumb: 'Share Status' },
loadComponent: () =>
import('./reports/share-status-report/share-status-report.component').then(
(m) => m.ShareStatusReportComponent
)
},
{
path: 'reports/nominal-shares',
data: { title: 'Nominal Share Report', breadcrumb: 'Nominal Share Report' },
loadComponent: () =>
import('./reports/share-nominal-report/share-nominal-report.component').then(
(m) => m.ShareNominalReportComponent
)
},
{
path: 'reports/share-wise',
data: { title: 'Share Wise Report', breadcrumb: 'Share Wise Report' },
loadComponent: () =>
import('./reports/share-wise-report/share-wise-report.component').then((m) => m.ShareWiseReportComponent)
},
{
path: 'reports/guarantor-detail',
data: { title: 'Guarantor Detail', breadcrumb: 'Guarantor Detail' },
loadComponent: () =>
import('./reports/share-guarantor-report/share-guarantor-report.component').then(
(m) => m.ShareGuarantorReportComponent
)
},
{
path: 'reports/member-interest',
data: { title: 'Total Interest Received & Receivable', breadcrumb: 'Total Int Rec' },
loadComponent: () =>
import('./reports/share-member-interest-report/share-member-interest-report.component').then(
(m) => m.ShareMemberInterestReportComponent
)
},
{
path: 'reports/enquiry',
data: { title: 'Share Enquiry', breadcrumb: 'Share Enquiry' },
loadComponent: () =>
import('./reports/share-enquiry/share-enquiry.component').then((m) => m.ShareEnquiryComponent)
},
{
path: 'reports/payment-certificate',
data: { title: 'Share Payment Certificate', breadcrumb: 'Payment Certificate' },
loadComponent: () =>
import('./reports/share-payment-certificate/share-payment-certificate.component').then(
(m) => m.SharePaymentCertificateComponent
)
},
{
path: 'reports/share-detail',
data: { title: 'Share Detail', breadcrumb: 'Share Detail' },
loadComponent: () =>
import('./reports/share-detail-report/share-detail-report.component').then(
(m) => m.ShareDetailReportComponent
)
},
{
path: 'reports/dividend-statement',
data: { title: 'Dividend Statement', breadcrumb: 'Dividend Statement' },
loadComponent: () =>
import('./reports/share-dividend-statement/share-dividend-statement.component').then(
(m) => m.ShareDividendStatementComponent
)
},
{
path: 'reports/folio-statement',
data: { title: 'Folio Statement', breadcrumb: 'Folio Statement' },
loadComponent: () =>
import('./reports/share-folio-statement/share-folio-statement.component').then(
(m) => m.ShareFolioStatementComponent
)
},
{
path: 'transfers',
data: { title: 'Share Transfer', breadcrumb: 'Share Transfer' },
loadComponent: () =>
import('./reports/share-transfer/share-transfer.component').then((m) => m.ShareTransferComponent)
},
{
path: 'reports/distinctive-numbers',
data: { title: 'Share Distinctive Number Detail', breadcrumb: 'Distinctive Numbers' },
loadComponent: () =>
import('./reports/share-distinctive-numbers/share-distinctive-numbers.component').then(
(m) => m.ShareDistinctiveNumbersComponent
)
},
{
path: 'reports/area-wise',
data: { title: 'Area Wise Shareholding', breadcrumb: 'Area Wise' },
loadComponent: () =>
import('./reports/share-area-wise/share-area-wise.component').then((m) => m.ShareAreaWiseComponent)
},
{
path: 'reports/labels',
data: { title: 'Share Labels', breadcrumb: 'Share Label' },
loadComponent: () => import('./reports/share-label/share-label.component').then((m) => m.ShareLabelComponent)
},
{
path: 'reports/letters',
data: { title: 'Share Letter', breadcrumb: 'Share Letter' },
loadComponent: () => import('./reports/share-letter/share-letter.component').then((m) => m.ShareLetterComponent)
},
{ {
path: 'create', path: 'create',
data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' }, data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' },
+172
View File
@@ -190,4 +190,176 @@ export class SharesService {
rejectShareAccountEdit(accountId: string, rejectReason?: string): Observable<any> { rejectShareAccountEdit(accountId: string, rejectReason?: string): Observable<any> {
return this.http.post(`/shareaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' }); return this.http.post(`/shareaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' });
} }
// ── Share reports ──────────────────────────────────────────────────────
//
// Each of these is a single server-side query returning finished rows plus its own totals, rather
// than the fetch-all-accounts-then-fan-out pattern the older letter-mgmt reports use. That pattern
// stops at the 200-account page it asks for and recomputes totals from whatever arrived, which a
// report a balance sheet has to reconcile to cannot do.
/**
* Share capital held per member as on a date, derived from settled purchases less redemptions.
*
* @param params asOnDate (yyyy-MM-dd), officeId, productId, includeZero — all optional.
*/
getShareFinalBalance(params: {
asOnDate?: string;
officeId?: number | string;
productId?: number | string;
includeZero?: boolean;
}): Observable<any> {
return this.http.get('/sharereports/final-balance', { params: this.cleanParams(params) });
}
/** One share account's movements in date order, with the running holding after each. */
getShareAccountStatement(
shareAccountId: number | string,
params: { fromDate?: string; toDate?: string } = {}
): Observable<any> {
return this.http.get(`/sharereports/account-statement/${shareAccountId}`, { params: this.cleanParams(params) });
}
/** Allotment register — one row per tranche of shares issued in the period. */
getShareIssuedReport(params: {
fromDate?: string;
toDate?: string;
officeId?: number | string;
productId?: number | string;
status?: number | string;
}): Observable<any> {
return this.http.get('/sharereports/share-issued', { params: this.cleanParams(params) });
}
/** Share accounts grouped by status and product, with the accounts behind each group. */
getShareStatusSummary(params: { officeId?: number | string; productId?: number | string } = {}): Observable<any> {
return this.http.get('/sharereports/status-summary', { params: this.cleanParams(params) });
}
/** Holdings measured against the share class's own nominal, minimum and maximum, at face value. */
getShareNominalReport(
params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {}
): Observable<any> {
return this.http.get('/sharereports/nominal-shares', { params: this.cleanParams(params) });
}
/** Shareholding pattern — members and capital per holding band, plus the per-product split. */
getShareWiseReport(
params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {}
): Observable<any> {
return this.http.get('/sharereports/share-wise', { params: this.cleanParams(params) });
}
/** Active loan guarantors with the shareholding each one holds in the society. */
getShareGuarantorDetail(params: { officeId?: number | string; onlyMembers?: boolean } = {}): Observable<any> {
return this.http.get('/sharereports/guarantor-detail', { params: this.cleanParams(params) });
}
/** Interest received and receivable from the members who hold share capital. */
getShareMemberLoanInterest(
params: { officeId?: number | string; includeClosedLoans?: boolean } = {}
): Observable<any> {
return this.http.get('/sharereports/member-loan-interest', { params: this.cleanParams(params) });
}
/**
* One member's whole share position — accounts, movements, dividends and linked savings.
*
* Sends `search` when the member is being looked for and `clientId` once one is chosen: a search
* matching several members comes back as a list to pick from rather than guessing between them.
*/
getShareEnquiry(params: { search?: string; clientId?: number | string }): Observable<any> {
return this.http.get('/sharereports/enquiry', { params: this.cleanParams(params) });
}
/** What a member actually paid for their shares, and against which receipt — for the certificate. */
getSharePaymentCertificate(shareAccountId: number | string): Observable<any> {
return this.http.get(`/sharereports/payment-certificate/${shareAccountId}`);
}
/** The full share register: every account with its class, terms, lock-in, charges and resolution. */
getShareDetailRegister(
params: { officeId?: number | string; productId?: number | string; status?: number | string } = {}
): Observable<any> {
return this.http.get('/sharereports/share-detail', { params: this.cleanParams(params) });
}
/** One member's dividends across every year, declared against paid. */
getShareDividendStatement(params: { search?: string; clientId?: number | string }): Observable<any> {
return this.http.get('/sharereports/dividend-statement', { params: this.cleanParams(params) });
}
/** One member's folio: every share account they hold, with the movements running across all of them. */
getShareFolioStatement(params: {
search?: string;
clientId?: number | string;
fromDate?: string;
toDate?: string;
}): Observable<any> {
return this.http.get('/sharereports/folio-statement', { params: this.cleanParams(params) });
}
/** The numbered share range each allotment covers, derived from issue order per share class. */
getShareDistinctiveNumbers(
params: { productId?: number | string; clientId?: number | string } = {}
): Observable<any> {
return this.http.get('/sharereports/distinctive-numbers', { params: this.cleanParams(params) });
}
/** Members and share capital grouped by the area their address puts them in. */
getShareAreaWise(params: { productId?: number | string } = {}): Observable<any> {
return this.http.get('/sharereports/area-wise', { params: this.cleanParams(params) });
}
/**
* Name, address, folio and holding for every shareholding member.
*
* One call behind both Share Label and Share Letter — they want the same rows and differ only in
* what is printed around them.
*/
getShareMailingList(
params: { productId?: number | string; officeId?: number | string; area?: string } = {}
): Observable<any> {
return this.http.get('/sharereports/mailing-list', { params: this.cleanParams(params) });
}
// ── Share transfers (maker-checker) ────────────────────────────────────
/** Records a transfer between two members. Nothing moves until a checker approves it. */
submitShareTransfer(data: any): Observable<any> {
return this.http.post('/sharetransfers', data);
}
/** The transfer register — every transfer with both members and the resolution behind it. */
getShareTransfers(status?: string): Observable<any> {
return this.http.get('/sharetransfers', { params: this.cleanParams({ status }) });
}
/** Approves a transfer: re-checks every rule, then moves the shares. */
approveShareTransfer(transferId: number): Observable<any> {
return this.http.post(`/sharetransfers/${transferId}/approve`, {});
}
rejectShareTransfer(transferId: number, rejectionReason?: string): Observable<any> {
return this.http.post(`/sharetransfers/${transferId}/reject`, { rejectionReason: rejectionReason || '' });
}
/**
* Drops empty filters rather than sending them blank — the report endpoints treat an absent
* parameter as "no filter", but a present-and-empty one would be read as a real value.
*/
private cleanParams(params: Record<string, any>): HttpParams {
let httpParams = new HttpParams();
Object.entries(params).forEach(
([
key,
value
]) => {
if (value !== null && value !== undefined && value !== '') {
httpParams = httpParams.set(key, String(value));
}
}
);
return httpParams;
}
} }
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260916', 'version': '260918',
'hash': 'c01615ee' 'hash': 'e53dfb5a'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };
+11
View File
@@ -1960,3 +1960,14 @@ mifosx-client-datatable-step * {
display: none !important; display: none !important;
} }
} }
/* Apply Collection Dialog Panel Styling */
.apply-collection-dialog-panel {
.mat-mdc-dialog-container,
.mdc-dialog__surface {
border-radius: 16px !important;
padding: 0 !important;
overflow: hidden !important;
box-shadow: 0 25px 60px -15px rgb(15 23 42 / 30%) !important;
}
}