Files
cbsnexora/src/app/tds-reports/tds-interest-report.component.ts
T

430 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}
}