- 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>
228 lines
7.5 KiB
TypeScript
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
|
|
);
|
|
}
|
|
}
|