/** * 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 { BranchScopeService } from '../core/branch-scope.service'; import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data'; import { buildTdsInterestWorkbook } from './tds-interest-export'; import { TdsReportsService } from './tds-reports.service'; import { NEAR_THRESHOLD_BAND, TdsAccountLine, TdsInterestRow, TdsInterestSummary, TdsReportFilter, tdsInterestRows, tdsInterestSummary } from './tds-reports.compute'; /** The three answers the register gives: TDS taken, not taken because of a declaration, none due. */ type TdsTab = 'paying' | 'declared' | 'not-paying'; @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); /** * Office names the loaded customers belong to, for Head Office's branch selector. Read off the * data rather than a fixed list, which named branches this bank does not have and left out the * ones it does. */ branches: string[] = []; /** 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: TdsTab = '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 declared: TdsInterestRow[] = []; private summaryCacheKey = ''; private cachedSummary: TdsInterestSummary | null = null; loading = false; loadError = ''; /** The "how to read this page" panel. */ showHelp = true; readonly accountTypeLabel: Record = { FD: 'Fixed deposit', RD: 'Recurring deposit', SB: 'Savings', OTHER: 'Other' }; 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. The platform labels each // customer with their office's name, so the login's office name is the filter — the GL branch // code list does not know every office and turned a branch login into an empty report. if (!this.scope.isHeadOffice) { this.filter.branchCode = this.scope.officeName; } 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.branches = this.branchesOf(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 branchesOf(deductees: TdsDeductee[]): string[] { const names = new Set(deductees.map((d) => d.branchCode).filter((n) => !!n)); return [...names].sort((a, b) => a.localeCompare(b)); } 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' }; } /** The limits and rates for the selected year, for the help panel. */ get rules(): { general: number; senior: number; rate: number; rateNoPan: number } | null { const cfg = FY_TAX_CONFIG[this.filter.fy]; if (!cfg) { return null; } return { general: cfg.thresholdGeneral, senior: cfg.thresholdSenior, rate: cfg.rate * 100, rateNoPan: cfg.rateNoPan * 100 }; } get canSelectBranch(): boolean { return this.scope.canSelectBranch; } get scopeLabel(): string { return this.filter.branchCode ? `${this.filter.branchCode} branch` : 'All branches'; } /** * One plain-language status per customer, so a reader does not have to work it out from the * threshold, crossed and saved columns together. */ statusOf(row: TdsInterestRow): { label: string; tone: 'muted' | 'warn' | 'near' | 'ok' } { if (!row.trackedAccounts) { return { label: 'Not tracked — no FD/RD', tone: 'muted' }; } if (row.hasDeclaration) { const beforeForm = Math.round(row.schedule.totalTds - row.actualTds); if (beforeForm > 0) { return { label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, tone: 'warn' }; } if (row.tdsSaved > 0) { return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' }; } return { label: `Form ${row.form} on record — below the limit so far`, tone: 'ok' }; } if (row.schedule.totalTds > 0) { const gap = Math.round(row.schedule.totalTds - row.actualTds); if (gap > 0) { return { label: `Over the limit — ₹${gap.toLocaleString('en-IN')} not yet deducted`, tone: 'warn' }; } if (gap < 0) { return { label: `Deducted ₹${(-gap).toLocaleString('en-IN')} more than due — check`, tone: 'warn' }; } return { label: 'Over the limit — TDS deducted', tone: 'ok' }; } if (row.actualTds > 0) { return { label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, tone: 'warn' }; } if (row.hasDeclaration && row.crossed) { return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' }; } if (row.nearThreshold) { return { label: `Near the limit — ₹${Math.round(row.headroom).toLocaleString('en-IN')} left`, tone: 'near' }; } return { label: 'Below the limit', tone: 'ok' }; } trackedOf(row: TdsInterestRow): TdsAccountLine[] { return row.accounts.filter((a) => a.tracked); } untrackedOf(row: TdsInterestRow): TdsAccountLine[] { return row.accounts.filter((a) => !a.tracked); } /** 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); // Three plain answers: whose TDS was taken, whose was not because they gave a declaration, and who // owed none. A declarant whose tax was partly taken before the form is shown with the declarants, // where both figures sit side by side. this.declared = this.allRows.filter((r) => r.hasDeclaration); this.paying = this.allRows.filter((r) => !r.hasDeclaration && (r.schedule.totalTds > 0 || r.actualTds > 0)); this.notPaying = this.allRows.filter((r) => !r.hasDeclaration && r.schedule.totalTds === 0 && r.actualTds === 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 declaredRows(): TdsInterestRow[] { this.ensureRows(); return this.declared; } 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[] { if (this.selectedTab === 'declared') { return this.declaredRows; } 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: TdsTab): 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; } trackRow(_: number, row: TdsInterestRow): string { return row.deductee.customerId; } async exportWorkbook(): Promise { this.exporting = true; try { const wb = buildTdsInterestWorkbook({ fy: this.filter.fy, scopeLabel: this.scopeLabel, rules: this.rules, statement: this.statute.statement, // What the screen shows under the current filters, every tab included. rows: this.rows, declaredRows: this.declaredRows, summary: this.summary, statusOf: (row) => this.statusOf(row) }); 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-Register-FY${this.filter.fy}.xlsx`; a.click(); URL.revokeObjectURL(url); } finally { this.exporting = false; } } }