This commit is contained in:
2026-09-23 15:51:10 +05:30
parent cda7c52d11
commit 08a427467a
123 changed files with 20687 additions and 4156 deletions
@@ -32,7 +32,6 @@ export class ShareAccountActionsResolver {
const actionName = route.paramMap.get('name');
const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId');
switch (actionName) {
case 'Apply Additional Shares':
case 'Redeem Shares':
case 'Approve Additional Shares':
case 'Reject Additional Shares':
@@ -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,168 @@
<!--
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="layer-group" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Product Summary</h2>
<div class="cert-head-s">
Every share class against its own authorised capital — shares issued, shares remaining, and the
distinctive-number range issued so far. "Issued" means paid up, not merely allotted.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !rows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !rows.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
@if (rows.length) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="layer-group" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.authorisedShares | number }}</div>
<div class="cert-kpi-l">Authorised Shares (all classes)</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.issuedShares | number }}</div>
<div class="cert-kpi-l">Shares Issued (paid up)</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.issuedCapital | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital Issued</div>
</div>
</div>
<div class="cert-kpi" [ngClass]="totals.unpaidShares ? 'cert-kpi--amber' : 'cert-kpi--blue'">
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.unpaidShares | number }}</div>
<div class="cert-kpi-l">Allotted, Unpaid</div>
</div>
</div>
</div>
}
@if (loading) {
<div class="srep-state">Loading product summary…</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 product is configured.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Share Class</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Authorised</th>
<th class="srep-num">Issued (Paid Up)</th>
<th class="srep-num">Available</th>
<th>Utilisation</th>
<th class="srep-num">Allotted, Unpaid</th>
<th>Distinctive Range</th>
<th class="srep-num">Accounts</th>
<th>Min / Nominal / Max</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.productId) {
<tr>
<td>
{{ row.productName }}
@if (row.productShortName) {
<div class="srep-mono srep-sub" style="color: inherit; opacity: 0.65">{{ row.productShortName }}</div>
}
</td>
<td class="srep-num">{{ row.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">
{{ row.authorisedShares | number }}
<div class="srep-sub" style="color: inherit; opacity: 0.65">
₹{{ row.authorisedCapital | number: '1.0-0' }}
</div>
</td>
<td class="srep-num">
{{ row.issuedShares | number }}
<div class="srep-sub" style="color: inherit; opacity: 0.65">
₹{{ row.issuedCapital | number: '1.0-0' }}
</div>
</td>
<td class="srep-num">{{ row.availableShares | number }}</td>
<td>
<div class="product-utilisation-bar">
<div class="product-utilisation-fill" [style.width.%]="utilisationPercent(row)"></div>
</div>
<div class="srep-mono" style="font-size: 0.72rem; color: #64748b">
{{ utilisationPercent(row) | number: '1.0-1' }}%
</div>
</td>
<td class="srep-num">
@if (row.unpaidShares) {
<span class="srep-pill srep-pill--amber">{{ row.unpaidShares | number }}</span>
<div class="srep-sub" style="color: inherit; opacity: 0.65">
₹{{ row.unpaidAmount | number: '1.0-0' }} · {{ row.unpaidAccounts }}
{{ row.unpaidAccounts === 1 ? 'account' : 'accounts' }}
</div>
} @else {
<span class="srep-pill srep-pill--green">0</span>
}
</td>
<td class="srep-mono">{{ distinctiveRange(row) }}</td>
<td class="srep-num">
{{ row.accountCount | number }}
<div class="srep-sub" style="color: inherit; opacity: 0.65">
{{ row.memberCount | number }} {{ row.memberCount === 1 ? 'member' : 'members' }}
</div>
</td>
<td class="srep-mono">
{{ row.minimumClientShares ?? '—' }} / {{ row.nominalClientShares ?? '—' }} /
{{ row.maximumClientShares ?? '—' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total — {{ rows.length | number }} share classes</td>
<td class="srep-num"></td>
<td class="srep-num">{{ totals.authorisedShares | number }}</td>
<td class="srep-num">{{ totals.issuedShares | number }}</td>
<td class="srep-num" colspan="2"></td>
<td class="srep-num">{{ totals.unpaidShares | number }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
@if (totals.unpaidShares) {
<div class="srep-warn">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>
<b>{{ totals.unpaidShares | number }}</b> shares across every class are allotted but not yet paid for —
<b>₹{{ totals.unpaidAmount | number: '1.2-2' }}</b> in all. They are excluded from "Issued" above; see Final
Balance for which members they belong to.
</span>
</div>
}
}
</div>
@@ -0,0 +1,168 @@
/**
* 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 { 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 ProductSummaryRow {
productId: number;
productName: string;
productShortName: string;
unitPrice: number;
minimumClientShares: number;
nominalClientShares: number;
maximumClientShares: number;
authorisedShares: number;
authorisedCapital: number;
issuedShares: number;
issuedCapital: number;
availableShares: number;
unpaidAccounts: number;
unpaidShares: number;
unpaidAmount: number;
distinctiveFrom: number | null;
distinctiveTo: number | null;
accountCount: number;
memberCount: number;
}
/**
* Share Product Summary — every share class measured against its own authorised capital: how many
* shares are issued, how many remain, and the distinctive-number range issued so far.
*
* <p>
* "Issued" is paid-up shares — settled purchases less redemptions, computed the same way every other
* report in this suite computes them — not {@code m_share_product.totalsubscribed_shares}. That
* platform column counts an allotment the moment it is submitted, before any money has necessarily
* arrived, so it runs ahead of what the society has actually received. A board reading this page is
* reading the same figure the GL Share Capital account would show, not a subscription count.
*/
@Component({
selector: 'mifosx-share-product-summary',
templateUrl: './share-product-summary.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FaIconComponent
]
})
export class ShareProductSummaryComponent implements OnInit {
private sharesService = inject(SharesService);
loading = true;
loadError = '';
rows: ProductSummaryRow[] = [];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareProductSummary()
.pipe(
catchError((err: any) => {
this.loadError = this.readError(err, 'The product summary could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.loading = false;
});
}
/** How much of a product's authorised capital is actually issued, for the progress bar. */
utilisationPercent(row: ProductSummaryRow): number {
if (!row.authorisedShares) {
return 0;
}
const pct = (row.issuedShares / row.authorisedShares) * 100;
return Math.max(0, Math.min(100, pct));
}
distinctiveRange(row: ProductSummaryRow): string {
if (!row.distinctiveFrom || !row.distinctiveTo) {
return 'None allotted yet';
}
return `${row.distinctiveFrom} – ${row.distinctiveTo}`;
}
get totals() {
return {
authorisedShares: this.rows.reduce((sum, r) => sum + Number(r.authorisedShares || 0), 0),
authorisedCapital: this.rows.reduce((sum, r) => sum + Number(r.authorisedCapital || 0), 0),
issuedShares: this.rows.reduce((sum, r) => sum + Number(r.issuedShares || 0), 0),
issuedCapital: this.rows.reduce((sum, r) => sum + Number(r.issuedCapital || 0), 0),
unpaidShares: this.rows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0),
unpaidAmount: this.rows.reduce((sum, r) => sum + Number(r.unpaidAmount || 0), 0)
};
}
exportCsv(): void {
shareReportCsv(
'share-product-summary',
[
'Product',
'Unit Price',
'Authorised Shares',
'Authorised Capital',
'Issued Shares',
'Issued Capital',
'Available Shares',
'Unpaid Accounts',
'Unpaid Shares',
'Unpaid Amount',
'Distinctive Range',
'Accounts',
'Members',
'Min / Nominal / Max Per Member'
],
this.rows.map((r) => [
r.productName,
r.unitPrice,
r.authorisedShares,
r.authorisedCapital,
r.issuedShares,
r.issuedCapital,
r.availableShares,
r.unpaidAccounts,
r.unpaidShares,
r.unpaidAmount,
this.distinctiveRange(r),
r.accountCount,
r.memberCount,
`${r.minimumClientShares ?? '—'} / ${r.nominalClientShares ?? '—'} / ${r.maximumClientShares ?? '—'}`
])
);
}
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,511 @@
/**
* 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;
}
/* A share class's own authorised-vs-issued fraction, read at a glance rather than only in the two
numbers beside it. */
.product-utilisation-bar {
width: 100%;
min-width: 90px;
height: 6px;
border-radius: 999px;
background: $srep-border-soft;
overflow: hidden;
}
.product-utilisation-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #2563eb, #059669);
}
/* 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
);
}
}
+996
View File
@@ -0,0 +1,996 @@
/**
* 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 chrome for Apply Collection and Apply Additional Shares dialogs.
// Each component @uses this, so every rule is emitted once per component
// and stays scoped to it -- nothing here leaks into the global stylesheet.
/* ── Design tokens ─────────────────────────────────────────────────────── */
$primary: #1e3a8a;
$primary-mid: #2448b0;
$accent: #2563eb;
$accent-soft: #eff6ff;
$accent-border: #bfdbfe;
$text-main: #0f172a;
$text-sub: #334155;
$text-muted: #64748b;
$border-color: #e2e8f0;
$bg-page: #f8fafc;
$bg-white: #fff;
$success: #059669;
$success-bg: #ecfdf5;
$success-border: #6ee7b7;
$danger: #dc2626;
$danger-bg: #fef2f2;
$danger-border: #fca5a5;
/* ── Utilities ─────────────────────────────────────────────────────────── */
.mono {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-variant-numeric: tabular-nums;
}
/* ── Dialog wrapper ────────────────────────────────────────────────────── */
.dialog-wrapper {
display: flex;
flex-direction: column;
max-height: 88vh;
background: $bg-white;
border-radius: 14px;
overflow: hidden;
}
/* ── Header ────────────────────────────────────────────────────────────── */
.dialog-header {
display: flex;
flex: none;
align-items: center;
gap: 0.85rem;
padding: 1.1rem 1.5rem;
background: linear-gradient(135deg, $primary 0%, $primary-mid 60%, #3b5fc0 100%);
color: #fff;
.header-icon {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 9px;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 20%);
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #fff;
}
}
.header-text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 1.08rem;
font-weight: 700;
letter-spacing: 0.01em;
}
p {
margin: 0.18rem 0 0;
font-size: 0.78rem;
opacity: 0.78;
}
}
.header-close {
color: rgb(255 255 255 / 80%);
flex: none;
&:hover {
color: #fff;
background: rgb(255 255 255 / 12%) !important;
}
}
}
/* ── Body ──────────────────────────────────────────────────────────────── */
.dialog-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 1.1rem;
min-height: 0;
max-height: none !important;
padding: 1.4rem 1.5rem !important;
overflow-y: auto;
background: $bg-page;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 99px;
}
}
.state-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem 0;
color: $text-muted;
font-size: 0.85rem;
}
/* ═══════════════════════════════════════════════════════════════════════
Collection Summary Card (sc-* prefix)
═══════════════════════════════════════════════════════════════════════ */
.sc-card {
border: 1px solid $accent-border;
border-radius: 10px;
background: $bg-white;
// No overflow:hidden — it clips row content in certain flex contexts
box-shadow: 0 2px 8px rgb(37 99 235 / 8%);
}
/* ── Each item row (Share / Entry Fee) ─────────────────────────────────── */
.sc-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1.15rem;
background: $bg-white;
border-left: 4px solid transparent;
transition:
background 0.15s,
border-color 0.2s;
&.sc-sep {
border-top: 1px solid $border-color;
}
&.sc-ok {
background: $success-bg;
border-left-color: $success;
}
&.sc-bad {
background: $danger-bg;
border-left-color: $danger;
}
}
/* Left side: label + required amount */
.sc-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.18rem;
}
.sc-label {
display: block;
font-size: 0.86rem;
font-weight: 700;
color: $text-main;
line-height: 1.3;
min-height: 1em; // guarantees height even if content is empty
}
.sc-sub {
display: block;
font-size: 0.72rem;
color: $text-muted;
line-height: 1.35;
min-height: 1em;
b {
font-weight: 600;
color: $text-sub;
}
}
/* Right side: collected amount + badge */
.sc-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex: none;
}
.sc-val {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-variant-numeric: tabular-nums;
font-size: 0.96rem;
font-weight: 700;
color: $text-muted;
letter-spacing: -0.01em;
transition: color 0.2s;
&.sc-val-ok {
color: $success;
}
&.sc-val-bad {
color: $danger;
}
}
.sc-tag {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: 99px;
font-size: 0.68rem;
font-weight: 700;
white-space: nowrap;
&.sc-tag-ok {
background: $success-bg;
color: $success;
border: 1px solid $success-border;
}
&.sc-tag-bad {
background: $danger-bg;
color: $danger;
border: 1px solid $danger-border;
}
}
/* ── Total row ─────────────────────────────────────────────────────────── */
.sc-total-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1.15rem;
border-top: 1px solid $border-color;
background: $bg-page;
transition: background 0.2s;
&.sc-total-ok {
background: $success-bg;
}
}
.sc-total-lbl {
font-size: 0.82rem;
font-weight: 800;
color: $text-main;
letter-spacing: 0.01em;
}
.sc-total-nums {
display: flex;
align-items: center;
gap: 0.45rem;
flex: none;
}
.sc-total-req {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 0.78rem;
font-weight: 500;
color: $text-muted;
}
.sc-arrow {
font-size: 0.8rem;
color: #94a3b8;
font-weight: 300;
}
.sc-total-got {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-variant-numeric: tabular-nums;
font-size: 1rem;
font-weight: 800;
color: $text-muted;
letter-spacing: -0.02em;
transition: color 0.2s;
&.sc-total-got-ok {
color: $success;
}
}
.sc-status-pill {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.65rem;
border-radius: 99px;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.04em;
white-space: nowrap;
transition: all 0.2s;
&.sc-pill-ok {
background: $success;
color: #fff;
box-shadow: 0 2px 6px rgb(5 150 105 / 30%);
}
&.sc-pill-pending {
background: $border-color;
color: $text-muted;
}
}
/* ═══════════════════════════════════════════════════════════════════════ */
/* ── Receipt step slots ────────────────────────────────────────────────── */
.slot {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.1rem 1.25rem;
border: 1px solid $border-color;
border-radius: 10px;
background: $bg-white;
box-shadow: 0 1px 4px rgb(15 23 42 / 4%);
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 3px 12px rgb(15 23 42 / 7%);
}
}
.slot-head {
display: flex;
align-items: center;
gap: 0.75rem;
}
.slot-step {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 26px;
height: 26px;
border-radius: 50%;
background: linear-gradient(135deg, $primary 0%, $accent 100%);
color: #fff;
font-size: 0.72rem;
font-weight: 800;
box-shadow: 0 2px 6px rgb(37 99 235 / 30%);
}
.slot-title {
flex: 1;
min-width: 0;
h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 700;
color: $text-main;
}
p {
margin: 0.12rem 0 0;
font-size: 0.72rem;
color: $text-muted;
}
}
.slot-search {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex: none;
padding: 0.38rem 0.72rem;
border: 1.5px solid $accent-border;
border-radius: 7px;
background: $accent-soft;
color: $accent;
font-size: 0.74rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
mat-icon {
width: 15px;
height: 15px;
font-size: 15px;
}
&:hover {
background: $accent;
border-color: $accent;
color: #fff;
box-shadow: 0 2px 8px rgb(37 99 235 / 25%);
}
}
/* ── Pickable option list ───────────────────────────────────────────────── */
.options {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 210px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 99px;
}
}
.option {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.72rem 0.9rem;
border: 1.5px solid $border-color;
border-radius: 7px;
cursor: pointer;
background: $bg-white;
transition: all 0.14s;
&:hover {
border-color: $accent;
background: $accent-soft;
box-shadow: 0 2px 6px rgb(37 99 235 / 10%);
}
&.option-busy {
opacity: 0.6;
pointer-events: none;
}
}
.option-radio {
flex: none;
width: 14px;
height: 14px;
border: 2px solid #cbd5e1;
border-radius: 50%;
transition: border-color 0.14s;
.option:hover & {
border-color: $accent;
}
}
.option-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.option-voucher {
font-size: 0.81rem;
font-weight: 700;
color: $text-main;
}
.option-meta {
font-size: 0.7rem;
color: $text-muted;
}
.option-desc {
font-size: 0.7rem;
color: $text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.option-amount {
flex: none;
font-size: 0.85rem;
font-weight: 700;
color: $text-main;
}
.option-info {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid $border-color;
border-radius: 5px;
background: transparent;
color: $text-muted;
cursor: pointer;
transition: all 0.14s;
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
&:hover {
color: $accent;
border-color: $accent;
background: $accent-soft;
}
}
/* ── Chosen receipt ─────────────────────────────────────────────────────── */
.chosen {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.75rem 1rem;
border: 2px solid $success-border;
border-radius: 9px;
background: $success-bg;
box-shadow: 0 2px 8px rgb(5 150 105 / 10%);
&.chosen-short {
border-color: $danger-border;
background: $danger-bg;
box-shadow: 0 2px 8px rgb(220 38 38 / 8%);
}
}
.chosen-badge {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 30px;
height: 30px;
border-radius: 7px;
background: $success;
.chosen-short & {
background: $danger;
}
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #fff;
}
}
.chosen-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.chosen-voucher {
font-size: 0.84rem;
font-weight: 700;
color: $text-main;
}
.chosen-gl {
font-size: 0.7rem;
color: $text-muted;
}
.chosen-amount {
flex: none;
font-size: 0.95rem;
font-weight: 800;
color: $text-main;
font-family: 'Roboto Mono', 'Courier New', monospace;
}
.chosen-clear {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid $danger-border;
border-radius: 5px;
background: transparent;
color: #f87171;
cursor: pointer;
transition: all 0.14s;
mat-icon {
width: 15px;
height: 15px;
font-size: 15px;
}
&:hover {
color: $danger;
background: $danger-bg;
border-color: $danger;
}
}
/* ── Slot notes ─────────────────────────────────────────────────────────── */
.slot-note {
margin: 0;
font-size: 0.74rem;
font-weight: 500;
&.note-good {
color: $success;
}
&.note-bad {
color: $danger;
font-weight: 700;
}
&.note-muted {
color: $text-muted;
}
}
.slot-empty {
margin: 0;
padding: 0.8rem 1rem;
border: 1.5px dashed $border-color;
border-radius: 7px;
background: $bg-page;
font-size: 0.75rem;
line-height: 1.55;
color: $text-muted;
}
/* ── Error alert ─────────────────────────────────────────────────────────── */
.alert {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.7rem 0.9rem;
border: 1.5px solid $danger-border;
border-radius: 7px;
background: $danger-bg;
color: $danger;
font-size: 0.76rem;
line-height: 1.45;
mat-icon {
flex: none;
width: 17px;
height: 17px;
font-size: 17px;
margin-top: 1px;
}
}
/* ── Footer ─────────────────────────────────────────────────────────────── */
.dialog-actions {
display: flex;
flex: none;
justify-content: flex-end;
align-items: center;
gap: 0.75rem;
min-height: 0;
padding: 1rem 1.5rem !important;
margin: 0 !important;
border-top: 1px solid $border-color;
background: $bg-white;
box-shadow: 0 -2px 8px rgb(15 23 42 / 5%);
// Both buttons: same height, same border-radius, same font
button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 0.3rem !important;
height: 40px !important;
min-width: 110px !important;
padding: 0 1.1rem !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 0.85rem !important;
line-height: 1 !important;
letter-spacing: 0.01em !important;
// Icon inside button — vertically center it and size it correctly
mat-icon {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
font-size: 18px !important;
line-height: 1 !important;
vertical-align: middle !important;
margin: 0 !important;
flex: none;
}
mat-spinner {
flex: none;
}
}
// Stroked cancel button
[mat-stroked-button] {
border-color: $border-color !important;
color: $text-sub !important;
&:hover {
border-color: #94a3b8 !important;
background: $bg-page !important;
}
}
// Primary action button (enabled)
[mat-flat-button][color='primary']:not([disabled]) {
background: linear-gradient(135deg, $accent 0%, #1d4ed8 100%) !important;
box-shadow: 0 2px 8px rgb(37 99 235 / 28%) !important;
color: #fff !important;
&:hover {
box-shadow: 0 4px 14px rgb(37 99 235 / 40%) !important;
transform: translateY(-1px) !important;
}
&:active {
transform: translateY(0) !important;
}
}
// Primary action button (disabled)
[mat-flat-button][color='primary'][disabled] {
background: #e2e8f0 !important;
color: #94a3b8 !important;
box-shadow: none !important;
}
}
/* ── Form fields (in search popup) ─────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
> span {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $text-muted;
}
select,
input {
padding: 0.55rem 0.7rem;
border: 1.5px solid $border-color;
border-radius: 7px;
font-size: 0.82rem;
color: $text-main;
background: #fff;
outline: none;
transition:
border-color 0.14s,
box-shadow 0.14s;
&:hover {
border-color: #94a3b8;
}
&:focus {
border-color: $accent;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
}
}
/* ── Sub-popups ─────────────────────────────────────────────────────────── */
.overlay {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: rgb(15 23 42 / 55%);
backdrop-filter: blur(2px);
}
.popup {
display: flex;
flex-direction: column;
width: 100%;
max-width: 460px;
max-height: 80vh;
border-radius: 12px;
background: #fff;
overflow: hidden;
box-shadow: 0 20px 50px -8px rgb(15 23 42 / 30%);
animation: popup-in 0.15s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes popup-in {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.popup-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 0.85rem 0.85rem 1.2rem;
border-bottom: 1px solid $border-color;
background: $bg-page;
h3 {
flex: 1;
margin: 0;
font-size: 0.92rem;
font-weight: 700;
color: $text-main;
}
}
.popup-body {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.2rem;
overflow-y: auto;
}
.popup-foot {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
padding: 0.8rem 1.2rem;
border-top: 1px solid $border-color;
background: $bg-page;
}
/* ── Receipt detail popup ───────────────────────────────────────────────── */
.detail-list {
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
border: 1px solid $border-color;
border-radius: 8px;
overflow: hidden;
> div {
display: flex;
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: none;
}
}
dt {
flex: none;
width: 110px;
padding: 0.6rem 0.9rem;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $text-muted;
background: $bg-page;
border-right: 1px solid $border-color;
}
dd {
flex: 1;
min-width: 0;
margin: 0;
padding: 0.6rem 0.9rem;
font-size: 0.8rem;
color: $text-main;
word-break: break-word;
}
}
.detail-heading {
margin: 0.4rem 0 0.1rem;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $text-muted;
}
.gl-table {
width: 100%;
border-collapse: collapse;
font-size: 0.76rem;
border: 1px solid $border-color;
border-radius: 7px;
overflow: hidden;
th,
td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid $border-color;
text-align: left;
}
tbody tr:last-child td {
border-bottom: none;
}
th {
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $text-muted;
background: $bg-page;
}
tbody tr:hover {
background: $accent-soft;
}
.num {
text-align: right;
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* 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/.
*/
/**
* One Share Collection receipt the counter can choose, in a single shape whatever it came from.
*
* {@code GET /shareadvancecollection/unattributed} answers with two differently-shaped lists — the
* member's own receipts under {@code own}, and every other receipt still going spare under
* {@code rows} — and normalising both to this on arrival is what lets a screen show one plain list
* of vouchers instead of two tables whose difference a counter has to understand before they can
* pick anything.
*
* Shared by the two screens that settle a share purchase (the first application's Apply Collection
* dialog and the Apply Additional Shares dialog) so a receipt reads identically on both.
*/
export interface ReceiptOption {
/** Stable identity for tracking and comparison; unique across both source lists. */
key: string;
/**
* The {@code m_share_advance_collection} row behind this receipt, or null while it is still only
* a voucher number. A null one is attached to the member on first pick.
*/
collectionId: number | null;
voucherNo: string;
glAccountNo: string;
glAccountName: string;
date: string;
amount: number;
description: string;
cashierName: string;
}
/**
* A receipt that already has an {@code m_share_advance_collection} row — ready to settle as-is.
*
* Also the shape {@code GET /shareadvancecollection/by-voucher} answers with, so a receipt found by
* number arrives here too, already attached.
*/
export function receiptFromOwn(row: any): ReceiptOption {
return {
key: `c${row.id}`,
collectionId: Number(row.id),
voucherNo: row.voucher_no || row.receipt_no || '',
glAccountNo: row.gl_code || '',
glAccountName: '',
date: String(row.collection_date || '').slice(0, 10),
amount: Number(row.amount || 0),
description: row.note || '',
cashierName: row.collected_by_name || ''
};
}
/** A receipt that is still only a voucher, attached to the member when it is picked. */
export function receiptFromUnattached(row: any): ReceiptOption {
return {
key: `v${row.id}`,
collectionId: null,
voucherNo: row.voucherNo || '',
glAccountNo: row.glAccountNo || '',
glAccountName: row.glAccountName || '',
date: String(row.txnDate || '').slice(0, 10),
amount: Number(row.amount || 0),
description: row.description || '',
cashierName: row.cashierName || ''
};
}
/**
* The GL account a receipt sits in, named where the name is known and coded where it is not.
*
* An attached receipt carries only its {@code gl_code}, so the name is looked up in whatever GL
* account list the screen already loaded for its voucher search rather than fetched again.
*/
export function receiptGlLabel(option: ReceiptOption, glAccounts: any[]): string {
const name = option.glAccountName || glAccounts.find((gl) => gl.gl_code === option.glAccountNo)?.name || '';
if (!option.glAccountNo) {
return name || '—';
}
return name ? `${option.glAccountNo} — ${name}` : option.glAccountNo;
}
@@ -6,292 +6,367 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="applySharesForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="requestedDatePicker.open()">
<mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="requestedDatePicker"
required
formControlName="requestedDate"
/>
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
<mat-datepicker #requestedDatePicker></mat-datepicker>
@if (applySharesForm.controls.requestedDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="dialog-wrapper">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>trending_up</mat-icon>
</div>
<div class="header-text">
<h2>Apply Additional Shares</h2>
<p>
Share account <strong class="mono">{{ data.accountNo }}</strong>
</p>
</div>
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- The board resolution authorising this tranche. Its own meeting and number, not the
one the account was opened under, so it is captured per purchase. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput required formControlName="resolutionNumber" />
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- ── Shares / Price / Amount strip — outside scroll area so always visible ── -->
<div class="shares-subheader">
<div class="shares-strip">
<div class="shares-cell shares-cell-input">
<span class="shares-label">Shares</span>
<input
class="req-input mono"
type="number"
min="1"
step="1"
inputmode="numeric"
placeholder="0"
[(ngModel)]="requestedShares"
(ngModelChange)="onSharesChange()"
/>
</div>
<div class="shares-cell">
<span class="shares-label">Unit Price</span>
<span class="shares-value mono">{{ unitPrice | number: '1.2-2' }}</span>
</div>
<div class="shares-cell shares-cell-total">
<span class="shares-label">Total Amount</span>
<span class="shares-value mono">{{ amount | number: '1.2-2' }}</span>
</div>
</div>
</div>
<mat-form-field (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
required
formControlName="resolutionDate"
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
<input matInput type="number" min="1" required formControlName="requestedShares" />
@if (applySharesForm.controls.requestedShares.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
<input matInput formControlName="unitPrice" />
</mat-form-field>
<!-- Shares x price, computed rather than entered, so the value posted to the GL is the
value the clerk read back to the member. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput formControlName="amount" />
<mat-hint
>{{ 'labels.inputs.Total No. of Shares' | translate }} &times;
{{ 'labels.inputs.Current Price' | translate }}</mat-hint
>
</mat-form-field>
<!-- Where the money comes from. Both routes land in Share Sundry, and approval is what
moves it out to share capital. -->
<div class="payment-mode">
<div class="payment-mode-label">{{ 'labels.inputs.Payment Option' | translate }}</div>
<mat-radio-group formControlName="paymentMode" class="payment-mode-options">
<mat-radio-button value="CASH">Apply Collection (Cash)</mat-radio-button>
<mat-radio-button value="SAVINGS">Debit from Savings</mat-radio-button>
</mat-radio-group>
</div>
@if (paymentMode === 'CASH') {
<div class="funding-panel">
<p class="funding-intro">
The member has already paid at the counter. Tick the receipts that make up this payment, or enter a
voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in
Share Sundry against them, and approving this request moves it into share capital.
</p>
<div class="gl-select-field">
<select
class="gl-select"
[(ngModel)]="glAccountNo"
[ngModelOptions]="{ standalone: true }"
[disabled]="lookingUpVoucher || 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
#voucherInput
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. 000123)"
[disabled]="lookingUpVoucher || submitting"
(keyup.enter)="lookupVoucher(voucherInput.value)"
/>
<button
mat-stroked-button
type="button"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)"
>
{{ lookingUpVoucher ? 'Finding…' : 'Find' }}
</button>
</div>
@if (voucherError) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ voucherError }}</span>
</div>
}
@if (loadingVouchers) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Loading the member's receipts…</span>
</div>
} @else if (vouchers.length === 0) {
<div class="funding-empty">
<mat-icon>info_outline</mat-icon>
<div>
<b>No unapplied receipt for this member.</b>
<div class="funding-empty-hint">
Collect the cash first from Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry
GL. Once it is approved, the voucher appears here.
</div>
</div>
</div>
} @else {
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
against one request, and they fund it together. -->
<table class="receipt-table">
<thead>
<tr>
<th></th>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.id) {
<tr [class.selected]="isVoucherSelected(v.id)" (click)="toggleVoucher(v.id)">
<td class="ta-center">
<input
type="checkbox"
[checked]="isVoucherSelected(v.id)"
(change)="toggleVoucher(v.id)"
(click)="$event.stopPropagation()"
/>
</td>
<td class="ta-left mono">{{ v.voucher_no || v.receipt_no }}</td>
<td class="ta-left">{{ v.collection_date }}</td>
<td class="ta-right">
<b>{{ v.amount | number: '1.2-2' }}</b>
</td>
</tr>
}
</tbody>
@if (selectedVoucherIds.length > 0) {
<tfoot>
<tr class="total-row">
<td></td>
<td class="ta-left" colspan="2">
{{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }}
</td>
<td class="ta-right">
<b>{{ selectedVoucherTotal | number: '1.2-2' }}</b>
</td>
</tr>
</tfoot>
}
</table>
<!-- Says how much is still missing rather than only that it is short, so the clerk
can tell the member what to pay without working it out. -->
@if (selectedVoucherIds.length > 0 && voucherShortfall > 0) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>
The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of
{{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first.
</span>
</div>
}
}
</div>
}
@if (paymentMode === 'SAVINGS') {
<div class="funding-panel">
<p class="funding-intro">
The amount is debited from the member's linked savings into Share Sundry when this is submitted, so
their balance drops now. Approving the request then moves it into share capital.
</p>
@if (checkingSavings) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Checking the linked savings account…</span>
</div>
} @else if (savingsEligibility) {
<div class="funding-summary">
@if (savingsEligibility.savingsAccountNo) {
<div class="funding-row">
<span>Savings account</span>
<span class="mono">{{ savingsEligibility.savingsAccountNo }}</span>
</div>
<div class="funding-row">
<span>Available balance</span>
<span>{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
</div>
}
<div class="funding-row funding-row--total">
<span>To debit</span>
<span>{{ amount | number: '1.2-2' }}</span>
</div>
</div>
@if (!savingsEligibility.eligible) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ savingsEligibility.reason }}</span>
</div>
}
}
</div>
}
@if (errorMsg) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- Said plainly, so a clerk does not tell the member their shares are issued while a
manager still has to approve the request. -->
<p class="pending-hint">
Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry,
only once it is approved — until then it appears under pending, not in the account's transactions.
</p>
<mat-dialog-content class="dialog-body">
<!-- ── Step 1: Board Resolution ──────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">1</span>
<div class="slot-title">
<h3>Board Resolution</h3>
<p>The meeting that authorised this tranche, not the one the account was opened under.</p>
</div>
</mat-card-content>
</header>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
<div class="field-row">
<label class="field">
<span>Resolution No.</span>
<input type="text" [(ngModel)]="resolutionNumber" placeholder="e.g. BR/2026/14" autocomplete="off" />
</label>
<label class="field">
<span>Meeting Date</span>
<input type="date" [(ngModel)]="resolutionDate" />
</label>
<label class="field">
<span>Request Date</span>
<input type="date" [(ngModel)]="requestedDate" />
</label>
</div>
</section>
<!-- ── Step 2: Funding ───────────────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">2</span>
<div class="slot-title">
<h3>Funding</h3>
<p>Approval posts Dr Sundry / Cr Share Capital either way.</p>
</div>
@if (funding === 'CASH') {
<button type="button" class="slot-search" (click)="openSearch()">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
}
</header>
<div class="funding-choice">
<button
type="button"
class="funding-btn"
[class.funding-on]="funding === 'CASH'"
(click)="chooseFunding('CASH')"
>
<mat-icon>payments</mat-icon>
<span class="funding-name">Cash</span>
<span class="funding-hint">Already paid at the counter</span>
</button>
<button mat-raised-button color="primary" [disabled]="!canSubmit">
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
<button
type="button"
class="funding-btn"
[class.funding-on]="funding === 'SAVINGS'"
(click)="chooseFunding('SAVINGS')"
>
<mat-icon>savings</mat-icon>
<span class="funding-name">Savings</span>
<span class="funding-hint">Debited on submit</span>
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
@if (funding === 'CASH') {
@if (loadingReceipts) {
<div class="state-block">
<mat-spinner diameter="24"></mat-spinner>
<span>Loading receipts…</span>
</div>
} @else if (receipt) {
<div class="chosen" [class.chosen-short]="shortfall > 0">
<div class="chosen-badge">
<mat-icon>{{ shortfall > 0 ? 'warning' : 'check_circle' }}</mat-icon>
</div>
<div class="chosen-main">
<span class="chosen-voucher mono">{{ receipt.voucherNo }}</span>
<span class="chosen-gl">{{ glLabel(receipt) }}</span>
</div>
<span class="chosen-amount mono">{{ receipt.amount | number: '1.2-2' }}</span>
<button type="button" class="chosen-clear" (click)="clearReceipt()" aria-label="Change receipt">
<mat-icon>close</mat-icon>
</button>
</div>
@if (shortfall > 0) {
<p class="slot-note note-bad">
Short by {{ shortfall | number: '1.2-2' }} — this receipt does not cover {{ amount | number: '1.2-2' }}.
</p>
} @else {
<p class="slot-note note-good">Covers the {{ amount | number: '1.2-2' }} tranche.</p>
}
} @else if (available.length) {
<ul class="options">
@for (option of available; track option.key) {
<li class="option" [class.option-busy]="attaching === option.key" (click)="pick(option)">
<span class="option-radio"></span>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.date }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
@if (attaching === option.key) {
<mat-spinner diameter="16"></mat-spinner>
} @else if (option.collectionId !== null) {
<button
type="button"
class="option-info"
(click)="openDetail(option, $event)"
aria-label="GL entries"
>
<mat-icon>receipt_long</mat-icon>
</button>
}
</li>
}
</ul>
} @else {
<p class="slot-empty">
No Share Collection receipt is waiting. Collect the cash at <em>Cashier › Transaction Entry</em> with entry
type <strong>Share Collection</strong>, or find an earlier voucher by number.
</p>
}
}
@if (funding === 'SAVINGS') {
@if (checkingSavings) {
<div class="state-block">
<mat-spinner diameter="24"></mat-spinner>
<span>Checking the linked savings account…</span>
</div>
} @else if (savings) {
<div class="chosen" [class.chosen-short]="!savings.eligible">
<div class="chosen-badge">
<mat-icon>{{ savings.eligible ? 'check_circle' : 'warning' }}</mat-icon>
</div>
<div class="chosen-main">
<span class="chosen-voucher">
@if (savings.savingsAccountNo) {
Savings <span class="mono">{{ savings.savingsAccountNo }}</span>
} @else {
Linked savings account
}
</span>
<span class="chosen-gl">
{{ savings.eligible ? 'Debited into Share Sundry on submit.' : savings.reason }}
</span>
</div>
@if (savings.savingsBalance !== null) {
<span class="chosen-amount mono">{{ savings.savingsBalance | number: '1.2-2' }}</span>
}
</div>
} @else {
<p class="slot-note note-muted">Enter the number of shares to check the member's balance.</p>
}
}
</section>
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
</mat-dialog-content>
<mat-dialog-actions class="dialog-actions">
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canSubmit" (click)="submit()">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Submitting…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Submit Request</span>
}
</button>
</mat-dialog-actions>
</div>
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
@if (searchOpen) {
<div class="overlay" (click)="closeSearch()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>Find a receipt</h3>
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<label class="field">
<span>GL Account</span>
<select [(ngModel)]="searchGlAccountNo" [disabled]="loadingGlAccounts">
<option value="">{{ loadingGlAccounts ? 'Loading…' : 'Select the account it was paid into' }}</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</label>
<label class="field">
<span>Voucher No.</span>
<input
type="text"
[(ngModel)]="searchVoucherNo"
placeholder="CR/18092026/0004"
(keyup.enter)="runSearch()"
autocomplete="off"
/>
</label>
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
<button
type="button"
mat-flat-button
color="primary"
[disabled]="!searchVoucherNo.trim() || !searchGlAccountNo.trim() || searching"
(click)="runSearch()"
>
@if (searching) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<span>Find &amp; Use</span>
}
</button>
</footer>
</div>
</div>
}
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
@if (viewing) {
<div class="overlay" (click)="closeDetail()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
</h3>
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<dl class="detail-list">
<div>
<dt>Date</dt>
<dd class="mono">{{ viewing.date }}</dd>
</div>
<div>
<dt>Amount</dt>
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
</div>
<div>
<dt>GL Account</dt>
<dd>{{ glLabel(viewing) }}</dd>
</div>
@if (viewing.cashierName) {
<div>
<dt>Collected By</dt>
<dd>{{ viewing.cashierName }}</dd>
</div>
}
@if (viewing.description) {
<div>
<dt>Description</dt>
<dd>{{ viewing.description }}</dd>
</div>
}
</dl>
<h4 class="detail-heading">Journal Entries</h4>
@if (loadingGlEntries) {
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
} @else if (viewingGlEntries?.length) {
<table class="gl-table">
<thead>
<tr>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (entry of viewingGlEntries; track $index) {
<tr>
<td>
<span class="mono">{{ entry.glCode }}</span> {{ entry.accountName }}
</td>
<td class="num mono">{{ entry.drCr === 'Dr' ? (entry.amount | number: '1.2-2') : '' }}</td>
<td class="num mono">{{ entry.drCr === 'Cr' ? (entry.amount | number: '1.2-2') : '' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
</footer>
</div>
</div>
}
@@ -6,231 +6,195 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1a3a6b;
$border: #d8e0ec;
$muted: #6b7a90;
$danger: #b3261e;
// Dialog chrome, receipt list, chosen state and popups come from the shared
// partial. Only what is unique to a tranche request lives below.
@use '../../share-dialog.shared' as *;
.container {
max-width: 37rem;
:host {
display: block;
width: 100%;
}
.payment-mode {
margin: 4px 0 16px;
/* ── Shares sub-header (sits between dialog-header and dialog-body, never scrolls) ── */
.shares-subheader {
display: flex;
flex: none; // same as header/footer — doesn't participate in body flex growth
padding: 0.85rem 1.5rem;
background: #f1f5f9;
border-bottom: 1px solid $border-color;
}
.payment-mode-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: $muted;
margin-bottom: 8px;
/* ── Shares / Price / Amount strip ─────────────────────────────────────── */
.shares-strip {
display: flex;
width: 100%;
border: 1px solid $accent-border;
border-radius: 10px;
overflow: hidden;
background: $bg-white;
box-shadow: 0 2px 8px rgb(37 99 235 / 7%);
}
.payment-mode-options {
.shares-cell {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
gap: 0.3rem;
padding: 0.9rem 1.1rem;
background: $bg-white;
border-right: 1px solid $border-color;
&:last-child {
border-right: none;
}
&.shares-cell-input {
// editable cell — slight highlight
background: #fafcff;
}
&.shares-cell-total {
background: $accent-soft;
.shares-label {
color: $accent;
}
.shares-value {
color: $primary;
}
}
}
/* The funding route's own detail, boxed off from the share fields above it: which voucher, or
which savings account, is what the clerk checks before submitting. */
.funding-panel {
border: 1px solid $border;
border-radius: 6px;
padding: 14px;
margin-bottom: 16px;
background: #fbfcfe;
.shares-label {
display: block;
font-size: 0.63rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $text-muted;
line-height: 1.3;
}
.funding-intro {
font-size: 13px;
color: $muted;
margin: 0 0 12px;
.shares-value {
display: block;
font-size: 1.05rem;
font-weight: 700;
color: $text-main;
letter-spacing: -0.01em;
line-height: 1.3;
}
.gl-select-field {
margin: 0 0 10px;
}
/* ── Inline number input inside the strip ──────────────────────────────── */
.gl-select {
.req-input {
width: 100%;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-size: 13px;
color: $primary;
background: #fff;
padding: 0;
border: none;
background: transparent;
font-size: 1.05rem;
font-weight: 700;
color: $text-main;
letter-spacing: -0.01em;
&:focus {
outline: none;
border-color: $primary;
}
&:disabled {
background: #f4f7fb;
color: $muted;
&::placeholder {
color: #cbd5e1;
font-weight: 400;
}
// Remove browser number arrows
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
}
.voucher-lookup {
/* ── Board resolution fields ────────────────────────────────────────────── */
.field-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.85rem;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
}
/* ── Cash / Savings funding selector ───────────────────────────────────── */
.funding-choice {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.funding-btn {
display: flex;
gap: 8px;
align-items: center;
margin: 0 0 12px;
}
.voucher-input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
letter-spacing: 0.4px;
color: $primary;
&:focus {
outline: none;
border-color: $primary;
}
&:disabled {
background: #f4f7fb;
color: $muted;
}
}
.funding-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
font-size: 13px;
color: $muted;
}
.funding-summary {
border: 1px solid $border;
border-radius: 6px;
background: #fff;
padding: 10px 12px;
font-size: 13px;
}
.funding-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 4px 0;
color: $primary;
> span:first-child {
color: $muted;
}
}
.funding-row--total {
border-top: 1px solid #eceff4;
margin-top: 4px;
padding-top: 8px;
font-weight: 500;
}
.mono {
font-family: 'Courier New', monospace;
}
.funding-empty {
display: flex;
gap: 10px;
flex-direction: column;
align-items: flex-start;
background: #f4f7fb;
border: 1px solid $border;
border-radius: 6px;
padding: 12px;
font-size: 13px;
}
.funding-empty-hint {
color: $muted;
margin-top: 4px;
}
.receipt-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: #fff;
border: 1px solid $border;
border-radius: 6px;
th {
background: #eef2f8;
color: $primary;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 8px;
}
td {
padding: 8px;
border-bottom: 1px solid #eceff4;
}
tbody tr {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
&.selected {
background: #e7effb;
}
}
/* The running total against the tranche amount — what decides whether the request is accepted,
and not something to be added up by eye once a second receipt is in play. */
tfoot .total-row {
border-top: 2px solid #cbd5e1;
td {
padding-top: 10px;
color: #1f3d7a;
}
}
}
.ta-left {
gap: 0.2rem;
padding: 0.85rem 1rem;
border: 2px solid $border-color;
border-radius: 10px;
background: $bg-white;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
mat-icon {
width: 22px;
height: 22px;
font-size: 22px;
color: $text-muted;
transition: color 0.15s;
margin-bottom: 0.15rem;
}
&:hover {
border-color: $accent;
background: $accent-soft;
box-shadow: 0 2px 8px rgb(37 99 235 / 12%);
transform: translateY(-1px);
mat-icon {
color: $accent;
}
}
&:active {
transform: translateY(0);
}
&.funding-on {
border-color: $accent;
background: linear-gradient(135deg, $accent-soft 0%, #e0eaff 100%);
box-shadow: 0 2px 10px rgb(37 99 235 / 18%);
mat-icon,
.funding-name {
color: $primary;
}
}
}
.ta-right {
text-align: right;
.funding-name {
font-size: 0.88rem;
font-weight: 700;
color: $text-main;
letter-spacing: 0.01em;
}
.ta-center {
text-align: center;
}
.alert-error {
display: flex;
align-items: center;
gap: 8px;
background: #fdecea;
color: $danger;
border: 1px solid #f5c6c2;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 12px;
}
.pending-hint {
font-size: 12px;
color: $muted;
margin: 0;
.funding-hint {
font-size: 0.7rem;
color: $text-muted;
line-height: 1.3;
}
@@ -6,109 +6,116 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatRadioModule } from '@angular/material/radio';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { of } from 'rxjs';
/** Custom Services */
import { SharesService } from 'app/shares/shares.service';
import { CashierService } from 'app/cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option';
/** Where the money for this tranche comes from. */
export type FundingSource = 'CASH' | 'SAVINGS';
/**
* Apply Additional Shares.
*
* A member buying a further tranche of shares, which — like the account's first application — is
* only a request until it is approved. Two things have to be captured that the bare share count
* does not carry:
*
* <ul>
* <li>the board resolution authorising this particular tranche, which is its own meeting and its
* own number, not the one the account was opened under;</li>
* <li>where the money is coming from, because a tranche nobody has paid for cannot be approved.</li>
* </ul>
* A member buying a further tranche, which — like the account's first application — is only a
* request until it is approved. Two things have to be captured that the bare share count does not
* carry: the board resolution authorising this particular tranche (its own meeting, its own number,
* not the one the account was opened under), and where the money is coming from, because a tranche
* nobody has paid for cannot be approved.
*
* Both funding routes converge on the Share Sundry holding account, so approval posts the same
* Dr Sundry / Cr Share Capital pair either way:
*
* <ul>
* <li><b>Cash</b> — the member already paid at the counter; the cashier's receipts put the money
* into Sundry at that point. Submitting only reserves those receipts against this tranche.
* More than one can be ticked, because a member often pays in instalments and no single
* receipt covers the tranche. They are checked here rather than at approval, while the member
* is still at the counter to make up any shortfall.</li>
* <li><b>Cash</b> — the member already paid at the counter and the cashier's receipt put the money
* into Sundry then. Submitting only reserves that receipt against this tranche. One receipt
* funds one tranche; it is checked here rather than at approval, while the member is still at
* the counter to make up any shortfall.</li>
* <li><b>Savings</b> — the member's savings are debited into Sundry on submit, so their balance
* drops immediately. Approval then moves it out to share capital.</li>
* </ul>
*
* Deliberately the same screen as the first application's Apply Collection dialog — same numbered
* steps, same receipt list, same voucher search — because it settles the same receipts for the same
* person, and two different designs for that were only ever two things to learn.
*/
@Component({
selector: 'mifosx-apply-shares',
templateUrl: './apply-shares.component.html',
styleUrls: ['./apply-shares.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
MatRadioModule,
MatProgressSpinnerModule,
MatIconModule
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplySharesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private sharesService = inject(SharesService);
private cashierService = inject(CashierService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
/** Shares account data. */
sharesAccountData: any;
public dialogRef = inject(MatDialogRef<ApplySharesComponent>);
public data = inject<{
shareAccountId: number;
clientId: number;
accountNo: string;
unitPrice: number;
}>(MAT_DIALOG_DATA);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
maxDate = new Date();
/** Apply Share Account form. */
applySharesForm: UntypedFormGroup;
/** Shares Account Id */
accountId: any;
/** Submission in flight, so the form cannot be posted twice. */
submitting = false;
/** Anything that stopped the submission, shown above the buttons. */
errorMsg = '';
/**
* The member's unapplied Share Sundry receipts, and which of them fund this tranche.
*
* Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance
* tomorrow — and neither receipt covers it alone. They are submitted together as one settlement
* rather than sending the member back to the counter for a single combined receipt.
*/
vouchers: any[] = [];
selectedVoucherIds: number[] = [];
loadingVouchers = false;
lookingUpVoucher = false;
voucherError = '';
// ── The tranche ─────────────────────────────────────────────────────────────────────────────
/** 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. */
glAccountNo = '';
glAccounts: any[] = [];
loadingGlAccounts = true;
/** How many shares this tranche is for. */
requestedShares: number | null = null;
/** The product's current market price. Read-only: the tranche's value and its GL posting must
* never be able to disagree because somebody typed over the price. */
unitPrice = 0;
/** Both as yyyy-MM-dd, the value a native date input reads and writes. */
requestedDate = '';
resolutionDate = '';
resolutionNumber = '';
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
get amount(): number {
return (Number(this.requestedShares) || 0) * this.unitPrice;
}
// ── Funding ─────────────────────────────────────────────────────────────────────────────────
funding: FundingSource | null = null;
/** Every Share Collection receipt that can fund this tranche, from both server lists, in one pool. */
available: ReceiptOption[] = [];
loadingReceipts = false;
/** The one receipt funding this tranche. One receipt, one tranche. */
receipt: ReceiptOption | null = null;
/** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */
attaching: string | null = null;
/** What the member's savings can cover, for the savings route. */
savingsEligibility: {
savings: {
eligible: boolean;
reason: string | null;
savingsAccountNo: string | null;
@@ -116,19 +123,24 @@ export class ApplySharesComponent implements OnInit {
} | null = null;
checkingSavings = false;
constructor() {
this.accountId = this.route.parent.snapshot.params['shareAccountId'];
this.route.data.subscribe((data: { shareAccountActionData: any }) => {
this.sharesAccountData = data.shareAccountActionData;
/** Re-checks the savings verdict as the share count settles, rather than on every keystroke of
* "1000" — which asks four times and lets the answers land out of order. */
private sharesChanged = new Subject<number>();
ngOnInit(): void {
this.unitPrice = Number(this.data.unitPrice) || 0;
// Opens on the business date rather than blank: a tranche is recorded the day it is taken, and
// asking a clerk to type that in is asking them to retype what the system already knows.
this.requestedDate = this.isoOf(this.settingsService.businessDate);
this.sharesChanged.pipe(debounceTime(400), distinctUntilChanged()).subscribe(() => {
if (this.funding === 'SAVINGS') {
this.checkSavings();
}
});
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createApplySharesAccountForm();
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
this.applySharesForm.get('amount').patchValue(this.amount || '');
// Needed only by the voucher search, which is the exception path -- loaded quietly in the
// background so opening that popup never waits on a request.
this.cashierService
.getShareAdvanceGlAccounts()
.pipe(catchError(() => of(null)))
@@ -138,191 +150,109 @@ export class ApplySharesComponent implements OnInit {
});
}
/**
* Creates the apply shares form.
*
* The request date opens on the business date rather than blank: an additional-share request is
* recorded the day it is taken, and a clerk who has to fill that in by hand is being asked to
* retype what the system already knows.
*/
createApplySharesAccountForm() {
this.applySharesForm = this.formBuilder.group({
requestedDate: [
this.settingsService.businessDate,
Validators.required
],
requestedShares: [
'',
Validators.required
],
unitPrice: [{ value: '', disabled: true }],
// Disabled, not readonly: the amount is derived from shares x price and must never be a
// figure somebody typed, or the tranche's value and its GL posting could disagree.
amount: [{ value: '', disabled: true }],
resolutionNumber: [
'',
Validators.required
],
resolutionDate: [
'',
Validators.required
],
paymentMode: [
'',
Validators.required
]
});
// Recompute as the share count is typed, so the clerk can read the amount back to the member
// before committing to a funding route.
this.applySharesForm.get('requestedShares').valueChanges.subscribe(() => {
this.applySharesForm.get('amount').patchValue(this.amount || '');
// A savings verdict is cleared the moment the amount changes rather than when the re-check
// returns, so a stale "can cover it" is never on screen beside a larger amount. The ticked
// vouchers stay: they are the receipts the member actually holds, and the running total below
// shows against the new amount whether they still cover it.
this.voucherError = '';
this.savingsEligibility = null;
});
// Debounced separately from the clearing above: the check is a server round-trip, and firing it
// on every keystroke of "1000" asks four times and lets the answers land out of order.
this.applySharesForm
.get('requestedShares')
.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
.subscribe(() => {
if (this.paymentMode === 'SAVINGS') {
this.checkSavings();
}
});
this.applySharesForm.get('paymentMode').valueChanges.subscribe((mode: string) => {
this.vouchers = [];
this.selectedVoucherIds = [];
this.voucherError = '';
this.errorMsg = '';
this.savingsEligibility = null;
if (mode === 'SAVINGS') {
this.checkSavings();
} else if (mode === 'CASH') {
this.loadVouchers();
}
});
onSharesChange(): void {
// The verdict is cleared the moment the amount changes rather than when the re-check returns, so
// a stale "can cover it" is never on screen beside a larger amount. The chosen receipt stays --
// it is the receipt the member actually holds, and the shortfall line below re-reads against the
// new amount on its own.
this.savings = null;
this.errorMsg = '';
this.sharesChanged.next(Number(this.requestedShares) || 0);
}
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
get amount(): number {
const shares = Number(this.applySharesForm?.get('requestedShares')?.value) || 0;
const price = Number(this.applySharesForm?.get('unitPrice')?.value) || 0;
return shares * price;
}
get paymentMode(): string {
return this.applySharesForm?.get('paymentMode')?.value || '';
}
/**
* 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.
*/
private loadVouchers(): void {
this.loadingVouchers = true;
this.cashierService
.getUnappliedShareAdvances(this.sharesAccountData.clientId)
.pipe(
catchError((err: any) => {
this.voucherError = this.readError(err, 'Could not load the member’s receipts.');
this.loadingVouchers = false;
return of(null);
})
)
.subscribe((res: any) => {
this.loadingVouchers = false;
if (!res) {
return;
}
this.vouchers = res.unappliedCollections || [];
if (this.vouchers.length === 1) {
this.selectedVoucherIds = [this.vouchers[0].id];
}
});
}
isVoucherSelected(id: number): boolean {
return this.selectedVoucherIds.includes(id);
}
toggleVoucher(id: number): void {
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
...this.selectedVoucherIds,
id
];
this.voucherError = '';
}
/**
* What the ticked receipts come to.
*
* Shown live, because the submission only goes through when the total covers the tranche, and
* adding the rows up by eye is exactly what leads to a rejected submission.
*/
get selectedVoucherTotal(): number {
return this.vouchers.filter((v) => this.isVoucherSelected(v.id)).reduce((sum, v) => sum + Number(v.amount || 0), 0);
}
/**
* 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.
*/
lookupVoucher(voucherNo: string): void {
const trimmed = (voucherNo || '').trim();
const glAccountNo = this.glAccountNo.trim();
if (!trimmed || !glAccountNo || this.lookingUpVoucher) {
chooseFunding(source: FundingSource): void {
if (this.funding === source) {
return;
}
this.lookingUpVoucher = true;
this.voucherError = '';
this.funding = source;
this.receipt = null;
this.savings = null;
this.errorMsg = '';
if (source === 'SAVINGS') {
this.checkSavings();
} else if (!this.available.length) {
this.loadReceipts();
}
}
/**
* Loads every Share Collection receipt that could fund this tranche — the member's own, and the
* ones still belonging to nobody — as one list.
*
* Nothing is pre-selected, 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 row
* they never looked at.
*/
private loadReceipts(): void {
this.loadingReceipts = true;
this.cashierService
.getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId)
.getShareCollectionReceipts(this.data.clientId)
.pipe(
catchError((err: any) => {
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`);
this.lookingUpVoucher = false;
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
this.loadingReceipts = false;
return of(null);
})
)
.subscribe((res: any) => {
this.lookingUpVoucher = false;
this.loadingReceipts = false;
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;
}
// Added to the selection rather than replacing it — a looked-up voucher is usually the
// second half of a part-payment, not a change of mind about the first. No amount check
// here: one instalment is not meant to cover the tranche on its own.
this.vouchers = [
res,
...this.vouchers.filter((v) => v.id !== res.id)
this.available = [
...(res.own || []).map((row: any) => receiptFromOwn(row)),
...(res.rows || []).map((row: any) => receiptFromUnattached(row))
];
if (!this.isVoucherSelected(res.id)) {
this.selectedVoucherIds = [
...this.selectedVoucherIds,
res.id
];
}
});
}
/**
* Chooses the receipt funding this tranche, attaching it to this member first if it is still only
* a voucher number.
*
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
* so it has to be attributed before it can fund anything. That attribution is exactly the
* by-voucher lookup a hand-typed number already runs; doing it on the pick makes it one deliberate
* choice against a member on screen, rather than an Attach in one table and a tick in another.
*/
pick(option: ReceiptOption): void {
if (this.attaching || this.submitting) {
return;
}
if (this.receipt?.key === option.key) {
this.receipt = null;
return;
}
if (option.collectionId !== null) {
this.receipt = option;
return;
}
this.attaching = option.key;
this.errorMsg = '';
this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => {
this.attaching = null;
if (!res) {
return;
}
// Attached now, so it takes its real row's place in the pool -- picking it again must never
// run the attach a second time.
const attached = receiptFromOwn(res);
this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry));
this.receipt = attached;
});
}
clearReceipt(): void {
this.receipt = null;
}
/** What the chosen receipt is still missing, zero once it covers the tranche. */
get shortfall(): number {
const short = this.amount - Number(this.receipt?.amount || 0);
return this.receipt && short > 0 ? short : 0;
}
/**
* Whether the member's savings can cover this tranche.
*
@@ -331,104 +261,215 @@ export class ApplySharesComponent implements OnInit {
* submission will be valued at. Deciding it in the browser would let the two drift apart.
*/
private checkSavings(): void {
const shares = Number(this.applySharesForm.get('requestedShares').value) || 0;
const shares = Number(this.requestedShares) || 0;
this.checkingSavings = true;
this.sharesService
.getAdditionalSharesSavingsEligibility(this.accountId, shares)
.getAdditionalSharesSavingsEligibility(String(this.data.shareAccountId), shares)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.checkingSavings = false;
if (!res) {
this.savingsEligibility = {
eligible: false,
reason: 'Could not check the savings account linked to this share account.',
savingsAccountNo: null,
savingsBalance: null
};
return;
}
this.savingsEligibility = {
eligible: !!res.eligible,
reason: res.reason ?? null,
savingsAccountNo: res.savingsAccountNo ?? null,
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
};
this.savings = res
? {
eligible: !!res.eligible,
reason: res.reason ?? null,
savingsAccountNo: res.savingsAccountNo ?? null,
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
}
: {
eligible: false,
reason: 'Could not check the savings account linked to this share account.',
savingsAccountNo: null,
savingsBalance: null
};
});
}
/**
* Whether the chosen funding route is ready — receipts that between them cover the tranche, or
* savings that can cover it.
* Whether the chosen funding route is ready — a receipt that covers the tranche, or savings that
* can.
*
* The total is checked here rather than only on submit, so a part-paid tranche is visibly short
* at the counter instead of being refused after the member has left.
* Checked here rather than only on submit, so a part-paid tranche is visibly short at the counter
* instead of being refused after the member has left.
*/
get fundingReady(): boolean {
if (this.paymentMode === 'CASH') {
return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount;
if (this.funding === 'CASH') {
return !!this.receipt && this.shortfall === 0;
}
if (this.paymentMode === 'SAVINGS') {
return !!this.savingsEligibility?.eligible;
if (this.funding === 'SAVINGS') {
return !!this.savings?.eligible;
}
return false;
}
/** The shortfall to tell the member about, or zero when the ticked receipts cover the tranche. */
get voucherShortfall(): number {
const short = this.amount - this.selectedVoucherTotal;
return short > 0 ? short : 0;
get canSubmit(): boolean {
return (
this.amount > 0 &&
!!this.requestedDate &&
!!this.resolutionNumber.trim() &&
!!this.resolutionDate &&
this.fundingReady &&
!this.submitting
);
}
get canSubmit(): boolean {
return this.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting;
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
searchOpen = false;
searchVoucherNo = '';
searchGlAccountNo = '';
searching = false;
/** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */
glAccounts: any[] = [];
loadingGlAccounts = true;
openSearch(): void {
this.searchOpen = true;
this.searchVoucherNo = '';
this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : '';
this.errorMsg = '';
}
closeSearch(): void {
this.searchOpen = false;
this.errorMsg = '';
}
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
const glAccountNo = this.searchGlAccountNo.trim();
// Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet,
// not that none is required.
if (!voucher || !glAccountNo || this.searching) {
return;
}
this.searching = true;
this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.searching = false;
if (!res) {
return;
}
const found = receiptFromOwn(res);
this.available = [
found,
...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo)
];
this.receipt = found;
this.searchOpen = false;
});
}
private findVoucher(voucher: string, glAccountNo: string) {
// No client-match check: any receipt can fund any share account, regardless of which member it
// was first recorded against.
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
catchError((err: any) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`);
return of(null);
})
);
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: ReceiptOption | null = null;
/** The receipt's own real Dr/Cr legs, read under its own transaction id — never the GL account
* picked in the search box, which is unrelated screen state naming a different account. */
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: ReceiptOption, event: Event): void {
event.stopPropagation();
if (option.collectionId === null) {
return;
}
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(option.collectionId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.viewingGlEntries = res?.entries || [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
/**
* Records the request. Nothing is issued here — the tranche sits pending until it is approved,
* which is what moves the money out of Sundry into share capital.
*/
submit() {
submit(): void {
if (!this.canSubmit) {
return;
}
this.submitting = true;
this.errorMsg = '';
const formValue = this.applySharesForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data: any = {
requestedShares: formValue.requestedShares,
requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat),
unitPrice: this.applySharesForm.get('unitPrice').value,
resolutionNumber: formValue.resolutionNumber,
meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat),
fundingSource: formValue.paymentMode,
const payload: any = {
requestedShares: this.requestedShares,
requestedDate: this.dateUtils.formatDate(this.dateOf(this.requestedDate), dateFormat),
unitPrice: this.unitPrice,
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.dateUtils.formatDate(this.dateOf(this.resolutionDate), dateFormat),
fundingSource: this.funding,
dateFormat,
locale
locale: this.settingsService.language.code
};
if (formValue.paymentMode === 'CASH') {
// First receipt leads, the rest ride along; the server settles them as one funding.
const [
leadId,
...additionalCollectionIds
] = this.selectedVoucherIds;
data.advanceCollectionId = leadId;
if (additionalCollectionIds.length > 0) {
data.additionalCollectionIds = additionalCollectionIds;
}
if (this.funding === 'CASH' && this.receipt) {
payload.advanceCollectionId = this.receipt.collectionId;
}
this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe({
next: () => {
this.router.navigate(['../../'], { relativeTo: this.route });
},
error: (err: any) => {
this.submitting = false;
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
}
});
this.sharesService
.executeSharesAccountCommand(String(this.data.shareAccountId), 'applyadditionalshares', payload)
.subscribe({
next: (res: any) => {
this.dialogRef.close(res || true);
},
error: (err: any) => {
this.submitting = false;
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
}
});
}
// ── Helpers ─────────────────────────────────────────────────────────────────────────────────
/** The GL account a receipt sits in, named from the list the voucher search already loaded. */
glLabel(option: ReceiptOption): string {
return receiptGlLabel(option, this.glAccounts);
}
/** A Date as the yyyy-MM-dd a native date input reads, in local time. */
private isoOf(date: Date): string {
const local = new Date(date);
return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}`;
}
/**
* A yyyy-MM-dd back into a local Date.
*
* Built from the parts rather than {@code new Date(iso)}, which reads the string as UTC midnight
* and so lands on the previous day for anyone west of Greenwich — a request dated a day early is
* not something a date picker should be able to produce.
*/
private dateOf(iso: string): Date {
const [
year,
month,
day
] = iso.split('-').map(Number);
return new Date(year, month - 1, day);
}
private readError(err: any, fallback: string): string {
@@ -21,9 +21,6 @@
@if (actions['Undo Approval']) {
<mifosx-undo-approval-shares-account></mifosx-undo-approval-shares-account>
}
@if (actions['Apply Additional Shares']) {
<mifosx-apply-shares></mifosx-apply-shares>
}
@if (actions['Redeem Shares']) {
<mifosx-redeem-shares></mifosx-redeem-shares>
}
@@ -17,7 +17,6 @@ import { RejectSharesAccountComponent } from './reject-shares-account/reject-sha
import { CloseSharesAccountComponent } from './close-shares-account/close-shares-account.component';
import { ActivateSharesAccountComponent } from './activate-shares-account/activate-shares-account.component';
import { UndoApprovalSharesAccountComponent } from './undo-approval-shares-account/undo-approval-shares-account.component';
import { ApplySharesComponent } from './apply-shares/apply-shares.component';
import { RedeemSharesComponent } from './redeem-shares/redeem-shares.component';
import { ApproveSharesComponent } from './approve-shares/approve-shares.component';
import { RejectSharesComponent } from './reject-shares/reject-shares.component';
@@ -37,7 +36,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
CloseSharesAccountComponent,
ActivateSharesAccountComponent,
UndoApprovalSharesAccountComponent,
ApplySharesComponent,
RedeemSharesComponent,
ApproveSharesComponent,
RejectSharesComponent
@@ -55,7 +53,6 @@ export class SharesAccountActionsComponent {
Close: boolean;
Activate: boolean;
'Undo Approval': boolean;
'Apply Additional Shares': boolean;
'Redeem Shares': boolean;
'Approve Additional Shares': boolean;
'Reject Additional Shares': boolean;
@@ -65,7 +62,6 @@ export class SharesAccountActionsComponent {
Close: false,
Activate: false,
'Undo Approval': false,
'Apply Additional Shares': false,
'Redeem Shares': false,
'Approve Additional Shares': false,
'Reject Additional Shares': false
@@ -6,222 +6,416 @@
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">
<div class="dialog-header">
<div class="header-icon">
<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">
<h2>Apply Share Collection</h2>
<p>
Share account <strong class="mono">{{ data.accountNo }}</strong>
</p>
</div>
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
<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>
@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>
</div>
} @else {
<div class="gl-select-field">
<select
class="gl-select"
[(ngModel)]="chargeGlAccountNo"
[disabled]="lookingUpCharge || submitting || loadingGlAccounts"
<mat-dialog-content class="dialog-body">
@if (loading) {
<div class="state-block">
<mat-spinner diameter="30"></mat-spinner>
<span>Loading receipts…</span>
</div>
} @else {
@if (requirement) {
<div class="sc-card">
<!-- Share row -->
<div
class="sc-row"
[class.sc-ok]="shareReceipt && shareShortfall === 0"
[class.sc-bad]="shareReceipt && shareShortfall > 0"
>
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
<div class="sc-left">
<span class="sc-label">Share Collection</span>
<span class="sc-sub"
>Required: <b>{{ requirement.shareAmount | number: '1.2-2' }}</b></span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="shareReceipt && shareShortfall === 0"
[class.sc-val-bad]="shareReceipt && shareShortfall > 0"
>
{{ shareReceipt ? (shareReceipt.amount | number: '1.2-2') : '—' }}
</span>
@if (shareShortfall > 0) {
<span class="sc-tag sc-tag-bad">-{{ shareShortfall | number: '1.2-2' }}</span>
} @else if (shareReceipt && shareShortfall === 0) {
<span class="sc-tag sc-tag-ok">✓ Covered</span>
}
</div>
</div>
<!-- Entry Fee row -->
@if (hasFee) {
<div
class="sc-row sc-sep"
[class.sc-ok]="chargeReceipt && feeShortfall === 0"
[class.sc-bad]="chargeReceipt && feeShortfall > 0"
>
<div class="sc-left">
<span class="sc-label">Entry Fee</span>
<span class="sc-sub"
>Required: <b>{{ requirement.chargeAmount | number: '1.2-2' }}</b></span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="chargeReceipt && feeShortfall === 0"
[class.sc-val-bad]="chargeReceipt && feeShortfall > 0"
>
{{ chargeReceipt ? (chargeReceipt.amount | number: '1.2-2') : '—' }}
</span>
@if (feeShortfall > 0) {
<span class="sc-tag sc-tag-bad">-{{ feeShortfall | number: '1.2-2' }}</span>
} @else if (chargeReceipt && feeShortfall === 0) {
<span class="sc-tag sc-tag-ok">✓ Covered</span>
}
</div>
</div>
}
<!-- Total row -->
<div class="sc-total-row" [class.sc-total-ok]="canApply">
<span class="sc-total-lbl">Total Payable</span>
<div class="sc-total-nums">
<span class="sc-total-req mono">{{ requirement.required | number: '1.2-2' }}</span>
<span class="sc-arrow">→</span>
<span class="sc-total-got mono" [class.sc-total-got-ok]="canApply">
{{
(shareReceipt?.amount || 0) + (hasFee && chargeReceipt ? chargeReceipt.amount || 0 : 0)
| number: '1.2-2'
}}
</span>
<span class="sc-status-pill" [class.sc-pill-ok]="canApply" [class.sc-pill-pending]="!canApply">
{{ canApply ? '✓ Ready' : 'Pending' }}
</span>
</div>
</div>
</div>
}
<!-- ── Slot 1: the share receipt ─────────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">1</span>
<div class="slot-title">
<h3>Share Collection Receipt</h3>
<p>Posts Dr Sundry / Cr Share Capital.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch('share')">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (shareReceipt) {
<div class="chosen" [class.chosen-short]="shareShortfall > 0">
<div class="chosen-badge">
<mat-icon>{{ shareShortfall > 0 ? 'warning' : 'check_circle' }}</mat-icon>
</div>
<div class="chosen-main">
<span class="chosen-voucher mono">{{ shareReceipt.voucherNo }}</span>
<span class="chosen-gl">{{ glLabel(shareReceipt) }}</span>
</div>
<span class="chosen-amount mono">{{ shareReceipt.amount | number: '1.2-2' }}</span>
<button type="button" class="chosen-clear" (click)="clear('share')" aria-label="Change receipt">
<mat-icon>close</mat-icon>
</button>
</div>
@if (shareShortfall > 0) {
<p class="slot-note note-bad">
Short by {{ shareShortfall | number: '1.2-2' }} — this receipt does not cover
{{ shareRequired | number: '1.2-2' }}.
</p>
} @else if (requirement) {
<p class="slot-note note-good">Covers the {{ shareRequired | number: '1.2-2' }} payable.</p>
}
} @else {
@if (optionsFor('share').length) {
<ul class="options">
@for (option of optionsFor('share'); track option.key) {
<li class="option" [class.option-busy]="attaching === option.key" (click)="pick('share', option)">
<span class="option-radio"></span>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.date }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
@if (attaching === option.key) {
<mat-spinner diameter="16"></mat-spinner>
} @else if (option.collectionId !== null) {
<button
type="button"
class="option-info"
(click)="openDetail(option, $event)"
aria-label="GL entries"
>
<mat-icon>receipt_long</mat-icon>
</button>
}
</li>
}
</ul>
} @else {
<p class="slot-empty">
No Share Collection receipt is waiting. Collect the cash at <em>Cashier › Transaction Entry</em> with
entry type <strong>Share Collection</strong>, or find an earlier voucher by number.
</p>
}
}
</section>
<!-- ── Slot 2: the entry fee receipt ─────────────────────────────────────── -->
@if (hasFee) {
<section class="slot">
<header class="slot-head">
<span class="slot-step">2</span>
<div class="slot-title">
<h3>Entry Fee Receipt</h3>
<p>Verification only — no GL entry is posted for it.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch('fee')">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (chargeReceipt) {
<div class="chosen" [class.chosen-short]="feeShortfall > 0">
<div class="chosen-badge">
<mat-icon>{{ feeShortfall > 0 ? 'warning' : 'check_circle' }}</mat-icon>
</div>
<div class="chosen-main">
<span class="chosen-voucher mono">{{ chargeReceipt.voucherNo }}</span>
<span class="chosen-gl">{{ glLabel(chargeReceipt) }}</span>
</div>
<span class="chosen-amount mono">{{ chargeReceipt.amount | number: '1.2-2' }}</span>
<button type="button" class="chosen-clear" (click)="clear('fee')" aria-label="Change receipt">
<mat-icon>close</mat-icon>
</button>
</div>
@if (feeShortfall > 0) {
<p class="slot-note note-bad">
Short by {{ feeShortfall | number: '1.2-2' }} — the fee is
{{ requirement!.chargeAmount | number: '1.2-2' }}.
</p>
} @else {
<p class="slot-note note-good">Entry fee verified.</p>
}
} @else {
@if (optionsFor('fee').length) {
<ul class="options">
@for (option of optionsFor('fee'); track option.key) {
<li class="option" [class.option-busy]="attaching === option.key" (click)="pick('fee', option)">
<span class="option-radio"></span>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.date }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
@if (attaching === option.key) {
<mat-spinner diameter="16"></mat-spinner>
} @else if (option.collectionId !== null) {
<button
type="button"
class="option-info"
(click)="openDetail(option, $event)"
aria-label="GL entries"
>
<mat-icon>receipt_long</mat-icon>
</button>
}
</li>
}
</ul>
} @else {
<p class="slot-empty">No separate fee receipt is waiting — find one by voucher number.</p>
}
<p class="slot-note note-muted">
Leave this empty if the fee was already collected inside the share receipt above.
</p>
}
</section>
}
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions class="dialog-actions">
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canApply" (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>
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
@if (searchFor) {
<div class="overlay" (click)="closeSearch()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>{{ searchFor === 'share' ? 'Find a share receipt' : 'Find an entry fee receipt' }}</h3>
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<label class="field">
<span>GL Account</span>
<select [(ngModel)]="searchGlAccountNo" [disabled]="loadingGlAccounts">
<option value="">{{ loadingGlAccounts ? 'Loading…' : 'Select the account it was paid into' }}</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</div>
</label>
<div class="voucher-lookup">
<label class="field">
<span>Voucher No.</span>
<input
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. CR/001/2608/0002)"
[(ngModel)]="chargeVoucherNo"
[disabled]="lookingUpCharge || submitting"
(keyup.enter)="lookupChargeVoucher()"
[(ngModel)]="searchVoucherNo"
placeholder="CR/18092026/0004"
(keyup.enter)="runSearch()"
autocomplete="off"
/>
<button
mat-stroked-button
type="button"
[disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting"
(click)="lookupChargeVoucher()"
>
{{ lookingUpCharge ? 'Finding…' : 'Find' }}
</button>
</div>
</label>
<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>
</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-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-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
</tr>
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</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="2">
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
</td>
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
}
</table>
</div>
<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.
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
<button
type="button"
mat-flat-button
color="primary"
[disabled]="!searchVoucherNo.trim() || !searchGlAccountNo.trim() || searching"
(click)="runSearch()"
>
@if (searching) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<span>Find &amp; Use</span>
}
</button>
</footer>
</div>
}
</mat-dialog-content>
</div>
}
<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>
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
@if (viewing) {
<div class="overlay" (click)="closeDetail()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
</h3>
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<dl class="detail-list">
<div>
<dt>Date</dt>
<dd class="mono">{{ viewing.date }}</dd>
</div>
<div>
<dt>Amount</dt>
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
</div>
<div>
<dt>GL Account</dt>
<dd>{{ glLabel(viewing) }}</dd>
</div>
@if (viewing.cashierName) {
<div>
<dt>Collected By</dt>
<dd>{{ viewing.cashierName }}</dd>
</div>
}
@if (viewing.description) {
<div>
<dt>Description</dt>
<dd>{{ viewing.description }}</dd>
</div>
}
</dl>
<h4 class="detail-heading">Journal Entries</h4>
@if (loadingGlEntries) {
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
} @else if (viewingGlEntries?.length) {
<table class="gl-table">
<thead>
<tr>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (entry of viewingGlEntries; track $index) {
<tr>
<td>
<span class="mono">{{ entry.glCode }}</span> {{ entry.accountName }}
</td>
<td class="num mono">{{ entry.drCr === 'Dr' ? (entry.amount | number: '1.2-2') : '' }}</td>
<td class="num mono">{{ entry.drCr === 'Cr' ? (entry.amount | number: '1.2-2') : '' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
</footer>
</div>
</div>
}
@@ -6,265 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1a3a6b;
$border: #d8e0ec;
$muted: #6b7a90;
$danger: #b3261e;
$success: #1e7d3c;
// Everything this dialog looks like lives in the shared partial, which its sibling Apply Additional
// Shares dialog uses too -- the two settle the same receipts for the same person and are meant to be
// the same screen.
@use '../../share-dialog.shared' as *;
.intro {
font-size: 13px;
color: $muted;
margin: 0 0 12px;
}
.requirement-summary {
display: flex;
flex-direction: column;
gap: 4px;
background: #f4f7fb;
border: 1px solid $border;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
color: $primary;
margin: 0 0 14px;
}
.requirement-progress {
font-size: 12px;
font-weight: 500;
color: $danger;
&.covered {
color: $success;
}
}
.voucher-section {
margin: 0 0 14px;
}
.voucher-section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: $muted;
margin: 0 0 6px;
}
.charge-receipt-found {
display: flex;
align-items: center;
gap: 8px;
background: #eefaf1;
border: 1px solid #bfe6cb;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: $success;
&.short {
background: #fdecea;
border-color: #f5c6c2;
color: $danger;
}
mat-icon {
flex-shrink: 0;
}
}
.charge-receipt-detail {
flex: 1;
min-width: 0;
}
.charge-receipt-hint {
font-size: 11px;
color: $muted;
margin-top: 4px;
}
.voucher-lookup {
display: flex;
gap: 8px;
align-items: center;
margin: 0 0 14px;
}
.voucher-input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
letter-spacing: 0.4px;
color: $primary;
&:focus {
outline: none;
border-color: $primary;
}
&:disabled {
background: #f4f7fb;
color: $muted;
}
}
.gl-select-field {
margin: 0 0 10px;
}
.gl-select {
:host {
display: block;
width: 100%;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-size: 13px;
color: $primary;
background: #fff;
&:focus {
outline: none;
border-color: $primary;
}
&:disabled {
background: #f4f7fb;
color: $muted;
}
}
.loading {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 0;
font-size: 13px;
color: $muted;
}
.empty {
display: flex;
gap: 10px;
align-items: flex-start;
background: #f4f7fb;
border: 1px solid $border;
border-radius: 6px;
padding: 12px;
font-size: 13px;
}
.empty-hint {
color: $muted;
margin-top: 4px;
}
.receipt-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 1px solid $border;
border-radius: 6px;
overflow: hidden;
th {
background: #eef2f8;
color: $primary;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 12px;
white-space: nowrap;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #eceff4;
color: #1f2937;
}
tbody tr:last-child td {
border-bottom: none;
}
/* Just wide enough for the box, so the voucher number starts near the left edge instead of
after a column of empty space. */
.col-tick {
width: 36px;
}
tbody tr {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
&.selected {
background: #e7effb;
}
}
/* Tabular figures so the amounts line up digit for digit down the column and against the total
below them — the whole point of the column is that it can be read as a sum. */
.amount-cell {
font-variant-numeric: tabular-nums;
font-weight: 500;
white-space: nowrap;
}
tfoot .total-row {
background: #f4f7fb;
border-top: 2px solid #cbd5e1;
td {
padding-top: 10px;
font-size: 13px;
font-weight: 600;
color: #1f3d7a;
border-bottom: none;
}
}
}
.ta-left {
text-align: left;
}
.ta-right {
text-align: right;
}
.ta-center {
text-align: center;
}
.mono {
font-family: 'Courier New', monospace;
}
.note {
margin-top: 10px;
font-size: 12px;
color: $muted;
}
.alert-error {
display: flex;
align-items: center;
gap: 8px;
background: #fdecea;
color: $danger;
border: 1px solid #f5c6c2;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 12px;
}
@@ -13,26 +13,32 @@ 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';
import { Dates } from 'app/core/utils/dates';
import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option';
/** Which of the two receipt slots a pick, a search or a clear is meant for. */
export type ReceiptSlot = 'share' | 'fee';
/**
* Applies a Share Advance receipt to an activated share account.
* Applies Share Advance receipts to an activated share account.
*
* The member's cash is already sitting in the Share Sundry liability from the cashier's advance
* collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees
* when an entry fee applies) and reveals the purchase entry in the transactions and passbook.
* The member's cash is already sitting in the Share Sundry liability from the cashier's collection.
* Settling here posts Dr Sundry / Cr Share Capital and reveals the purchase in the transactions and
* the passbook.
*
* The list shows the member's unapplied receipts for the business date, since a member settling an
* application at the counter has almost always paid for it today, and an older unapplied receipt of
* theirs is more likely money taken for something else. Nothing in it is ticked for the clerk — see
* {@link ngOnInit}.
* Two receipts settle an application and no more: one for the share value, and — when the product
* charges one — one for the entry fee. They are chosen in two separate slots because they are two
* different things: the share receipt is money that moves into share capital, while the fee receipt
* only proves the fee was already collected and is never posted a second time (its money was
* recognised as income by the cashier's own entry). Picking both out of one ticked list was what
* made that distinction invisible.
*
* A receipt outside that narrow list is reached by voucher number through {@link lookupVoucher} —
* cash taken on an earlier day, or a slip the member is holding that is quicker to type than to find.
* Every available receipt is offered in both slots, whether it already names this member or is a
* voucher paid straight into a GL account. A voucher that names nobody yet is attached to this
* member at the moment it is picked, so the counter does an Attach-then-tick as a single click
* rather than as two steps in two different tables.
*/
@Component({
selector: 'mifosx-apply-collection-dialog',
@@ -50,89 +56,60 @@ import { Dates } from 'app/core/utils/dates';
})
export class ApplyCollectionDialogComponent implements OnInit {
private cashierService = inject(CashierService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
loading = true;
submitting = false;
errorMsg = '';
receipts: any[] = [];
/**
* Receipts chosen for this settlement, in the order they were picked.
*
* More than one because a member often pays an application in instalments — ₹4,500 today, the
* ₹500 balance tomorrow — and neither receipt settles the account on its own. They are applied
* together as a single settlement rather than forcing the member back to the counter for a fresh
* receipt covering the whole amount.
*/
selectedIds: number[] = [];
/** Voucher number quoted by the member, for a receipt the list does not show. */
voucherNo = '';
lookingUp = false;
/** 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. */
glAccountNo = '';
glAccounts: any[] = [];
loadingGlAccounts = true;
/** A second, separately-tracked receipt search for the entry fee — a member sometimes pays the
* share value and the fee as two separate receipts, posted to two different GL accounts, so one
* voucher search covering only the share value would never reach the fee on its own.
*
* Kept entirely apart from {@link receipts}/{@link selectedIds}: this receipt is sent to the
* server as its own field (see {@link apply}) so it is never swept into the Sundry-side sum and
* never gets a GL entry of its own — it is proof the fee was paid, not money that needs to move,
* since it never sat in Sundry in the first place. */
chargeVoucherNo = '';
chargeGlAccountNo = '';
lookingUpCharge = false;
chargeReceipt: any = null;
/** What this account needs before a collection can be applied — fetched up front so the counter
* knows the full figure (and that it may take two receipts) before a search ever comes up short. */
requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null;
loadingRequirement = true;
public dialogRef = inject(MatDialogRef<ApplyCollectionDialogComponent>);
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
loading = true;
submitting = false;
errorMsg = '';
/** Every receipt that can be picked, from both server lists, in one pool. */
available: ReceiptOption[] = [];
/** The receipt covering the share value. Required — nothing settles without it. */
shareReceipt: ReceiptOption | null = null;
/** The receipt covering the entry fee. Verification only: never posted to a GL account. */
chargeReceipt: ReceiptOption | null = null;
/** What this account needs before a collection can be applied, fetched up front. */
requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null;
/** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */
attaching: string | null = null;
ngOnInit(): void {
// One call for both lists -- the member's own receipts and every other Share Collection receipt
// still available. They come from the same table under the same filters, so asking twice only
// ever fetched the same rows twice.
this.cashierService
.getUnappliedShareAdvances(this.data.clientId)
.getShareCollectionReceipts(this.data.clientId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the member’s receipts.');
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
this.loading = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
const today = this.dateUtils.formatDate(this.settingsService.businessDate, 'yyyy-MM-dd');
this.receipts = (res.unappliedCollections || []).filter(
(receipt: any) => this.collectionDateOf(receipt) === today
);
// Deliberately nothing ticked, not even when a single receipt is listed. Applying a
// collection turns the member's money into share capital, so which receipt it comes out of
// should be a choice the clerk made rather than one they inherited from a pre-ticked box
// and never looked at.
this.available = [
...(res.own || []).map((row: any) => receiptFromOwn(row)),
...(res.rows || []).map((row: any) => receiptFromUnattached(row))
];
// Nothing pre-selected, not even when a single receipt is listed. Settling turns the
// member's money into share capital, so which receipt it comes out of should be a choice
// somebody made rather than one they inherited and never looked at.
}
this.loading = false;
});
// Needed only by the voucher search, which is the exception path -- loaded quietly in the
// background so opening that popup never waits on a request.
this.cashierService
.getShareAdvanceGlAccounts()
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the GL account list.');
this.loadingGlAccounts = false;
return of(null);
})
)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glAccounts = res?.glAccounts || [];
this.loadingGlAccounts = false;
@@ -149,14 +126,80 @@ export class ApplyCollectionDialogComponent implements OnInit {
required: Number(res.required || 0)
};
}
this.loadingRequirement = false;
});
}
// ── The two slots ───────────────────────────────────────────────────────────────────────────
/** Receipts offered for a slot: everything available except whatever the other slot already holds. */
optionsFor(slot: ReceiptSlot): ReceiptOption[] {
const taken = slot === 'share' ? this.chargeReceipt : this.shareReceipt;
return taken ? this.available.filter((option) => option.key !== taken.key) : this.available;
}
selected(slot: ReceiptSlot): ReceiptOption | null {
return slot === 'share' ? this.shareReceipt : this.chargeReceipt;
}
isPicked(slot: ReceiptSlot, option: ReceiptOption): boolean {
return this.selected(slot)?.key === option.key;
}
/**
* 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
* server checks the Sundry-side receipts against in {@link apply}.
* Chooses a receipt for a slot, attaching it to this member first if it is still only a voucher
* number.
*
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
* so it has to be attributed before it can settle anything. That attribution is exactly the
* by-voucher lookup a hand-typed voucher number already runs; doing it on the pick means the
* counter makes one deliberate choice against a member they can see, instead of an Attach in one
* table followed by a tick in another.
*/
pick(slot: ReceiptSlot, option: ReceiptOption): void {
if (this.attaching || this.submitting) {
return;
}
if (this.isPicked(slot, option)) {
this.clear(slot);
return;
}
if (option.collectionId !== null) {
this.assign(slot, option);
return;
}
this.attaching = option.key;
this.errorMsg = '';
this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => {
this.attaching = null;
if (!res) {
return;
}
// Attached now, so it takes its real row's place in the pool -- picking it a second time (or
// in the other slot) must never run the attach again.
const attached = receiptFromOwn(res);
this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry));
this.assign(slot, attached);
});
}
clear(slot: ReceiptSlot): void {
this.assign(slot, null);
}
private assign(slot: ReceiptSlot, option: ReceiptOption | null): void {
if (slot === 'share') {
this.shareReceipt = option;
} else {
this.chargeReceipt = option;
}
}
// ── What still has to be covered ────────────────────────────────────────────────────────────
/**
* What the share receipt has to reach — the share value alone once the fee has its own receipt,
* otherwise the combined share-plus-fee total, matching exactly what the server checks.
*/
get shareRequired(): number {
if (!this.requirement) {
@@ -165,20 +208,19 @@ export class ApplyCollectionDialogComponent implements OnInit {
return this.chargeReceipt ? this.requirement.shareAmount : this.requirement.required;
}
/** What the share receipts are still missing, zero once fully covered. */
get shortfall(): number {
const short = this.shareRequired - this.selectedTotal;
return short > 0 ? short : 0;
/** What the chosen share receipt is still missing, zero once it covers the requirement. */
get shareShortfall(): number {
const short = this.shareRequired - Number(this.shareReceipt?.amount || 0);
return this.shareReceipt && short > 0 ? short : 0;
}
/** Whether a verified entry fee receipt still needs to be found — false when there is no fee, or
* when the fee was already folded into the share receipt(s) above. */
get chargeUnverified(): boolean {
return !!this.requirement && this.requirement.chargeAmount > 0 && !this.chargeReceipt;
/** Whether this product charges an entry fee at all. */
get hasFee(): boolean {
return !!this.requirement && this.requirement.chargeAmount > 0;
}
/** Whether the found entry fee receipt actually covers the fee. */
get chargeShortfall(): number {
/** What the chosen fee receipt is still missing, zero once it covers the fee. */
get feeShortfall(): number {
if (!this.requirement || !this.chargeReceipt) {
return 0;
}
@@ -187,162 +229,129 @@ export class ApplyCollectionDialogComponent implements OnInit {
}
/**
* A receipt's collection date as {@code yyyy-MM-dd}.
* Whether the settlement can go through.
*
* Normalises both shapes the API can send — that string, or Fineract's {@code [yyyy, m, d]} array
* — rather than assuming one, so the filter cannot silently match nothing if the serialiser
* behind this endpoint changes.
* Blocked on a shortfall rather than letting the server reject it: the figures are all on screen
* already, so a submission that can only come back as an error is a round trip nobody needed.
*/
private collectionDateOf(receipt: any): string {
const raw = receipt?.collection_date ?? receipt?.collectionDate;
if (Array.isArray(raw) && raw.length >= 3) {
const [
year,
month,
day
] = raw;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
return typeof raw === 'string' ? raw.slice(0, 10) : '';
get canApply(): boolean {
return !!this.shareReceipt && this.shareShortfall === 0 && this.feeShortfall === 0 && !this.submitting;
}
isSelected(id: number): boolean {
return this.selectedIds.includes(id);
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
/** Slot the open search popup is searching for, or null while it is closed. */
searchFor: ReceiptSlot | null = null;
searchVoucherNo = '';
searchGlAccountNo = '';
searching = false;
/** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */
glAccounts: any[] = [];
loadingGlAccounts = true;
openSearch(slot: ReceiptSlot): void {
this.searchFor = slot;
this.searchVoucherNo = '';
this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : '';
this.errorMsg = '';
}
toggle(id: number): void {
this.selectedIds = this.isSelected(id) ? this.selectedIds.filter((x) => x !== id) : [
...this.selectedIds,
id
];
closeSearch(): void {
this.searchFor = null;
this.errorMsg = '';
}
/**
* What the selected receipts come to.
*
* Shown live as receipts are ticked, because the settlement only goes through when the total
* covers the account — checking that by adding up the rows by eye is exactly what leads to a
* rejected submission.
*/
get selectedTotal(): number {
return this.receipts.filter((r) => this.isSelected(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0);
}
/**
* Resolves the voucher number the member quotes into a receipt and adds it to the list.
*
* The list is deliberately narrow — this member, today, unapplied — so the search is how a
* receipt outside that gets settled: one taken yesterday, or one the member holds a slip for that
* is quicker to type than to find. The lookup itself is not restricted to this member, so a
* voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing
* later inside apply().
*/
lookupVoucher(): void {
const voucher = this.voucherNo.trim();
const glAccountNo = this.glAccountNo.trim();
if (!voucher || !glAccountNo || this.lookingUp) {
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
const glAccountNo = this.searchGlAccountNo.trim();
const slot = this.searchFor;
// Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet,
// not that none is required.
if (!voucher || !glAccountNo || !slot || this.searching) {
return;
}
this.lookingUp = true;
this.searching = true;
this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.lookingUp = false;
this.searching = false;
if (!res) {
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.
this.receipts = [
res,
...this.receipts.filter((r) => r.id !== res.id)
const found = receiptFromOwn(res);
this.available = [
found,
...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo)
];
if (!this.isSelected(res.id)) {
this.selectedIds = [
...this.selectedIds,
res.id
];
}
this.voucherNo = '';
this.assign(slot, found);
this.searchFor = null;
});
}
/**
* Same lookup as {@link lookupVoucher}, against its own GL account and voucher fields — for a
* receipt that covers the entry fee rather than the share value.
*
* Deliberately NOT added to {@link receipts}/{@link selectedIds}: this receipt is kept on its own
* ({@link chargeReceipt}) and sent to the server as a distinct field, so it is never swept into the
* Sundry-side total and never gets a settlement GL entry of its own — it proves the fee was paid,
* it does not move money that never sat in Sundry to begin with.
*/
lookupChargeVoucher(): void {
const voucher = this.chargeVoucherNo.trim();
const glAccountNo = this.chargeGlAccountNo.trim();
if (!voucher || !glAccountNo || this.lookingUpCharge) {
return;
}
this.lookingUpCharge = true;
this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.lookingUpCharge = false;
if (!res) {
return;
}
this.chargeReceipt = res;
this.chargeVoucherNo = '';
});
}
/** Clears a wrongly-found entry fee receipt so a different voucher can be searched instead. */
clearChargeReceipt(): void {
this.chargeReceipt = null;
}
/**
* Resolves one voucher number into the receipt it names, common to both lookups above. Each caller
* decides what to do with the result — added to the shared table for a share receipt, kept on its
* 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: any receipt can settle any share account, regardless of which member it
// was first 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.`);
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`);
return of(null);
})
);
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: ReceiptOption | null = null;
/**
* The receipt's own real Dr/Cr legs, read under its own {@code gl_transaction_id} — never the GL
* account picked in a search box, which is unrelated screen state naming a different account.
*/
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: ReceiptOption, event: Event): void {
event.stopPropagation();
if (option.collectionId === null) {
return;
}
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(option.collectionId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.viewingGlEntries = res?.entries || [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
apply(): void {
if (this.selectedIds.length === 0 || this.submitting) {
if (!this.canApply || !this.shareReceipt) {
return;
}
this.submitting = true;
this.errorMsg = '';
// The first receipt leads on the URL; the rest ride along in the body, and the server settles
// them as one transaction. The entry fee receipt, when one was found, rides along as its own
// field — never folded into additionalCollectionIds, so the server never sums it into the
// Sundry-side total or posts a GL entry for it.
const [
leadId,
...additionalCollectionIds
] = this.selectedIds;
const data: any = { shareAccountId: this.data.shareAccountId, additionalCollectionIds };
// The share receipt leads on the URL and is the only one the server sums into the Sundry-side
// total. The fee receipt rides along as its own field so it is never added to that total and
// never gets a GL entry of its own -- its money was already recognised as income when the
// cashier took it.
const payload: any = { shareAccountId: this.data.shareAccountId, additionalCollectionIds: [] };
if (this.chargeReceipt) {
data.chargeCollectionId = this.chargeReceipt.id;
payload.chargeCollectionId = this.chargeReceipt.collectionId;
}
this.cashierService
.applyShareAdvance(leadId, data)
.applyShareAdvance(this.shareReceipt.collectionId as number, payload)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Applying the collection failed.');
@@ -357,6 +366,11 @@ export class ApplyCollectionDialogComponent implements OnInit {
});
}
/** The GL account a receipt sits in, named from the list the voucher search already loaded. */
glLabel(option: ReceiptOption): string {
return receiptGlLabel(option, this.glAccounts);
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
@@ -384,18 +384,59 @@
/* Print CSS */
@media print {
/* Hide non-overlay contents during print */
::ng-deep html,
::ng-deep body {
height: auto !important;
overflow: visible !important;
}
/* Hide non-overlay contents during print. Deliberately NOT `::ng-deep` here: under Angular's
emulated encapsulation a bare `body` selector never carries this component's scoping
attribute, so a scoped rule can never match the real <body> and is safely inert — whereas
`::ng-deep` emits it fully unscoped into the GLOBAL stylesheet for the rest of the session
(Angular never removes a component's injected styles when it's destroyed), silently hiding
every other page's print output app-wide the moment this dialog is opened once. The app
shell (nav, toolbar, sidebar) is already hidden for every print via the global rules in
main.scss, so this dialog doesn't need its own global override to get the same effect. */
body > :not(.cdk-overlay-container) {
display: none !important;
}
/* The dialog's own scroll region clips anything past 80vh -- printing only captures what a
scrollable/max-height box shows on screen, so both constraints must be lifted. */
.dialog-content-container {
max-height: none !important;
overflow: visible !important;
}
/* CDK's overlay wrapper/backdrop/pane are all position: fixed. Unset them so the full certificate prints statically. */
::ng-deep .cdk-overlay-backdrop {
display: none !important;
}
::ng-deep .cdk-overlay-container,
::ng-deep .cdk-global-overlay-wrapper,
::ng-deep .cdk-overlay-pane,
::ng-deep .mat-mdc-dialog-container,
::ng-deep .mdc-dialog__container,
::ng-deep .mdc-dialog__surface {
position: static !important;
display: block !important;
max-height: none !important;
max-width: none !important;
height: auto !important;
width: auto !important;
overflow: visible !important;
transform: none !important;
box-shadow: none !important;
background: transparent !important;
}
/* Make certificate fill page */
#share-certificate-print {
position: absolute;
left: 0;
top: 0;
position: static !important;
width: 100% !important;
height: 100% !important;
height: auto !important;
padding: 0 !important;
margin: 0 !important;
background-color: #fff !important;
@@ -411,7 +452,135 @@
display: none !important;
}
.mat-mdc-dialog-container {
::ng-deep .mat-mdc-dialog-container {
padding: 0 !important;
}
/* Compact every spacing/font value so the whole certificate fits a single printed page */
.certificate-container {
padding: 6px !important;
}
.certificate-border-inner {
padding: 14px !important;
}
.certificate-header {
margin-bottom: 10px !important;
padding-bottom: 10px !important;
.bank-name {
font-size: 19px !important;
margin-bottom: 2px !important;
}
.bank-sub {
font-size: 10px !important;
margin-bottom: 8px !important;
}
.cert-title {
font-size: 19px !important;
letter-spacing: 2px !important;
margin-bottom: 2px !important;
}
.cert-subtitle {
font-size: 9px !important;
}
}
.cert-meta-row {
margin-bottom: 10px !important;
padding-bottom: 8px !important;
.meta-label {
font-size: 9px !important;
margin-bottom: 1px !important;
}
.meta-value {
font-size: 12px !important;
}
}
.cert-body-section {
margin-bottom: 10px !important;
.cert-main-text {
font-size: 11px !important;
line-height: 1.4 !important;
margin-bottom: 10px !important;
}
}
.issued-to-details {
padding: 8px 14px !important;
margin: 8px auto !important;
.client-name-details {
font-size: 14px !important;
}
.client-ids,
.client-address {
font-size: 10px !important;
}
}
.certificate-details-table {
margin-bottom: 12px !important;
td {
padding: 5px 10px !important;
font-size: 10px !important;
}
.lbl-col-highlight {
font-size: 12px !important;
}
.val-col-highlight {
font-size: 13px !important;
}
}
.allotment-table {
margin-bottom: 12px !important;
th {
padding: 4px 8px !important;
font-size: 8px !important;
}
td {
padding: 4px 8px !important;
font-size: 9px !important;
}
}
.cert-footer {
margin-top: 8px !important;
.seal-ring {
width: 60px !important;
height: 60px !important;
}
.seal-text {
font-size: 8px !important;
}
.closing-text {
font-size: 10px !important;
}
.signature-line {
height: 22px !important;
}
.signatory-title {
font-size: 10px !important;
}
}
}
@@ -15,6 +15,7 @@ import { MatDialog } from '@angular/material/dialog';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component';
import { ApplyCollectionDialogComponent } from './apply-collection-dialog/apply-collection-dialog.component';
import { ApplySharesComponent } from '../shares-account-actions/apply-shares/apply-shares.component';
/** Custom Services */
import { SharesService } from '../shares.service';
@@ -149,6 +150,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
@@ -171,7 +176,6 @@ export class SharesAccountViewComponent implements OnInit {
case 'Close':
case 'Activate':
case 'Undo Approval':
case 'Apply Additional Shares':
case 'Redeem Shares':
case 'Approve Additional Shares':
case 'Reject Additional Shares':
@@ -186,6 +190,9 @@ export class SharesAccountViewComponent implements OnInit {
case 'Apply Collection':
this.applyCollection();
break;
case 'Apply Additional Shares':
this.applyAdditionalShares();
break;
case 'Share Certificate':
this.viewShareCertificate();
break;
@@ -202,7 +209,11 @@ export class SharesAccountViewComponent implements OnInit {
*/
private applyCollection() {
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
width: '560px',
// Two receipt slots and a requirement strip, nothing wider -- the screen used to carry a
// multi-tick table plus a second, separate attach table, which is what needed 820px.
width: '780px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId,
@@ -213,18 +224,54 @@ export class SharesAccountViewComponent implements OnInit {
if (result) {
// Account data comes from a route resolver, so re-running the route is what refreshes the
// newly revealed transaction and the now-paid charges.
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
this.router.navigate([
'/clients',
this.sharesAccountData.clientId,
'shares-accounts',
this.sharesAccountData.id
]);
});
this.reloadAccount();
}
});
}
/**
* Raises a request for a further tranche of shares on this account.
*
* A dialog rather than its own page, like Apply Collection beside it: both are a short form filled
* in with the member standing at the counter, and sending the clerk to a separate screen only
* meant losing the account they were looking at to come back to.
*/
private applyAdditionalShares() {
const dialogRef = this.dialog.open(ApplySharesComponent, {
width: '780px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId,
accountNo: this.sharesAccountData.accountNo,
unitPrice: this.sharesAccountData.currentMarketPrice
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) {
this.reloadAccount();
}
});
}
/**
* Re-runs this account's route so its resolver refetches everything on screen.
*
* Through the root first, because Angular reuses the component when only the same route's data
* changes — which would leave the page showing exactly the figures the dialog just changed.
*/
private reloadAccount() {
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
this.router.navigate([
'/clients',
this.sharesAccountData.clientId,
'shares-accounts',
this.sharesAccountData.id
]);
});
}
/**
* Whether the member's money has actually been taken and the shares issued.
*
+141
View File
@@ -41,6 +41,147 @@ 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/product-summary',
data: { title: 'Share Product Summary', breadcrumb: 'Product Summary' },
loadComponent: () =>
import('./reports/share-product-summary/share-product-summary.component').then(
(m) => m.ShareProductSummaryComponent
)
},
{
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' },
-2
View File
@@ -30,7 +30,6 @@ import { RejectSharesAccountComponent } from './shares-account-actions/reject-sh
import { CloseSharesAccountComponent } from './shares-account-actions/close-shares-account/close-shares-account.component';
import { ActivateSharesAccountComponent } from './shares-account-actions/activate-shares-account/activate-shares-account.component';
import { UndoApprovalSharesAccountComponent } from './shares-account-actions/undo-approval-shares-account/undo-approval-shares-account.component';
import { ApplySharesComponent } from './shares-account-actions/apply-shares/apply-shares.component';
import { RedeemSharesComponent } from './shares-account-actions/redeem-shares/redeem-shares.component';
import { ApproveSharesComponent } from './shares-account-actions/approve-shares/approve-shares.component';
import { ApproveShareDialogComponent } from './shares-account-actions/approve-shares/approve-share-dialog/approve-share-dialog.component';
@@ -83,7 +82,6 @@ import { MatInputModule } from '@angular/material/input';
CloseSharesAccountComponent,
ActivateSharesAccountComponent,
UndoApprovalSharesAccountComponent,
ApplySharesComponent,
RedeemSharesComponent,
ApproveSharesComponent,
ApproveShareDialogComponent,
+180
View File
@@ -190,4 +190,184 @@ 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) });
}
/**
* Every share product's authorised capital against what has actually been paid up — shares issued,
* shares remaining, unpaid allotments and the distinctive-number range, per product.
*/
getShareProductSummary(): Observable<any> {
return this.http.get('/sharereports/product-summary');
}
/** 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;
}
}