/** * 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 { 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; } } }