/** * 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 { 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 ); } }