A
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user