done
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-30 11:09:14 +05:30
parent 07a4c8b8e3
commit 9f95d01196
57 changed files with 5705 additions and 681 deletions
@@ -19,6 +19,9 @@ import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component'
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
import { NocCertificateComponent } from './certificates/noc/noc.component';
import { DatabaseExportComponent } from './database-export/database-export.component';
import { ShareMemberComponent } from './share-member/share-member.component';
import { MembershipRegisterComponent } from './membership-register/membership-register.component';
import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component';
const routes: Routes = [
Route.withShell([
@@ -37,6 +40,21 @@ const routes: Routes = [
component: ShareCertificateComponent,
data: { title: 'Share Certificate', breadcrumb: 'Share Certificate' }
},
{
path: 'letter-mgmt/share-member',
component: ShareMemberComponent,
data: { title: 'Share Members', breadcrumb: 'Share Members' }
},
{
path: 'letter-mgmt/membership-register',
component: MembershipRegisterComponent,
data: { title: 'Membership Register', breadcrumb: 'Membership Register' }
},
{
path: 'letter-mgmt/share-allotment-report',
component: ShareAllotmentReportComponent,
data: { title: 'Share Allotment Report', breadcrumb: 'Share Allotment Report' }
},
{
path: 'letter-mgmt/balance-certificate',
component: BalanceCertificateComponent,
+7 -1
View File
@@ -24,6 +24,9 @@ import { HypothecationCertificateComponent } from './certificates/hypothecation/
import { NocCertificateComponent } from './certificates/noc/noc.component';
import { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component';
import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.component';
import { ShareMemberComponent } from './share-member/share-member.component';
import { MembershipRegisterComponent } from './membership-register/membership-register.component';
import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component';
@NgModule({
imports: [
@@ -42,7 +45,10 @@ import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.compon
HypothecationCertificateComponent,
NocCertificateComponent,
CertificatePreviewDialogComponent,
DscSignDialogComponent
DscSignDialogComponent,
ShareMemberComponent,
MembershipRegisterComponent,
ShareAllotmentReportComponent
]
})
export class LetterMgmtModule {}
@@ -0,0 +1,198 @@
<!--
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="book" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Membership Register</h2>
<div class="cert-head-s">
Statutory register of members — one line per member, in order of admission.
</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 -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="id-card" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRows.length | number }}</div>
<div class="cert-kpi-l">Members on Register</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">{{ activeMembers() | number }}</div>
<div class="cert-kpi-l">Holding Shares</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">{{ totalShares() | number }}</div>
<div class="cert-kpi-l">Total Shares Held</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">₹{{ totalValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Member / Membership No / Address</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or 000000012" />
</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>Holding</label>
<select [(ngModel)]="statusFilter">
<option value="">All members</option>
<option value="active">Holding shares</option>
<option value="pending">No shares yet</option>
</select>
</div>
<div class="cert-filter">
<label>Admitted from</label>
<input type="date" [(ngModel)]="fromDate" />
</div>
<div class="cert-filter">
<label>Admitted to</label>
<input type="date" [(ngModel)]="toDate" />
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
</div>
<!-- Register -->
<div class="cert-card">
@if (loading) {
<div class="cert-empty">Loading membership register…</div>
} @else if (loadError) {
<div class="cert-empty">
Could not load the membership register.
<button class="cert-cta-btn mr-retry" (click)="loadRegister()">Retry</button>
</div>
} @else if (!filteredRows.length) {
<div class="cert-empty">
@if (rows.length) {
No member matches these filters.
} @else {
No members on the register yet.
}
</div>
} @else {
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>S.No</th>
<th>Member's Name</th>
<th>Membership No</th>
<th>Address</th>
<th>Date of Admission</th>
<th class="num">No. of Shares Held</th>
<th class="num">Share Value</th>
<th>Branch</th>
<th>Mobile</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.clientId) {
<tr
class="mr-row"
tabindex="0"
role="link"
matTooltip="Open {{ row.memberName }}'s share account"
(click)="openMember(row)"
(keydown.enter)="openMember(row)"
(keydown.space)="$event.preventDefault(); openMember(row)"
>
<td class="mr-serial">{{ row.serial }}</td>
<td class="cert-cust-name">
{{ row.memberName }}
@if (row.accountCount > 1) {
<span class="cert-pill mr-pill--multi" matTooltip="Holds {{ row.accountCount }} share accounts">
{{ row.accountCount }} a/c
</span>
}
</td>
<td class="cert-folio">{{ row.membershipNo }}</td>
<td class="mr-address" [matTooltip]="row.address">{{ row.address }}</td>
<td>{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }}</td>
<td class="num cert-shares">
{{ row.sharesHeld | number }}
@if (row.pendingShares) {
<span class="mr-pending" matTooltip="{{ row.pendingShares }} shares pending approval">
+{{ row.pendingShares | number }}
</span>
}
</td>
<td class="num">₹{{ row.shareValue | number: '1.0-0' }}</td>
<td>{{ row.branch }}</td>
<td>{{ row.mobileNo }}</td>
<td class="cert-row-actions">
<fa-icon icon="chevron-right" class="mr-chevron"></fa-icon>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="mr-total">
<td colspan="5">Total — {{ filteredRows.length | number }} members</td>
<td class="num">{{ totalShares() | number }}</td>
<td class="num">₹{{ totalValue() | number: '1.0-0' }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="cert-note">
One line per member, not per share account — a member holding several share accounts appears once with their
holdings summed. Serial numbers follow date of admission. A "+n" beside the holding is shares applied for but not
yet approved.
</div>
</div>
@@ -0,0 +1,116 @@
/**
* 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/.
*/
.mr-row {
cursor: pointer;
transition: background 0.12s ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
}
&:focus-visible {
outline: 2px solid var(--cert-blue);
outline-offset: -2px;
}
&:hover .mr-chevron,
&:focus-visible .mr-chevron {
opacity: 1;
}
}
.mr-chevron {
opacity: 0;
color: var(--cert-blue);
transition: opacity 0.12s ease;
}
/* The serial is the register's own numbering, so it reads as a label rather than a figure. */
.mr-serial {
font-weight: 700;
color: var(--cert-g500);
font-variant-numeric: tabular-nums;
}
.mr-address {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Shares still awaiting approval sit beside the holding rather than inside it — they are
not yet share capital, and adding them to the figure would overstate the register. */
.mr-pending {
margin-left: 6px;
font-size: 11px;
font-weight: 700;
color: var(--cert-amber);
}
.mr-pill--multi {
margin-left: 8px;
background: var(--cert-blue-l);
color: var(--cert-blue-d);
font-size: 10px;
padding: 2px 7px;
}
.cert-table {
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot .mr-total > td {
background: var(--cert-g50);
font-weight: 800;
color: var(--cert-g900);
border-top: 2px solid var(--cert-g200);
padding: 12px;
white-space: nowrap;
}
}
.mr-retry {
margin-left: 10px;
}
/* A register is a document that gets signed and filed, so printing drops the chrome. */
@media print {
.cert-filters,
.cert-head-r,
.cert-kpis,
.mr-chevron {
display: none !important;
}
.cert-card {
border: none;
}
.mr-address {
max-width: none;
white-space: normal;
}
.cert-table {
font-size: 10px;
}
/* Repeat the column headings on every printed page of a long register. */
.cert-table thead {
display: table-header-group;
}
.mr-row {
break-inside: avoid;
}
}
@@ -0,0 +1,349 @@
/**
* 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 { MatTooltip } from '@angular/material/tooltip';
import { forkJoin, of, Observable } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares/shares.service';
import { ClientsService } from '../../clients/clients.service';
/**
* One line of the statutory Membership Register.
*
* The register is a record of *members*, not of share accounts — a member holding three
* share accounts is one member on one line, with their holdings summed. That is the whole
* reason this cannot just be the share-account list with different column headings.
*/
export interface MembershipRegisterRow {
serial: number;
clientId: string;
memberName: string;
membershipNo: string;
address: string;
admissionDate: string;
sharesHeld: number;
/** Shares applied for but not yet approved — still funded out of Sundry. */
pendingShares: number;
shareValue: number;
branch: string;
mobileNo: string;
/** Share accounts rolled into this line — a member may hold several. */
accountCount: number;
/** The account opened to navigate to; the member's first/primary share account. */
primaryAccountId: number;
/** True once the member holds at least one approved share. */
isActive: boolean;
}
@Component({
selector: 'mifosx-membership-register',
templateUrl: './membership-register.component.html',
styleUrls: [
'../certificates/certificates.shared.scss',
'./membership-register.component.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class MembershipRegisterComponent implements OnInit {
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
private router = inject(Router);
loading = true;
loadError = false;
rows: MembershipRegisterRow[] = [];
// ── Filters ──
search = '';
branchFilter = '';
statusFilter = '';
fromDate = '';
toDate = '';
private clientDataCache = new Map<string, Observable<any>>();
private clientAddressCache = new Map<string, Observable<any>>();
ngOnInit(): void {
this.loadRegister();
}
loadRegister(): void {
this.loading = true;
this.loadError = false;
this.clientDataCache.clear();
this.clientAddressCache.clear();
this.sharesService
.getAllShareAccounts(0, 200)
.pipe(
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
switchMap((items: any[]) => {
if (!items.length) {
return of([] as any[]);
}
return forkJoin(items.map((item) => this.loadAccount(item))).pipe(
map((accounts) => accounts.filter((a) => !!a))
);
}),
catchError(() => {
this.loadError = true;
return of([] as any[]);
})
)
.subscribe((accounts) => {
this.rows = this.collapseToMembers(accounts);
this.loading = false;
});
}
private loadAccount(listItem: any): Observable<any | null> {
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
return forkJoin({
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
client: clientId ? this.getClientDataCached(clientId) : of(null),
address: clientId ? this.getClientAddressCached(clientId) : of([])
}).pipe(catchError(() => of(null)));
}
private getClientDataCached(clientId: string): Observable<any> {
if (!this.clientDataCache.has(clientId)) {
this.clientDataCache.set(
clientId,
this.clientsService.getClientData(clientId, true).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientDataCache.get(clientId)!;
}
private getClientAddressCached(clientId: string): Observable<any> {
if (!this.clientAddressCache.has(clientId)) {
this.clientAddressCache.set(
clientId,
this.clientsService.getClientAddressData(clientId, true).pipe(
catchError(() => of([])),
shareReplay(1)
)
);
}
return this.clientAddressCache.get(clientId)!;
}
/**
* Collapses share accounts into one line per member, then numbers the lines by date of
* admission — a register's serial number is the order members joined, so it has to be
* assigned after sorting, not carried over from the account list.
*/
private collapseToMembers(accounts: any[]): MembershipRegisterRow[] {
const byMember = new Map<string, MembershipRegisterRow>();
for (const entry of accounts) {
const { sa, client, address } = entry;
if (!sa) {
continue;
}
const clientId = String(sa.clientId ?? client?.id ?? '');
if (!clientId) {
continue;
}
const approved = sa.summary?.totalApprovedShares || 0;
const pending = sa.summary?.totalPendingForApprovalShares || 0;
const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0;
const existing = byMember.get(clientId);
if (existing) {
existing.sharesHeld += approved;
existing.pendingShares += pending;
existing.shareValue += approved * unitPrice;
existing.accountCount += 1;
existing.isActive = existing.isActive || approved > 0;
// Keep the earliest account as the navigation target, so the link is stable
// regardless of the order the API returned the accounts in.
existing.primaryAccountId = Math.min(existing.primaryAccountId, sa.id);
continue;
}
byMember.set(clientId, {
serial: 0,
clientId,
memberName: sa.clientName || client?.displayName || '—',
membershipNo: client?.accountNo || sa.clientAccountNo || '—',
address: this.firstAddress(address),
admissionDate:
this.toIsoDate(client?.activationDate) ||
this.toIsoDate(client?.timeline?.activatedOnDate) ||
this.toIsoDate(sa.timeline?.activatedDate) ||
'',
sharesHeld: approved,
pendingShares: pending,
shareValue: approved * unitPrice,
branch: client?.officeName || sa.officeName || '—',
mobileNo: client?.mobileNo || '—',
accountCount: 1,
primaryAccountId: sa.id,
isActive: approved > 0
});
}
// Oldest admission first: member no. 1 is the society's first member.
const ordered = Array.from(byMember.values()).sort((a, b) =>
(a.admissionDate || '').localeCompare(b.admissionDate || '')
);
ordered.forEach((row, i) => (row.serial = i + 1));
return ordered;
}
private toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
y,
m,
d
] = value;
if (!y) {
return '';
}
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
}
return String(value);
}
private firstAddress(address: any): string {
const list = Array.isArray(address) ? address : address?.pageItems || [];
if (!list.length) {
return '—';
}
const a = list[0];
const parts = [
a.addressLine1,
a.addressLine2,
a.townVillage,
a.city,
a.stateName,
a.countryName,
a.postalCode
]
.filter((p: any) => !!p && String(p).trim())
.join(', ');
return parts || '—';
}
// ── Derived list ──
get filteredRows(): MembershipRegisterRow[] {
const q = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.branch !== this.branchFilter) return false;
if (this.statusFilter === 'active' && !r.isActive) return false;
if (this.statusFilter === 'pending' && r.isActive) return false;
// Admission-date window — the usual way a register is pulled for an AGM or an audit.
if (this.fromDate && (!r.admissionDate || r.admissionDate < this.fromDate)) return false;
if (this.toDate && (!r.admissionDate || r.admissionDate > this.toDate)) return false;
if (!q) return true;
return (
r.memberName.toLowerCase().includes(q) ||
r.membershipNo.toLowerCase().includes(q) ||
r.address.toLowerCase().includes(q) ||
r.mobileNo.toLowerCase().includes(q)
);
});
}
get branchOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
}
totalShares(): number {
return this.filteredRows.reduce((s, r) => s + r.sharesHeld, 0);
}
totalValue(): number {
return this.filteredRows.reduce((s, r) => s + r.shareValue, 0);
}
activeMembers(): number {
return this.filteredRows.filter((r) => r.isActive).length;
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.statusFilter = '';
this.fromDate = '';
this.toDate = '';
}
openMember(row: MembershipRegisterRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.primaryAccountId,
'general'
]);
}
exportCsv(): void {
const header = [
'S.No',
"Member's Name",
'Membership No',
'Address',
'Date of Admission',
'No. of Shares Held',
'Share Value',
'Branch',
'Mobile'
];
const rows = this.filteredRows.map((r) => [
r.serial,
r.memberName,
r.membershipNo,
r.address,
r.admissionDate,
r.sharesHeld,
r.shareValue,
r.branch,
r.mobileNo
]);
const csv = [
header,
...rows
]
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).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 = 'membership-register.csv';
link.click();
URL.revokeObjectURL(url);
}
print(): void {
window.print();
}
}
@@ -0,0 +1,221 @@
<!--
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">Share Allotment Report</h2>
<div class="cert-head-s">
Every tranche of shares issued — allotment date, number, value and payment status.
</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 -->
<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">{{ filteredRows.length | 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">{{ allottedShares() | number }}</div>
<div class="cert-kpi-l">Shares Allotted</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">₹{{ allottedValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Value Allotted</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">{{ pendingShares() | number }}</div>
<div class="cert-kpi-l">Pending Approval</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Member / Membership No / 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>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All</option>
<option value="ALLOTTED">Allotted</option>
<option value="PENDING">Pending approval</option>
<option value="REJECTED">Rejected</option>
</select>
</div>
<div class="cert-filter">
<label>Allotted from</label>
<input type="date" [(ngModel)]="fromDate" />
</div>
<div class="cert-filter">
<label>Allotted to</label>
<input type="date" [(ngModel)]="toDate" />
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
</div>
<!-- Report -->
<div class="cert-card">
@if (loading) {
<div class="cert-empty">Loading allotments…</div>
} @else if (loadError) {
<div class="cert-empty">
Could not load the allotment report.
<button class="cert-cta-btn sar-retry" (click)="loadAllotments()">Retry</button>
</div>
} @else if (!filteredRows.length) {
<div class="cert-empty">
@if (rows.length) {
No allotment matches these filters.
} @else {
No shares have been allotted yet.
}
</div>
} @else {
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>S.No</th>
<th>Member Name</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Product</th>
<th>Branch</th>
<th>Allotment Date</th>
<th class="num">No. of Shares</th>
<th class="num">Unit Price</th>
<th class="num">Value</th>
<th>Share Nos.</th>
<th>Resolution</th>
<th>Status</th>
<th>Payment</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.serial) {
<tr
class="sar-row"
tabindex="0"
role="link"
matTooltip="Open share account {{ row.shareAccountNo }}"
(click)="openAccount(row)"
(keydown.enter)="openAccount(row)"
(keydown.space)="$event.preventDefault(); openAccount(row)"
>
<td class="sar-serial">{{ row.serial }}</td>
<td class="cert-cust-name">{{ row.memberName }}</td>
<td class="cert-folio">{{ row.membershipNo }}</td>
<td class="cert-folio">{{ row.shareAccountNo }}</td>
<td>{{ row.productName }}</td>
<td>{{ row.branch }}</td>
<td>{{ row.allotmentDate ? (row.allotmentDate | date: 'd MMM y') : '—' }}</td>
<td class="num cert-shares">{{ row.numberOfShares | number }}</td>
<td class="num">₹{{ row.unitPrice | number: '1.0-2' }}</td>
<td class="num">₹{{ row.value | number: '1.0-0' }}</td>
<td class="cert-folio">
@if (row.shareNoFrom) {
{{ row.shareNoFrom }}–{{ row.shareNoTo }}
} @else {
—
}
</td>
<td>
{{ row.resolutionNumber }}
@if (row.meetingDate) {
<span class="sar-meeting">({{ row.meetingDate | date: 'd MMM y' }})</span>
}
</td>
<td>
<span class="cert-pill" [class]="'sar-pill--' + row.status.toLowerCase()">{{ row.statusLabel }}</span>
</td>
<td>
<span class="cert-pill" [class]="'sar-pay--' + row.status.toLowerCase()">{{ row.paymentStatus }}</span>
</td>
<td class="cert-row-actions">
<fa-icon icon="chevron-right" class="sar-chevron"></fa-icon>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="sar-total">
<td colspan="7">Total allotted — {{ filteredRows.length | number }} tranches</td>
<td class="num">{{ allottedShares() | number }}</td>
<td></td>
<td class="num">₹{{ allottedValue() | number: '1.0-0' }}</td>
<td colspan="5"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="cert-note">
One line per tranche, so a member who bought twice appears twice with each allotment date. Totals count allotted
shares only. "In Sundry" means the money has been received but the tranche is not yet approved, so it is not yet
share capital.
</div>
</div>
@@ -0,0 +1,125 @@
/**
* 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/.
*/
.sar-row {
cursor: pointer;
transition: background 0.12s ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
}
&:focus-visible {
outline: 2px solid var(--cert-blue);
outline-offset: -2px;
}
&:hover .sar-chevron,
&:focus-visible .sar-chevron {
opacity: 1;
}
}
.sar-chevron {
opacity: 0;
color: var(--cert-blue);
transition: opacity 0.12s ease;
}
.sar-serial {
font-weight: 700;
color: var(--cert-g500);
font-variant-numeric: tabular-nums;
}
/* The meeting date qualifies the resolution rather than standing on its own. */
.sar-meeting {
margin-left: 4px;
font-size: 11px;
color: var(--cert-g500);
white-space: nowrap;
}
.cert-table {
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot .sar-total > td {
background: var(--cert-g50);
font-weight: 800;
color: var(--cert-g900);
border-top: 2px solid var(--cert-g200);
padding: 12px;
white-space: nowrap;
}
}
/* Allotment status */
.sar-pill--allotted {
background: var(--cert-green-l);
color: var(--cert-green);
}
.sar-pill--pending {
background: var(--cert-amber-l);
color: var(--cert-amber);
}
.sar-pill--rejected {
background: color-mix(in srgb, #dc2626 12%, transparent);
color: #b91c1c;
}
/* Payment status is deliberately toned down against the allotment status beside it —
the two track the same event, and matching intensities read as two separate facts. */
.sar-pay--allotted {
background: color-mix(in srgb, var(--cert-green) 10%, transparent);
color: var(--cert-green);
}
.sar-pay--pending {
background: var(--cert-blue-l);
color: var(--cert-blue-d);
}
.sar-pay--rejected {
background: var(--cert-g100);
color: var(--cert-g700);
}
.sar-retry {
margin-left: 10px;
}
@media print {
.cert-filters,
.cert-head-r,
.cert-kpis,
.sar-chevron {
display: none !important;
}
.cert-card {
border: none;
}
.cert-table {
font-size: 10px;
}
.cert-table thead {
display: table-header-group;
}
.sar-row {
break-inside: avoid;
}
}
@@ -0,0 +1,376 @@
/**
* 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 { MatTooltip } from '@angular/material/tooltip';
import { forkJoin, of, Observable } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares/shares.service';
import { ClientsService } from '../../clients/clients.service';
/** Where a tranche stands: paid and allotted, awaiting approval, or refused. */
export type AllotmentStatus = 'ALLOTTED' | 'PENDING' | 'REJECTED';
/**
* One allotment — a single tranche of shares issued to a member.
*
* This is deliberately per-*tranche*, not per-account: the report exists to track the issue
* of new shares, so a member who bought 100 shares in April and 50 more in August is two
* lines with two allotment dates, which is exactly what the account-level view cannot show.
*/
export interface AllotmentRow {
serial: number;
clientId: string;
accountId: number;
memberName: string;
membershipNo: string;
shareAccountNo: string;
productName: string;
branch: string;
/** Date this tranche was purchased/allotted. */
allotmentDate: string;
numberOfShares: number;
unitPrice: number;
/** numberOfShares x unitPrice — the value of this tranche. */
value: number;
/** Board resolution authorising the issue, falling back to the account's own. */
resolutionNumber: string;
meetingDate: string;
/** Running share-number range this tranche covers, per account. */
shareNoFrom: number;
shareNoTo: number;
status: AllotmentStatus;
statusLabel: string;
/** Whether the money for this tranche has cleared out of Sundry. */
paymentStatus: string;
}
@Component({
selector: 'mifosx-share-allotment-report',
templateUrl: './share-allotment-report.component.html',
styleUrls: [
'../certificates/certificates.shared.scss',
'./share-allotment-report.component.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class ShareAllotmentReportComponent implements OnInit {
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
private router = inject(Router);
loading = true;
loadError = false;
rows: AllotmentRow[] = [];
// ── Filters ──
search = '';
branchFilter = '';
productFilter = '';
statusFilter = '';
fromDate = '';
toDate = '';
private clientDataCache = new Map<string, Observable<any>>();
ngOnInit(): void {
this.loadAllotments();
}
loadAllotments(): void {
this.loading = true;
this.loadError = false;
this.clientDataCache.clear();
this.sharesService
.getAllShareAccounts(0, 200)
.pipe(
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
switchMap((items: any[]) => {
if (!items.length) {
return of([] as AllotmentRow[]);
}
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(
map((perAccount) => perAccount.flat())
);
}),
catchError(() => {
this.loadError = true;
return of([] as AllotmentRow[]);
})
)
.subscribe((rows) => {
// Most recent issue first — this report is read to see what was just allotted.
const ordered = rows.sort((a, b) => (b.allotmentDate || '').localeCompare(a.allotmentDate || ''));
ordered.forEach((row, i) => (row.serial = i + 1));
this.rows = ordered;
this.loading = false;
});
}
private buildAllotments(listItem: any): Observable<AllotmentRow[]> {
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
return forkJoin({
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
client: clientId ? this.getClientDataCached(clientId) : of(null)
}).pipe(
map(({ sa, client }: any) => this.mapAccount(sa, client)),
catchError(() => of([] as AllotmentRow[]))
);
}
private getClientDataCached(clientId: string): Observable<any> {
if (!this.clientDataCache.has(clientId)) {
this.clientDataCache.set(
clientId,
this.clientsService.getClientData(clientId, true).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientDataCache.get(clientId)!;
}
/**
* Expands one account's `purchasedShares` into allotment lines.
*
* Charge payments are dropped — a fee is not a share movement. Redemptions are dropped too:
* this report tracks the *issue* of shares, and netting a buy-back into it would understate
* what was actually allotted in the period.
*/
private mapAccount(sa: any, client: any): AllotmentRow[] {
const clientId = String(sa.clientId ?? client?.id ?? '');
const memberName = sa.clientName || client?.displayName || '—';
const membershipNo = client?.accountNo || sa.clientAccountNo || '—';
const branch = client?.officeName || sa.officeName || '—';
const purchases = (sa.purchasedShares || [])
.filter((txn: any) => {
const type = txn?.type?.value;
return type !== 'Charge Payment' && type !== 'Redeem';
})
.slice()
.sort(
(a: any, b: any) =>
new Date(this.toIsoDate(a.purchasedDate)).getTime() - new Date(this.toIsoDate(b.purchasedDate)).getTime()
);
// Share numbers run consecutively within an account, so the range is assigned by walking
// the tranches in date order. Only approved tranches consume numbers — a pending or
// rejected request has not been allotted any.
let nextShareNumber = sa.id * 5000 + 201;
return purchases.map((txn: any) => {
const shares = Number(txn.numberOfShares) || 0;
const unitPrice = Number(txn.purchasedPrice) || 0;
const status = this.statusOf(txn);
let from = 0;
let to = 0;
if (status === 'ALLOTTED' && shares > 0) {
from = nextShareNumber;
to = from + shares - 1;
nextShareNumber += shares;
}
return {
serial: 0,
clientId,
accountId: sa.id,
memberName,
membershipNo,
shareAccountNo: sa.accountNo || String(sa.id),
productName: sa.productName || '—',
branch,
allotmentDate: this.toIsoDate(txn.purchasedDate),
numberOfShares: shares,
unitPrice,
value: shares * unitPrice,
resolutionNumber: txn.resolutionNumber || sa.resolutionNumber || '—',
meetingDate: this.toIsoDate(txn.meetingDate) || this.toIsoDate(sa.resolutionDate),
shareNoFrom: from,
shareNoTo: to,
status,
statusLabel: txn?.status?.value || status,
paymentStatus: this.paymentStatusOf(status)
} as AllotmentRow;
});
}
private statusOf(txn: any): AllotmentStatus {
const code = txn?.status?.code || '';
if (code === 'purchasedSharesStatusType.rejected') {
return 'REJECTED';
}
if (code === 'purchasedSharesStatusType.applied') {
return 'PENDING';
}
return 'ALLOTTED';
}
/**
* Payment status follows approval, because that is what actually moves the money: until a
* tranche is approved its funds sit in Share Sundry, and approval is the event that posts
* Dr Sundry / Cr Share Capital.
*/
private paymentStatusOf(status: AllotmentStatus): string {
switch (status) {
case 'ALLOTTED':
return 'Paid';
case 'PENDING':
return 'In Sundry';
default:
return 'Refunded';
}
}
private toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
y,
m,
d
] = value;
if (!y) {
return '';
}
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
}
return String(value);
}
// ── Derived list ──
get filteredRows(): AllotmentRow[] {
const q = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.branch !== this.branchFilter) return false;
if (this.productFilter && r.productName !== this.productFilter) return false;
if (this.statusFilter && r.status !== this.statusFilter) return false;
if (this.fromDate && (!r.allotmentDate || r.allotmentDate < this.fromDate)) return false;
if (this.toDate && (!r.allotmentDate || r.allotmentDate > this.toDate)) return false;
if (!q) return true;
return (
r.memberName.toLowerCase().includes(q) ||
r.membershipNo.toLowerCase().includes(q) ||
r.shareAccountNo.toLowerCase().includes(q) ||
r.resolutionNumber.toLowerCase().includes(q)
);
});
}
get branchOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
}
get productOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort();
}
/** Shares actually issued — pending and rejected tranches are not allotments. */
allottedShares(): number {
return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.numberOfShares, 0);
}
allottedValue(): number {
return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.value, 0);
}
pendingShares(): number {
return this.filteredRows.filter((r) => r.status === 'PENDING').reduce((s, r) => s + r.numberOfShares, 0);
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
this.fromDate = '';
this.toDate = '';
}
openAccount(row: AllotmentRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.accountId,
'general'
]);
}
exportCsv(): void {
const header = [
'S.No',
'Member Name',
'Membership No',
'Share A/c No',
'Product',
'Branch',
'Allotment Date',
'No. of Shares',
'Unit Price',
'Value',
'Share No. From',
'Share No. To',
'Resolution No',
'Meeting Date',
'Status',
'Payment Status'
];
const rows = this.filteredRows.map((r) => [
r.serial,
r.memberName,
r.membershipNo,
r.shareAccountNo,
r.productName,
r.branch,
r.allotmentDate,
r.numberOfShares,
r.unitPrice,
r.value,
r.shareNoFrom || '',
r.shareNoTo || '',
r.resolutionNumber,
r.meetingDate,
r.statusLabel,
r.paymentStatus
]);
const csv = [
header,
...rows
]
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).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 = 'share-allotment-report.csv';
link.click();
URL.revokeObjectURL(url);
}
print(): void {
window.print();
}
}
@@ -0,0 +1,191 @@
<!--
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">Share Members</h2>
<div class="cert-head-s">
Every share member on the register with their holding. Select a row to open that share account.
</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 -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="id-card" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalMembers() | number }}</div>
<div class="cert-kpi-l">Members</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="book" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRows.length | 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">{{ totalShares() | number }}</div>
<div class="cert-kpi-l">Shares Held</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">₹{{ totalValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Member / Membership No / Folio / A/c No</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. 000000012 or member name" />
</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>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="closed">Closed</option>
<option value="rejected">Rejected</option>
</select>
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
</div>
<!-- Register -->
<div class="cert-card">
@if (loading) {
<div class="cert-empty">Loading share members…</div>
} @else if (loadError) {
<div class="cert-empty">
Could not load share members.
<button class="cert-cta-btn sm-retry" (click)="loadMembers()">Retry</button>
</div>
} @else if (!filteredRows.length) {
<div class="cert-empty">
@if (rows.length) {
No share member matches these filters.
} @else {
No share members on the register yet.
}
</div>
} @else {
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>S.No</th>
<th>Membership No</th>
<th>Member Name</th>
<th>Folio No</th>
<th>Share A/c No</th>
<th>Product</th>
<th>Branch</th>
<th>Address</th>
<th>Mobile</th>
<th>Date of Admission</th>
<th class="num">Shares Held</th>
<th class="num">Pending</th>
<th class="num">Unit Price</th>
<th class="num">Share Value</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.accountId; let i = $index) {
<tr
class="sm-row"
tabindex="0"
role="link"
matTooltip="Open share account {{ row.shareAccountNo }}"
(click)="openAccount(row)"
(keydown.enter)="openAccount(row)"
(keydown.space)="$event.preventDefault(); openAccount(row)"
>
<td>{{ i + 1 }}</td>
<td class="cert-folio">{{ row.membershipNo }}</td>
<td class="cert-cust-name">{{ row.memberName }}</td>
<td class="cert-folio">{{ row.folioNo }}</td>
<td class="cert-folio">{{ row.shareAccountNo }}</td>
<td>{{ row.productName }}</td>
<td>{{ row.branch }}</td>
<td class="sm-address" [matTooltip]="row.address">{{ row.address }}</td>
<td>{{ row.mobileNo }}</td>
<td>{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }}</td>
<td class="num cert-shares">{{ row.approvedShares | number }}</td>
<td class="num">{{ row.pendingShares ? (row.pendingShares | number) : '—' }}</td>
<td class="num">₹{{ row.unitPrice | number: '1.0-2' }}</td>
<td class="num">₹{{ row.shareValue | number: '1.0-0' }}</td>
<td>
<span class="cert-pill" [class]="'sm-pill--' + row.statusTone">{{ row.statusLabel }}</span>
</td>
<td class="cert-row-actions">
<fa-icon icon="chevron-right" class="sm-chevron"></fa-icon>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="cert-note">
Share Value counts approved shares only — shares still pending approval are held in the Share Sundry GL and are not
yet part of share capital.
</div>
</div>
@@ -0,0 +1,99 @@
/**
* 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/.
*/
/* Every row navigates, so the whole row reads as the target rather than a lone link cell. */
.sm-row {
cursor: pointer;
transition: background 0.12s ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
}
&:focus-visible {
outline: 2px solid var(--cert-blue);
outline-offset: -2px;
}
/* The chevron only earns its space once the row is the one being pointed at. */
&:hover .sm-chevron,
&:focus-visible .sm-chevron {
opacity: 1;
}
}
.sm-chevron {
opacity: 0;
color: var(--cert-blue);
transition: opacity 0.12s ease;
}
/* Addresses are long and would otherwise set the table's width on their own; the
full text stays reachable through the row tooltip. */
.sm-address {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cert-table {
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
/* Status tones — mapped from the account status rather than typed per row. */
.sm-pill--active {
background: var(--cert-green-l);
color: var(--cert-green);
}
.sm-pill--pending {
background: var(--cert-amber-l);
color: var(--cert-amber);
}
.sm-pill--closed {
background: var(--cert-g100);
color: var(--cert-g700);
}
.sm-pill--rejected {
background: color-mix(in srgb, #dc2626 12%, transparent);
color: #b91c1c;
}
.sm-retry {
margin-left: 10px;
}
/* Printing the register: drop the chrome and let the table use the full page. */
@media print {
.cert-filters,
.cert-head-r,
.cert-kpis,
.sm-chevron {
display: none !important;
}
.cert-card {
border: none;
}
.sm-address {
max-width: none;
white-space: normal;
}
.cert-table {
font-size: 10px;
}
}
@@ -0,0 +1,359 @@
/**
* 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 { MatTooltip } from '@angular/material/tooltip';
import { forkJoin, of, Observable } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares/shares.service';
import { ClientsService } from '../../clients/clients.service';
/**
* One share member as this register shows them: the member's identity on the left,
* their holding on the right. Kept flat (rather than nesting the raw payload) so the
* template can filter on any column without reaching through optional chains.
*/
export interface ShareMemberRow {
/** Share account id — the navigation target, never shown as a column. */
accountId: number;
clientId: string;
/** Client account no, which is what the branch calls the membership number. */
membershipNo: string;
memberName: string;
folioNo: string;
shareAccountNo: string;
productName: string;
branch: string;
address: string;
mobileNo: string;
/** Date of admission — when the member was activated, not when this account opened. */
admissionDate: string;
approvedShares: number;
pendingShares: number;
unitPrice: number;
shareValue: number;
statusLabel: string;
/** Drives the status pill's colour class. */
statusTone: 'active' | 'pending' | 'closed' | 'rejected';
}
@Component({
selector: 'mifosx-share-member',
templateUrl: './share-member.component.html',
styleUrls: [
'../certificates/certificates.shared.scss',
'./share-member.component.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class ShareMemberComponent implements OnInit {
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
private router = inject(Router);
loading = true;
loadError = false;
rows: ShareMemberRow[] = [];
// ── Filters ──
search = '';
branchFilter = '';
productFilter = '';
statusFilter = '';
/**
* One member can hold several share accounts, so both client lookups are cached per
* client id — otherwise a member with four accounts costs four identical round trips.
*/
private clientDataCache = new Map<string, Observable<any>>();
private clientAddressCache = new Map<string, Observable<any>>();
ngOnInit(): void {
this.loadMembers();
}
loadMembers(): void {
this.loading = true;
this.loadError = false;
this.clientDataCache.clear();
this.clientAddressCache.clear();
this.sharesService
.getAllShareAccounts(0, 200)
.pipe(
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
switchMap((items: any[]) => {
if (!items.length) {
return of([] as ShareMemberRow[]);
}
return forkJoin(items.map((item) => this.buildRow(item))).pipe(
map((rows) => rows.filter((r): r is ShareMemberRow => !!r))
);
}),
catchError(() => {
this.loadError = true;
return of([] as ShareMemberRow[]);
})
)
.subscribe((rows) => {
// Newest admissions first: a register is read from the most recent entry back.
this.rows = rows.sort((a, b) => (b.admissionDate || '').localeCompare(a.admissionDate || ''));
this.loading = false;
});
}
/**
* The list payload already carries clientId, so the account read and both client reads
* fire together rather than in sequence. A row whose account read fails is dropped
* instead of failing the whole register.
*/
private buildRow(listItem: any): Observable<ShareMemberRow | null> {
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
return forkJoin({
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
client: clientId ? this.getClientDataCached(clientId) : of(null),
address: clientId ? this.getClientAddressCached(clientId) : of([])
}).pipe(
map(({ sa, client, address }: any) => this.mapToRow(sa, client, address)),
catchError(() => of(null))
);
}
private getClientDataCached(clientId: string): Observable<any> {
if (!this.clientDataCache.has(clientId)) {
this.clientDataCache.set(
clientId,
this.clientsService.getClientData(clientId, true).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientDataCache.get(clientId)!;
}
private getClientAddressCached(clientId: string): Observable<any> {
if (!this.clientAddressCache.has(clientId)) {
this.clientAddressCache.set(
clientId,
this.clientsService.getClientAddressData(clientId, true).pipe(
catchError(() => of([])),
shareReplay(1)
)
);
}
return this.clientAddressCache.get(clientId)!;
}
private mapToRow(sa: any, client: any, address: any): ShareMemberRow {
const approvedShares = sa.summary?.totalApprovedShares || 0;
const pendingShares = sa.summary?.totalPendingForApprovalShares || 0;
const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0;
const clientId = String(sa.clientId ?? client?.id ?? '');
const statusValue = sa.status?.value || '';
return {
accountId: sa.id,
clientId,
membershipNo: client?.accountNo || sa.clientAccountNo || '—',
memberName: sa.clientName || client?.displayName || '—',
folioNo: `F-${client?.accountNo || String(client?.id ?? sa.clientId ?? '').padStart(5, '0')}`,
shareAccountNo: sa.accountNo || String(sa.id),
productName: sa.productName || '—',
branch: client?.officeName || sa.officeName || '—',
address: this.firstAddress(address),
mobileNo: client?.mobileNo || '—',
// Admission is the member's activation with the society, which is what the
// Membership Register records — the share account may be opened much later.
admissionDate:
this.toIsoDate(client?.activationDate) ||
this.toIsoDate(client?.timeline?.activatedOnDate) ||
this.toIsoDate(sa.timeline?.activatedDate) ||
'',
approvedShares,
pendingShares,
unitPrice,
// Only approved shares carry value: pending ones are still sitting in Sundry.
shareValue: approvedShares * unitPrice,
statusLabel: statusValue || '—',
statusTone: this.toneFor(sa.status)
};
}
private toneFor(status: any): ShareMemberRow['statusTone'] {
if (status?.rejected) return 'rejected';
if (status?.closed) return 'closed';
if (status?.active) return 'active';
const v = String(status?.value || '').toLowerCase();
if (v.includes('reject')) return 'rejected';
if (v.includes('close')) return 'closed';
if (v.includes('active')) return 'active';
return 'pending';
}
/** Fineract returns dates as [y, m, d] tuples on most reads and ISO strings on some. */
private toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
y,
m,
d
] = value;
if (!y) {
return '';
}
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
}
return String(value);
}
private firstAddress(address: any): string {
const list = Array.isArray(address) ? address : address?.pageItems || [];
if (!list.length) {
return '—';
}
const a = list[0];
const parts = [
a.addressLine1,
a.addressLine2,
a.townVillage,
a.city,
a.stateName,
a.countryName,
a.postalCode
]
.filter((p: any) => !!p && String(p).trim())
.join(', ');
return parts || '—';
}
// ── Derived list ──
get filteredRows(): ShareMemberRow[] {
const q = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.branch !== this.branchFilter) return false;
if (this.productFilter && r.productName !== this.productFilter) return false;
if (this.statusFilter && r.statusTone !== this.statusFilter) return false;
if (!q) return true;
return (
r.memberName.toLowerCase().includes(q) ||
r.membershipNo.toLowerCase().includes(q) ||
r.shareAccountNo.toLowerCase().includes(q) ||
r.folioNo.toLowerCase().includes(q) ||
r.mobileNo.toLowerCase().includes(q)
);
});
}
get branchOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
}
get productOptions(): string[] {
return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort();
}
/** Distinct members, not accounts — one member holding three accounts counts once. */
totalMembers(): number {
return new Set(this.filteredRows.map((r) => r.clientId)).size;
}
totalShares(): number {
return this.filteredRows.reduce((s, r) => s + r.approvedShares, 0);
}
totalValue(): number {
return this.filteredRows.reduce((s, r) => s + r.shareValue, 0);
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
}
/** Opens the member's share account — the same view the client's own tab links to. */
openAccount(row: ShareMemberRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.accountId,
'general'
]);
}
exportCsv(): void {
const header = [
'S.No',
'Membership No',
'Member Name',
'Folio No',
'Share A/c No',
'Product',
'Branch',
'Address',
'Mobile',
'Date of Admission',
'Shares Held',
'Pending Shares',
'Unit Price',
'Share Value',
'Status'
];
const rows = this.filteredRows.map((r, i) => [
i + 1,
r.membershipNo,
r.memberName,
r.folioNo,
r.shareAccountNo,
r.productName,
r.branch,
r.address,
r.mobileNo,
r.admissionDate,
r.approvedShares,
r.pendingShares,
r.unitPrice,
r.shareValue,
r.statusLabel
]);
const csv = [
header,
...rows
]
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).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 = 'share-members.csv';
link.click();
URL.revokeObjectURL(url);
}
print(): void {
window.print();
}
}