Files
cbsnexora/src/app/shares/reports/share-letter/share-letter.component.ts
T
niteshandClaude Sonnet 5 c7428d0cb2 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>
2026-09-18 13:06:02 +05:30

228 lines
7.5 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { OrganizationService } from 'app/organization/organization.service';
import { MailingRow, addressLines } from '../share-label/share-label.component';
import { shareReportPrint } from '../share-report.util';
/** The letters a society actually sends its shareholders, as starting text. */
const TEMPLATES: { key: string; label: string; subject: string; body: string }[] = [
{
key: 'AGM',
label: 'Notice of Annual General Meeting',
subject: 'Notice of the Annual General Meeting',
body:
'You are hereby informed that the Annual General Meeting of the society will be held on {{MEETING_DATE}} at {{VENUE}}.\n\n' +
'As a shareholding member holding {{SHARES}} shares (Folio {{FOLIO}}), you are entitled to attend and vote.\n\n' +
'Your presence is requested.'
},
{
key: 'DIVIDEND',
label: 'Dividend intimation',
subject: 'Dividend declared on your shareholding',
body:
'We are pleased to inform you that the society has declared a dividend for the year {{YEAR}}.\n\n' +
'Against your holding of {{SHARES}} shares (Folio {{FOLIO}}), the dividend due to you will be credited to your ' +
'linked savings account. Please contact your branch if it is not received within fifteen days.'
},
{
key: 'SHARE_CALL',
label: 'Call to complete shareholding',
subject: 'Completion of your share subscription',
body:
'Our records show that your share account stands at {{SHARES}} shares (Folio {{FOLIO}}), which is below the ' +
'minimum subscription your share class requires.\n\n' +
'You are requested to complete your subscription at your branch at the earliest so that your membership rights ' +
'remain unaffected.'
},
{
key: 'BLANK',
label: 'Blank letter',
subject: '',
body: ''
}
];
/**
* Share Letter — a letter to shareholding members, merged with their own details and printed.
*
* <p>
* Runs off the same mailing list the labels do, so the letter and the envelope always agree on who
* is being written to. Templates are starting text rather than fixed forms: the body is editable
* before printing, because the letter a society actually sends is never quite the one a system
* imagined.
*
* <p>
* Placeholders are merged per member — {{NAME}}, {{FOLIO}}, {{SHARES}} and the rest — so one edit
* produces every letter, each addressed to the member it is going to.
*/
@Component({
selector: 'mifosx-share-letter',
templateUrl: './share-letter.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareLetterComponent implements OnInit {
private sharesService = inject(SharesService);
private organizationService = inject(OrganizationService);
loading = true;
loadError = '';
rows: MailingRow[] = [];
institution: any = null;
readonly templates = TEMPLATES;
templateKey = 'AGM';
subject = TEMPLATES[0].subject;
body = TEMPLATES[0].body;
search = '';
areaFilter = '';
/** Which members this run is for; empty means everyone shown. */
selectedIds: number[] = [];
ngOnInit(): void {
this.sharesService
.getShareMailingList({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The mailing list could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.loading = false;
});
this.organizationService
.getInstitutionProfile()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.institution = res));
}
applyTemplate(): void {
const template = TEMPLATES.find((t) => t.key === this.templateKey);
if (template) {
this.subject = template.subject;
this.body = template.body;
}
}
get areaOptions(): string[] {
return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
}
get filteredRows(): MailingRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.areaFilter && r.area !== this.areaFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.folioNumber,
r.area
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
/** Nothing ticked means the whole filtered list — the common case is "write to all of these". */
get recipients(): MailingRow[] {
if (!this.selectedIds.length) {
return this.filteredRows;
}
return this.filteredRows.filter((r) => this.selectedIds.includes(r.clientId));
}
isSelected(clientId: number): boolean {
return this.selectedIds.includes(clientId);
}
toggle(clientId: number): void {
this.selectedIds = this.isSelected(clientId) ? this.selectedIds.filter((id) => id !== clientId) : [
...this.selectedIds,
clientId
];
}
selectAll(): void {
this.selectedIds = [];
}
lines(row: MailingRow): string[] {
return addressLines(row);
}
/** What one member's letter substitutes, keyed by the token that stands for it in the body. */
private mergeFields(row: MailingRow): Record<string, string> {
return {
NAME: row.clientName || '',
FOLIO: row.folioNumber || '',
SHARES: Number(row.sharesHeld || 0).toLocaleString('en-IN'),
SHARE_VALUE: Number(row.shareValue || 0).toLocaleString('en-IN'),
SHARE_ACCOUNTS: row.shareAccountNos || '',
BRANCH: row.officeName || '',
AREA: row.area || ''
};
}
/**
* The tokens the editor's help text offers, derived from the merge itself so the two can never
* drift apart — and assembled here rather than written in the template, because a literal
* `{{NAME}}` in an Angular template is an expression, not the text a letter-writer needs to see.
*/
readonly placeholders = Object.keys(this.mergeFields({} as MailingRow)).map((key) => `{{${key}}}`);
/** The member's own details merged into the body, one letter at a time. */
merged(row: MailingRow): string {
const fields = this.mergeFields(row);
// An unrecognised token is left standing rather than blanked: a letter that visibly still says
// {{VENUE}} tells the writer to fill it in; one silently emptied goes out with a hole in it.
return (this.body || '').replace(/\{\{([A-Z_]+)\}\}/g, (token, key: string) => fields[key] ?? token);
}
get today(): string {
const now = new Date();
return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}