430 lines
12 KiB
TypeScript
430 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 * as ExcelJS from 'exceljs';
|
||
|
||
import { BranchScopeService } from '../core/branch-scope.service';
|
||
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
|
||
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||
import { setTdsDeductees, tdsFys } from './tds-reports-data';
|
||
import { TdsReportsService } from './tds-reports.service';
|
||
import {
|
||
NEAR_THRESHOLD_BAND,
|
||
TdsInterestRow,
|
||
TdsInterestSummary,
|
||
TdsReportFilter,
|
||
tdsInterestRows,
|
||
tdsInterestSummary
|
||
} from './tds-reports.compute';
|
||
|
||
@Component({
|
||
selector: 'mifosx-tds-interest-report',
|
||
templateUrl: './tds-interest-report.component.html',
|
||
styleUrls: ['./tds-reports.shared.scss'],
|
||
imports: [
|
||
...STANDALONE_SHARED_IMPORTS,
|
||
FaIconComponent,
|
||
FormsModule
|
||
]
|
||
})
|
||
export class TdsInterestReportComponent implements OnInit {
|
||
private scope = inject(BranchScopeService);
|
||
private tdsService = inject(TdsReportsService);
|
||
|
||
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||
/** Filled by the route resolver from Organization › TDS Configuration. */
|
||
readonly financialYears = tdsFys();
|
||
readonly quarterLabel = QUARTER_LABEL;
|
||
readonly nearBand = NEAR_THRESHOLD_BAND;
|
||
readonly quarters: Quarter[] = [
|
||
1,
|
||
2,
|
||
3,
|
||
4
|
||
];
|
||
|
||
filter: TdsReportFilter = {
|
||
fy: tdsFys()[0] ?? '',
|
||
branchCode: '',
|
||
quarter: 0,
|
||
band: '',
|
||
search: '',
|
||
crossedOnly: false,
|
||
nearThresholdOnly: false
|
||
};
|
||
|
||
/** Which customer's quarter-wise detail is open. */
|
||
expandedId: string | null = null;
|
||
exporting = false;
|
||
selectedTab: 'paying' | 'not-paying' = 'paying';
|
||
|
||
/** Rows per page; 0 shows the whole list. */
|
||
readonly pageSizeOptions = [
|
||
25,
|
||
50,
|
||
100,
|
||
0
|
||
];
|
||
pageSize = 25;
|
||
pageIndex = 0;
|
||
|
||
/**
|
||
* Row and summary computation is memoised against the filter, because every getter below is
|
||
* read many times per change-detection cycle (the KPI block alone reads `summary` a dozen
|
||
* times) and recomputing the whole book on each read is what made this table slow enough to
|
||
* need paging in the first place.
|
||
*/
|
||
private rowsCacheKey = '';
|
||
private allRows: TdsInterestRow[] = [];
|
||
private paying: TdsInterestRow[] = [];
|
||
private notPaying: TdsInterestRow[] = [];
|
||
private summaryCacheKey = '';
|
||
private cachedSummary: TdsInterestSummary | null = null;
|
||
|
||
loading = false;
|
||
loadError = '';
|
||
|
||
ngOnInit(): void {
|
||
if (!this.financialYears.length) {
|
||
this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.';
|
||
return;
|
||
}
|
||
// A branch login sees only its own book and cannot widen the scope.
|
||
const own = this.scope.ownBranchCode;
|
||
if (own) {
|
||
this.filter.branchCode = own;
|
||
}
|
||
this.loadDeductees();
|
||
}
|
||
|
||
/**
|
||
* Loads the interest the bank actually credited for the selected year.
|
||
*
|
||
* Everything below computes on whatever `setTdsDeductees` holds, so the load is the only place
|
||
* that knows where the figures come from.
|
||
*/
|
||
loadDeductees(): void {
|
||
this.loading = true;
|
||
this.loadError = '';
|
||
// 'FY 2026-27' → 2026, the opening calendar year the platform filters on.
|
||
const openingYear = Number((this.filter.fy.match(/(\d{4})/) || [])[1]) || undefined;
|
||
this.tdsService.getDepositInterest(openingYear).subscribe({
|
||
next: (deductees) => {
|
||
this.loading = false;
|
||
setTdsDeductees(deductees);
|
||
this.invalidateCaches();
|
||
},
|
||
error: (err: any) => {
|
||
this.loading = false;
|
||
setTdsDeductees([]);
|
||
this.invalidateCaches();
|
||
this.loadError =
|
||
err?.error?.defaultUserMessage || 'Could not load deposit interest. The figures below cannot be shown.';
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Both caches are keyed on the filter alone, so a fresh load must drop them together. Clearing
|
||
* only the summary left the KPIs counting the new data while the tables kept the empty list
|
||
* cached on the first render, before the load returned.
|
||
*/
|
||
private invalidateCaches(): void {
|
||
this.rowsCacheKey = '';
|
||
this.summaryCacheKey = '';
|
||
this.cachedSummary = null;
|
||
}
|
||
|
||
/**
|
||
* A different year needs a different set of interest credits, so the year re-reads from the
|
||
* platform rather than re-filtering the set already in hand.
|
||
*/
|
||
onYearChanged(): void {
|
||
this.onFilterChanged();
|
||
this.loadDeductees();
|
||
}
|
||
|
||
/**
|
||
* Provision and return references for the selected year. The charge moved from
|
||
* section 194A to section 393(1) on 01 April 2026 and the quarterly return
|
||
* became Form 140, so the labels are read from the config rather than written
|
||
* into the template.
|
||
*/
|
||
get statute(): { section: string; statement: string; frequency: string } {
|
||
const cfg = FY_TAX_CONFIG[this.filter.fy];
|
||
return {
|
||
section: cfg?.interestTdsSection ?? '—',
|
||
statement: cfg?.tdsStatementForm ?? '—',
|
||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||
};
|
||
}
|
||
|
||
get canSelectBranch(): boolean {
|
||
return this.scope.canSelectBranch;
|
||
}
|
||
|
||
get scopeLabel(): string {
|
||
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
|
||
}
|
||
|
||
/** Recomputes the book only when the filter actually changed. Side-effect free. */
|
||
private ensureRows(): void {
|
||
const key = JSON.stringify(this.filter);
|
||
if (key === this.rowsCacheKey) {
|
||
return;
|
||
}
|
||
this.rowsCacheKey = key;
|
||
this.allRows = tdsInterestRows(this.filter);
|
||
this.paying = this.allRows.filter((r) => r.schedule.totalTds > 0);
|
||
this.notPaying = this.allRows.filter((r) => r.schedule.totalTds === 0);
|
||
}
|
||
|
||
get rows(): TdsInterestRow[] {
|
||
this.ensureRows();
|
||
return this.allRows;
|
||
}
|
||
|
||
get payingRows(): TdsInterestRow[] {
|
||
this.ensureRows();
|
||
return this.paying;
|
||
}
|
||
|
||
get notPayingRows(): TdsInterestRow[] {
|
||
this.ensureRows();
|
||
return this.notPaying;
|
||
}
|
||
|
||
get summary(): TdsInterestSummary {
|
||
const key = JSON.stringify(this.filter);
|
||
if (key !== this.summaryCacheKey || !this.cachedSummary) {
|
||
this.summaryCacheKey = key;
|
||
this.cachedSummary = tdsInterestSummary(this.filter);
|
||
}
|
||
return this.cachedSummary;
|
||
}
|
||
|
||
/** Every row of the tab on screen, before paging. */
|
||
get visibleRows(): TdsInterestRow[] {
|
||
return this.selectedTab === 'paying' ? this.payingRows : this.notPayingRows;
|
||
}
|
||
|
||
get pageCount(): number {
|
||
if (!this.pageSize) {
|
||
return 1;
|
||
}
|
||
return Math.max(1, Math.ceil(this.visibleRows.length / this.pageSize));
|
||
}
|
||
|
||
/**
|
||
* Clamped rather than assigned, so a filter that shrinks the list can never leave the table
|
||
* rendering an out-of-range page, and nothing mutates state during change detection.
|
||
*/
|
||
get currentPage(): number {
|
||
return Math.min(this.pageIndex, this.pageCount - 1);
|
||
}
|
||
|
||
/** The slice actually rendered. */
|
||
get pagedRows(): TdsInterestRow[] {
|
||
const rows = this.visibleRows;
|
||
if (!this.pageSize) {
|
||
return rows;
|
||
}
|
||
const start = this.currentPage * this.pageSize;
|
||
return rows.slice(start, start + this.pageSize);
|
||
}
|
||
|
||
get rangeStart(): number {
|
||
if (!this.visibleRows.length) {
|
||
return 0;
|
||
}
|
||
return this.pageSize ? this.currentPage * this.pageSize + 1 : 1;
|
||
}
|
||
|
||
get rangeEnd(): number {
|
||
const total = this.visibleRows.length;
|
||
return this.pageSize ? Math.min(total, (this.currentPage + 1) * this.pageSize) : total;
|
||
}
|
||
|
||
goToPage(page: number): void {
|
||
this.pageIndex = Math.max(0, Math.min(page, this.pageCount - 1));
|
||
// The open quarter-wise detail belongs to a row that is no longer on screen.
|
||
this.expandedId = null;
|
||
}
|
||
|
||
/** Keeps the first row currently on screen in view when the page size changes. */
|
||
onPageSizeChange(size: number): void {
|
||
const anchor = this.currentPage * (this.pageSize || 1);
|
||
this.pageSize = Number(size);
|
||
this.pageIndex = this.pageSize ? Math.floor(anchor / this.pageSize) : 0;
|
||
this.expandedId = null;
|
||
}
|
||
|
||
/** A different filter is a different list, so paging starts over. */
|
||
onFilterChanged(): void {
|
||
this.pageIndex = 0;
|
||
this.expandedId = null;
|
||
}
|
||
|
||
selectTab(tab: 'paying' | 'not-paying'): void {
|
||
this.selectedTab = tab;
|
||
this.onFilterChanged();
|
||
}
|
||
|
||
get hasFilters(): boolean {
|
||
return !!(
|
||
this.filter.search ||
|
||
this.filter.quarter ||
|
||
this.filter.band ||
|
||
this.filter.crossedOnly ||
|
||
this.filter.nearThresholdOnly
|
||
);
|
||
}
|
||
|
||
clearFilters(): void {
|
||
this.filter = {
|
||
...this.filter,
|
||
quarter: 0,
|
||
band: '',
|
||
search: '',
|
||
crossedOnly: false,
|
||
nearThresholdOnly: false
|
||
};
|
||
this.onFilterChanged();
|
||
}
|
||
|
||
/** Jump straight to the customers worth chasing a declaration from. */
|
||
showNearThreshold(): void {
|
||
this.filter = { ...this.filter, crossedOnly: false, nearThresholdOnly: true };
|
||
this.onFilterChanged();
|
||
}
|
||
|
||
toggleExpanded(row: TdsInterestRow): void {
|
||
this.expandedId = this.expandedId === row.deductee.customerId ? null : row.deductee.customerId;
|
||
}
|
||
|
||
isExpanded(row: TdsInterestRow): boolean {
|
||
return this.expandedId === row.deductee.customerId;
|
||
}
|
||
|
||
branchName(code: string): string {
|
||
return glBranchName(code);
|
||
}
|
||
|
||
trackRow(_: number, row: TdsInterestRow): string {
|
||
return row.deductee.customerId;
|
||
}
|
||
|
||
async exportWorkbook(): Promise<void> {
|
||
this.exporting = true;
|
||
try {
|
||
const wb = new ExcelJS.Workbook();
|
||
const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`);
|
||
|
||
ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]);
|
||
ws.addRow([`Scope: ${this.scopeLabel}`]);
|
||
ws.addRow([]);
|
||
ws.addRow([
|
||
'Customer',
|
||
'CIF',
|
||
'PAN',
|
||
'Branch',
|
||
'Form',
|
||
'Band',
|
||
'Age',
|
||
'Q1',
|
||
'Q2',
|
||
'Q3',
|
||
'Q4',
|
||
'Interest (FD/RD)',
|
||
'Savings interest (exempt)',
|
||
'Threshold',
|
||
'Crossed in',
|
||
'Rate %',
|
||
'TDS payable',
|
||
'Declaration',
|
||
'UIN',
|
||
'TDS saved',
|
||
'Paid without deduction'
|
||
]);
|
||
|
||
for (const r of this.rows) {
|
||
ws.addRow([
|
||
r.deductee.name,
|
||
r.deductee.customerId,
|
||
r.deductee.pan || 'NOT ON RECORD',
|
||
this.branchName(r.branchCode),
|
||
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
|
||
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
|
||
r.age,
|
||
r.quarters[1],
|
||
r.quarters[2],
|
||
r.quarters[3],
|
||
r.quarters[4],
|
||
r.tdsApplicableInterest,
|
||
r.exemptInterest,
|
||
r.threshold,
|
||
r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—',
|
||
r.rateApplied * 100,
|
||
r.schedule.totalTds,
|
||
r.hasDeclaration ? 'Yes' : 'No',
|
||
r.declarationUin ?? '',
|
||
r.tdsSaved,
|
||
r.paidWithoutDeduction
|
||
]);
|
||
}
|
||
|
||
const s = this.summary;
|
||
ws.addRow([]);
|
||
ws.addRow([
|
||
'TOTAL',
|
||
`${s.deductees} deductees`,
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
s.byQuarter[0].interest,
|
||
s.byQuarter[1].interest,
|
||
s.byQuarter[2].interest,
|
||
s.byQuarter[3].interest,
|
||
s.applicableInterest,
|
||
s.exemptInterest,
|
||
'',
|
||
'',
|
||
'',
|
||
s.totalTds,
|
||
`${s.declarations} filed`,
|
||
'',
|
||
s.tdsSaved,
|
||
s.paidWithoutDeduction
|
||
]);
|
||
|
||
ws.getRow(1).font = { bold: true, size: 13 };
|
||
ws.getRow(4).font = { bold: true };
|
||
ws.columns.forEach((c) => (c.width = 18));
|
||
|
||
const buffer = await wb.xlsx.writeBuffer();
|
||
const blob = new Blob([buffer], {
|
||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `TDS-Interest-FY${this.filter.fy}.xlsx`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} finally {
|
||
this.exporting = false;
|
||
}
|
||
}
|
||
}
|