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

450 lines
14 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 { 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<TdsAccountLine['type'], string> = {
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<void> {
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;
}
}
}