feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
@@ -80,7 +80,7 @@
<div class="tdsr__filters">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="filter.fy">
<select [(ngModel)]="filter.fy" (ngModelChange)="onFilterChanged()">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
@@ -90,7 +90,7 @@
@if (canSelectBranch) {
<label class="tdsr__filter">
<span>Branch</span>
<select [(ngModel)]="filter.branchCode">
<select [(ngModel)]="filter.branchCode" (ngModelChange)="onFilterChanged()">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
@@ -101,7 +101,7 @@
<label class="tdsr__filter">
<span>Quarter</span>
<select [(ngModel)]="filter.quarter">
<select [(ngModel)]="filter.quarter" (ngModelChange)="onFilterChanged()">
<option [ngValue]="0">Whole year</option>
@for (q of quarters; track q) {
<option [ngValue]="q">{{ quarterLabel[q] }}</option>
@@ -111,7 +111,7 @@
<label class="tdsr__filter">
<span>Threshold band</span>
<select [(ngModel)]="filter.band">
<select [(ngModel)]="filter.band" (ngModelChange)="onFilterChanged()">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="SENIOR">Senior citizen (60+)</option>
@@ -120,16 +120,21 @@
<label class="tdsr__filter tdsr__filter--wide">
<span>Customer / CIF / PAN</span>
<input type="text" [(ngModel)]="filter.search" placeholder="name, CIF or PAN" />
<input
type="text"
[(ngModel)]="filter.search"
(ngModelChange)="onFilterChanged()"
placeholder="name, CIF or PAN"
/>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" />
<input type="checkbox" [(ngModel)]="filter.crossedOnly" (ngModelChange)="onFilterChanged()" />
<span>Threshold crossed only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" />
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" (ngModelChange)="onFilterChanged()" />
<span>Near threshold only</span>
</label>
@@ -138,6 +143,26 @@
}
</div>
<!-- Tabs Selector -->
<div class="tdsr__tabs">
<button
type="button"
class="tdsr__tab"
[class.tdsr__tab--active]="selectedTab === 'paying'"
(click)="selectTab('paying')"
>
Paying TDS ({{ payingRows.length }})
</button>
<button
type="button"
class="tdsr__tab"
[class.tdsr__tab--active]="selectedTab === 'not-paying'"
(click)="selectTab('not-paying')"
>
Not Paying TDS ({{ notPayingRows.length }})
</button>
</div>
<!-- Table -->
<div class="tdsr__card">
<div class="tdsr__table-wrap">
@@ -160,7 +185,7 @@
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
@for (row of pagedRows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
@@ -311,15 +336,70 @@
</tr>
}
}
@if (rows.length === 0) {
@if (visibleRows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No deductees match the current filters.
<fa-icon icon="info-circle"></fa-icon> No customers in this section match the filters.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (visibleRows.length > 0) {
<div class="tdsr__pager">
<div class="tdsr__pager-l">
<span>
Showing <strong>{{ rangeStart | number }}–{{ rangeEnd | number }}</strong> of
<strong>{{ visibleRows.length | number }}</strong>
{{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
</span>
<label class="tdsr__pager-sizel">
Rows
<select class="tdsr__pager-size" [ngModel]="pageSize" (ngModelChange)="onPageSizeChange($event)">
@for (size of pageSizeOptions; track size) {
<option [ngValue]="size">{{ size || 'All' }}</option>
}
</select>
</label>
</div>
@if (pageCount > 1) {
<div class="tdsr__pager-r">
<button type="button" class="tdsr__pager-btn" (click)="goToPage(0)" [disabled]="currentPage === 0">
First
</button>
<button
type="button"
class="tdsr__pager-btn"
(click)="goToPage(currentPage - 1)"
[disabled]="currentPage === 0"
aria-label="Previous page"
>
<fa-icon icon="chevron-left"></fa-icon>
</button>
<span class="tdsr__pager-cur">Page {{ currentPage + 1 | number }} of {{ pageCount | number }}</span>
<button
type="button"
class="tdsr__pager-btn"
(click)="goToPage(currentPage + 1)"
[disabled]="currentPage >= pageCount - 1"
aria-label="Next page"
>
<fa-icon icon="chevron-right"></fa-icon>
</button>
<button
type="button"
class="tdsr__pager-btn"
(click)="goToPage(pageCount - 1)"
[disabled]="currentPage >= pageCount - 1"
>
Last
</button>
</div>
}
</div>
}
</div>
</div>
@@ -62,6 +62,30 @@ export class TdsInterestReportComponent implements OnInit {
/** 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;
ngOnInit(): void {
// A branch login sees only its own book and cannot widen the scope.
@@ -94,12 +118,107 @@ export class TdsInterestReportComponent implements OnInit {
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[] {
return tdsInterestRows(this.filter);
this.ensureRows();
return this.allRows;
}
get payingRows(): TdsInterestRow[] {
this.ensureRows();
return this.paying;
}
get notPayingRows(): TdsInterestRow[] {
this.ensureRows();
return this.notPaying;
}
get summary(): TdsInterestSummary {
return tdsInterestSummary(this.filter);
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 {
@@ -121,11 +240,13 @@ export class TdsInterestReportComponent implements OnInit {
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 {
+103
View File
@@ -544,3 +544,106 @@
color: var(--tdsr-blue);
}
}
/* ── Tab Switcher ── */
.tdsr__tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 1px solid var(--tdsr-line);
padding-bottom: 2px;
}
.tdsr__tab {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--tdsr-muted);
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--tdsr-ink);
}
&--active {
color: var(--tdsr-blue);
border-bottom-color: var(--tdsr-blue);
}
}
/* ── Pager ── */
.tdsr__pager {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid var(--tdsr-line);
background: var(--tdsr-bg);
font-size: 12px;
color: var(--tdsr-muted);
strong {
color: var(--tdsr-ink);
}
}
.tdsr__pager-l,
.tdsr__pager-r {
display: flex;
align-items: center;
gap: 10px;
}
.tdsr__pager-sizel {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.tdsr__pager-size {
padding: 5px 8px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: #fff;
font-size: 12px;
color: var(--tdsr-ink);
}
.tdsr__pager-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 28px;
padding: 0 9px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: #fff;
font-size: 12px;
font-weight: 700;
color: var(--tdsr-ink);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--tdsr-bg);
}
&:disabled {
opacity: 0.45;
cursor: default;
}
}
.tdsr__pager-cur {
padding: 0 2px;
font-weight: 700;
color: var(--tdsr-ink);
white-space: nowrap;
}