399 lines
12 KiB
TypeScript
399 lines
12 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 { FormsModule } from '@angular/forms';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { SettingsService } from 'app/settings/settings.service';
|
|
import { exportWorkbook } from '../cashier/cashier-export';
|
|
import {
|
|
InterestPostingDue,
|
|
InterestPostingReport,
|
|
InterestPostingReportDetail,
|
|
InterestPostingReportService,
|
|
InterestPreviewRow,
|
|
ReportScope
|
|
} from './interest-posting-report.service';
|
|
|
|
/**
|
|
* Savings interest posting reports.
|
|
*
|
|
* On a product's posting date the system generates, at noon, the list of what each savings account
|
|
* will be paid. It is reviewed and approved here; the approved amounts are posted when the last
|
|
* branch closes the day, and until then no branch can close. Regenerating replaces the report and
|
|
* needs approving again.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-interest-posting-report',
|
|
templateUrl: './interest-posting-report.component.html',
|
|
styleUrls: ['./interest-posting-report.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
FormsModule,
|
|
FaIconComponent
|
|
]
|
|
})
|
|
export class InterestPostingReportComponent implements OnInit {
|
|
private reportService = inject(InterestPostingReportService);
|
|
private settingsService = inject(SettingsService);
|
|
|
|
postingDate = '';
|
|
loading = false;
|
|
busy = false;
|
|
errorMessage = '';
|
|
notice = '';
|
|
|
|
reports: InterestPostingReport[] = [];
|
|
selected: InterestPostingReportDetail | null = null;
|
|
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
|
|
due: InterestPostingDue[] = [];
|
|
|
|
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
|
|
tab: ReportScope = 'own';
|
|
isHeadOffice = false;
|
|
myBranch = '';
|
|
|
|
ngOnInit(): void {
|
|
this.postingDate = this.businessDateIso();
|
|
this.reportService.context().subscribe({
|
|
next: (ctx) => {
|
|
this.isHeadOffice = !!ctx?.headOffice;
|
|
this.myBranch = ctx?.officeName ?? '';
|
|
},
|
|
error: () => (this.isHeadOffice = false)
|
|
});
|
|
this.load();
|
|
}
|
|
|
|
switchTab(tab: ReportScope): void {
|
|
if (this.tab === tab) {
|
|
return;
|
|
}
|
|
this.tab = tab;
|
|
this.selected = null;
|
|
this.notice = '';
|
|
this.load();
|
|
}
|
|
|
|
/** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
|
|
private confirmOnBehalf(officeName: string, action: string): boolean {
|
|
if (this.tab !== 'others') {
|
|
return true;
|
|
}
|
|
return confirm(
|
|
`${officeName} has not opened today. You are about to ${action} its interest posting report on its behalf, ` +
|
|
'from the head office. Continue?'
|
|
);
|
|
}
|
|
|
|
load(): void {
|
|
this.loading = true;
|
|
this.errorMessage = '';
|
|
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
|
|
next: (reports) => {
|
|
this.reports = reports ?? [];
|
|
this.loading = false;
|
|
const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
|
|
if (stillThere) {
|
|
this.open(stillThere);
|
|
} else {
|
|
this.selected = null;
|
|
}
|
|
},
|
|
error: (err) => {
|
|
this.loading = false;
|
|
this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
|
|
}
|
|
});
|
|
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
|
|
next: (due) => (this.due = due ?? []),
|
|
error: () => (this.due = [])
|
|
});
|
|
}
|
|
|
|
generate(item: InterestPostingDue): void {
|
|
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
this.notice = '';
|
|
this.errorMessage = '';
|
|
this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
|
|
next: (res) => {
|
|
this.busy = false;
|
|
this.notice = `Report generated for ${item.productName} — ${item.officeName}. Review and approve it.`;
|
|
this.load();
|
|
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
|
|
},
|
|
error: (err) => {
|
|
this.busy = false;
|
|
this.errorMessage = this.messageOf(err, 'Could not generate the report.');
|
|
}
|
|
});
|
|
}
|
|
|
|
open(report: InterestPostingReport): void {
|
|
this.errorMessage = '';
|
|
this.reportService.detail(report.id).subscribe({
|
|
next: (detail) => (this.selected = detail),
|
|
error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
|
|
});
|
|
}
|
|
|
|
regenerate(report: InterestPostingReport): void {
|
|
if (
|
|
!confirm(
|
|
`Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
|
|
) ||
|
|
!this.confirmOnBehalf(report.officeName, 'regenerate')
|
|
) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
this.notice = '';
|
|
this.errorMessage = '';
|
|
this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
|
|
next: (res) => {
|
|
this.busy = false;
|
|
this.notice = `Report regenerated for ${report.productName}. Review and approve it.`;
|
|
this.selected = null;
|
|
this.load();
|
|
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
|
|
},
|
|
error: (err) => {
|
|
this.busy = false;
|
|
this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
|
|
}
|
|
});
|
|
}
|
|
|
|
approve(report: InterestPostingReport): void {
|
|
if (
|
|
!confirm(
|
|
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` +
|
|
'It will be posted when the branch closes the day.'
|
|
) ||
|
|
!this.confirmOnBehalf(report.officeName, 'approve')
|
|
) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
this.notice = '';
|
|
this.errorMessage = '';
|
|
this.reportService.approve(report.id).subscribe({
|
|
next: () => {
|
|
this.busy = false;
|
|
this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
|
|
this.load();
|
|
},
|
|
error: (err) => {
|
|
this.busy = false;
|
|
this.errorMessage = this.messageOf(err, 'Could not approve the report.');
|
|
}
|
|
});
|
|
}
|
|
|
|
canRegenerate(report: InterestPostingReport): boolean {
|
|
return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
|
|
}
|
|
|
|
canApprove(report: InterestPostingReport): boolean {
|
|
return report.canAct && report.status === 'GENERATED';
|
|
}
|
|
|
|
statusClass(status: string): string {
|
|
return (
|
|
{
|
|
GENERATED: 'ipr-badge--pending',
|
|
APPROVED: 'ipr-badge--approved',
|
|
POSTED: 'ipr-badge--posted',
|
|
SUPERSEDED: 'ipr-badge--muted'
|
|
}[status] ?? 'ipr-badge--muted'
|
|
);
|
|
}
|
|
|
|
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
|
|
get pendingCount(): number {
|
|
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
|
|
}
|
|
|
|
// ── Preview: what each account has earned so far, as if posted today. View only. ──
|
|
|
|
showPreview = false;
|
|
previewLoading = false;
|
|
previewRows: InterestPreviewRow[] = [];
|
|
previewAsOf = '';
|
|
|
|
openPreview(): void {
|
|
this.showPreview = true;
|
|
this.previewLoading = true;
|
|
this.errorMessage = '';
|
|
this.reportService.preview().subscribe({
|
|
next: (rows) => {
|
|
this.previewRows = rows ?? [];
|
|
this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso();
|
|
this.previewLoading = false;
|
|
},
|
|
error: (err) => {
|
|
this.previewLoading = false;
|
|
this.previewRows = [];
|
|
this.errorMessage = this.messageOf(err, 'Could not load the interest preview.');
|
|
}
|
|
});
|
|
}
|
|
|
|
closePreview(): void {
|
|
this.showPreview = false;
|
|
}
|
|
|
|
get previewTotal(): number {
|
|
return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
|
|
}
|
|
|
|
/** Totals by branch and product, for the summary above the account list and the export. */
|
|
get previewGroups(): {
|
|
officeName: string;
|
|
productName: string;
|
|
postingDate: string;
|
|
accounts: number;
|
|
interest: number;
|
|
}[] {
|
|
const map = new Map<
|
|
string,
|
|
{ officeName: string; productName: string; postingDate: string; accounts: number; interest: number }
|
|
>();
|
|
this.previewRows.forEach((r) => {
|
|
const key = `${r.officeName}|${r.productName}`;
|
|
const g = map.get(key) ?? {
|
|
officeName: r.officeName,
|
|
productName: r.productName,
|
|
postingDate: r.postingDate,
|
|
accounts: 0,
|
|
interest: 0
|
|
};
|
|
g.accounts += 1;
|
|
g.interest += Number(r.interest || 0);
|
|
map.set(key, g);
|
|
});
|
|
return [...map.values()];
|
|
}
|
|
|
|
exportPreview(): void {
|
|
const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`;
|
|
exportWorkbook(
|
|
[
|
|
{
|
|
sheetName: 'By account',
|
|
title: 'Savings Interest Preview',
|
|
subtitle: stamp,
|
|
headers: [
|
|
'Branch',
|
|
'Product',
|
|
'Account No',
|
|
'Customer',
|
|
'Balance',
|
|
'Rate %',
|
|
'Cycle Start',
|
|
'Posting Date',
|
|
'Interest So Far'
|
|
],
|
|
widths: [
|
|
18,
|
|
22,
|
|
20,
|
|
26,
|
|
16,
|
|
8,
|
|
12,
|
|
12,
|
|
16
|
|
],
|
|
rows: [
|
|
...this.previewRows.map((r) => [
|
|
r.officeName,
|
|
r.productName,
|
|
r.accountNo,
|
|
r.clientName,
|
|
Number(r.balance || 0),
|
|
Number(r.rate || 0),
|
|
r.cycleStart,
|
|
r.postingDate,
|
|
Number(r.interest || 0)
|
|
]),
|
|
[
|
|
'TOTAL',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
this.previewTotal
|
|
]
|
|
]
|
|
},
|
|
{
|
|
sheetName: 'By branch & product',
|
|
title: 'Savings Interest Preview — Totals',
|
|
subtitle: stamp,
|
|
headers: [
|
|
'Branch',
|
|
'Product',
|
|
'Posting Date',
|
|
'Accounts',
|
|
'Interest So Far'
|
|
],
|
|
widths: [
|
|
18,
|
|
22,
|
|
12,
|
|
10,
|
|
16
|
|
],
|
|
rows: [
|
|
...this.previewGroups.map((g) => [
|
|
g.officeName,
|
|
g.productName,
|
|
g.postingDate,
|
|
g.accounts,
|
|
g.interest
|
|
]),
|
|
[
|
|
'TOTAL',
|
|
'',
|
|
'',
|
|
this.previewRows.length,
|
|
this.previewTotal
|
|
]
|
|
]
|
|
}
|
|
],
|
|
`savings-interest-preview-${this.previewAsOf}`
|
|
);
|
|
}
|
|
|
|
private businessDateIso(): string {
|
|
const businessDate = this.settingsService.businessDate as any;
|
|
const parsed = businessDate ? new Date(businessDate) : new Date();
|
|
if (isNaN(parsed.getTime())) {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
const month = String(parsed.getMonth() + 1).padStart(2, '0');
|
|
const day = String(parsed.getDate()).padStart(2, '0');
|
|
return `${parsed.getFullYear()}-${month}-${day}`;
|
|
}
|
|
|
|
private messageOf(err: any, fallback: string): string {
|
|
return (
|
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
|
|
);
|
|
}
|
|
}
|