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