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:
@@ -390,6 +390,17 @@ export class CashierService {
|
||||
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Approved share-collection receipts that name no member yet.
|
||||
*
|
||||
* A receipt taken straight into a GL account carries no member of its own, so it is never
|
||||
* attributed automatically. This lists them so the counter can see the voucher they just raised
|
||||
* and attach it deliberately, instead of being told there is nothing to apply.
|
||||
*/
|
||||
getUnattributedShareCollections(): Observable<any> {
|
||||
return this.http.get('/shareadvancecollection/unattributed');
|
||||
}
|
||||
|
||||
/** Receipts still sitting in Share Sundry, optionally for a single member. */
|
||||
getUnappliedShareAdvances(clientId?: number): Observable<any> {
|
||||
const suffix = clientId ? `?clientId=${clientId}` : '';
|
||||
|
||||
@@ -1300,72 +1300,76 @@
|
||||
<!-- Share Operations -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Share Operations</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/nominal-shares']">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Nominal Share Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-wise']">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Share Wise Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/guarantor-detail']">
|
||||
<fa-icon icon="user-shield" class="menu-item-icon"></fa-icon> Guarantor Detail
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/member-interest']">
|
||||
<fa-icon icon="calculator" class="menu-item-icon"></fa-icon> Total Int Rec
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/enquiry']">
|
||||
<fa-icon icon="search" class="menu-item-icon"></fa-icon> Share Enquiry
|
||||
</button>
|
||||
</div>
|
||||
<!-- Share Documents -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Share Documents</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/payment-certificate']">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Share Payment Certificate
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-detail']">
|
||||
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Share Detail
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/dividend-statement']">
|
||||
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Dividend Statement
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/transfers']">
|
||||
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Share Transfer
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/folio-statement']">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Folio Statement
|
||||
</button>
|
||||
</div>
|
||||
<!-- Share Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Share Reports</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/final-balance']">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Final Balance
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/account-statement']">
|
||||
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Account Statement
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-issued']">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
|
||||
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-status']">
|
||||
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status
|
||||
</button>
|
||||
</div>
|
||||
<!-- Share Admin -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Share Admin</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/distinctive-numbers']">
|
||||
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Devede No Detail
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/area-wise']">
|
||||
<fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/labels']">
|
||||
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/letters']">
|
||||
<fa-icon icon="envelope" class="menu-item-icon"></fa-icon> Share Letter
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/letter-mgmt/share-member']">
|
||||
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -208,20 +208,20 @@
|
||||
</label>
|
||||
<div class="lookup-input-group">
|
||||
<input
|
||||
#voucherInput
|
||||
type="text"
|
||||
class="lookup-input font-mono"
|
||||
placeholder="Voucher no. (e.g. 000123)"
|
||||
[(ngModel)]="voucherNo"
|
||||
[disabled]="lookingUpVoucher || submitting"
|
||||
(keyup.enter)="lookupVoucher(voucherInput.value)"
|
||||
(keyup.enter)="lookupVoucher()"
|
||||
/>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="find-btn"
|
||||
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
|
||||
(click)="lookupVoucher(voucherInput.value)"
|
||||
[disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
|
||||
(click)="lookupVoucher()"
|
||||
>
|
||||
@if (lookingUpVoucher) {
|
||||
<mat-spinner diameter="14"></mat-spinner>
|
||||
@@ -234,6 +234,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipts tagged "Share Collection" that belong to nobody yet. A deposit straight into
|
||||
a GL account names no member, so it is never attributed on its own -- and never
|
||||
filtered by this member's client id either, since the cashier's typed description is
|
||||
free text and could easily be wrong. Attaching one is still a click made against a
|
||||
member on screen, exactly like typing the voucher number above. -->
|
||||
@if (unattributed.length) {
|
||||
<div class="unattributed-section">
|
||||
<div class="section-title text-amber">
|
||||
<mat-icon class="sec-icon">person_add</mat-icon>
|
||||
<span>Unattached Share Collection Receipts — Attach to This Tranche</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="receipt-table unattributed-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-left">Voucher No</th>
|
||||
<th class="ta-left">Date</th>
|
||||
<th class="ta-left">Deposited To</th>
|
||||
<th class="ta-left">Description</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
<th class="col-attach ta-center">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (u of unattributed; track u.id) {
|
||||
<tr [class.own-client-row]="isOwnClientVoucher(u)">
|
||||
<td class="ta-left">
|
||||
<span class="voucher-badge font-mono">{{ u.voucherNo }}</span>
|
||||
@if (isOwnClientVoucher(u)) {
|
||||
<span class="own-client-badge">This member's receipt</span>
|
||||
}
|
||||
</td>
|
||||
<td class="ta-left">{{ u.txnDate }}</td>
|
||||
<td class="ta-left">{{ u.glAccountNo }} — {{ u.glAccountName }}</td>
|
||||
<td class="ta-left desc-cell" [title]="u.description">{{ u.description || '—' }}</td>
|
||||
<td class="ta-right amount-cell">{{ u.amount | number: '1.2-2' }}</td>
|
||||
<td class="ta-center col-attach">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
class="attach-btn"
|
||||
[disabled]="lookingUpVoucher || submitting"
|
||||
(click)="attachUnattributedVoucher(u)"
|
||||
>
|
||||
Attach
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (voucherError) {
|
||||
<div class="alert-box alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
|
||||
@@ -481,6 +481,69 @@ mat-form-field {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Unattached share-collection receipts. Set apart in amber so it never reads as this member's own
|
||||
settled list above: these belong to nobody until somebody attaches one. */
|
||||
.unattributed-section {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid $warning-border;
|
||||
border-radius: 8px;
|
||||
background: $warning-bg;
|
||||
|
||||
.section-title {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0;
|
||||
|
||||
&.text-amber {
|
||||
color: $warning;
|
||||
|
||||
.sec-icon {
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unattributed-table {
|
||||
background: $bg-surface;
|
||||
|
||||
.col-attach {
|
||||
width: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
.attach-btn {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.65rem;
|
||||
}
|
||||
|
||||
// A row already linked to the member whose screen this is -- the same voucher is also sitting,
|
||||
// pre-loaded and tickable, in the Vouchers/Receipts table below. This is not a duplicate to
|
||||
// clear up; it is the same receipt shown two ways for two different purposes (find it here,
|
||||
// select it there), so the row is called out instead of removed.
|
||||
.own-client-row {
|
||||
background: $success-bg;
|
||||
|
||||
&:hover {
|
||||
background: $success-bg;
|
||||
}
|
||||
|
||||
.voucher-badge {
|
||||
border-color: $success-border;
|
||||
}
|
||||
}
|
||||
|
||||
.own-client-badge {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
|
||||
@@ -100,6 +100,24 @@ export class ApplySharesComponent implements OnInit {
|
||||
lookingUpVoucher = false;
|
||||
voucherError = '';
|
||||
|
||||
/** Voucher / Receipt No shown in the "Find Specific Voucher" box. Mirrors whichever receipt is
|
||||
* ticked below (see {@link toggleVoucher}) so a tick is visibly confirmed up there too, and
|
||||
* carries whatever the counter has typed in when they search by hand instead. */
|
||||
voucherNo = '';
|
||||
|
||||
/**
|
||||
* Share-collection receipts that are approved but belong to nobody yet.
|
||||
*
|
||||
* A receipt paid straight into a GL account names no member of its own — a GL account is nobody's
|
||||
* account — so it is never attributed automatically, and never filtered by this member's client id
|
||||
* (the cashier's typed description is free text and can easily be wrong; nothing here is trusted
|
||||
* to match a member by parsing it). Listing it is not attribution: it only makes the voucher
|
||||
* findable, so a counter that tagged a receipt "Share Collection" moments ago is not told there is
|
||||
* nothing to fund this tranche with. Attaching it below is still a deliberate click against the
|
||||
* member on screen.
|
||||
*/
|
||||
unattributed: any[] = [];
|
||||
|
||||
/** GL account the deposit was posted to — required, and always picked by hand. Never resolved
|
||||
* from a share product's accounting setup, so a broken or missing product mapping can never
|
||||
* make a genuine voucher unreachable. */
|
||||
@@ -242,6 +260,7 @@ export class ApplySharesComponent implements OnInit {
|
||||
this.vouchers = [];
|
||||
this.selectedVoucherIds = [];
|
||||
this.voucherError = '';
|
||||
this.voucherNo = '';
|
||||
this.errorMsg = '';
|
||||
this.savingsEligibility = null;
|
||||
if (mode === 'SAVINGS') {
|
||||
@@ -266,8 +285,9 @@ export class ApplySharesComponent implements OnInit {
|
||||
/**
|
||||
* Loads the member's unapplied Share Sundry receipts so they can be ticked off.
|
||||
*
|
||||
* A single receipt is ticked for them: with only one candidate there is nothing to choose, and
|
||||
* making the clerk tick it adds a step that can only be got wrong by forgetting it.
|
||||
* Deliberately nothing ticked, not even when a single receipt is listed — which receipt funds the
|
||||
* tranche is a choice the person applying it makes, not one they inherit from a pre-ticked radio
|
||||
* they never looked at.
|
||||
*/
|
||||
private loadVouchers(): void {
|
||||
this.loadingVouchers = true;
|
||||
@@ -286,16 +306,49 @@ export class ApplySharesComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
this.vouchers = res.unappliedCollections || [];
|
||||
if (this.vouchers.length === 1) {
|
||||
this.selectedVoucherIds = [this.vouchers[0].id];
|
||||
}
|
||||
});
|
||||
|
||||
this.cashierService
|
||||
.getUnattributedShareCollections()
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => (this.unattributed = res?.rows || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches one of the unattributed share-collection receipts to this member's tranche.
|
||||
*
|
||||
* Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already
|
||||
* sitting in this list is either a fresh cashier deposit (which does carry its own GL account) or
|
||||
* an existing, already-adopted {@code m_share_advance_collection} row (the "linked to …" ones),
|
||||
* whose GL account can genuinely come back blank — its subquery reads the posting the ORIGINAL
|
||||
* adoption made, which a clerk-disbursement-funded receipt does not always leave behind cleanly.
|
||||
* Either way the backend needs no GL account to resolve a voucher it can already find by number, so
|
||||
* a blank one here must never block the click the way it rightly blocks a hand-typed search with
|
||||
* nothing chosen yet.
|
||||
*/
|
||||
attachUnattributedVoucher(row: any): void {
|
||||
if (this.lookingUpVoucher || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.resolveVoucher(row.voucherNo, row.glAccountNo || '');
|
||||
}
|
||||
|
||||
isVoucherSelected(id: number): boolean {
|
||||
return this.selectedVoucherIds.includes(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an unattributed-list row is already linked to the member this screen belongs to.
|
||||
*
|
||||
* Such a row also appears, pre-loaded and ready to tick, in the Vouchers/Receipts table below —
|
||||
* that is not a bug to hide, it is the same receipt reachable two ways. This only flags it so the
|
||||
* counter understands why it shows up here too, rather than reading it as an unrelated stray
|
||||
* voucher that happens to look identical.
|
||||
*/
|
||||
isOwnClientVoucher(u: any): boolean {
|
||||
return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.sharesAccountData.clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One receipt funds one tranche — no combining instalments here (unlike the first-application
|
||||
* Apply Collection dialog, which still allows several). Picking a different receipt replaces the
|
||||
@@ -305,15 +358,18 @@ export class ApplySharesComponent implements OnInit {
|
||||
const wasSelected = this.isVoucherSelected(id);
|
||||
this.selectedVoucherIds = wasSelected ? [] : [id];
|
||||
this.voucherError = '';
|
||||
// The GL Account picker above follows whichever receipt is ticked — its own actual GL account
|
||||
// (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt
|
||||
// that plainly does have one is already chosen.
|
||||
if (!wasSelected) {
|
||||
const voucher = this.vouchers.find((v) => v.id === id);
|
||||
if (voucher?.gl_code) {
|
||||
this.glAccountNo = voucher.gl_code;
|
||||
}
|
||||
if (wasSelected) {
|
||||
this.voucherNo = '';
|
||||
return;
|
||||
}
|
||||
// The GL Account and Find Specific Voucher fields above follow whichever receipt is ticked --
|
||||
// their own actual GL account and voucher number (see gl_code / voucher_no on the row), not left
|
||||
// showing an unrelated value or blank once a receipt that plainly has them is already chosen.
|
||||
const voucher = this.vouchers.find((v) => v.id === id);
|
||||
if (voucher?.gl_code) {
|
||||
this.glAccountNo = voucher.gl_code;
|
||||
}
|
||||
this.voucherNo = voucher?.voucher_no || voucher?.receipt_no || '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,24 +386,40 @@ export class ApplySharesComponent implements OnInit {
|
||||
* Resolves a voucher number the member quotes and adds it to the list.
|
||||
*
|
||||
* Kept alongside the tick list rather than replacing it: the member usually quotes the number on
|
||||
* the slip in their hand, which is faster than finding it among their receipts. The lookup is
|
||||
* deliberately not restricted to this member, so a voucher belonging to somebody else is
|
||||
* surfaced here as a plain mismatch rather than silently ignored.
|
||||
* the slip in their hand, which is faster than finding it among their receipts. The lookup is not
|
||||
* restricted to this member — any receipt found this way can be attached here, whichever member it
|
||||
* was originally recorded against.
|
||||
*/
|
||||
lookupVoucher(voucherNo: string): void {
|
||||
const trimmed = (voucherNo || '').trim();
|
||||
lookupVoucher(): void {
|
||||
const trimmed = this.voucherNo.trim();
|
||||
const glAccountNo = this.glAccountNo.trim();
|
||||
// A hand-typed search genuinely needs both fields: an empty GL account here means nothing has
|
||||
// been chosen yet, not that one isn't needed — unlike {@link attachUnattributedVoucher}, which
|
||||
// already knows exactly which receipt it means and never has this ambiguity.
|
||||
if (!trimmed || !glAccountNo || this.lookingUpVoucher) {
|
||||
return;
|
||||
}
|
||||
this.resolveVoucher(trimmed, glAccountNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the
|
||||
* receipt it finds to the list, selected.
|
||||
*
|
||||
* Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank
|
||||
* by the Find button's own disabled state) and {@link attachUnattributedVoucher} (a click on a row
|
||||
* already on screen, whose GL account can legitimately be blank) — the actual lookup and its
|
||||
* bookkeeping afterwards are identical either way.
|
||||
*/
|
||||
private resolveVoucher(voucherNo: string, glAccountNo: string): void {
|
||||
this.lookingUpVoucher = true;
|
||||
this.voucherError = '';
|
||||
|
||||
this.cashierService
|
||||
.getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId)
|
||||
.getShareAdvanceByVoucher(voucherNo, glAccountNo, this.sharesAccountData.clientId)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`);
|
||||
this.voucherError = this.readError(err, `Voucher ${voucherNo} could not be found.`);
|
||||
this.lookingUpVoucher = false;
|
||||
return of(null);
|
||||
})
|
||||
@@ -357,10 +429,8 @@ export class ApplySharesComponent implements OnInit {
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) {
|
||||
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
|
||||
return;
|
||||
}
|
||||
// No client-match check here by deliberate choice: any receipt can be attached to any
|
||||
// share account, regardless of which member it was originally recorded against.
|
||||
// Replaces the selection rather than adding to it — one receipt funds one tranche here, so
|
||||
// finding a voucher by number is a different way to pick THE receipt, not a second one.
|
||||
this.vouchers = [
|
||||
@@ -368,6 +438,13 @@ export class ApplySharesComponent implements OnInit {
|
||||
...this.vouchers.filter((v) => v.id !== res.id)
|
||||
];
|
||||
this.selectedVoucherIds = [res.id];
|
||||
this.voucherNo = res.voucher_no || res.receipt_no || voucherNo;
|
||||
if (res.gl_code) {
|
||||
this.glAccountNo = res.gl_code;
|
||||
}
|
||||
// Once attached it belongs to this member, so it leaves the unattributed list rather than
|
||||
// sitting there inviting a second, conflicting attachment.
|
||||
this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucherNo);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+547
-201
@@ -6,103 +6,94 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>Apply Share Advance Collection</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p class="intro">
|
||||
Tick the receipts that make up this payment to settle share account <b>{{ data.accountNo }}</b
|
||||
>, then apply. Today's unapplied receipts for this member are listed below — tick more than one where they paid in
|
||||
instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income.
|
||||
</p>
|
||||
|
||||
<!-- What this account needs, up front — so the counter knows the full figure, and that it may take
|
||||
a second receipt for the entry fee, before a search ever comes up short. -->
|
||||
@if (requirement) {
|
||||
<div class="requirement-summary">
|
||||
<span class="requirement-line">
|
||||
Required: <b>{{ requirement.shareAmount | number: '1.2-2' }}</b> (Shares)
|
||||
@if (requirement.chargeAmount > 0) {
|
||||
+ <b>{{ requirement.chargeAmount | number: '1.2-2' }}</b> (Entry Fee) =
|
||||
<b>{{ requirement.required | number: '1.2-2' }}</b> Total
|
||||
}
|
||||
</span>
|
||||
@if (selectedIds.length > 0) {
|
||||
<span class="requirement-progress" [class.covered]="shortfall === 0">
|
||||
@if (shortfall === 0) {
|
||||
Share receipts covered
|
||||
} @else {
|
||||
{{ selectedTotal | number: '1.2-2' }} of {{ shareRequired | number: '1.2-2' }} collected —
|
||||
{{ shortfall | number: '1.2-2' }} short
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<div class="dialog-wrapper">
|
||||
<!-- Dialog Header Banner -->
|
||||
<div class="dialog-header-banner">
|
||||
<div class="header-icon-box">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- For a receipt the list does not carry — one taken on an earlier day, or a slip the member is
|
||||
holding that is quicker to type than to find. Both the GL account the deposit was posted to
|
||||
and the voucher number are required — never resolved from a share product's own accounting
|
||||
setup, so a broken or missing product mapping can never make a genuine voucher unreachable. -->
|
||||
<div class="voucher-section">
|
||||
<div class="voucher-section-label">Share Receipt</div>
|
||||
<div class="gl-select-field">
|
||||
<select class="gl-select" [(ngModel)]="glAccountNo" [disabled]="lookingUp || submitting || loadingGlAccounts">
|
||||
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
|
||||
@for (gl of glAccounts; track gl.id) {
|
||||
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="voucher-lookup">
|
||||
<input
|
||||
type="text"
|
||||
class="voucher-input"
|
||||
placeholder="Voucher no. (e.g. CR/001/2608/0001)"
|
||||
[(ngModel)]="voucherNo"
|
||||
[disabled]="lookingUp || submitting"
|
||||
(keyup.enter)="lookupVoucher()"
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUp || submitting"
|
||||
(click)="lookupVoucher()"
|
||||
>
|
||||
{{ lookingUp ? 'Finding…' : 'Find' }}
|
||||
</button>
|
||||
<div class="header-text font-accent">
|
||||
<h2>Apply Share Advance Collection</h2>
|
||||
<p>
|
||||
Settling share account <strong class="font-mono">{{ data.accountNo }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" mat-icon-button class="close-header-btn" [mat-dialog-close]="false">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shown only when this account actually has an outstanding fee -- a member sometimes pays it as
|
||||
a second receipt into a different GL account rather than folding it into the share-value
|
||||
deposit. This search only ever verifies that receipt exists; it never moves its money -- that
|
||||
stays exactly where the cashier already deposited it, unlike the share receipts above which
|
||||
genuinely move out of Sundry. -->
|
||||
@if (requirement && requirement.chargeAmount > 0) {
|
||||
<div class="voucher-section">
|
||||
<div class="voucher-section-label">Entry Fee Receipt — verification only, no GL entry is posted for it</div>
|
||||
<mat-dialog-content class="dialog-body">
|
||||
<!-- Intro Banner -->
|
||||
<div class="dialog-intro-banner">
|
||||
<mat-icon class="banner-icon">info</mat-icon>
|
||||
<span>
|
||||
Tick the receipts that make up this payment to settle share account <strong>{{ data.accountNo }}</strong
|
||||
>. Multiple receipts can be selected for instalment payments. Click
|
||||
<mat-icon class="inline-icon">visibility</mat-icon> to view full receipt popup details.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (chargeReceipt) {
|
||||
<div class="charge-receipt-found" [class.short]="chargeShortfall > 0">
|
||||
<mat-icon>{{ chargeShortfall > 0 ? 'error_outline' : 'check_circle_outline' }}</mat-icon>
|
||||
<span class="charge-receipt-detail">
|
||||
<b class="mono">{{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }}</b>
|
||||
— {{ chargeReceipt.amount | number: '1.2-2' }}
|
||||
@if (chargeShortfall > 0) {
|
||||
(short by {{ chargeShortfall | number: '1.2-2' }})
|
||||
} @else {
|
||||
— entry fee verified
|
||||
}
|
||||
</span>
|
||||
<button mat-button type="button" [disabled]="submitting" (click)="clearChargeReceipt()">Change</button>
|
||||
<!-- Requirement Summary Stat Card -->
|
||||
@if (requirement) {
|
||||
<div class="requirement-summary-card">
|
||||
<div class="req-header">
|
||||
<mat-icon class="req-icon">calculate</mat-icon>
|
||||
<span>Tranche Requirement</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="gl-select-field">
|
||||
|
||||
<div class="req-metrics font-accent">
|
||||
<div class="metric-item">
|
||||
<span class="m-label">Shares Value</span>
|
||||
<span class="m-val font-mono">{{ requirement.shareAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
@if (requirement.chargeAmount > 0) {
|
||||
<span class="metric-plus">+</span>
|
||||
<div class="metric-item">
|
||||
<span class="m-label">Entry Fee</span>
|
||||
<span class="m-val font-mono">{{ requirement.chargeAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<span class="metric-equals">=</span>
|
||||
<div class="metric-item highlight-total">
|
||||
<span class="m-label">Total Required</span>
|
||||
<span class="m-val font-mono font-semibold">{{ requirement.required | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedIds.length > 0) {
|
||||
<div class="req-status-bar mt-6">
|
||||
@if (shortfall === 0) {
|
||||
<span class="status-badge badge-success">
|
||||
<mat-icon>check_circle</mat-icon> Share receipts fully covered
|
||||
</span>
|
||||
} @else {
|
||||
<span class="status-badge badge-warning">
|
||||
<mat-icon>warning</mat-icon> {{ selectedTotal | number: '1.2-2' }} of
|
||||
{{ shareRequired | number: '1.2-2' }} collected — {{ shortfall | number: '1.2-2' }} short
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SHARE RECEIPT LOOKUP SECTION -->
|
||||
<div class="search-section">
|
||||
<div class="section-label">
|
||||
<mat-icon class="sec-icon">search</mat-icon>
|
||||
<span>Share Receipt Lookup</span>
|
||||
</div>
|
||||
|
||||
<div class="lookup-toolbar">
|
||||
<div class="gl-select-box">
|
||||
<label class="toolbar-label"><mat-icon class="mini-icon">account_tree</mat-icon> GL Account</label>
|
||||
<select
|
||||
class="gl-select"
|
||||
[(ngModel)]="chargeGlAccountNo"
|
||||
[disabled]="lookingUpCharge || submitting || loadingGlAccounts"
|
||||
class="custom-select"
|
||||
[(ngModel)]="glAccountNo"
|
||||
[disabled]="lookingUp || submitting || loadingGlAccounts"
|
||||
>
|
||||
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
|
||||
@for (gl of glAccounts; track gl.id) {
|
||||
@@ -111,121 +102,476 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="voucher-lookup">
|
||||
<input
|
||||
type="text"
|
||||
class="voucher-input"
|
||||
placeholder="Voucher no. (e.g. CR/001/2608/0002)"
|
||||
[(ngModel)]="chargeVoucherNo"
|
||||
[disabled]="lookingUpCharge || submitting"
|
||||
(keyup.enter)="lookupChargeVoucher()"
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting"
|
||||
(click)="lookupChargeVoucher()"
|
||||
>
|
||||
{{ lookingUpCharge ? 'Finding…' : 'Find' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="charge-receipt-hint">
|
||||
Leave this blank if the fee was already paid as part of the share receipt above.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="loading">
|
||||
<mat-spinner diameter="28"></mat-spinner>
|
||||
<span>Loading receipts…</span>
|
||||
</div>
|
||||
} @else if (receipts.length === 0) {
|
||||
<div class="empty">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>
|
||||
<b>No unapplied receipt for this member today.</b>
|
||||
<div class="empty-hint">
|
||||
If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from
|
||||
Cashier › Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the
|
||||
voucher appears here.
|
||||
<div class="voucher-lookup-box">
|
||||
<label class="toolbar-label"><mat-icon class="mini-icon">receipt</mat-icon> Voucher / Receipt No</label>
|
||||
<div class="lookup-input-group">
|
||||
<input
|
||||
type="text"
|
||||
class="lookup-input font-mono"
|
||||
placeholder="Voucher no. (e.g. CR/001/2608/0001)"
|
||||
[(ngModel)]="voucherNo"
|
||||
[disabled]="lookingUp || submitting"
|
||||
(keyup.enter)="lookupVoucher()"
|
||||
/>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="find-btn"
|
||||
[disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUp || submitting"
|
||||
(click)="lookupVoucher()"
|
||||
>
|
||||
@if (lookingUp) {
|
||||
<mat-spinner diameter="14"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>search</mat-icon>
|
||||
<span>Find</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<table class="receipt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-tick"></th>
|
||||
<th class="ta-left">Voucher / Receipt</th>
|
||||
<th class="ta-left">Date</th>
|
||||
<th class="ta-left">Description</th>
|
||||
<th class="ta-left">Collected By</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of receipts; track r.id) {
|
||||
<tr [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
|
||||
<td class="ta-center col-tick">
|
||||
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
|
||||
against one application, and all of them settle it together. -->
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(r.id)"
|
||||
(change)="toggle(r.id)"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</td>
|
||||
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
|
||||
<td class="ta-left">{{ r.collection_date }}</td>
|
||||
<td class="ta-left" [title]="r.note">{{ r.note || '—' }}</td>
|
||||
<td class="ta-left">{{ r.collected_by_name || '—' }}</td>
|
||||
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- ENTRY FEE RECEIPT SECTION (If charge exists) -->
|
||||
@if (requirement && requirement.chargeAmount > 0) {
|
||||
<div class="search-section fee-section">
|
||||
<div class="section-label">
|
||||
<mat-icon class="sec-icon font-warning">assignment_turned_in</mat-icon>
|
||||
<span>Entry Fee Receipt — Verification Only (No GL Entry)</span>
|
||||
</div>
|
||||
|
||||
@if (chargeReceipt) {
|
||||
<div class="charge-receipt-found" [class.short]="chargeShortfall > 0">
|
||||
<mat-icon>{{ chargeShortfall > 0 ? 'error_outline' : 'check_circle' }}</mat-icon>
|
||||
<span class="charge-receipt-detail font-accent">
|
||||
<b class="font-mono">{{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }}</b>
|
||||
— {{ chargeReceipt.amount | number: '1.2-2' }}
|
||||
@if (chargeShortfall > 0) {
|
||||
<span class="short-text">(short by {{ chargeShortfall | number: '1.2-2' }})</span>
|
||||
} @else {
|
||||
<span class="verified-text">— Entry fee verified</span>
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="change-btn"
|
||||
[disabled]="submitting"
|
||||
(click)="clearChargeReceipt()"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="lookup-toolbar">
|
||||
<div class="gl-select-box">
|
||||
<label class="toolbar-label"><mat-icon class="mini-icon">account_tree</mat-icon> Fee GL Account</label>
|
||||
<select
|
||||
class="custom-select"
|
||||
[(ngModel)]="chargeGlAccountNo"
|
||||
[disabled]="lookingUpCharge || submitting || loadingGlAccounts"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}
|
||||
</option>
|
||||
@for (gl of glAccounts; track gl.id) {
|
||||
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="voucher-lookup-box">
|
||||
<label class="toolbar-label"><mat-icon class="mini-icon">receipt</mat-icon> Fee Voucher No</label>
|
||||
<div class="lookup-input-group">
|
||||
<input
|
||||
type="text"
|
||||
class="lookup-input font-mono"
|
||||
placeholder="Voucher no. (e.g. CR/001/2608/0002)"
|
||||
[(ngModel)]="chargeVoucherNo"
|
||||
[disabled]="lookingUpCharge || submitting"
|
||||
(keyup.enter)="lookupChargeVoucher()"
|
||||
/>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="find-btn"
|
||||
[disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting"
|
||||
(click)="lookupChargeVoucher()"
|
||||
>
|
||||
@if (lookingUpCharge) {
|
||||
<mat-spinner diameter="14"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>search</mat-icon>
|
||||
<span>Find</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="charge-receipt-hint">
|
||||
Leave this blank if the entry fee was already paid as part of the main share receipt above.
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
<!-- Shown from the first tick rather than the second: the total against what the account needs
|
||||
is what decides whether the settlement is accepted, so it should be on screen as soon as
|
||||
there is anything to total. -->
|
||||
@if (selectedIds.length > 0) {
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td class="col-tick"></td>
|
||||
<td class="ta-left" colspan="4">
|
||||
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
|
||||
</td>
|
||||
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- UNATTRIBUTED RECEIPTS SECTION -->
|
||||
@if (unattributed.length) {
|
||||
<div class="unattributed-section">
|
||||
<div class="section-label text-amber">
|
||||
<mat-icon class="sec-icon">person_add</mat-icon>
|
||||
<span
|
||||
>Unattached Share Collection Receipts — Attach to <strong>{{ data.accountNo }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="receipt-table unattributed-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-left">Voucher No</th>
|
||||
<th class="ta-left">Date</th>
|
||||
<th class="ta-left">Deposited To</th>
|
||||
<th class="ta-left">Description</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
<th class="col-attach ta-center">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (u of unattributed; track u.id) {
|
||||
<tr [class.own-client-row]="isOwnClientVoucher(u)">
|
||||
<td class="ta-left">
|
||||
<span class="voucher-badge font-mono">{{ u.voucherNo }}</span>
|
||||
@if (isOwnClientVoucher(u)) {
|
||||
<span class="own-client-badge">This member's receipt</span>
|
||||
}
|
||||
</td>
|
||||
<td class="ta-left">{{ u.txnDate }}</td>
|
||||
<td class="ta-left">{{ u.glAccountNo }} — {{ u.glAccountName }}</td>
|
||||
<td class="ta-left desc-cell" [title]="u.description">{{ u.description || '—' }}</td>
|
||||
<td class="ta-right amount-cell">{{ u.amount | number: '1.2-2' }}</td>
|
||||
<td class="ta-center col-attach">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
class="attach-btn"
|
||||
[disabled]="lookingUp || submitting"
|
||||
(click)="attachUnattributed(u)"
|
||||
>
|
||||
Attach
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert-box alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- RECEIPTS ARRAY LIST -->
|
||||
<div class="vouchers-array-container mt-8">
|
||||
@if (loading) {
|
||||
<div class="funding-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Loading member receipts…</span>
|
||||
</div>
|
||||
} @else if (receipts.length === 0) {
|
||||
<div class="funding-empty">
|
||||
<mat-icon class="empty-icon">receipt_long</mat-icon>
|
||||
<div class="empty-text">
|
||||
<strong>No unapplied receipt found for this member today.</strong>
|
||||
<p>
|
||||
If cash was taken on an earlier day, enter that voucher number above. Otherwise collect cash first at
|
||||
<em>Cashier › Transaction Entry</em> into Share Sundry GL.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="vouchers-card">
|
||||
<!-- Array Header Toolbar -->
|
||||
<div class="vouchers-header">
|
||||
<div class="vouchers-title font-accent">
|
||||
<mat-icon class="v-icon">receipt</mat-icon>
|
||||
<span>Member Unapplied Receipts</span>
|
||||
<span class="badge-count">{{ receipts.length }}</span>
|
||||
</div>
|
||||
|
||||
@if (receipts.length > 1) {
|
||||
<div class="select-all-action">
|
||||
<button type="button" mat-button class="select-all-btn" (click)="toggleAll()">
|
||||
<mat-icon>{{
|
||||
isAllSelected()
|
||||
? 'check_box'
|
||||
: isSomeSelected()
|
||||
? 'indeterminate_check_box'
|
||||
: 'check_box_outline_blank'
|
||||
}}</mat-icon>
|
||||
<span
|
||||
>{{ isAllSelected() ? 'Deselect All' : 'Select All' }} ({{ selectedIds.length }}/{{
|
||||
receipts.length
|
||||
}})</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Table View -->
|
||||
<div class="table-responsive">
|
||||
<table class="receipt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-checkbox ta-center">
|
||||
@if (receipts.length > 1) {
|
||||
<input
|
||||
type="checkbox"
|
||||
class="custom-checkbox"
|
||||
[checked]="isAllSelected()"
|
||||
[indeterminate]="isSomeSelected()"
|
||||
(change)="toggleAll()"
|
||||
/>
|
||||
} @else {
|
||||
<span>#</span>
|
||||
}
|
||||
</th>
|
||||
<th class="ta-left">Voucher / Receipt No</th>
|
||||
<th class="ta-left">Date</th>
|
||||
<th class="ta-left">Description</th>
|
||||
<th class="ta-left">Collected By</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
<th class="col-actions ta-center">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of receipts; track r.id) {
|
||||
<tr class="receipt-row" [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
|
||||
<td class="col-checkbox ta-center" (click)="$event.stopPropagation()">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="custom-checkbox"
|
||||
[checked]="isSelected(r.id)"
|
||||
(change)="toggle(r.id)"
|
||||
/>
|
||||
</td>
|
||||
<td class="ta-left">
|
||||
<span class="voucher-badge font-mono">{{ r.voucher_no || r.receipt_no }}</span>
|
||||
</td>
|
||||
<td class="ta-left date-cell">{{ r.collection_date }}</td>
|
||||
<td class="ta-left desc-cell" [title]="r.note">{{ r.note || 'Share Collection' }}</td>
|
||||
<td class="ta-left user-cell">
|
||||
<span class="user-chip">
|
||||
<mat-icon class="chip-icon">person_outline</mat-icon>
|
||||
{{ r.collected_by_name || 'System' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="ta-right amount-cell">
|
||||
<span class="val font-semibold">{{ r.amount | number: '1.2-2' }}</span>
|
||||
</td>
|
||||
<td class="col-actions ta-center" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="view-detail-btn"
|
||||
title="View Full Details Popup"
|
||||
(click)="openReceiptDetail(r, $event)"
|
||||
>
|
||||
<mat-icon class="eye-icon">visibility</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Summary Footer -->
|
||||
<div class="vouchers-footer">
|
||||
<div class="selected-summary">
|
||||
<span class="summary-text font-accent">
|
||||
<strong>{{ selectedIds.length }}</strong> of <strong>{{ receipts.length }}</strong> receipts selected
|
||||
</span>
|
||||
|
||||
<div class="summary-amounts">
|
||||
<span class="total-label">Selected Total:</span>
|
||||
<span class="total-value font-semibold">{{ selectedTotal | number: '1.2-2' }}</span>
|
||||
<span class="target-divider">/</span>
|
||||
<span class="target-value">Required {{ shareRequired | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="coverage-status">
|
||||
@if (selectedIds.length === 0) {
|
||||
<span class="status-badge badge-neutral"> <mat-icon>info</mat-icon> Tick at least 1 receipt </span>
|
||||
} @else if (shortfall > 0) {
|
||||
<span class="status-badge badge-warning">
|
||||
<mat-icon>warning</mat-icon> Shortfall {{ shortfall | number: '1.2-2' }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="status-badge badge-success"> <mat-icon>check_circle</mat-icon> Fully Covered </span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</table>
|
||||
|
||||
<div class="note">
|
||||
Their total must cover the account's share value — plus any entry fee, unless it was verified as its own receipt
|
||||
above. Anything paid over that is credited to the member's savings account.
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting"
|
||||
(click)="apply()"
|
||||
>
|
||||
{{ submitting ? 'Applying…' : 'Apply Collection' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
<!-- Dialog Actions Bar -->
|
||||
<mat-dialog-actions align="end" class="dialog-actions-bar">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="action-btn cancel-btn"
|
||||
[mat-dialog-close]="false"
|
||||
[disabled]="submitting"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="action-btn submit-btn"
|
||||
[disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting"
|
||||
(click)="apply()"
|
||||
>
|
||||
@if (submitting) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
<span>Applying…</span>
|
||||
} @else {
|
||||
<mat-icon>check</mat-icon>
|
||||
<span>Apply Collection</span>
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
|
||||
<!-- RECEIPT DETAIL POPUP MODAL -->
|
||||
@if (viewingReceiptDetail) {
|
||||
<div class="modal-backdrop animate-fade-in" (click)="closeReceiptDetail()">
|
||||
<div class="voucher-modal-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="header-title font-accent">
|
||||
<mat-icon class="m-icon">receipt_long</mat-icon>
|
||||
<span>Receipt Details</span>
|
||||
<span class="modal-badge font-mono">{{
|
||||
viewingReceiptDetail.voucher_no || viewingReceiptDetail.receipt_no
|
||||
}}</span>
|
||||
</div>
|
||||
<button type="button" mat-icon-button class="close-btn" (click)="closeReceiptDetail()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Voucher / Receipt No</span>
|
||||
<span class="detail-val font-mono font-semibold">{{
|
||||
viewingReceiptDetail.voucher_no || viewingReceiptDetail.receipt_no || '—'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Receipt Reference</span>
|
||||
<span class="detail-val font-mono">{{ viewingReceiptDetail.receipt_no || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Client ID</span>
|
||||
<span class="detail-val font-mono">{{ viewingReceiptDetail.client_id || data?.clientId || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Collection Date</span>
|
||||
<span class="detail-val">{{ viewingReceiptDetail.collection_date || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Status</span>
|
||||
<span class="status-pill status-unapplied">{{ viewingReceiptDetail.status || 'UNAPPLIED' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Collected By</span>
|
||||
<span class="detail-val">{{ viewingReceiptDetail.collected_by_name || 'System' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">GL Entries — What This Receipt Actually Posted</span>
|
||||
@if (loadingReceiptGlEntries) {
|
||||
<div class="gl-entries-loading">
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
<span>Loading the ledger entries…</span>
|
||||
</div>
|
||||
} @else if (!viewingReceiptGlEntries?.length) {
|
||||
<div class="gl-entries-empty">No posted GL entries found for this receipt yet.</div>
|
||||
} @else {
|
||||
<table class="gl-entries-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-left">GL Account</th>
|
||||
<th class="ta-center">Dr / Cr</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (e of viewingReceiptGlEntries; track e.glCode) {
|
||||
<tr>
|
||||
<td class="ta-left font-mono">{{ e.glCode }} — {{ e.accountName }}</td>
|
||||
<td class="ta-center">
|
||||
<span class="drcr-badge" [class.dr]="e.drCr === 'Dr'" [class.cr]="e.drCr === 'Cr'">{{
|
||||
e.drCr
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="ta-right font-mono">{{ e.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width highlight-amount-box">
|
||||
<span class="detail-label">Receipt Amount</span>
|
||||
<span class="amount-val font-mono">{{ viewingReceiptDetail.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">Full Note / Description</span>
|
||||
<div class="note-box font-accent">
|
||||
{{ viewingReceiptDetail.note || 'Share collection deposit to Share Sundry GL' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
[color]="isSelected(viewingReceiptDetail.id) ? 'warn' : 'primary'"
|
||||
class="modal-action-btn"
|
||||
(click)="toggle(viewingReceiptDetail.id); closeReceiptDetail()"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isSelected(viewingReceiptDetail.id) ? 'remove_circle_outline' : 'check_circle_outline'
|
||||
}}</mat-icon>
|
||||
<span>{{ isSelected(viewingReceiptDetail.id) ? 'Unselect Receipt' : 'Select Receipt' }}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" mat-stroked-button (click)="closeReceiptDetail()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+934
-165
File diff suppressed because it is too large
Load Diff
+165
-13
@@ -13,7 +13,7 @@ import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/materia
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { CashierService } from 'app/cashier/cashier.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
@@ -97,6 +97,18 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null;
|
||||
loadingRequirement = true;
|
||||
|
||||
/**
|
||||
* Share-collection receipts that are approved but belong to nobody yet.
|
||||
*
|
||||
* <p>
|
||||
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
|
||||
* so it is never attributed on its own. Listing it here is not attribution: it only makes the
|
||||
* voucher findable, so a counter that tagged a receipt "Share Collection" ten minutes ago is not
|
||||
* told there is nothing to apply. Attaching it is still a click a person makes against a member
|
||||
* they can see.
|
||||
*/
|
||||
unattributed: any[] = [];
|
||||
|
||||
public dialogRef = inject(MatDialogRef<ApplyCollectionDialogComponent>);
|
||||
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
|
||||
|
||||
@@ -124,6 +136,11 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
this.loading = false;
|
||||
});
|
||||
|
||||
this.cashierService
|
||||
.getUnattributedShareCollections()
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => (this.unattributed = res?.rows || []));
|
||||
|
||||
this.cashierService
|
||||
.getShareAdvanceGlAccounts()
|
||||
.pipe(
|
||||
@@ -135,12 +152,17 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.glAccounts = res?.glAccounts || [];
|
||||
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
|
||||
// this list to accounts that actually hold an eligible receipt today, so a single option is
|
||||
// unambiguous. Two or more still means the counter chooses by hand.
|
||||
// Auto-picked only when it is genuinely the only option. The list now carries every
|
||||
// postable GL account again (see the backend's own comment), so picking index 0 whenever
|
||||
// one loads would silently select whichever account sorts first by gl_code -- Cash at
|
||||
// Vault, almost always -- the exact bug this was fixed for once already. Two or more
|
||||
// candidates always means the counter chooses by hand.
|
||||
if (this.glAccounts.length === 1 && !this.glAccountNo) {
|
||||
this.glAccountNo = this.glAccounts[0].gl_code;
|
||||
}
|
||||
if (this.glAccounts.length === 1 && !this.chargeGlAccountNo) {
|
||||
this.chargeGlAccountNo = this.glAccounts[0].gl_code;
|
||||
}
|
||||
this.loadingGlAccounts = false;
|
||||
});
|
||||
|
||||
@@ -159,6 +181,92 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Receipt object currently being viewed in detail popup. */
|
||||
viewingReceiptDetail: any = null;
|
||||
|
||||
/**
|
||||
* The receipt's own real Dr/Cr GL legs, under its own {@code gl_transaction_id} — never the GL
|
||||
* account currently picked in the by-voucher search box above, which is unrelated screen state
|
||||
* that can name a completely different account than the one this receipt actually sits in.
|
||||
*/
|
||||
viewingReceiptGlEntries: any[] | null = null;
|
||||
loadingReceiptGlEntries = false;
|
||||
|
||||
openReceiptDetail(receipt: any, event?: Event): void {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.viewingReceiptDetail = receipt;
|
||||
this.viewingReceiptGlEntries = null;
|
||||
this.loadingReceiptGlEntries = true;
|
||||
this.cashierService
|
||||
.getShareAdvanceGlEntries(receipt.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.viewingReceiptGlEntries = res?.entries || [];
|
||||
this.loadingReceiptGlEntries = false;
|
||||
});
|
||||
}
|
||||
|
||||
closeReceiptDetail(): void {
|
||||
this.viewingReceiptDetail = null;
|
||||
this.viewingReceiptGlEntries = null;
|
||||
}
|
||||
|
||||
toggleAll(): void {
|
||||
if (this.isAllSelected()) {
|
||||
this.selectedIds = [];
|
||||
} else {
|
||||
this.selectedIds = this.receipts.map((r) => r.id);
|
||||
}
|
||||
this.syncLookupFieldsToSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a ticked receipt's own GL account and voucher number into the Share Receipt Lookup
|
||||
* fields above, so a tick is visibly confirmed rather than leaving those fields blank while the
|
||||
* table below shows the real selection. Particularly worth seeing across instalments, where two
|
||||
* receipts making up one settlement can sit in two different GL accounts, exactly like this
|
||||
* member's own 200902 and 400105 receipts do.
|
||||
*
|
||||
* Shows the most recently ticked receipt when several are selected — there is no single GL
|
||||
* account to show once more than one is chosen, and the most recent tick is the one the counter
|
||||
* just made. Clears both fields once nothing is ticked.
|
||||
*/
|
||||
private syncLookupFieldsToSelection(): void {
|
||||
if (!this.selectedIds.length) {
|
||||
this.glAccountNo = '';
|
||||
this.voucherNo = '';
|
||||
return;
|
||||
}
|
||||
const lastId = this.selectedIds[this.selectedIds.length - 1];
|
||||
const receipt = this.receipts.find((r) => r.id === lastId);
|
||||
if (receipt) {
|
||||
this.glAccountNo = receipt.gl_code || this.glAccountNo;
|
||||
this.voucherNo = receipt.voucher_no || receipt.receipt_no || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an unattributed-list row is already linked to the member this dialog is settling.
|
||||
*
|
||||
* Such a row also appears, pre-loaded, in the Member Unapplied Receipts table below — that is not
|
||||
* a duplicate to clear up, it is the same receipt reachable two ways. This only flags it so the
|
||||
* counter understands why it shows up here too, rather than reading it as an unrelated stray
|
||||
* voucher that happens to look identical.
|
||||
*/
|
||||
isOwnClientVoucher(u: any): boolean {
|
||||
return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.data.clientId);
|
||||
}
|
||||
|
||||
isAllSelected(): boolean {
|
||||
return this.receipts.length > 0 && this.selectedIds.length === this.receipts.length;
|
||||
}
|
||||
|
||||
isSomeSelected(): boolean {
|
||||
return this.selectedIds.length > 0 && this.selectedIds.length < this.receipts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the share receipts still need to reach — the share value alone once the entry fee has its
|
||||
* own verified receipt, otherwise the combined share-plus-fee total, matching exactly what the
|
||||
@@ -221,6 +329,7 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
...this.selectedIds,
|
||||
id
|
||||
];
|
||||
this.syncLookupFieldsToSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,12 +352,48 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
* voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing
|
||||
* later inside apply().
|
||||
*/
|
||||
/**
|
||||
* Attaches one of the unattributed share-collection receipts to this member.
|
||||
*
|
||||
* <p>
|
||||
* Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already
|
||||
* on this list is either a fresh cashier deposit (which does carry its own GL account) or an
|
||||
* already-adopted {@code m_share_advance_collection} row (the "linked to …" ones), whose GL account
|
||||
* can legitimately come back blank — its subquery reads the posting the original adoption made,
|
||||
* which a clerk-disbursement-funded receipt does not always leave behind cleanly. Either way the
|
||||
* backend needs no GL account to resolve a voucher it can already find by number, so a blank one
|
||||
* here must never block the click the way it rightly blocks a hand-typed search with nothing chosen
|
||||
* yet. The list never decides whose money it is; the click does.
|
||||
*/
|
||||
attachUnattributed(row: any): void {
|
||||
if (this.lookingUp || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.resolveVoucher(row.voucherNo, row.glAccountNo || '');
|
||||
}
|
||||
|
||||
lookupVoucher(): void {
|
||||
const voucher = this.voucherNo.trim();
|
||||
const glAccountNo = this.glAccountNo.trim();
|
||||
// A hand-typed search genuinely needs both fields: an empty GL account here means nothing has
|
||||
// been chosen yet, not that one isn't needed — unlike {@link attachUnattributed}, which already
|
||||
// knows exactly which receipt it means and never has this ambiguity.
|
||||
if (!voucher || !glAccountNo || this.lookingUp) {
|
||||
return;
|
||||
}
|
||||
this.resolveVoucher(voucher, glAccountNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the
|
||||
* receipt it finds to the table, ticked.
|
||||
*
|
||||
* Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank
|
||||
* by the Find button's own disabled state) and {@link attachUnattributed} (a click on a row already
|
||||
* on screen, whose GL account can legitimately be blank) — the actual lookup and its bookkeeping
|
||||
* afterwards are identical either way.
|
||||
*/
|
||||
private resolveVoucher(voucher: string, glAccountNo: string): void {
|
||||
this.lookingUp = true;
|
||||
this.errorMsg = '';
|
||||
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
|
||||
@@ -257,8 +402,8 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
// Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which
|
||||
// are left for the clerk: typing a voucher number IS the deliberate choice of that receipt,
|
||||
// so making them tick it again would be asking the same question twice.
|
||||
// are left for the clerk: attaching a specific voucher IS the deliberate choice of that
|
||||
// receipt, so making them tick it again would be asking the same question twice.
|
||||
this.receipts = [
|
||||
res,
|
||||
...this.receipts.filter((r) => r.id !== res.id)
|
||||
@@ -269,7 +414,19 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
res.id
|
||||
];
|
||||
}
|
||||
// Once attached it belongs to this member, so it leaves the unattributed list rather than
|
||||
// sitting there inviting a second, conflicting attachment.
|
||||
this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucher);
|
||||
// Cleared, not left showing this voucher: an instalment payment often means searching for a
|
||||
// second receipt right after this one, and the box should be ready for that number, not still
|
||||
// holding the one just added.
|
||||
this.voucherNo = '';
|
||||
// The GL Account picker still follows this receipt, exactly as it does when an already-listed
|
||||
// receipt is ticked — res.gl_code is this receipt's own real GL account (see findByVoucher()'s
|
||||
// subquery on the server), not whatever the search box happened to hold before this search.
|
||||
if (res.gl_code) {
|
||||
this.glAccountNo = res.gl_code;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,14 +468,9 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
* own for the entry fee — since the two are never allowed to mix into the same sum.
|
||||
*/
|
||||
private findVoucher(voucher: string, glAccountNo: string) {
|
||||
// No client-match check here by deliberate choice: any receipt can be attached to any share
|
||||
// account, regardless of which member it was originally recorded against.
|
||||
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
|
||||
map((res: any) => {
|
||||
if (Number(res.client_id) !== Number(this.data.clientId)) {
|
||||
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
|
||||
return null;
|
||||
}
|
||||
return res;
|
||||
}),
|
||||
catchError((err) => {
|
||||
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`);
|
||||
return of(null);
|
||||
|
||||
@@ -149,6 +149,10 @@ export class SharesAccountViewComponent implements OnInit {
|
||||
// capital that was never issued.
|
||||
if (!this.isSettled) {
|
||||
this.buttonConfig.removeOption('Share Certificate');
|
||||
// A further tranche on top of an application the member never paid for makes no sense: the
|
||||
// first allotment is still sitting unissued, and a second one would only grow a holding that
|
||||
// does not exist yet. Settle the original collection first, then add to it.
|
||||
this.buttonConfig.removeButton('Apply Additional Shares');
|
||||
} else {
|
||||
// "Apply Collection" settles the account's ORIGINAL application funding -- a one-time
|
||||
// step. Once collectionApplied is true there is nothing left to settle this way; any
|
||||
@@ -202,7 +206,9 @@ export class SharesAccountViewComponent implements OnInit {
|
||||
*/
|
||||
private applyCollection() {
|
||||
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
|
||||
width: '560px',
|
||||
width: '820px',
|
||||
maxWidth: '92vw',
|
||||
panelClass: 'apply-collection-dialog-panel',
|
||||
data: {
|
||||
shareAccountId: this.sharesAccountData.id,
|
||||
clientId: this.sharesAccountData.clientId,
|
||||
|
||||
@@ -41,6 +41,139 @@ const routes: Routes = [
|
||||
data: { title: 'Share Holders', breadcrumb: 'Share Holders' },
|
||||
component: ShareHoldersComponent
|
||||
},
|
||||
// Share reports. Lazily loaded one component at a time: a clerk opening the Share menu for a
|
||||
// withdrawal should not pay for four report screens they did not ask for, and each is
|
||||
// self-contained enough that there is nothing to share by bundling them together.
|
||||
{
|
||||
path: 'reports/final-balance',
|
||||
data: { title: 'Share Final Balance', breadcrumb: 'Final Balance' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-final-balance/share-final-balance.component').then(
|
||||
(m) => m.ShareFinalBalanceComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/account-statement',
|
||||
data: { title: 'Share Account Statement', breadcrumb: 'Account Statement' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-account-statement/share-account-statement.component').then(
|
||||
(m) => m.ShareAccountStatementComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/share-issued',
|
||||
data: { title: 'Share Issued Register', breadcrumb: 'Share Issued' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-issued-report/share-issued-report.component').then(
|
||||
(m) => m.ShareIssuedReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/share-status',
|
||||
data: { title: 'Share Status Report', breadcrumb: 'Share Status' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-status-report/share-status-report.component').then(
|
||||
(m) => m.ShareStatusReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/nominal-shares',
|
||||
data: { title: 'Nominal Share Report', breadcrumb: 'Nominal Share Report' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-nominal-report/share-nominal-report.component').then(
|
||||
(m) => m.ShareNominalReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/share-wise',
|
||||
data: { title: 'Share Wise Report', breadcrumb: 'Share Wise Report' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-wise-report/share-wise-report.component').then((m) => m.ShareWiseReportComponent)
|
||||
},
|
||||
{
|
||||
path: 'reports/guarantor-detail',
|
||||
data: { title: 'Guarantor Detail', breadcrumb: 'Guarantor Detail' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-guarantor-report/share-guarantor-report.component').then(
|
||||
(m) => m.ShareGuarantorReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/member-interest',
|
||||
data: { title: 'Total Interest Received & Receivable', breadcrumb: 'Total Int Rec' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-member-interest-report/share-member-interest-report.component').then(
|
||||
(m) => m.ShareMemberInterestReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/enquiry',
|
||||
data: { title: 'Share Enquiry', breadcrumb: 'Share Enquiry' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-enquiry/share-enquiry.component').then((m) => m.ShareEnquiryComponent)
|
||||
},
|
||||
{
|
||||
path: 'reports/payment-certificate',
|
||||
data: { title: 'Share Payment Certificate', breadcrumb: 'Payment Certificate' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-payment-certificate/share-payment-certificate.component').then(
|
||||
(m) => m.SharePaymentCertificateComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/share-detail',
|
||||
data: { title: 'Share Detail', breadcrumb: 'Share Detail' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-detail-report/share-detail-report.component').then(
|
||||
(m) => m.ShareDetailReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/dividend-statement',
|
||||
data: { title: 'Dividend Statement', breadcrumb: 'Dividend Statement' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-dividend-statement/share-dividend-statement.component').then(
|
||||
(m) => m.ShareDividendStatementComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/folio-statement',
|
||||
data: { title: 'Folio Statement', breadcrumb: 'Folio Statement' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-folio-statement/share-folio-statement.component').then(
|
||||
(m) => m.ShareFolioStatementComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'transfers',
|
||||
data: { title: 'Share Transfer', breadcrumb: 'Share Transfer' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-transfer/share-transfer.component').then((m) => m.ShareTransferComponent)
|
||||
},
|
||||
{
|
||||
path: 'reports/distinctive-numbers',
|
||||
data: { title: 'Share Distinctive Number Detail', breadcrumb: 'Distinctive Numbers' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-distinctive-numbers/share-distinctive-numbers.component').then(
|
||||
(m) => m.ShareDistinctiveNumbersComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'reports/area-wise',
|
||||
data: { title: 'Area Wise Shareholding', breadcrumb: 'Area Wise' },
|
||||
loadComponent: () =>
|
||||
import('./reports/share-area-wise/share-area-wise.component').then((m) => m.ShareAreaWiseComponent)
|
||||
},
|
||||
{
|
||||
path: 'reports/labels',
|
||||
data: { title: 'Share Labels', breadcrumb: 'Share Label' },
|
||||
loadComponent: () => import('./reports/share-label/share-label.component').then((m) => m.ShareLabelComponent)
|
||||
},
|
||||
{
|
||||
path: 'reports/letters',
|
||||
data: { title: 'Share Letter', breadcrumb: 'Share Letter' },
|
||||
loadComponent: () => import('./reports/share-letter/share-letter.component').then((m) => m.ShareLetterComponent)
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' },
|
||||
|
||||
@@ -190,4 +190,176 @@ export class SharesService {
|
||||
rejectShareAccountEdit(accountId: string, rejectReason?: string): Observable<any> {
|
||||
return this.http.post(`/shareaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||
}
|
||||
|
||||
// ── Share reports ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Each of these is a single server-side query returning finished rows plus its own totals, rather
|
||||
// than the fetch-all-accounts-then-fan-out pattern the older letter-mgmt reports use. That pattern
|
||||
// stops at the 200-account page it asks for and recomputes totals from whatever arrived, which a
|
||||
// report a balance sheet has to reconcile to cannot do.
|
||||
|
||||
/**
|
||||
* Share capital held per member as on a date, derived from settled purchases less redemptions.
|
||||
*
|
||||
* @param params asOnDate (yyyy-MM-dd), officeId, productId, includeZero — all optional.
|
||||
*/
|
||||
getShareFinalBalance(params: {
|
||||
asOnDate?: string;
|
||||
officeId?: number | string;
|
||||
productId?: number | string;
|
||||
includeZero?: boolean;
|
||||
}): Observable<any> {
|
||||
return this.http.get('/sharereports/final-balance', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** One share account's movements in date order, with the running holding after each. */
|
||||
getShareAccountStatement(
|
||||
shareAccountId: number | string,
|
||||
params: { fromDate?: string; toDate?: string } = {}
|
||||
): Observable<any> {
|
||||
return this.http.get(`/sharereports/account-statement/${shareAccountId}`, { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** Allotment register — one row per tranche of shares issued in the period. */
|
||||
getShareIssuedReport(params: {
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
officeId?: number | string;
|
||||
productId?: number | string;
|
||||
status?: number | string;
|
||||
}): Observable<any> {
|
||||
return this.http.get('/sharereports/share-issued', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** Share accounts grouped by status and product, with the accounts behind each group. */
|
||||
getShareStatusSummary(params: { officeId?: number | string; productId?: number | string } = {}): Observable<any> {
|
||||
return this.http.get('/sharereports/status-summary', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** Holdings measured against the share class's own nominal, minimum and maximum, at face value. */
|
||||
getShareNominalReport(
|
||||
params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {}
|
||||
): Observable<any> {
|
||||
return this.http.get('/sharereports/nominal-shares', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** Shareholding pattern — members and capital per holding band, plus the per-product split. */
|
||||
getShareWiseReport(
|
||||
params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {}
|
||||
): Observable<any> {
|
||||
return this.http.get('/sharereports/share-wise', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** Active loan guarantors with the shareholding each one holds in the society. */
|
||||
getShareGuarantorDetail(params: { officeId?: number | string; onlyMembers?: boolean } = {}): Observable<any> {
|
||||
return this.http.get('/sharereports/guarantor-detail', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** Interest received and receivable from the members who hold share capital. */
|
||||
getShareMemberLoanInterest(
|
||||
params: { officeId?: number | string; includeClosedLoans?: boolean } = {}
|
||||
): Observable<any> {
|
||||
return this.http.get('/sharereports/member-loan-interest', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/**
|
||||
* One member's whole share position — accounts, movements, dividends and linked savings.
|
||||
*
|
||||
* Sends `search` when the member is being looked for and `clientId` once one is chosen: a search
|
||||
* matching several members comes back as a list to pick from rather than guessing between them.
|
||||
*/
|
||||
getShareEnquiry(params: { search?: string; clientId?: number | string }): Observable<any> {
|
||||
return this.http.get('/sharereports/enquiry', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** What a member actually paid for their shares, and against which receipt — for the certificate. */
|
||||
getSharePaymentCertificate(shareAccountId: number | string): Observable<any> {
|
||||
return this.http.get(`/sharereports/payment-certificate/${shareAccountId}`);
|
||||
}
|
||||
|
||||
/** The full share register: every account with its class, terms, lock-in, charges and resolution. */
|
||||
getShareDetailRegister(
|
||||
params: { officeId?: number | string; productId?: number | string; status?: number | string } = {}
|
||||
): Observable<any> {
|
||||
return this.http.get('/sharereports/share-detail', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** One member's dividends across every year, declared against paid. */
|
||||
getShareDividendStatement(params: { search?: string; clientId?: number | string }): Observable<any> {
|
||||
return this.http.get('/sharereports/dividend-statement', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** One member's folio: every share account they hold, with the movements running across all of them. */
|
||||
getShareFolioStatement(params: {
|
||||
search?: string;
|
||||
clientId?: number | string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
}): Observable<any> {
|
||||
return this.http.get('/sharereports/folio-statement', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** The numbered share range each allotment covers, derived from issue order per share class. */
|
||||
getShareDistinctiveNumbers(
|
||||
params: { productId?: number | string; clientId?: number | string } = {}
|
||||
): Observable<any> {
|
||||
return this.http.get('/sharereports/distinctive-numbers', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/** Members and share capital grouped by the area their address puts them in. */
|
||||
getShareAreaWise(params: { productId?: number | string } = {}): Observable<any> {
|
||||
return this.http.get('/sharereports/area-wise', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Name, address, folio and holding for every shareholding member.
|
||||
*
|
||||
* One call behind both Share Label and Share Letter — they want the same rows and differ only in
|
||||
* what is printed around them.
|
||||
*/
|
||||
getShareMailingList(
|
||||
params: { productId?: number | string; officeId?: number | string; area?: string } = {}
|
||||
): Observable<any> {
|
||||
return this.http.get('/sharereports/mailing-list', { params: this.cleanParams(params) });
|
||||
}
|
||||
|
||||
// ── Share transfers (maker-checker) ────────────────────────────────────
|
||||
|
||||
/** Records a transfer between two members. Nothing moves until a checker approves it. */
|
||||
submitShareTransfer(data: any): Observable<any> {
|
||||
return this.http.post('/sharetransfers', data);
|
||||
}
|
||||
|
||||
/** The transfer register — every transfer with both members and the resolution behind it. */
|
||||
getShareTransfers(status?: string): Observable<any> {
|
||||
return this.http.get('/sharetransfers', { params: this.cleanParams({ status }) });
|
||||
}
|
||||
|
||||
/** Approves a transfer: re-checks every rule, then moves the shares. */
|
||||
approveShareTransfer(transferId: number): Observable<any> {
|
||||
return this.http.post(`/sharetransfers/${transferId}/approve`, {});
|
||||
}
|
||||
|
||||
rejectShareTransfer(transferId: number, rejectionReason?: string): Observable<any> {
|
||||
return this.http.post(`/sharetransfers/${transferId}/reject`, { rejectionReason: rejectionReason || '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops empty filters rather than sending them blank — the report endpoints treat an absent
|
||||
* parameter as "no filter", but a present-and-empty one would be read as a real value.
|
||||
*/
|
||||
private cleanParams(params: Record<string, any>): HttpParams {
|
||||
let httpParams = new HttpParams();
|
||||
Object.entries(params).forEach(
|
||||
([
|
||||
key,
|
||||
value
|
||||
]) => {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
httpParams = httpParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
);
|
||||
return httpParams;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260916',
|
||||
'hash': 'c01615ee'
|
||||
'version': '260918',
|
||||
'hash': 'e53dfb5a'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
@@ -1960,3 +1960,14 @@ mifosx-client-datatable-step * {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply Collection Dialog Panel Styling */
|
||||
.apply-collection-dialog-panel {
|
||||
.mat-mdc-dialog-container,
|
||||
.mdc-dialog__surface {
|
||||
border-radius: 16px !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
box-shadow: 0 25px 60px -15px rgb(15 23 42 / 30%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user