fix: restore content silently dropped from the dev merge (KYC exception, TDS reports, Form 121)

The demo-backup merge's git 3-way auto-resolve dropped several hunks
without flagging conflicts, wherever demo had independently touched
adjacent lines: the KYC Exception feature (client-kyc-step, kyc-tab,
create-client, clients.service — checkbox, derivation logic, banners,
contact-log endpoints), TDS report routes/menu links (reports-routing,
shell), the Form 121 client tab (clients-routing, clients-view), the
kyc-management exceptions dashboard, and the Form 121/15G-H register's
real-data view integration. Restored by diffing demo-backup against
dev per file and re-applying each genuine gap while preserving
demo-backup's own independent work (Approval Center tab style, Loan
Origination/Investment menus, form-97-98/risk-review report routes).
This commit is contained in:
2026-08-06 15:12:06 +05:30
parent 7e69468995
commit 89bb76455e
17 changed files with 2136 additions and 316 deletions
@@ -966,6 +966,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
this.linkIncomeToTurnover();
}
/**
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
* and annual income for annual/yearly turnover).
*/
private linkIncomeToTurnover(): void {
const monthlyIncome = this.createClientForm.get('monthlyIncome');
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
const annualIncome = this.createClientForm.get('annualIncome');
const annualTurnover = this.createClientForm.get('annualTurnover');
if (monthlyIncome && monthlyTurnover) {
monthlyIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
});
}
if (annualIncome && annualTurnover) {
annualIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
});
}
}
/**
@@ -723,6 +723,53 @@
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
</div>
}
<!-- KYC Exception Flag -->
<div
class="exception-flag-box"
style="
margin-top: 20px;
padding: 16px;
border: 1px solid #f59e0b;
background: #fffbeb;
border-radius: 8px;
clear: both;
"
>
<h5
style="
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #b45309;
display: flex;
align-items: center;
gap: 8px;
"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
<!-- ═══ SECTION: KYC Document Management ═══ -->
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
@@ -152,7 +153,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocumentName: 'KYC Document Type',
kycDocumentNumber: 'KYC Document Number',
amlCheckStatus: 'AML Check Status',
bannedEntityMatch: 'Banned Entity List Match'
bannedEntityMatch: 'Banned Entity List Match',
remarks: 'Reason for Re-edit'
};
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
@@ -274,6 +276,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
riskCalculationReason = '';
showRiskRulesTable = false;
/**
* Exception state as last read back from the server. Needed to tell "this client never had an
* exception" from "this client had one and has now cleared it" — only the latter should be
* stamped resolved and surface in the Resolved tab.
*/
persistedIsException = false;
persistedExceptionReason: string | null = null;
/** PAN verification */
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
panVerifying = false;
@@ -423,8 +433,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumber: [
'',
[
Validators.pattern(/^\d{12}$/),
Validators.maxLength(12)
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/),
Validators.maxLength(14)
]
],
panAadhaarSeedingStatus: [''],
@@ -468,6 +478,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
allowRiskOverride: [false],
allowAmlOverride: [false],
remarks: [''],
isException: [false],
exceptionReason: [[]],
amlCheckStatus: [
'CLEAR',
Validators.required
@@ -610,8 +622,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
dormantFlag: 'ACTIVE',
accountStatus: 'ACTIVE',
form60Type: 'FORM_60',
form15Type: '15G'
form15Type: '15G',
isException: false,
exceptionReason: []
});
this.persistedIsException = false;
this.persistedExceptionReason = null;
this.kycId = '';
this.kycLoaded = false;
this.kycStatus = 0;
@@ -942,8 +958,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form60Date: parseDate(data.form60Date),
form15Applicable: data.form15Applicable || false,
form15Type: data.form15Type || '15G',
form15FY: data.form15FY || ''
form15FY: data.form15FY || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
// Set remarks as required if KYC is verified
if (this.isKycVerified) {
this.kycForm.get('remarks')?.setValidators(Validators.required);
this.kycForm.get('remarks')?.updateValueAndValidity();
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
}
this.persistedIsException = data.isException === true;
this.persistedExceptionReason = data.exceptionReason || null;
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
@@ -1843,7 +1869,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form15Applicable: v.form15Applicable || false,
form15Type: v.form15Type || null,
form15FY: v.form15FY || null,
remarks: v.remarks || null
remarks: v.remarks || null,
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
...deriveKycExceptionFields({
panVerified: this.isNonIndividual
? this.entPanVerifyStatus === 'VERIFIED'
: this.panVerifyStatus === 'VERIFIED',
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
// company with a permanent exception.
aadhaarApplicable: !this.isNonIndividual,
hadException: this.persistedIsException,
previousReason: this.persistedExceptionReason,
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
})
};
}
@@ -1856,7 +1898,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.enterprisePan ||
v.cinNumber ||
v.kycDocumentName ||
v.form15Applicable
v.form15Applicable ||
v.isException
);
}
@@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -193,6 +194,11 @@ const routes: Routes = [
component: Form60TabComponent,
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
},
{
path: 'form121',
component: Form121TabComponent,
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
path: 'documents',
component: DocumentsTabComponent,
@@ -545,6 +545,15 @@
>
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./form121']"
routerLinkActive
#form121="routerLinkActive"
[active]="form121.isActive"
>
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
</a>
<a
mat-tab-link
[routerLink]="['./documents']"
@@ -42,6 +42,32 @@
<!-- KYC Summary (read-only when not in edit mode) -->
@if (!isEditMode && kycData) {
@if (kycData.isException && !kycData.exceptionResolved) {
<div
class="kyc-exception-banner"
style="
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
display: flex;
align-items: flex-start;
gap: 12px;
"
>
<mat-icon style="color: #d97706; margin-top: 2px">warning</mat-icon>
<div>
<strong style="color: #92400e; font-size: 14px">KYC Exception Active</strong>
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0">
Account is open under a temporary KYC Exception. Reason:
<strong>{{ kycData.exceptionReason || 'Other' }}</strong
>. Please contact the customer and complete verification as soon as possible.
</p>
</div>
</div>
}
<!-- Verification Table -->
<div class="kv-table">
<div class="kv-table__header">
@@ -740,6 +766,32 @@
</mat-select>
</mat-form-field>
<!-- KYC Exception Flag -->
<div
class="full-span exception-controls-row"
style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap"
>
<mat-checkbox formControlName="isException" style="margin-top: 8px">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed"
>Enterprise PAN Verification Failed</mat-option
>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<!-- Remarks -->
<mat-form-field appearance="outline" class="full-span">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
@@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
@@ -121,7 +122,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
'',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
],
remarks: ['']
remarks: [''],
isException: [false],
exceptionReason: [[]]
});
ngOnInit(): void {
@@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
// the full 12-digit number to re-verify, so don't pre-fill.
aadhaarNumber: '',
gstinNumber: data.gstinNumber || '',
remarks: data.remarks || ''
remarks: data.remarks || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
}
});
@@ -195,6 +200,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
}
}
/**
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
*
* A field counts as verified if it was verified in this session OR is already verified on the
* stored record; either way the customer has cleared that check. Verifying one of two
* outstanding items narrows the exception to the remaining one rather than closing it.
*/
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
return deriveKycExceptionFields({
panVerified,
aadhaarVerified,
aadhaarApplicable: !this.isNonIndividual,
hadException: this.kycData?.isException === true,
previousReason: this.kycData?.exceptionReason || null,
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
});
}
saveKyc(): void {
if (this.kycForm.invalid) {
return;
@@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null
remarks: this.kycForm.value.remarks || null,
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -244,6 +274,38 @@ export class KycTabComponent implements OnInit, OnDestroy {
});
}
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
if (this.isEditMode) {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
const payload: any = {
locale: 'en',
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
next: () => {
this.loadKycData();
callback();
},
error: (err: any) => {
this.verifying[field] = false;
this.verificationStatus[field] = 'FAILED';
this.verificationError[field] =
err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
}
});
} else {
callback();
}
}
verifyPAN(): void {
if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
@@ -254,31 +316,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.pan = null;
this.verificationDetails.pan = null;
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
});
});
}, 'pan');
}
/** Show Aadhaar input in summary view for re-verification */
@@ -305,31 +369,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.aadhaarOtpSent = false;
this.aadhaarOtp = '';
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
this.saveKycBeforeVerify(() => {
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
}, 'aadhaar');
}
/** Step 2: Verify OTP entered by user */
@@ -380,31 +446,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.gstin = null;
this.verificationDetails.gstin = null;
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
});
});
}, 'gstin');
}
formatDate(date: any): string {
+43
View File
@@ -123,6 +123,20 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
}
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
* Only populated after the Checker has approved and the send succeeded. */
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
return this.http.get<{
emailSubject: string | null;
emailBody: string | null;
emailSent: boolean;
emailSentAt: string | null;
emailOpenedAt: string | null;
emailFrom: string | null;
emailTo: string | null;
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
}
getClientDataAndTemplate(clientId: string) {
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
return this.http.get(`/clients/${clientId}`, { params: httpParams });
@@ -958,4 +972,33 @@ export class ClientsService {
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
}
getKycExceptions(resolved = false): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
}
/**
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
* WHATSAPP channels — actually transmits the notice.
*
* The destination is resolved server-side from the client record; it is deliberately not part of
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
*
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
* response rather than assuming success, since the log row is written either way.
*/
saveKycContactLog(
clientId: string | number,
data: { channel: string; notes: string; officerName?: string }
): Observable<any> {
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
}
getKycContactLog(clientId: string | number): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
}
}
@@ -430,8 +430,22 @@ export class CreateClientComponent {
}
this.validationError = '';
// Save KYC
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
if (this.clientKycStep?.hasKycData) {
if (!this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: (res: any) => {
if (this.clientKycStep) {
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
}
},
error: () => {}
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService
.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload)
.subscribe({ error: () => {} });
}
}
// Upload photo & signature
if (this.clientKycStep?.photographFile) {
@@ -696,12 +710,20 @@ export class CreateClientComponent {
// Persist the KYC document picker selection + uploaded files as a client identifier
this.saveKycDocProof(clientId);
// Save KYC only if it wasn't already created during the KYC step
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
// Save/update KYC to ensure latest exception fields are persisted
if (this.clientKycStep?.hasKycData) {
if (!this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
}
} else {
navigateToClient();
}
+14
View File
@@ -618,6 +618,12 @@
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1629,6 +1635,14 @@
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #15803d"></fa-icon> Deposit Interest &amp;
TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<fa-icon icon="building-columns" class="menu-item-icon" style="color: #15803d"></fa-icon> TDS Remitted to
Govt
</button>
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
Hypothecation Certificate
@@ -29,6 +29,11 @@
Account Status
<span class="km-cnt km-cnt--red">34</span>
</button>
<button class="km-itab" [class.active]="activeSection === 'exceptions'" (click)="showSection('exceptions')">
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exceptions
<span class="km-cnt km-cnt--amb">{{ exceptionRows.length }}</span>
</button>
</div>
<div class="km-main">
@@ -722,6 +727,284 @@
</div>
</div>
}
<!-- KYC EXCEPTIONS -->
@if (activeSection === 'exceptions') {
<div class="km-page">
<div class="km-page-head">
<span class="km-page-title">KYC Exceptions Dashboard</span>
<div class="km-actions">
<button class="km-btn km-btn-g" (click)="toast('Exporting exception report...')">
<fa-icon icon="download"></fa-icon> Export Exceptions
</button>
</div>
</div>
<div class="km-stat-row">
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-amber)">{{ exceptionRows.length }}</div>
<div class="km-stat-l">Pending Exceptions</div>
<div class="km-stat-t" style="color: var(--km-amber)">Immediate attention</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-red)">
{{ notYetContactedCount }}
</div>
<div class="km-stat-l">Not Yet Contacted</div>
<div class="km-stat-t" style="color: var(--km-red)">Action required</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-orange)">
{{ panMismatchesCount }}
</div>
<div class="km-stat-l">PAN Mismatches</div>
<div class="km-stat-t" style="color: var(--km-orange)">Requires physical review</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-blue)">
{{ aadhaarFailuresCount }}
</div>
<div class="km-stat-l">Aadhaar Failures</div>
<div class="km-stat-t" style="color: var(--km-blue)">Re-KYC OTP needed</div>
</div>
</div>
<div class="km-card km-mt">
<!-- Tab Toggle Header -->
<div
class="km-ch"
style="
border-bottom: 1px solid var(--km-g200);
padding-bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
"
>
<div style="display: flex; gap: 8px; align-items: center">
<div class="km-ci" style="background: #fef3c7; color: #d97706">
<fa-icon icon="exclamation-triangle"></fa-icon>
</div>
<div class="km-ct">Exceptions Register</div>
</div>
<div class="km-inner-nav" style="width: 100%; border: none; margin: 10px 0 0 0; padding: 0; gap: 8px">
<button
class="km-itab"
[class.active]="activeExceptionSubTab === 'pending'"
(click)="activeExceptionSubTab = 'pending'"
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
>
<fa-icon icon="clock"></fa-icon>
Pending Exceptions ({{ pendingExceptions.length }})
</button>
<button
class="km-itab"
[class.active]="activeExceptionSubTab === 'resolved'"
(click)="activeExceptionSubTab = 'resolved'"
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
>
<fa-icon icon="check-circle"></fa-icon>
Resolved Exceptions ({{ resolvedExceptions.length }})
</button>
</div>
</div>
<!-- PENDING EXCEPTIONS TAB -->
@if (activeExceptionSubTab === 'pending') {
@if (pendingExceptions.length === 0) {
<div class="km-empty" style="padding: 40px; text-align: center">
<div class="km-empty-i">🎉</div>
<div style="font-weight: 500; font-size: 16px; margin-top: 12px">All exceptions resolved!</div>
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
There are no active client KYC exceptions on record.
</div>
</div>
} @else {
<table class="km-tbl">
<thead>
<tr>
<th>Client / Account</th>
<th>Branch</th>
<th>Exception Reason &amp; Date</th>
<th>Risk</th>
<th>Contact Status</th>
<th>Contact Logs</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (c of pendingExceptions; track c.id) {
<tr>
<td>
<div
class="km-an"
[routerLink]="['/clients', c.id, 'kyc']"
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
>
{{ c.name }}
</div>
<div class="km-ai">{{ c.account }}</div>
</td>
<td>{{ c.contact.branch }}</td>
<td>
<div style="font-weight: 500; color: #b45309">{{ c.exceptionReason }}</div>
<div class="km-small">Opened: {{ c.exceptionDate }}</div>
</td>
<td>
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<span class="km-del d-ok">Contacted</span>
<div class="km-small" style="margin-top: 2px">
Last: {{ c.contactHistory[c.contactHistory.length - 1].date }}
</div>
} @else {
<span class="km-del d-fa">Not Contacted</span>
}
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
@for (log of c.contactHistory; track $index) {
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
<em>— {{ log.officer }}</em>
@if (log.platformSent) {
@if (log.delivered) {
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
>Sent {{ log.destinationMasked }}</span
>
} @else {
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
}
}
</div>
}
</div>
} @else {
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
}
</td>
<td>
<div style="display: flex; gap: 8px">
<button
class="km-btn km-btn-ot"
style="border-color: var(--km-blue); color: var(--km-blue)"
[routerLink]="['/clients', c.id, 'kyc']"
>
<fa-icon icon="user-check"></fa-icon> Do KYC
</button>
<button class="km-btn km-btn-ot" (click)="openContactDialog(c)">
<fa-icon icon="phone"></fa-icon> Log Call
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
}
}
<!-- RESOLVED EXCEPTIONS TAB -->
@if (activeExceptionSubTab === 'resolved') {
@if (resolvedExceptions.length === 0) {
<div class="km-empty" style="padding: 40px; text-align: center">
<div class="km-empty-i" style="font-size: 32px; filter: grayscale(1)">📋</div>
<div style="font-weight: 500; font-size: 16px; margin-top: 12px; color: var(--km-g500)">
No resolved exceptions
</div>
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
Resolved KYC exceptions will appear here once verification is completed.
</div>
</div>
} @else {
<table class="km-tbl">
<thead>
<tr>
<th>Client / Account</th>
<th>Branch</th>
<th>Exception Reason &amp; Resolution</th>
<th>Risk</th>
<th>Contact Logs</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (c of resolvedExceptions; track c.id) {
<tr>
<td>
<div
class="km-an"
[routerLink]="['/clients', c.id, 'kyc']"
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
>
{{ c.name }}
</div>
<div class="km-ai">{{ c.account }}</div>
</td>
<td>{{ c.contact.branch }}</td>
<td>
<div style="font-weight: 500; color: var(--km-g500); text-decoration: line-through">
{{ c.exceptionReason }}
</div>
<div class="km-small">Resolved On: {{ c.exceptionResolvedOn }}</div>
</td>
<td>
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
@for (log of c.contactHistory; track $index) {
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
<em>— {{ log.officer }}</em>
@if (log.platformSent) {
@if (log.delivered) {
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
>Sent {{ log.destinationMasked }}</span
>
} @else {
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
}
}
</div>
}
</div>
} @else {
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
}
</td>
<td>
<span
style="
display: inline-flex;
align-items: center;
gap: 4px;
background: #e6f4ea;
color: #137333;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
font-size: 12px;
"
>
<fa-icon icon="check-circle"></fa-icon> Exception Resolved
</span>
</td>
</tr>
}
</tbody>
</table>
}
}
</div>
</div>
}
</div>
<!-- CLIENT DETAIL MODAL -->
@@ -1165,6 +1448,251 @@
</div>
}
<!-- LOG CONTACT DIALOG -->
@if (contactDialogOpen && current) {
<div class="km-overlay" (click)="closeContactDialog()">
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
<div class="km-mh">
<div class="km-mh-av">{{ current.av }}</div>
<div>
<div class="km-mh-name">Log Customer Contact</div>
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
</div>
<div class="km-mclose" (click)="closeContactDialog()">✕</div>
</div>
<div class="km-mbody hv-body">
<div class="hv-addr">
<div class="hv-addr-label">Customer Contact Info</div>
<div class="hv-addr-name">{{ current.name }}</div>
<div class="hv-addr-text">
<fa-icon icon="mobile-alt" class="vkyc-ic"></fa-icon> {{ current.contact.mobile }}
</div>
<div class="hv-addr-text">
<fa-icon icon="envelope" class="vkyc-ic"></fa-icon> {{ current.contact.email }}
</div>
</div>
<div class="hv-row">
<label class="hv-label">Contact Channel<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="contactChannel">
@for (ch of contactChannels; track ch.value) {
<option [value]="ch.value">
{{ ch.label }}{{ ch.sends ? ' — sends a notice now' : ' — record only' }}
</option>
}
</select>
@if (selectedChannelSends) {
<div class="hv-hint hv-hint-send">
<fa-icon icon="paper-plane" class="vkyc-ic"></fa-icon>
A KYC notice will be sent to the customer when you save.
</div>
} @else {
<div class="hv-hint">Records an action you already took. Nothing is sent to the customer.</div>
}
@if (selectedChannelUnavailable) {
<div class="hv-hint hv-hint-error">
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on
file — choose a different channel.
</div>
}
</div>
<div class="hv-row">
<label class="hv-label">KYC Officer Logging<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="contactOfficer">
@for (officer of bankOfficers; track officer) {
<option [value]="officer">{{ officer }}</option>
}
</select>
</div>
<div class="hv-row">
<label class="hv-label">Summary / Notes<span class="hv-req">*</span></label>
<textarea
class="hv-input hv-textarea"
rows="3"
placeholder="Enter details of conversation e.g. Customer promised to visit branch next Tuesday..."
[(ngModel)]="contactNotes"
></textarea>
</div>
@if (contactError) {
<div class="hv-hint hv-hint-error" role="alert">
<fa-icon icon="exclamation-circle" class="vkyc-ic"></fa-icon> {{ contactError }}
</div>
}
</div>
<div class="hv-footer">
<button class="km-btn" [disabled]="contactSaving" (click)="closeContactDialog()">Cancel</button>
<button class="km-btn km-btn-t" [disabled]="contactSaveDisabled" (click)="saveContactLog()">
{{ contactSaveLabel }}
</button>
</div>
</div>
</div>
}
<!-- RESOLVE KYC DIALOG -->
@if (resolveDialogOpen && current) {
<div class="km-overlay" (click)="closeResolveDialog()">
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
<div class="km-mh" style="border-bottom: 2px solid var(--km-green)">
<div class="km-mh-av" style="background: var(--km-green)">{{ current.av }}</div>
<div>
<div class="km-mh-name">Resolve KYC Exception</div>
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
</div>
<div class="km-mclose" (click)="closeResolveDialog()">✕</div>
</div>
<div class="km-mbody hv-body">
<div
style="
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
font-size: 13px;
color: #166534;
"
>
<strong>Pending Reason:</strong> {{ current.exceptionReason }}
@if (current.exceptionRemarks) {
<div style="margin-top: 4px; font-style: italic">"{{ current.exceptionRemarks }}"</div>
}
</div>
@if (showPanResolution) {
<div
style="
border: 1px solid var(--km-g300);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
background: #fafafa;
"
>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<input type="checkbox" id="resolvePanChk" [(ngModel)]="resolvePanActive" />
<label for="resolvePanChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
>Resolve PAN Card Exception</label
>
</div>
@if (resolvePanActive) {
<div class="hv-row" style="margin-left: 20px">
<label class="hv-label">Verified PAN Number<span class="hv-req">*</span></label>
<input
type="text"
class="hv-input"
[(ngModel)]="resolvePanNumber"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase"
/>
@if (resolvePanNumber && resolvePanNumber.length !== 10) {
<div style="color: red; font-size: 11px; margin-top: 4px">PAN must be exactly 10 characters.</div>
}
</div>
}
</div>
}
@if (showAadhaarResolution) {
<div
style="
border: 1px solid var(--km-g300);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
background: #fafafa;
"
>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<input type="checkbox" id="resolveAadhChk" [(ngModel)]="resolveAadhaarActive" />
<label for="resolveAadhChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
>Resolve Aadhaar Card Exception</label
>
</div>
@if (resolveAadhaarActive) {
<div class="hv-row" style="margin-left: 20px">
<label class="hv-label">Verified Aadhaar Number<span class="hv-req">*</span></label>
<input
type="text"
class="hv-input"
[(ngModel)]="resolveAadhaarNumber"
placeholder="e.g. 597855025299"
maxlength="12"
/>
@if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) {
<div style="color: red; font-size: 11px; margin-top: 4px">Aadhaar must be exactly 12 digits.</div>
}
</div>
}
</div>
}
@if (showGeneralResolution) {
<div
style="
border: 1px solid var(--km-g300);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
background: #fafafa;
"
>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<input type="checkbox" id="resolveGenChk" [(ngModel)]="resolveGeneralActive" />
<label for="resolveGenChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
>Resolve General Exception</label
>
</div>
@if (resolveGeneralActive) {
<div style="margin-left: 20px">
<div class="hv-row">
<label class="hv-label">Verification Document Type<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="resolveGeneralDocumentType">
<option value="OVD">Officially Valid Document (Passport/Voter ID)</option>
</select>
</div>
<div class="hv-row">
<label class="hv-label">Document Identification Number<span class="hv-req">*</span></label>
<input
type="text"
class="hv-input"
[(ngModel)]="resolveGeneralDocumentNumber"
placeholder="Enter verified document number"
/>
</div>
</div>
}
</div>
}
<div class="hv-row">
<label class="hv-label">Resolution Comments<span class="hv-req">*</span></label>
<textarea
class="hv-input hv-textarea"
rows="3"
placeholder="Describe how verification was resolved..."
[(ngModel)]="resolveRemarks"
></textarea>
</div>
</div>
<div class="hv-footer">
<button class="km-btn" (click)="closeResolveDialog()">Cancel</button>
<button class="km-btn km-btn-g" [disabled]="!isResolveValid" (click)="saveResolveKyc()">
Complete KYC &amp; Resolve Exception
</button>
</div>
</div>
</div>
}
<!-- TOAST -->
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -1588,6 +1588,58 @@
min-height: 60px;
}
/* Inline note under a field. The default (neutral) form states that nothing is transmitted;
the -send and -error variants call out the two cases the officer must not misread. */
.hv-hint {
font-size: 11px;
line-height: 1.45;
color: var(--km-g500);
display: flex;
align-items: flex-start;
gap: 6px;
}
.hv-hint-send {
color: #0f766e;
background: #f0fdfa;
border: 1px solid #99f6e4;
border-radius: 4px;
padding: 6px 8px;
font-weight: 500;
}
.hv-hint-error {
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 4px;
padding: 6px 8px;
font-weight: 500;
}
/* Delivery outcome chip on a contact-log row. A logged attempt and a delivered notice are
different facts, so they never share a colour. */
.km-sent-tag {
display: inline-block;
margin-left: 6px;
padding: 0 5px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
vertical-align: baseline;
}
.km-sent-ok {
background: #e6f4ea;
color: #137333;
}
.km-sent-fail {
background: #fef2f2;
color: #b91c1c;
}
.hv-footer {
padding: 14px 22px;
border-top: 1px solid var(--km-g200);
@@ -1648,3 +1700,30 @@
color: #0f172a;
word-break: break-all;
}
/* KYC Exceptions Styles */
.km-ci-e {
background: var(--km-amber-l) !important;
color: var(--km-amber) !important;
}
.contact-logs-list {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 10px;
font-family: inherit;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
}
@@ -6,10 +6,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from '../clients/clients.service';
interface KmNotice {
no: string;
@@ -31,6 +32,24 @@ interface KmEvidence {
docket?: string;
}
/** One outreach attempt, as returned by GET /kyc/exceptions/{clientId}/contact-log. */
interface KmContactLog {
date: string;
/** Display label for the channel used, e.g. 'SMS' / 'In-Branch Visit'. */
action: string;
notes: string;
officer: string;
/** True when this channel transmits (SMS/Email/WhatsApp) rather than being recorded offline. */
platformSent?: boolean;
/** True only when the notice actually reached the gateway. */
delivered?: boolean;
/** SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED | NOT_APPLICABLE */
deliveryStatus?: string;
/** Officer-facing outcome sentence — shown verbatim so a failure is never mistaken for a send. */
deliveryMessage?: string;
destinationMasked?: string;
}
interface KmClient {
id: string;
av: string;
@@ -66,6 +85,17 @@ interface KmClient {
pendingActions: string[];
actionTaken: string[];
evidence: KmEvidence | null;
isException?: boolean;
exceptionReason?: string;
exceptionRemarks?: string;
exceptionDate?: string;
exceptionResolved?: boolean;
exceptionResolvedOn?: string;
contactHistory?: KmContactLog[];
kycId?: string;
/** Whether the customer has a mobile / email on file — drives channel availability in the dialog. */
hasMobile?: boolean;
hasEmail?: boolean;
}
@Component({
@@ -79,7 +109,8 @@ interface KmClient {
]
})
export class KycManagementComponent implements OnInit {
activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts';
private clientsService = inject(ClientsService);
activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts';
modalOpen = false;
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
@@ -529,15 +560,419 @@ export class KycManagementComponent implements OnInit {
};
alertRows: KmClient[] = [];
exceptionRows: KmClient[] = [];
pendingExceptions: KmClient[] = [];
resolvedExceptions: KmClient[] = [];
activeExceptionSubTab: 'pending' | 'resolved' = 'pending';
selectedExceptionReason = '';
selectedExceptionContactStatus = '';
// Dialog properties for exceptions
contactDialogOpen = false;
contactChannel = 'PHONE_CALL';
contactNotes = '';
contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)';
/** In-flight guard — a double-click must not send the customer two notices. */
contactSaving = false;
/** Populated when the save itself failed (not when the save worked but delivery didn't). */
contactError = '';
/**
* Channels offered in the Log Customer Contact dialog. `sends` marks the ones where saving
* transmits a real notice to the customer, as opposed to recording an action the officer already
* took offline — the dialog labels and the confirm button both key off it.
*/
readonly contactChannels: { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' }[] = [
{ value: 'PHONE_CALL', label: 'Phone Call', sends: false },
{ value: 'SMS', label: 'SMS', sends: true, requires: 'mobile' },
{ value: 'EMAIL', label: 'Email', sends: true, requires: 'email' },
{ value: 'WHATSAPP', label: 'WhatsApp Message', sends: true, requires: 'mobile' },
{ value: 'BRANCH_VISIT', label: 'In-Branch Visit', sends: false },
{ value: 'FIELD_VISIT', label: 'Agent Field Visit', sends: false }
];
resolveDialogOpen = false;
resolveDocumentType = 'PAN';
resolveDocumentNumber = '';
resolveRemarks = '';
resolvePanActive = false;
resolvePanNumber = '';
resolveAadhaarActive = false;
resolveAadhaarNumber = '';
resolveGeneralActive = false;
resolveGeneralDocumentType = 'OVD';
resolveGeneralDocumentNumber = '';
showPanResolution = false;
showAadhaarResolution = false;
showGeneralResolution = false;
ngOnInit(): void {
this.alertRows = Object.values(this.clients);
this.loadExceptions();
}
showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void {
loadExceptions(): void {
this.clientsService.getKycExceptions(false).subscribe({
next: (data) => {
this.pendingExceptions = data.map((item: any) => this.mapException(item, false));
this.exceptionRows = this.pendingExceptions;
this.loadContactHistories(this.pendingExceptions);
},
error: (err) => {
console.error('Failed to load active exceptions', err);
this.pendingExceptions = [];
this.exceptionRows = [];
}
});
this.clientsService.getKycExceptions(true).subscribe({
next: (data) => {
this.resolvedExceptions = data.map((item: any) => this.mapException(item, true));
this.loadContactHistories(this.resolvedExceptions);
},
error: (err) => {
console.error('Failed to load resolved exceptions', err);
this.resolvedExceptions = [];
}
});
}
private mapException(item: any, resolved: boolean): KmClient {
return {
id: item.clientId?.toString(),
av: (item.clientDisplayName || 'Client')
.split(' ')
.map((n: string) => n[0])
.join('')
.substring(0, 2)
.toUpperCase(),
name: item.clientDisplayName,
meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`,
account: item.clientAccountNo || `CLI-${item.clientId}`,
state: 'alert',
risk: item.riskCategoryCode?.toLowerCase() || 'low',
riskLabel: item.riskCategory || 'Low Risk',
expiry: 'Pending',
daysLeft: 30,
kyc: {
type: item.kycType || 'Individual',
initiated: item.initiatedOn || '—',
lastVerified: item.verifiedOn || '—',
validity: '—',
pan: item.panNumber || '—',
aadhaar: item.aadhaarNumberMasked || '—',
panStatus: item.panVerified ? 'Verified' : 'Verification Failed',
aadhaarStatus: item.aadhaarVerified ? 'Verified' : 'Verification Failed',
panLink: 'Not Linked'
},
contact: {
// Masked server-side; the full values never reach the browser. 'Not on file' is meaningful
// here — it's why an SMS or Email channel will be unavailable for this customer.
mobile: item.clientMobileMasked || 'Not on file',
email: item.clientEmailMasked || 'Not on file',
address: 'Client Address, Bhopal MP 462002',
branch: item.clientOfficeName || 'Bhopal Main'
},
notices: [],
noticesSent: 0,
noticePending: resolved ? 'Resolved Exception' : 'Pending verification',
nextStage: resolved ? 'Exception Resolved' : 'KYC Exceptions review',
pendingActions: resolved ? [] : ['Resolve Exception reasons'],
actionTaken: resolved ? ['KYC Exception resolved'] : [],
evidence: null,
isException: true,
// No invented default — a record whose reason was never stored (or was cleared by the old
// resolve path) must not be relabelled as a PAN failure it may never have had.
exceptionReason: item.exceptionReason || 'Reason not recorded',
exceptionRemarks: item.remarks || '',
exceptionDate: item.initiatedOn || '—',
exceptionResolved: resolved,
exceptionResolvedOn: item.exceptionResolvedOn || '—',
contactHistory: [],
kycId: item.id?.toString(),
hasMobile: item.hasMobile === true,
hasEmail: item.hasEmail === true
};
}
/**
* Pull each row's persisted outreach history. Fired after the exception list loads; a failure on
* one row leaves that row's history empty rather than blanking the whole grid.
*/
private loadContactHistories(rows: KmClient[]): void {
for (const row of rows) {
if (!row.id) continue;
this.clientsService.getKycContactLog(row.id).subscribe({
next: (logs) => {
row.contactHistory = (logs || []).map((log: any) => this.mapContactLog(log));
},
error: (err) => {
console.error(`Failed to load contact log for client ${row.id}`, err);
}
});
}
}
get notYetContactedCount(): number {
return this.exceptionRows.filter((c) => !c.contactHistory || c.contactHistory.length === 0).length;
}
get panMismatchesCount(): number {
return this.exceptionRows.filter((c) => c.exceptionReason?.includes('PAN')).length;
}
get aadhaarFailuresCount(): number {
return this.exceptionRows.filter((c) => c.exceptionReason?.includes('Aadhaar')).length;
}
showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void {
this.activeSection = section;
}
openContactDialog(client: KmClient): void {
this.current = client;
this.contactChannel = 'PHONE_CALL';
this.contactNotes = '';
this.contactError = '';
this.contactSaving = false;
this.contactOfficer = this.bankOfficers[1]; // Neha Verma
this.contactDialogOpen = true;
}
closeContactDialog(): void {
this.contactDialogOpen = false;
}
/** The channel currently picked in the dialog. */
get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined {
return this.contactChannels.find((c) => c.value === this.contactChannel);
}
/** True when saving will transmit a notice to the customer, not merely record one. */
get selectedChannelSends(): boolean {
return this.selectedChannel?.sends === true;
}
/**
* True when the picked channel needs a destination the customer doesn't have. Blocks the save so
* the officer isn't left reading a failure they could have seen up front.
*/
get selectedChannelUnavailable(): boolean {
const requires = this.selectedChannel?.requires;
if (!requires || !this.current) return false;
return requires === 'mobile' ? this.current.hasMobile === false : this.current.hasEmail === false;
}
get contactSaveDisabled(): boolean {
return !this.contactNotes?.trim() || !this.contactChannel || this.contactSaving || this.selectedChannelUnavailable;
}
/** 'Send & Save' when a notice goes out, plain 'Save Log' when it's an offline record. */
get contactSaveLabel(): string {
if (this.contactSaving) return this.selectedChannelSends ? 'Sending…' : 'Saving…';
return this.selectedChannelSends ? 'Send & Save' : 'Save Log';
}
saveContactLog(): void {
if (!this.current || this.contactSaveDisabled) return;
const client = this.current;
this.contactSaving = true;
this.contactError = '';
this.clientsService
.saveKycContactLog(client.id, {
channel: this.contactChannel,
notes: this.contactNotes.trim(),
officerName: this.contactOfficer
})
.subscribe({
next: (log: any) => {
this.contactSaving = false;
client.contactHistory = [
...(client.contactHistory || []),
this.mapContactLog(log)
];
// The request succeeding does not mean the customer was reached — a gateway failure comes
// back as HTTP 200 with delivered=false, so report what actually happened.
if (log?.platformSent && !log?.delivered) {
this.toast(log?.deliveryMessage || `Logged, but the ${log?.channelLabel || 'message'} could not be sent.`);
} else {
this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`);
}
this.contactDialogOpen = false;
},
error: (err) => {
this.contactSaving = false;
// Keep the dialog open — the officer's notes are still in the textarea and would
// otherwise be lost.
this.contactError =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'Could not save the contact log. Please try again.';
}
});
}
private mapContactLog(log: any): KmContactLog {
return {
date: log?.contactedAt ? this.formatLogDate(log.contactedAt) : '—',
action: log?.channelLabel || log?.channel || 'Contact',
notes: log?.notes || '',
officer: (log?.officerName || '').split(' — ')[0],
platformSent: log?.platformSent,
delivered: log?.delivered,
deliveryStatus: log?.deliveryStatus,
deliveryMessage: log?.deliveryMessage,
destinationMasked: log?.destinationMasked
};
}
/**
* `contactedAt` arrives as a LocalDateTime — Jackson serialises it as an ISO string or as a
* [y, m, d, h, min, s] array depending on the tenant's date-format setting, so handle both.
*/
private formatLogDate(value: any): string {
const date = Array.isArray(value)
? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0)
: new Date(value);
if (isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
get isResolveValid(): boolean {
if (!this.resolveRemarks?.trim()) return false;
let anyActive = false;
if (this.showPanResolution && this.resolvePanActive) {
anyActive = true;
if (!this.resolvePanNumber || this.resolvePanNumber.trim().length !== 10) return false;
}
if (this.showAadhaarResolution && this.resolveAadhaarActive) {
anyActive = true;
if (!this.resolveAadhaarNumber || this.resolveAadhaarNumber.trim().length !== 12) return false;
}
if (this.showGeneralResolution && this.resolveGeneralActive) {
anyActive = true;
if (!this.resolveGeneralDocumentNumber?.trim()) return false;
}
return anyActive;
}
openResolveDialog(client: KmClient): void {
this.current = client;
const reason = client.exceptionReason || '';
this.showPanResolution = reason.includes('PAN');
this.showAadhaarResolution = reason.includes('Aadhaar');
this.showGeneralResolution = !this.showPanResolution && !this.showAadhaarResolution;
this.resolvePanActive = this.showPanResolution;
this.resolveAadhaarActive = this.showAadhaarResolution;
this.resolveGeneralActive = this.showGeneralResolution;
this.resolvePanNumber = '';
this.resolveAadhaarNumber = '';
this.resolveGeneralDocumentType = 'OVD';
this.resolveGeneralDocumentNumber = '';
this.resolveRemarks = '';
this.resolveDialogOpen = true;
}
closeResolveDialog(): void {
this.resolveDialogOpen = false;
}
saveResolveKyc(): void {
if (!this.current || !this.current.kycId) return;
const clientId = this.current.id;
const kycId = this.current.kycId;
const today = new Date();
const day = today.getDate().toString().padStart(2, '0');
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
const monthStr = months[today.getMonth()];
const year = today.getFullYear();
const dateStr = `${day} ${monthStr} ${year}`;
const resolvingPan = this.showPanResolution && this.resolvePanActive;
const resolvingAadhaar = this.showAadhaarResolution && this.resolveAadhaarActive;
const resolvingGeneral = this.showGeneralResolution && this.resolveGeneralActive;
const remainsPan = this.showPanResolution && !this.resolvePanActive;
const remainsAadhaar = this.showAadhaarResolution && !this.resolveAadhaarActive;
const isFullyResolved = !remainsPan && !remainsAadhaar;
const resolvedKycData: any = {
locale: 'en',
dateFormat: 'dd MMMM yyyy',
remarks: this.resolveRemarks || 'Resolved via exceptions dashboard'
};
if (isFullyResolved) {
resolvedKycData.isException = false;
resolvedKycData.exceptionResolved = true;
resolvedKycData.exceptionResolvedOn = dateStr;
// Keep exceptionReason — the Resolved tab shows what was resolved, and clearing it destroys
// the audit record of why the exception was ever raised.
} else {
resolvedKycData.isException = true;
resolvedKycData.exceptionResolved = false;
const remainingReasons: string[] = [];
if (remainsPan) remainingReasons.push('PAN Card Verification Failed');
if (remainsAadhaar) remainingReasons.push('Aadhaar Verification Failed');
resolvedKycData.exceptionReason = remainingReasons.join(', ');
}
if (resolvingPan) {
resolvedKycData.panNumber = this.resolvePanNumber.toUpperCase().trim();
resolvedKycData.panVerified = true;
}
if (resolvingAadhaar) {
const aadhaarRaw = this.resolveAadhaarNumber.trim();
resolvedKycData.aadhaarNumberMasked = 'XXXXXXXX' + aadhaarRaw.slice(-4);
resolvedKycData.aadhaarVerified = true;
}
if (resolvingGeneral) {
resolvedKycData.kycDocumentName = this.resolveGeneralDocumentType;
resolvedKycData.kycDocumentNumber = this.resolveGeneralDocumentNumber;
resolvedKycData.isException = false;
resolvedKycData.exceptionResolved = true;
resolvedKycData.exceptionResolvedOn = dateStr;
// exceptionReason preserved — see the fully-resolved branch above.
}
this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({
next: () => {
const msg = isFullyResolved
? `KYC Exception fully resolved and completed for ${this.current?.name}`
: `KYC Exception partially resolved for ${this.current?.name}`;
this.toast(msg);
this.loadExceptions();
this.resolveDialogOpen = false;
},
error: (err) => {
console.error('Failed to resolve exception', err);
this.toast('Failed to resolve exception. Please try again.');
}
});
}
openModal(id: string): void {
const c = this.clients[id];
if (!c) return;
@@ -505,3 +505,72 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--cert-g900);
}
/* Form 121 register — eligibility warnings and the quarter-wise drill-down */
/* A row the branch needs to look at: an ineligible declaration, or the quarter
in which the 194A threshold was crossed. */
.cert-row--warn > td {
background: color-mix(in srgb, var(--cert-amber) 7%, transparent);
}
.cert-row--detail > td {
background: var(--cert-g50);
padding: 16px;
}
.cert-note {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
border-left: 3px solid var(--cert-blue);
font-size: 12px;
color: var(--cert-g900);
line-height: 1.5;
fa-icon {
color: var(--cert-blue);
flex-shrink: 0;
margin-top: 1px;
}
ul {
margin: 6px 0 0;
padding-left: 18px;
}
li + li {
margin-top: 4px;
}
&--warn {
background: color-mix(in srgb, var(--cert-amber) 8%, transparent);
border-left-color: var(--cert-amber);
fa-icon {
color: var(--cert-amber);
}
}
}
/* The quarter table nested inside an expanded row. */
.cert-table--inner {
background: #fff;
border: 1px solid var(--cert-g200);
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
th {
background: #fff;
border-bottom: 1px solid var(--cert-g200);
}
.cert-pill {
margin-left: 6px;
}
}
@@ -11,158 +11,347 @@
<div class="cert-head-l">
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Form 121</h2>
<div class="cert-head-s">TDS-waiver declarations for FD / RD interest income — Form 121.</div>
<h2 class="cert-head-t">{{ statute.form }}</h2>
<div class="cert-head-s">
@if (viewMode === 'demo') {
Declarations to stop tax being deducted on fixed and recurring deposit interest under
{{ statute.section }}. Reported in {{ statute.statement }}; the consolidated statement of declarations is
uploaded {{ statute.frequency }}.
} @else {
Live customer declarations, sourced from client accounts and KYC profiles.
}
</div>
</div>
</div>
<div class="cert-head-r">
@if (viewMode === 'demo') {
<button
type="button"
class="cert-cta-btn"
(click)="toggleViewMode()"
matTooltip="Use real client KYC and account data from Fineract"
>
<fa-icon icon="database"></fa-icon> Real Declarations
</button>
} @else {
<button
type="button"
class="cert-cta-btn cert-cta-btn--muted"
(click)="toggleViewMode()"
matTooltip="Back to the sample data page"
>
<fa-icon icon="table"></fa-icon> Sample Data
</button>
}
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ count15G() | number }}</div>
<div class="cert-kpi-l">Form 121 (under 60)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ count15H() | number }}</div>
<div class="cert-kpi-l">Form 121 (60+)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
<div class="cert-kpi-l">Filed this FY</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Projected Interest</div>
</div>
</div>
</div>
@if (viewMode === 'real') {
<mifosx-real-form15gh-view></mifosx-real-form15gh-view>
}
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Customer / Customer ID / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
@if (viewMode === 'demo') {
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
<div class="cert-kpi-l">General band (under 60)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
<div class="cert-kpi-l">Senior citizen band (60+)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
<div class="cert-kpi-l">Filed this FY</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Projected Interest</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">TDS Saved</div>
</div>
</div>
@if (countIneligible() > 0) {
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
<div class="cert-kpi-l">Not Eligible</div>
</div>
</div>
}
</div>
<div class="cert-filter">
<label>Form Type</label>
<select [(ngModel)]="kindFilter">
<option value="">All Form 121</option>
<option value="15G">Form 121 (Below 60 years)</option>
<option value="15H">Form 121 (Senior Citizen 60+)</option>
</select>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Customer / Customer ID / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Threshold Band</label>
<select [(ngModel)]="bandFilter">
<option value="">All bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="SENIOR">Senior citizen (60+)</option>
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="FILED">Filed</option>
<option value="PENDING">Pending</option>
</select>
</div>
<div class="cert-filter">
<label>Eligibility</label>
<select [(ngModel)]="ineligibleOnly">
<option [ngValue]="false">All customers</option>
<option [ngValue]="true">Not eligible only</option>
</select>
</div>
@if (search || bandFilter || statusFilter || ineligibleOnly) {
<button type="button" class="cert-clear" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon> Clear
</button>
}
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="FILED">Filed</option>
<option value="PENDING">Pending</option>
</select>
</div>
@if (search || kindFilter || statusFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Form Type</th>
<th>Age</th>
<th>Branch</th>
<th class="num">Deposit Principal</th>
<th class="num">Est. Interest ({{ records[0]?.fy }})</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.customerId) {
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
<div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="formKind(r) === '15G'"
[class.cert-pill--amber]="formKind(r) === '15H'"
>
Form 121 ({{ formKind(r) }})
</span>
</td>
<td class="small">{{ age(r.dob) }} yrs</td>
<td class="small">{{ r.branch }}</td>
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
<td class="num">
<strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
</td>
<td>
@if (r.status === 'FILED') {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
@if (r.filedOn) {
<div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
<th>Customer · PAN</th>
<th>Form · Band</th>
<th>Age</th>
<th>Branch</th>
<th class="num">Est. Interest</th>
<th class="num">Threshold</th>
<th class="num">TDS Payable</th>
<th class="num">TDS Saved</th>
<th>Settlement</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.record.customerId) {
<tr [class.cert-row--warn]="!row.eligibility.eligible">
<td>
<div class="cert-cust-name">{{ row.record.customerName }}</div>
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div>
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div>
</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="row.band === 'GENERAL'"
[class.cert-pill--amber]="row.band === 'SENIOR'"
>
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }}
</span>
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
</td>
<td class="small">{{ row.age }} yrs</td>
<td class="small">{{ row.record.branch }}</td>
<td class="num">
<strong class="cert-interest">₹{{ row.record.estimatedInterest | number: '1.0-0' }}</strong>
</td>
<td class="num small muted">₹{{ row.schedule.threshold | number: '1.0-0' }}</td>
<td class="num">
@if (row.schedule.totalTds > 0) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="small muted">—</span>
}
} @else {
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print declaration"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No declarations match the current filters.
</td>
</tr>
}
</tbody>
</table>
</td>
<td class="num">
@if (row.tdsSaved > 0) {
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
} @else {
<span class="small muted">—</span>
}
</td>
<td class="small">
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
</span>
<div class="small muted">product-driven</div>
</td>
<td>
@if (row.record.status === 'FILED') {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
@if (row.record.filedOn) {
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div>
}
} @else {
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
}
@if (!row.eligibility.eligible) {
<div class="cert-pill cert-pill--amber">
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
</div>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn"
(click)="toggleExpanded(row)"
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
>
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(row)"
matTooltip="View / Print declaration"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(row)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
@if (isExpanded(row)) {
<tr class="cert-row--detail">
<td colspan="11">
@if (!row.eligibility.eligible) {
<div class="cert-note cert-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Declaration cannot be accepted</strong>
<ul>
@for (reason of row.eligibility.reasons; track reason) {
<li>{{ reason }}</li>
}
</ul>
</div>
</div>
}
@for (note of row.eligibility.unassessed; track note) {
<div class="cert-note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@for (note of row.schedule.notes; track note) {
<div class="cert-note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
<table class="cert-table cert-table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="cert-pill cert-pill--amber">threshold crossed</span>
}
@if (line.declarationActive) {
<span class="cert-pill cert-pill--green">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="small muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
<tr>
<td class="small"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ row.schedule.totalInterest | number: '1.0-0' }}</strong>
</td>
<td></td>
<td></td>
<td class="num">
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
@if (row.schedule.shieldedInterest > 0) {
<div class="small muted">
₹{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
the declaration — reportable in the quarterly {{ statute.statement }} return.
</div>
}
</td>
</tr>
}
}
@if (filteredRows.length === 0) {
<tr>
<td colspan="11" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No declarations match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
@@ -21,9 +21,31 @@ import {
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadForm15ghPdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
import { RealForm15ghViewComponent } from './real-form15gh-view/real-form15gh-view.component';
export type Form15Kind = '15G' | '15H';
import {
Declaration,
DeclarationForm,
EligibilityResult,
FY_TAX_CONFIG,
Form15Kind,
TdsMode,
TdsSchedule,
ageInFy,
buildSchedule,
checkEligibility,
declarationFormFor,
formKindFor,
isSeniorInFy,
normaliseFy,
projectQuarterlyCredits,
tdsModeFor
} from './tds-15gh.compute';
export type { Form15Kind };
export type Form15Status = 'FILED' | 'PENDING';
/** Which deduction trigger the payee falls under. Age-based, still. */
export type ThresholdBand = 'GENERAL' | 'SENIOR';
export interface Form15ghRecord {
customerId: string;
@@ -32,18 +54,46 @@ export interface Form15ghRecord {
customerAddress: string;
dob: string;
branch: string;
/** Aggregate interest the bank will pay this FY. */
/** Aggregate interest the bank will pay this FY across the customer's time deposits. */
estimatedInterest: number;
/** Customer's total estimated income for the FY. */
/** Customer's total estimated income for the FY, as declared by them. */
estimatedIncome: number;
/** Sum of FD / RD principals tied to the declaration. */
depositPrincipal: number;
fy: string;
status: Form15Status;
filedOn?: string;
/** "Yes / No" for: has any income tax been assessed for the customer
under the IT Act in the past six years? */
/**
* "Yes / No" for: has any income tax been assessed for the customer under the
* IT Act in the past six years?
*/
assessed: boolean;
/**
* The deposit product's interest-posting frequency. Tax falls due when interest
* is credited, so this — and not any customer preference — decides whether it
* settles quarterly or at year end.
*/
interestPostingPeriodType: string;
}
/** A register row with everything the engine derived for it, computed once. */
export interface Form15ghRow {
record: Form15ghRecord;
/** The form actually in force for the record's year — '121' from FY 2026-27. */
form: DeclarationForm;
/**
* The 15G/15H label. Only a form distinction up to FY 2025-26; from FY 2026-27
* the forms merged and this reflects the threshold band alone.
*/
kind: Form15Kind;
band: ThresholdBand;
age: number;
mode: TdsMode;
/** Schedule as things stand, declaration included where one is on record. */
schedule: TdsSchedule;
/** Tax the declaration kept out of the customer's pocket this year. */
tdsSaved: number;
eligibility: EligibilityResult;
}
@Component({
@@ -55,7 +105,8 @@ export interface Form15ghRecord {
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
MatTooltip,
RealForm15ghViewComponent
]
})
export class Form15ghComponent implements OnInit {
@@ -63,40 +114,89 @@ export class Form15ghComponent implements OnInit {
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
/** Mode toggle: 'demo' uses mock seed data; 'real' pulls live Fineract data. */
viewMode: 'demo' | 'real' = 'demo';
// ── Filters ──
search = '';
/** Which form. Empty = both. */
kindFilter: '' | Form15Kind = '';
/** Which deduction trigger. Empty = both bands. */
bandFilter: '' | ThresholdBand = '';
statusFilter: '' | Form15Status = '';
/** Show only declarations the branch cannot lawfully accept. */
ineligibleOnly = false;
records: Form15ghRecord[] = [];
/** Which row's quarter-wise breakdown is open. */
expandedId: string | null = null;
rows: Form15ghRow[] = [];
ngOnInit(): void {
this.records = seedForm15gh();
// Derived once. The template reads these many times per change-detection
// pass, and re-running the schedules on every read is what made the other
// register screens stall.
this.rows = seedForm15gh().map((record) => this.buildRow(record));
}
/** Compute age in years from DoB. */
age(dob: string): number {
if (!dob) return 0;
const today = new Date();
const birth = new Date(dob);
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age;
private buildRow(record: Form15ghRecord): Form15ghRow {
const ctx = {
fy: record.fy,
dateOfBirth: record.dob,
pan: record.customerPan,
resident: true
};
const declaration: Declaration | null =
record.status === 'FILED'
? {
kind: formKindFor(record.dob, record.fy),
// The register holds the filing date; a declaration operates from
// the day the branch accepted it.
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
fy: record.fy
}
: null;
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
// The register carries a projected annual figure rather than real postings,
// so spread it across the year to drive the schedule.
const credits = projectQuarterlyCredits(record.estimatedInterest, record.fy);
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
const withoutDeclaration = buildSchedule(mode, credits, { ...ctx, declaration: null });
return {
record,
form: declarationFormFor(record.fy, record.dob),
kind: formKindFor(record.dob, record.fy),
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
age: ageInFy(record.dob, record.fy),
mode,
schedule,
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
eligibility: checkEligibility(ctx, {
estimatedInterest: record.estimatedInterest,
estimatedIncome: record.estimatedIncome,
incomeOnRecord: true
})
};
}
/** 15H once 60+, 15G otherwise. */
formKind(r: Form15ghRecord): Form15Kind {
return this.age(r.dob) >= 60 ? '15H' : '15G';
}
get filteredRecords(): Form15ghRecord[] {
get filteredRows(): Form15ghRow[] {
const q = this.search.trim().toLowerCase();
return this.records.filter((r) => {
if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false;
if (this.statusFilter && r.status !== this.statusFilter) return false;
if (!q) return true;
return this.rows.filter((row) => {
if (this.bandFilter && row.band !== this.bandFilter) {
return false;
}
if (this.statusFilter && row.record.status !== this.statusFilter) {
return false;
}
if (this.ineligibleOnly && row.eligibility.eligible) {
return false;
}
if (!q) {
return true;
}
const r = row.record;
return (
r.customerName.toLowerCase().includes(q) ||
r.customerId.toLowerCase().includes(q) ||
@@ -105,32 +205,67 @@ export class Form15ghComponent implements OnInit {
});
}
count15G(): number {
return this.records.filter((r) => this.formKind(r) === '15G').length;
countGeneralBand(): number {
return this.rows.filter((r) => r.band === 'GENERAL').length;
}
count15H(): number {
return this.records.filter((r) => this.formKind(r) === '15H').length;
countSeniorBand(): number {
return this.rows.filter((r) => r.band === 'SENIOR').length;
}
/**
* The declaration in force across the register, and the provision charging the
* tax. Both changed on 01 April 2026, so they are read from the config rather
* than written into the template.
*/
get statute(): { form: string; section: string; statement: string; frequency: string } {
const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27');
const cfg = FY_TAX_CONFIG[fy];
return {
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
section: cfg.interestTdsSection,
statement: cfg.tdsStatementForm,
frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
countFiled(): number {
return this.records.filter((r) => r.status === 'FILED').length;
return this.rows.filter((r) => r.record.status === 'FILED').length;
}
/** Declarations on record that the branch could not lawfully have accepted. */
countIneligible(): number {
return this.rows.filter((r) => !r.eligibility.eligible).length;
}
totalInterest(): number {
return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0);
return this.filteredRows.reduce((s, r) => s + r.record.estimatedInterest, 0);
}
/** Tax the declarations kept out of customers' pockets, across the filter. */
totalTdsSaved(): number {
return this.filteredRows.reduce((s, r) => s + r.tdsSaved, 0);
}
clearFilters(): void {
this.search = '';
this.kindFilter = '';
this.bandFilter = '';
this.statusFilter = '';
this.ineligibleOnly = false;
}
viewCertificate(r: Form15ghRecord): void {
toggleExpanded(row: Form15ghRow): void {
this.expandedId = this.expandedId === row.record.customerId ? null : row.record.customerId;
}
isExpanded(row: Form15ghRow): boolean {
return this.expandedId === row.record.customerId;
}
viewCertificate(row: Form15ghRow): void {
const data: CertificatePreviewDialogData = {
kind: 'form15gh',
form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) }
form15gh: { record: row.record, kind: row.kind, age: row.age }
};
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
@@ -142,20 +277,34 @@ export class Form15ghComponent implements OnInit {
});
}
downloadCertificate(r: Form15ghRecord): void {
const kind = this.formKind(r);
downloadCertificate(row: Form15ghRow): void {
const r = row.record;
// A declaration the branch cannot accept must not go out as a signed PDF.
if (!row.eligibility.eligible) {
this.snack.open(`Cannot issue: ${row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
return;
}
this.dscSign.promptThenDownload(
{
title: `Form 121 (${kind}) Declaration`,
title: `${row.form === '121' ? 'Form 121' : `Form ${row.form}`} Declaration`,
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
},
(dsc) => downloadForm15ghPdf(r, kind, dsc)
(dsc) => downloadForm15ghPdf(r, row.kind, dsc)
);
}
toggleViewMode(): void {
this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo';
}
}
// ───────────────────── Mock seed (top-level export) ─────────────────────
// ───────────────────────── Mock seed (top-level export) ─────────────────────
/**
* Two of these customers are deliberately ineligible — their estimated income
* leaves a tax liability, so a Form 121 declaration cannot be accepted from them.
* That gives the register's eligibility warnings something to show.
*/
export function seedForm15gh(): Form15ghRecord[] {
return [
{
@@ -165,86 +314,109 @@ export function seedForm15gh(): Form15ghRecord[] {
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
dob: '1985-02-14',
branch: 'Mumbai — Andheri (E)',
estimatedInterest: 38500,
estimatedIncome: 850000,
depositPrincipal: 500000,
fy: 'FY 2025-26',
status: 'FILED',
filedOn: '2025-04-10',
assessed: true
estimatedInterest: 38_500,
// Over the basic exemption limit — not eligible for 15G.
estimatedIncome: 850_000,
depositPrincipal: 500_000,
fy: 'FY 2026-27',
status: 'PENDING',
assessed: true,
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
},
{
customerId: 'CUST556677',
customerName: 'Karthik Rao',
customerPan: 'PQRSR2233Z',
customerPan: 'PQRPR2233Z',
customerAddress: '24, MG Road, Bengaluru 560001',
dob: '1972-09-04',
branch: 'Bengaluru — MG Road',
estimatedInterest: 52800,
estimatedIncome: 320000,
depositPrincipal: 700000,
fy: 'FY 2025-26',
estimatedInterest: 52_800,
estimatedIncome: 320_000,
depositPrincipal: 700_000,
fy: 'FY 2026-27',
status: 'PENDING',
assessed: false
assessed: false,
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
},
{
customerId: 'CUST667788',
customerName: 'Lakshmi Naidu',
customerPan: 'XYZAN7788Q',
customerPan: 'XYZPN7788Q',
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
dob: '1958-06-22',
branch: 'Hyderabad — Banjara Hills',
estimatedInterest: 88200,
estimatedIncome: 410000,
depositPrincipal: 1200000,
fy: 'FY 2025-26',
estimatedInterest: 88_200,
estimatedIncome: 410_000,
depositPrincipal: 1_200_000,
fy: 'FY 2026-27',
status: 'FILED',
filedOn: '2025-04-15',
assessed: true
filedOn: '2026-04-15',
assessed: true,
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
},
{
customerId: 'CUST334455',
customerName: 'Anita Verma',
customerPan: 'EFGHV5678A',
customerPan: 'EFGPV5678A',
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
dob: '1962-11-30',
branch: 'New Delhi — Connaught Place',
estimatedInterest: 64500,
estimatedIncome: 280000,
depositPrincipal: 900000,
fy: 'FY 2025-26',
estimatedInterest: 64_500,
estimatedIncome: 280_000,
depositPrincipal: 900_000,
fy: 'FY 2026-27',
status: 'FILED',
filedOn: '2025-04-05',
assessed: false
filedOn: '2026-04-05',
assessed: false,
interestPostingPeriodType: 'savingsPostingInterestPeriodType.annual'
},
{
customerId: 'CUST990011',
customerName: 'Arjun Banerjee',
customerPan: 'TUVWB4455F',
customerPan: 'TUVPB4455F',
customerAddress: '42, Park Street, Kolkata 700016',
dob: '1991-07-19',
branch: 'Kolkata — Park Street',
estimatedInterest: 14200,
estimatedIncome: 620000,
depositPrincipal: 200000,
fy: 'FY 2025-26',
estimatedInterest: 14_200,
// Over the basic exemption limit — not eligible for 15G.
estimatedIncome: 620_000,
depositPrincipal: 200_000,
fy: 'FY 2026-27',
status: 'PENDING',
assessed: true
assessed: true,
interestPostingPeriodType: 'savingsPostingInterestPeriodType.monthly'
},
{
customerId: 'CUST330044',
customerName: 'Madhavi Singh',
customerPan: 'TUVWS3344K',
customerPan: 'TUVPS3344K',
customerAddress: 'D-5, Greater Kailash, New Delhi 110048',
dob: '1955-03-08',
branch: 'New Delhi — Connaught Place',
estimatedInterest: 145000,
estimatedIncome: 320000,
depositPrincipal: 2000000,
fy: 'FY 2025-26',
// A 15H declarant faces no ceiling on interest — only a nil liability.
estimatedInterest: 145_000,
estimatedIncome: 320_000,
depositPrincipal: 2_000_000,
fy: 'FY 2026-27',
status: 'FILED',
filedOn: '2025-04-02',
assessed: false
filedOn: '2026-04-02',
assessed: false,
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
},
{
customerId: 'CUST778899',
customerName: 'Vikram Malhotra',
customerPan: '',
customerAddress: 'Flat 405, Green Glen Layout, Outer Ring Road, Bengaluru 560103',
dob: '1980-05-15',
branch: 'Bengaluru — MG Road',
estimatedInterest: 60_000,
estimatedIncome: 350_000,
depositPrincipal: 800_000,
fy: 'FY 2026-27',
status: 'PENDING',
assessed: true,
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
}
];
}
+12
View File
@@ -59,6 +59,18 @@ const routes: Routes = [
loadComponent: () =>
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
},
{
path: 'tds-interest',
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
loadComponent: () =>
import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent)
},
{
path: 'tds-remittance',
data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' },
loadComponent: () =>
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
},
{
path: ':filter',
data: { routeParamBreadcrumb: 'filter' },