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