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) } });
}
/**
* 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. */
getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : '';
+23 -19
View File
@@ -1300,72 +1300,76 @@
<!-- Share Operations -->
<div class="deposit-menu-group">
<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
</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
</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
</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
</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
</button>
</div>
<!-- Share Documents -->
<div class="deposit-menu-group">
<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
</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
</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
</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
</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
</button>
</div>
<!-- Share Reports -->
<div class="deposit-menu-group">
<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
</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
</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
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
</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
</button>
</div>
<!-- Share Admin -->
<div class="deposit-menu-group">
<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
</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"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/area-wise']">
<fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
</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
</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
</button>
</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>
<div class="lookup-input-group">
<input
#voucherInput
type="text"
class="lookup-input font-mono"
placeholder="Voucher no. (e.g. 000123)"
[(ngModel)]="voucherNo"
[disabled]="lookingUpVoucher || submitting"
(keyup.enter)="lookupVoucher(voucherInput.value)"
(keyup.enter)="lookupVoucher()"
/>
<button
mat-flat-button
color="primary"
type="button"
class="find-btn"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)"
[disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher()"
>
@if (lookingUpVoucher) {
<mat-spinner diameter="14"></mat-spinner>
@@ -234,6 +234,62 @@
</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) {
<div class="alert-box alert-error">
<mat-icon>error_outline</mat-icon>
@@ -481,6 +481,69 @@ mat-form-field {
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 {
width: 100%;
border-collapse: separate;
@@ -100,6 +100,24 @@ export class ApplySharesComponent implements OnInit {
lookingUpVoucher = false;
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
* from a share product's accounting setup, so a broken or missing product mapping can never
* make a genuine voucher unreachable. */
@@ -242,6 +260,7 @@ export class ApplySharesComponent implements OnInit {
this.vouchers = [];
this.selectedVoucherIds = [];
this.voucherError = '';
this.voucherNo = '';
this.errorMsg = '';
this.savingsEligibility = null;
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.
*
* A single receipt is ticked for them: with only one candidate there is nothing to choose, and
* making the clerk tick it adds a step that can only be got wrong by forgetting it.
* Deliberately nothing ticked, not even when a single receipt is listed — which receipt funds the
* 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 {
this.loadingVouchers = true;
@@ -286,16 +306,49 @@ export class ApplySharesComponent implements OnInit {
return;
}
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 {
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
* 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);
this.selectedVoucherIds = wasSelected ? [] : [id];
this.voucherError = '';
// The GL Account picker above follows whichever receipt is ticked — its own actual GL account
// (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt
// that plainly does have one is already chosen.
if (!wasSelected) {
const voucher = this.vouchers.find((v) => v.id === id);
if (voucher?.gl_code) {
this.glAccountNo = voucher.gl_code;
}
if (wasSelected) {
this.voucherNo = '';
return;
}
// 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);
if (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.
*
* 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
* deliberately not restricted to this member, so a voucher belonging to somebody else is
* surfaced here as a plain mismatch rather than silently ignored.
* the slip in their hand, which is faster than finding it among their receipts. The lookup is not
* restricted to this member — any receipt found this way can be attached here, whichever member it
* was originally recorded against.
*/
lookupVoucher(voucherNo: string): void {
const trimmed = (voucherNo || '').trim();
lookupVoucher(): void {
const trimmed = this.voucherNo.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) {
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.voucherError = '';
this.cashierService
.getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId)
.getShareAdvanceByVoucher(voucherNo, glAccountNo, this.sharesAccountData.clientId)
.pipe(
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;
return of(null);
})
@@ -357,10 +429,8 @@ export class ApplySharesComponent implements OnInit {
if (!res) {
return;
}
if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) {
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
return;
}
// 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.
// 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.
this.vouchers = [
@@ -368,6 +438,13 @@ export class ApplySharesComponent implements OnInit {
...this.vouchers.filter((v) => v.id !== 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,103 +6,94 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h2 mat-dialog-title>Apply Share Advance Collection</h2>
<mat-dialog-content>
<p class="intro">
Tick the receipts that make up this payment to settle share account <b>{{ data.accountNo }}</b
>, then apply. Today's unapplied receipts for this member are listed below — tick more than one where they paid in
instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income.
</p>
<!-- What this account needs, up front — so the counter knows the full figure, and that it may take
a second receipt for the entry fee, before a search ever comes up short. -->
@if (requirement) {
<div class="requirement-summary">
<span class="requirement-line">
Required: <b>{{ requirement.shareAmount | number: '1.2-2' }}</b> (Shares)
@if (requirement.chargeAmount > 0) {
+ <b>{{ requirement.chargeAmount | number: '1.2-2' }}</b> (Entry Fee) =
<b>{{ requirement.required | number: '1.2-2' }}</b> Total
}
</span>
@if (selectedIds.length > 0) {
<span class="requirement-progress" [class.covered]="shortfall === 0">
@if (shortfall === 0) {
Share receipts covered
} @else {
{{ selectedTotal | number: '1.2-2' }} of {{ shareRequired | number: '1.2-2' }} collected —
{{ shortfall | number: '1.2-2' }} short
}
</span>
}
<div class="dialog-wrapper">
<!-- Dialog Header Banner -->
<div class="dialog-header-banner">
<div class="header-icon-box">
<mat-icon>account_balance_wallet</mat-icon>
</div>
}
<!-- For a receipt the list does not carry — one taken on an earlier day, or a slip the member is
holding that is quicker to type than to find. Both the GL account the deposit was posted to
and the voucher number are required — never resolved from a share product's own accounting
setup, so a broken or missing product mapping can never make a genuine voucher unreachable. -->
<div class="voucher-section">
<div class="voucher-section-label">Share Receipt</div>
<div class="gl-select-field">
<select class="gl-select" [(ngModel)]="glAccountNo" [disabled]="lookingUp || submitting || loadingGlAccounts">
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</div>
<div class="voucher-lookup">
<input
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. CR/001/2608/0001)"
[(ngModel)]="voucherNo"
[disabled]="lookingUp || submitting"
(keyup.enter)="lookupVoucher()"
/>
<button
mat-stroked-button
type="button"
[disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUp || submitting"
(click)="lookupVoucher()"
>
{{ lookingUp ? 'Finding…' : 'Find' }}
</button>
<div class="header-text font-accent">
<h2>Apply Share Advance Collection</h2>
<p>
Settling share account <strong class="font-mono">{{ data.accountNo }}</strong>
</p>
</div>
<button type="button" mat-icon-button class="close-header-btn" [mat-dialog-close]="false">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- Shown only when this account actually has an outstanding fee -- a member sometimes pays it as
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) {
<div class="voucher-section">
<div class="voucher-section-label">Entry Fee Receipt — verification only, no GL entry is posted for it</div>
<mat-dialog-content class="dialog-body">
<!-- Intro Banner -->
<div class="dialog-intro-banner">
<mat-icon class="banner-icon">info</mat-icon>
<span>
Tick the receipts that make up this payment to settle share account <strong>{{ data.accountNo }}</strong
>. Multiple receipts can be selected for instalment payments. Click
<mat-icon class="inline-icon">visibility</mat-icon> to view full receipt popup details.
</span>
</div>
@if (chargeReceipt) {
<div class="charge-receipt-found" [class.short]="chargeShortfall > 0">
<mat-icon>{{ chargeShortfall > 0 ? 'error_outline' : 'check_circle_outline' }}</mat-icon>
<span class="charge-receipt-detail">
<b class="mono">{{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }}</b>
— {{ chargeReceipt.amount | number: '1.2-2' }}
@if (chargeShortfall > 0) {
(short by {{ chargeShortfall | number: '1.2-2' }})
} @else {
— entry fee verified
}
</span>
<button mat-button type="button" [disabled]="submitting" (click)="clearChargeReceipt()">Change</button>
<!-- Requirement Summary Stat Card -->
@if (requirement) {
<div class="requirement-summary-card">
<div class="req-header">
<mat-icon class="req-icon">calculate</mat-icon>
<span>Tranche Requirement</span>
</div>
} @else {
<div class="gl-select-field">
<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>
}
</div>
}
</div>
}
<!-- SHARE RECEIPT LOOKUP SECTION -->
<div class="search-section">
<div class="section-label">
<mat-icon class="sec-icon">search</mat-icon>
<span>Share Receipt Lookup</span>
</div>
<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="gl-select"
[(ngModel)]="chargeGlAccountNo"
[disabled]="lookingUpCharge || submitting || loadingGlAccounts"
class="custom-select"
[(ngModel)]="glAccountNo"
[disabled]="lookingUp || submitting || loadingGlAccounts"
>
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
@for (gl of glAccounts; track gl.id) {
@@ -111,121 +102,476 @@
</select>
</div>
<div class="voucher-lookup">
<input
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. CR/001/2608/0002)"
[(ngModel)]="chargeVoucherNo"
[disabled]="lookingUpCharge || submitting"
(keyup.enter)="lookupChargeVoucher()"
/>
<button
mat-stroked-button
type="button"
[disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting"
(click)="lookupChargeVoucher()"
>
{{ lookingUpCharge ? 'Finding…' : 'Find' }}
</button>
</div>
<div class="charge-receipt-hint">
Leave this blank if the fee was already paid as part of the share receipt above.
</div>
}
</div>
}
@if (errorMsg) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
@if (loading) {
<div class="loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Loading receipts…</span>
</div>
} @else if (receipts.length === 0) {
<div class="empty">
<mat-icon>info_outline</mat-icon>
<div>
<b>No unapplied receipt for this member today.</b>
<div class="empty-hint">
If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from
Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the
voucher appears here.
<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
type="text"
class="lookup-input font-mono"
placeholder="Voucher no. (e.g. CR/001/2608/0001)"
[(ngModel)]="voucherNo"
[disabled]="lookingUp || submitting"
(keyup.enter)="lookupVoucher()"
/>
<button
mat-flat-button
color="primary"
type="button"
class="find-btn"
[disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUp || submitting"
(click)="lookupVoucher()"
>
@if (lookingUp) {
<mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button>
</div>
</div>
</div>
</div>
} @else {
<table class="receipt-table">
<thead>
<tr>
<th class="col-tick"></th>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-left">Description</th>
<th class="ta-left">Collected By</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (r of receipts; track r.id) {
<tr [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
<td class="ta-center col-tick">
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
against one application, and all of them settle it together. -->
<input
type="checkbox"
[checked]="isSelected(r.id)"
(change)="toggle(r.id)"
(click)="$event.stopPropagation()"
/>
</td>
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
<td class="ta-left">{{ r.collection_date }}</td>
<td class="ta-left" [title]="r.note">{{ r.note || '—' }}</td>
<td class="ta-left">{{ r.collected_by_name || '—' }}</td>
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
</tr>
<!-- ENTRY FEE RECEIPT SECTION (If charge exists) -->
@if (requirement && requirement.chargeAmount > 0) {
<div class="search-section fee-section">
<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) {
<div class="charge-receipt-found" [class.short]="chargeShortfall > 0">
<mat-icon>{{ chargeShortfall > 0 ? 'error_outline' : 'check_circle' }}</mat-icon>
<span class="charge-receipt-detail font-accent">
<b class="font-mono">{{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }}</b>
— {{ chargeReceipt.amount | number: '1.2-2' }}
@if (chargeShortfall > 0) {
<span class="short-text">(short by {{ chargeShortfall | number: '1.2-2' }})</span>
} @else {
<span class="verified-text">— Entry fee verified</span>
}
</span>
<button
mat-stroked-button
type="button"
class="change-btn"
[disabled]="submitting"
(click)="clearChargeReceipt()"
>
Change
</button>
</div>
} @else {
<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
class="custom-select"
[(ngModel)]="chargeGlAccountNo"
[disabled]="lookingUpCharge || submitting || loadingGlAccounts"
>
<option value="" disabled>
{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}
</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</div>
<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
type="text"
class="lookup-input font-mono"
placeholder="Voucher no. (e.g. CR/001/2608/0002)"
[(ngModel)]="chargeVoucherNo"
[disabled]="lookingUpCharge || submitting"
(keyup.enter)="lookupChargeVoucher()"
/>
<button
mat-flat-button
color="primary"
type="button"
class="find-btn"
[disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting"
(click)="lookupChargeVoucher()"
>
@if (lookingUpCharge) {
<mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button>
</div>
</div>
</div>
<div class="charge-receipt-hint">
Leave this blank if the entry fee was already paid as part of the main share receipt above.
</div>
}
</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>
</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) {
<div class="alert-box alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- RECEIPTS ARRAY LIST -->
<div class="vouchers-array-container mt-8">
@if (loading) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Loading member receipts…</span>
</div>
} @else if (receipts.length === 0) {
<div class="funding-empty">
<mat-icon class="empty-icon">receipt_long</mat-icon>
<div class="empty-text">
<strong>No unapplied receipt found for this member today.</strong>
<p>
If cash was taken on an earlier day, enter that voucher number above. Otherwise collect cash first at
<em>Cashier &rsaquo; Transaction Entry</em> into Share Sundry GL.
</p>
</div>
</div>
} @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">
<thead>
<tr>
<th class="col-checkbox ta-center">
@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">Description</th>
<th class="ta-left">Collected By</th>
<th class="ta-right">Amount</th>
<th class="col-actions ta-center">Detail</th>
</tr>
</thead>
<tbody>
@for (r of receipts; track r.id) {
<tr class="receipt-row" [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
<td class="col-checkbox ta-center" (click)="$event.stopPropagation()">
<input
type="checkbox"
class="custom-checkbox"
[checked]="isSelected(r.id)"
(change)="toggle(r.id)"
/>
</td>
<td class="ta-left">
<span class="voucher-badge font-mono">{{ r.voucher_no || r.receipt_no }}</span>
</td>
<td class="ta-left date-cell">{{ r.collection_date }}</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>
}
</tbody>
</table>
</div>
<!-- Summary Footer -->
<div class="vouchers-footer">
<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>
}
</table>
<div class="note">
Their total must cover the account's share value — plus any entry fee, unless it was verified as its own receipt
above. Anything paid over that is credited to the member's savings account.
</div>
}
</mat-dialog-content>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting"
(click)="apply()"
>
{{ submitting ? 'Applying…' : 'Apply Collection' }}
</button>
</mat-dialog-actions>
<!-- Dialog Actions Bar -->
<mat-dialog-actions align="end" class="dialog-actions-bar">
<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"
type="button"
class="action-btn submit-btn"
[disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting"
(click)="apply()"
>
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Collection</span>
}
</button>
</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 { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError, map } from 'rxjs/operators';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.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;
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 data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
@@ -124,6 +136,11 @@ export class ApplyCollectionDialogComponent implements OnInit {
this.loading = false;
});
this.cashierService
.getUnattributedShareCollections()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.unattributed = res?.rows || []));
this.cashierService
.getShareAdvanceGlAccounts()
.pipe(
@@ -135,12 +152,17 @@ export class ApplyCollectionDialogComponent implements OnInit {
)
.subscribe((res: any) => {
this.glAccounts = res?.glAccounts || [];
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
// this list to accounts that actually hold an eligible receipt today, so a single option is
// unambiguous. Two or more still means the counter chooses by hand.
// Auto-picked only when it is genuinely the only option. The list now carries every
// postable GL account again (see the backend's own comment), so picking index 0 whenever
// 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) {
this.glAccountNo = this.glAccounts[0].gl_code;
}
if (this.glAccounts.length === 1 && !this.chargeGlAccountNo) {
this.chargeGlAccountNo = this.glAccounts[0].gl_code;
}
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
* 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,
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
* 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 {
const voucher = this.voucherNo.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) {
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.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
@@ -257,8 +402,8 @@ export class ApplyCollectionDialogComponent implements OnInit {
return;
}
// 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,
// so making them tick it again would be asking the same question twice.
// are left for the clerk: attaching a specific voucher IS the deliberate choice of that
// receipt, so making them tick it again would be asking the same question twice.
this.receipts = [
res,
...this.receipts.filter((r) => r.id !== res.id)
@@ -269,7 +414,19 @@ export class ApplyCollectionDialogComponent implements OnInit {
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 = '';
// 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.
*/
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(
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) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`);
return of(null);
@@ -149,6 +149,10 @@ export class SharesAccountViewComponent implements OnInit {
// capital that was never issued.
if (!this.isSettled) {
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 {
// "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
@@ -202,7 +206,9 @@ export class SharesAccountViewComponent implements OnInit {
*/
private applyCollection() {
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
width: '560px',
width: '820px',
maxWidth: '92vw',
panelClass: 'apply-collection-dialog-panel',
data: {
shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId,
+133
View File
@@ -41,6 +41,139 @@ const routes: Routes = [
data: { title: 'Share Holders', breadcrumb: 'Share Holders' },
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',
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> {
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 */
export default {
'mifos_x': {
'version': '260916',
'hash': 'c01615ee'
'version': '260918',
'hash': 'e53dfb5a'
},
'allow_switching_backend_instance': true
};
+11
View File
@@ -1960,3 +1960,14 @@ mifosx-client-datatable-step * {
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;
}
}