/** * 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. * *
* 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. * *
* 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