feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes

- Add the full Share Reports surface (final balance, account statement,
  issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry,
  payment certificate, share detail, dividend statement, folio statement,
  distinctive numbers, area-wise, mailing labels/letters, share transfer) as
  standalone routed components, wired into the Share menu
- Wire the unattributed Share Collection receipt list into Apply Collection
  and Apply Additional Shares, with an Attach action independent of
  account/client
- Sync the GL Account and Voucher No fields to whichever receipt is ticked or
  found, and surface the receipt's real Dr/Cr GL entries in its detail popup
- Block Apply Additional Shares until the account's original application is
  settled

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:06:02 +05:30
co-authored by Claude Sonnet 5
parent e53dfb5a66
commit c7428d0cb2
51 changed files with 10721 additions and 429 deletions
@@ -0,0 +1,124 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head srep-no-print">
<div class="cert-head-l">
<fa-icon icon="tag" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Labels</h2>
<div class="cert-head-s">
Printable address labels for shareholding members — ready for an ordinary label sheet.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !printableRows.length">
<fa-icon icon="print"></fa-icon> Print Labels
</button>
</div>
</div>
<div class="cert-filters srep-no-print">
<div class="cert-filter">
<label>Member / Folio / Area</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or Bhopal" />
</div>
</div>
<div class="cert-filter">
<label>Area</label>
<select [(ngModel)]="areaFilter">
<option value="">All areas</option>
@for (a of areaOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Labels per row</label>
<select [(ngModel)]="columns">
<option [ngValue]="2">2 across</option>
<option [ngValue]="3">3 across</option>
</select>
</div>
<div class="cert-filter">
<label>Show folio no.</label>
<select [(ngModel)]="showFolio">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
<div class="cert-filter">
<label>Show holding</label>
<select [(ngModel)]="showHolding">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the mailing list…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No shareholding member matches the filters applied.</div>
} @else {
<div class="srep-state srep-no-print">
{{ printableRows.length | number }} labels ready to print
@if (unaddressableRows.length) {
· {{ unaddressableRows.length | number }} member{{ unaddressableRows.length === 1 ? '' : 's' }} cannot be posted
to
}
</div>
<div class="slab-sheet" [style.grid-template-columns]="'repeat(' + columns + ', 1fr)'">
@for (row of printableRows; track row.clientId) {
<div class="slab">
<div class="slab-name">{{ row.clientName }}</div>
@for (line of lines(row); track line) {
<div class="slab-line">{{ line }}</div>
}
@if (showFolio && row.folioNumber) {
<div class="slab-meta">Folio {{ row.folioNumber }}</div>
}
@if (showHolding) {
<div class="slab-meta">{{ row.sharesHeld | number }} shares · ₹{{ row.shareValue | number: '1.0-0' }}</div>
}
</div>
}
</div>
<!-- Who cannot be posted to. Kept off the sheet but never out of sight: a blank label is a
wasted sheet, and a member with no address is one the society silently stops reaching. -->
@if (unaddressableRows.length) {
<div class="srep-warn srep-no-print">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>
<b>{{ unaddressableRows.length | number }}</b> shareholding member{{
unaddressableRows.length === 1 ? ' has' : 's have'
}}
no address on file, so no label is printed for them: {{ unaddressableRows[0].clientName }}
@if (unaddressableRows.length > 1) {
and {{ unaddressableRows.length - 1 | number }} other{{ unaddressableRows.length > 2 ? 's' : '' }}
}
. Record an address against the member to include them in the next run.
</span>
</div>
}
}
</div>
@@ -0,0 +1,197 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { shareReportCsv, shareReportPrint } from '../share-report.util';
export interface MailingRow {
clientId: number;
folioNumber: string;
clientName: string;
mobileNo: string;
officeName: string;
addressLine1: string;
addressLine2: string;
street: string;
city: string;
district: string;
postalCode: string;
area: string;
sharesHeld: number;
shareValue: number;
shareAccountNos: string;
addressable: boolean;
}
/** Assembles one member's address into the lines an envelope actually carries. */
export function addressLines(row: MailingRow): string[] {
return [
row.addressLine1,
row.addressLine2,
row.street,
[
row.city,
row.district
]
.filter(Boolean)
.join(', '),
row.postalCode ? `PIN ${row.postalCode}` : ''
]
.map((line) => (line || '').trim())
.filter(Boolean);
}
/**
* Share Label — printable address labels for shareholding members.
*
* <p>
* Laid out as a grid sized to ordinary label sheets and printed straight from the browser, so a
* mailing run needs no separate mail-merge tool. Members with no usable address are listed
* separately rather than printed as a blank label: a blank label is a wasted sheet and a member who
* silently never hears from the society.
*/
@Component({
selector: 'mifosx-share-label',
templateUrl: './share-label.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareLabelComponent implements OnInit {
private sharesService = inject(SharesService);
loading = true;
loadError = '';
rows: MailingRow[] = [];
totals: any = null;
search = '';
areaFilter = '';
/** Labels per row on the sheet — 2 and 3 cover the sheets a branch actually buys. */
columns = 3;
showFolio = true;
showHolding = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareMailingList({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The mailing list could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get areaOptions(): string[] {
return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
}
/** Only members who can actually be posted to — the rest are listed below, not printed. */
get printableRows(): MailingRow[] {
return this.filteredRows.filter((r) => r.addressable);
}
get unaddressableRows(): MailingRow[] {
return this.filteredRows.filter((r) => !r.addressable);
}
get filteredRows(): MailingRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.areaFilter && r.area !== this.areaFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.folioNumber,
r.area,
r.postalCode
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
lines(row: MailingRow): string[] {
return addressLines(row);
}
clearFilters(): void {
this.search = '';
this.areaFilter = '';
}
exportCsv(): void {
shareReportCsv(
'share-mailing-labels',
[
'Member',
'Folio No',
'Address',
'City',
'District',
'Postal Code',
'Mobile',
'Shares Held',
'Addressable'
],
this.filteredRows.map((r) => [
r.clientName,
r.folioNumber,
this.lines(r).join(' / '),
r.city,
r.district,
r.postalCode,
r.mobileNo,
r.sharesHeld,
r.addressable ? 'Yes' : 'No'
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}