done
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user