feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user