feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes
- Add the full Share Reports surface (final balance, account statement, issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry, payment certificate, share detail, dividend statement, folio statement, distinctive numbers, area-wise, mailing labels/letters, share transfer) as standalone routed components, wired into the Share menu - Wire the unattributed Share Collection receipt list into Apply Collection and Apply Additional Shares, with an Attach action independent of account/client - Sync the GL Account and Voucher No fields to whichever receipt is ticked or found, and surface the receipt's real Dr/Cr GL entries in its detail popup - Block Apply Additional Shares until the account's original application is settled Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+209
@@ -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> </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> </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> </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
|
||||
);
|
||||
}
|
||||
}
|
||||
+152
@@ -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> </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>
|
||||
+206
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+171
@@ -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> </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>
|
||||
+151
@@ -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> </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> </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> </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> </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> </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> </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> </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> </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
|
||||
);
|
||||
}
|
||||
}
|
||||
+166
@@ -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 & 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> </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>
|
||||
+197
@@ -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> </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
|
||||
);
|
||||
}
|
||||
}
|
||||
+160
@@ -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>
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { SHARE_ACCOUNT_STATUS, fundingLabel, shareReportPrint } from '../share-report.util';
|
||||
|
||||
/**
|
||||
* Share Payment Certificate — proof of what a member paid for their shares, and against which
|
||||
* receipt.
|
||||
*
|
||||
* <p>
|
||||
* Distinct from a share certificate, which certifies ownership: this certifies payment, so it leads
|
||||
* with amounts, dates and the voucher each one was received on. It is the document a member is asked
|
||||
* for when the money, not the holding, has to be proved.
|
||||
*
|
||||
* <p>
|
||||
* Every tranche is shown rather than one total — a member who paid in instalments paid on several
|
||||
* dates against several receipts, and a certificate carrying a single figure reconciles to none of
|
||||
* them.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-payment-certificate',
|
||||
templateUrl: './share-payment-certificate.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class SharePaymentCertificateComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
accountSearch = '';
|
||||
accountOptions: any[] = [];
|
||||
selectedAccountId: number | null = null;
|
||||
|
||||
loading = false;
|
||||
loadError = '';
|
||||
account: any = null;
|
||||
payments: any[] = [];
|
||||
charges: any[] = [];
|
||||
totals: any = null;
|
||||
|
||||
/** The society's own name and address, so the printed certificate is on its letterhead. */
|
||||
institution: any = null;
|
||||
|
||||
private searchTerm$ = new Subject<string>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.searchTerm$
|
||||
.pipe(
|
||||
debounceTime(350),
|
||||
distinctUntilChanged(),
|
||||
switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))))
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.accountOptions = Array.isArray(res) ? res : res?.pageItems || [];
|
||||
});
|
||||
|
||||
this.organizationService
|
||||
.getInstitutionProfile()
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => (this.institution = res));
|
||||
|
||||
const preselected = this.route.snapshot.queryParamMap.get('shareAccountId');
|
||||
if (preselected) {
|
||||
this.selectedAccountId = Number(preselected);
|
||||
this.load();
|
||||
}
|
||||
this.searchTerm$.next('');
|
||||
}
|
||||
|
||||
onAccountSearch(term: string): void {
|
||||
this.accountSearch = term;
|
||||
this.searchTerm$.next(term.trim());
|
||||
}
|
||||
|
||||
selectAccount(option: any): void {
|
||||
this.selectedAccountId = option.id;
|
||||
this.accountOptions = [];
|
||||
this.accountSearch = `${option.clientName} — ${option.accountNo}`;
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (!this.selectedAccountId) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getSharePaymentCertificate(this.selectedAccountId)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The payment certificate could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.account = res?.account || null;
|
||||
this.payments = res?.payments || [];
|
||||
this.charges = res?.charges || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
statusLabel(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
statusClass(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
funding(source: string): string {
|
||||
return fundingLabel(source);
|
||||
}
|
||||
|
||||
/** Whatever identifies the money that came in: a voucher, else the receipt, else the route. */
|
||||
receiptRef(payment: any): string {
|
||||
return (
|
||||
payment.voucherNo || payment.receiptNo || (payment.fundingSource === 'SAVINGS' ? 'Debited from savings' : '—')
|
||||
);
|
||||
}
|
||||
|
||||
/** Amount in words, because a certificate carrying a figure alone is trivially altered. */
|
||||
get amountInWords(): string {
|
||||
const amount = Math.round(Number(this.totals?.totalPaid || 0));
|
||||
return amount ? `${this.toWords(amount)} rupees only` : '';
|
||||
}
|
||||
|
||||
private toWords(value: number): string {
|
||||
const ones = [
|
||||
'',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
'ten',
|
||||
'eleven',
|
||||
'twelve',
|
||||
'thirteen',
|
||||
'fourteen',
|
||||
'fifteen',
|
||||
'sixteen',
|
||||
'seventeen',
|
||||
'eighteen',
|
||||
'nineteen'
|
||||
];
|
||||
const tens = [
|
||||
'',
|
||||
'',
|
||||
'twenty',
|
||||
'thirty',
|
||||
'forty',
|
||||
'fifty',
|
||||
'sixty',
|
||||
'seventy',
|
||||
'eighty',
|
||||
'ninety'
|
||||
];
|
||||
|
||||
const twoDigits = (n: number): string =>
|
||||
n < 20 ? ones[n] : `${tens[Math.floor(n / 10)]}${n % 10 ? '-' + ones[n % 10] : ''}`;
|
||||
|
||||
// Indian grouping — a co-operative's certificates read in lakh and crore, not million.
|
||||
const parts: string[] = [];
|
||||
const crore = Math.floor(value / 10000000);
|
||||
const lakh = Math.floor((value % 10000000) / 100000);
|
||||
const thousand = Math.floor((value % 100000) / 1000);
|
||||
const hundred = Math.floor((value % 1000) / 100);
|
||||
const rest = value % 100;
|
||||
|
||||
if (crore) {
|
||||
parts.push(`${twoDigits(crore)} crore`);
|
||||
}
|
||||
if (lakh) {
|
||||
parts.push(`${twoDigits(lakh)} lakh`);
|
||||
}
|
||||
if (thousand) {
|
||||
parts.push(`${twoDigits(thousand)} thousand`);
|
||||
}
|
||||
if (hundred) {
|
||||
parts.push(`${ones[hundred]} hundred`);
|
||||
}
|
||||
if (rest) {
|
||||
parts.push(twoDigits(rest));
|
||||
}
|
||||
const words = parts.join(' ').trim();
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Zero';
|
||||
}
|
||||
|
||||
get today(): string {
|
||||
const now = new Date();
|
||||
return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared table, pill and print styling for the share reports.
|
||||
*
|
||||
* Layered on top of certificates.shared.scss, which already supplies the page frame
|
||||
* (.cert-root / .cert-head / .cert-kpis / .cert-filters). Only what those do not cover lives here.
|
||||
*/
|
||||
|
||||
$srep-border: #e2e8f0;
|
||||
$srep-border-soft: #f1f5f9;
|
||||
$srep-head-bg: #f8fafc;
|
||||
$srep-text: #0f172a;
|
||||
$srep-muted: #64748b;
|
||||
|
||||
.srep-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.srep-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: $srep-head-bg;
|
||||
color: $srep-muted;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-bottom: 1px solid $srep-border;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-bottom: 1px solid $srep-border-soft;
|
||||
color: $srep-text;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr.srep-row--click {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
}
|
||||
|
||||
// A row that is over — closed, rejected, never activated — still belongs on the register, but it
|
||||
// must not read like a live holding. Dimmed and marked at the edge rather than hidden, because a
|
||||
// closed account that silently disappears is its own kind of confusion at a counter.
|
||||
tbody tr.srep-row--muted {
|
||||
background: #fafbfc;
|
||||
color: $srep-muted;
|
||||
|
||||
td {
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
box-shadow: inset 3px 0 0 #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
// Allotted but never collected. Flagged, not dimmed: this one is live and wants chasing.
|
||||
tbody tr.srep-row--flag {
|
||||
background: #fffdf5;
|
||||
|
||||
td:first-child {
|
||||
box-shadow: inset 3px 0 0 #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.srep-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.srep-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
// A second line under a figure, for the qualifier the figure alone cannot carry — "50 unpaid"
|
||||
// beneath a held count of nothing.
|
||||
.srep-sub {
|
||||
margin-top: 1px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
background: $srep-head-bg;
|
||||
font-weight: 700;
|
||||
border-top: 2px solid $srep-border;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.srep-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&.srep-pill--green {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&.srep-pill--amber {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&.srep-pill--red {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.srep-pill--blue {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&.srep-pill--grey {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.srep-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 1rem;
|
||||
color: $srep-muted;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.srep-state--error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* A narrowed view says so, right where the numbers are, so it is never read as the whole society. */
|
||||
.srep-scope-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
/* Account picker — results drop under the search box rather than into a dialog, so choosing an
|
||||
account is one click from typing rather than a modal that has to be dismissed. */
|
||||
.srep-picker {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
margin-top: 2px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 24px -8px rgb(15 23 42 / 18%);
|
||||
}
|
||||
|
||||
.srep-picker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid $srep-border-soft;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
}
|
||||
|
||||
.srep-picker-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.srep-picker-meta {
|
||||
font-size: 0.72rem;
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
/* Inline distribution bar — the shareholding pattern reads as a shape before it reads as numbers,
|
||||
and a chart library for one bar per row would be a dependency for nothing. */
|
||||
.srep-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: $srep-border-soft;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.srep-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #60a5fa);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Statement header — the identity block a printed statement has to carry. */
|
||||
.srep-stmt-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.srep-stmt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.6rem 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.srep-stmt-l {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: $srep-muted;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.srep-stmt-v {
|
||||
font-size: 0.84rem;
|
||||
color: $srep-text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Something the reader has to know before trusting the number above it — an exclusion, a gap, a
|
||||
figure that will not reconcile until someone acts. Loud enough to be read, not an error. */
|
||||
.srep-warn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
margin: 0.6rem 0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-left: 4px solid #d97706;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* The selected tab, in a screen that has two halves rather than two pages. */
|
||||
.srep-tab--active {
|
||||
background: #eff6ff !important;
|
||||
border-color: #2563eb !important;
|
||||
color: #1e40af !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Printed certificate — a document, not a screen: it carries the society's letterhead, a body in
|
||||
sentences and a signature block, because that is what a member is handed and files. */
|
||||
.spc-doc {
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.spc-letterhead {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid $srep-text;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.spc-org {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: $srep-text;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.spc-org-sub {
|
||||
font-size: 0.78rem;
|
||||
color: $srep-muted;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.spc-title {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.spc-body {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
color: $srep-text;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.spc-words {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.spc-foot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin-top: 2.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.spc-sign {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.spc-sign-line {
|
||||
width: 170px;
|
||||
border-top: 1px solid $srep-text;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Label sheet — a grid sized to the label stationery a branch buys, printed straight from here. */
|
||||
.slab-sheet {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.slab {
|
||||
border: 1px dashed $srep-border;
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
min-height: 92px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: $srep-text;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.slab-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.slab-line {
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.slab-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 0.7rem;
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
/* One letter, one page. */
|
||||
.slet-page {
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
margin-top: 0.85rem;
|
||||
background: #fff;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.slet-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.slet-date {
|
||||
white-space: nowrap;
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
.slet-subject {
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.slet-salutation {
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.slet-body {
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.slet-body-input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.6;
|
||||
color: $srep-text;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.cert-head-r,
|
||||
.cert-filters,
|
||||
.srep-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.slab {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
.slet-page {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spc-doc {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.srep-table-wrap {
|
||||
overflow: visible;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.srep-table th {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@@ -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"> filtered </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> </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> </label>
|
||||
<button class="cert-cta-btn" [class.srep-tab--active]="tab === 'register'" (click)="tab = 'register'">
|
||||
Transfer register
|
||||
</button>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </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> </label>
|
||||
<button class="cert-cta-btn" (click)="submit()" [disabled]="!canSubmit">
|
||||
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user