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

This commit is contained in:
2026-08-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
@@ -1750,6 +1750,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return salutation ? `${salutation} ${full}` : full;
}
/**
* The name PAN and Aadhaar are actually matched against.
*
* Deliberately not {@link fullNamePreview}: that prefixes the salutation, while
* ClientKycWritePlatformServiceJpaRepositoryImpl builds the verification name from firstname +
* middlename + lastname only. Values are passed through untouched rather than capitalised, so what
* the KYC step displays is exactly what goes to the provider.
*/
get verificationName(): string {
return [
this.createClientForm?.get('firstname')?.value,
this.createClientForm?.get('middlename')?.value,
this.createClientForm?.get('lastname')?.value
]
.map((part: any) => (part || '').toString().trim())
.filter(Boolean)
.join(' ');
}
/** Date of birth as captured, sent to the provider formatted dd/MM/yyyy. */
get verificationDateOfBirth(): Date | null {
const value = this.createClientForm?.get('dateOfBirth')?.value;
if (!value) {
return null;
}
if (value instanceof Date) {
return value;
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
getSelectedGender(): any {
const val = this.createClientForm.get('genderId')?.value;
return this.genderOptions?.find((g: any) => g.id === val) || null;
@@ -38,10 +38,17 @@ import { ClientsService } from 'app/clients/clients.service';
}
</div>
<p class="sanctions-msg">
Screening <strong>{{ clientName }}</strong> against<br />
<strong>UN Security Council</strong> &amp;
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
Screening <strong>{{ clientName }}</strong> against the<br />
<strong>UN Security Council Consolidated List</strong><br />
<span class="sanctions-lists"
>ISIL (Da&rsquo;esh) &amp; Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists</span
>
</p>
@if (listDataAsOf) {
<p class="list-as-of">
Sanctions list data as of <strong>{{ listDataAsOf }}</strong>
</p>
}
<p class="regulatory-note">{{ regulatoryBasis }}</p>
</div>
}
@@ -54,8 +61,8 @@ import { ClientsService } from 'app/clients/clients.service';
<h3 class="approve-title">No Sanctions Match Found</h3>
<p class="approve-detail">
<strong>{{ clientName }}</strong> has been screened against the
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
match was found.
<strong>UN Security Council Consolidated List</strong> (ISIL &amp; Al-Qaida and Taliban sanctions lists). No
confirmed match was found.
</p>
<div class="approve-meta">
<div class="meta-row">
@@ -222,6 +229,20 @@ import { ClientsService } from 'app/clients/clients.service';
text-align: center;
}
.list-as-of {
font-size: 12px;
color: #374151;
text-align: center;
margin: 4px 0 0;
}
.sanctions-lists {
display: inline-block;
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.regulatory-note {
font-size: 11px;
color: #9ca3af;
@@ -550,16 +571,24 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
screeningLogId: number | null = null;
listDataAsOf: string | null = null;
/**
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
* the older Master Direction this screen used to name.
*/
regulatoryBasis =
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
'Screened under Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and Chapter IX of the ' +
'RBI Know Your Customer Directions, 2025 (amended as on December 29, 2025).';
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
progressValue = 0;
currentStep = 0;
// Steps describe the screening that actually runs against the UNSC consolidated list; there is no
// FIU-IND lookup behind this call, so it is no longer claimed here.
progressSteps = [
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
{ label: 'Cross-checking listed aliases and entities...', icon: 'database' as any },
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
];
@@ -577,6 +606,9 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
next: (result: any) => {
this.apiResult = result;
this.apiDone = true;
// Surfaced while the screening is still running, so the operator can see which snapshot of
// the UNSC list the check is being made against rather than only learning it afterwards.
this.listDataAsOf = this.formatListDate(result?.listDataAsOf);
},
error: (err: any) => {
this.apiError = err;
@@ -652,13 +684,7 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
'Unable to connect to sanctions screening service.';
} else {
this.screeningLogId = this.apiResult?.screeningLogId || null;
this.listDataAsOf = this.apiResult?.listDataAsOf
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
: null;
this.listDataAsOf = this.formatListDate(this.apiResult?.listDataAsOf);
if (this.apiResult?.matchFound) {
this.matchFound = true;
this.matchCount = this.apiResult.matchCount || 0;
@@ -668,6 +694,18 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
}, 400);
}
/** Formats the date the UNSC snapshot was loaded; null when the backend did not report one. */
private formatListDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
return null;
}
return parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
ngOnDestroy() {
clearInterval(this.progressTimer);
}
@@ -56,6 +56,42 @@
}
}
<!-- What PAN / Aadhaar are matched against. Shown before the officer clicks Verify, because
the provider matches on exactly these two values and returns nothing identifiable when
they disagree — a blind check that fails is much harder to act on than a visible one. -->
@if (!isNonIndividual) {
<div
class="identity-basis full-width"
[class.identity-basis--incomplete]="!verificationName || !verificationDateOfBirth"
>
<mat-icon class="identity-basis__icon">badge</mat-icon>
<div class="identity-basis__body">
<span class="identity-basis__legend">Documents are verified against the General tab</span>
<div class="identity-basis__pairs">
<span class="identity-basis__pair">
<span class="identity-basis__k">Name</span>
<span class="identity-basis__v">{{ verificationName || 'Not entered' }}</span>
</span>
<span class="identity-basis__pair">
<span class="identity-basis__k">Date of Birth</span>
<span class="identity-basis__v">
{{ verificationDateOfBirth ? (verificationDateOfBirth | date: 'dd MMM yyyy') : 'Not entered' }}
</span>
</span>
</div>
@if (!verificationName || !verificationDateOfBirth) {
<span class="identity-basis__warn">
Both are required for PAN and Aadhaar verification — complete them on the General tab first.
</span>
} @else {
<span class="identity-basis__hint">
Must match the card exactly. Salutation is excluded; a mismatch in either fails verification.
</span>
}
</div>
</div>
}
<!-- Individual PAN with inline verify -->
@if (!isMinor && kycForm.get('hasPan')?.value) {
<div class="form-field verify-field">
@@ -105,8 +141,109 @@
@if (panVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
<span class="verify-chip declared"><fa-icon icon="file-signature"></fa-icon> Declaration</span>
}
</div>
</div>
@if (panVerifyStatus === 'VERIFIED') {
<div
class="pal-card full-width"
[class.pal-card--ok]="panAadhaarSeedingStatus === 'Y'"
[class.pal-card--fail]="panAadhaarSeedingStatus === 'N'"
[class.pal-card--info]="panAadhaarSeedingStatus === 'E'"
[class.pal-card--pending]="
panAadhaarSeedingStatus !== 'Y' && panAadhaarSeedingStatus !== 'N' && panAadhaarSeedingStatus !== 'E'
"
style="margin-bottom: 16px"
>
<div class="pal-card__left">
<div class="pal-card__icon">
<mat-icon>credit_card</mat-icon>
</div>
<div class="pal-card__txt">
<div class="pal-card__small">Verification Status</div>
<div class="pal-card__title">PAN &harr; Aadhaar</div>
<div class="pal-card__status">
@if (panAadhaarSeedingStatus === 'Y') {
<mat-icon>check_circle</mat-icon> Linked (Seeded)
} @else if (panAadhaarSeedingStatus === 'N') {
<mat-icon>cancel</mat-icon> Not Linked
} @else if (panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon> Exempt
} @else {
<mat-icon>radio_button_unchecked</mat-icon> Pending
}
</div>
</div>
</div>
<div class="pal-card__badge">
@if (panAadhaarSeedingStatus === 'Y') {
<mat-icon>check</mat-icon>
} @else if (panAadhaarSeedingStatus === 'N') {
<mat-icon>close</mat-icon>
} @else if (panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon>
} @else {
<mat-icon>schedule</mat-icon>
}
</div>
<div class="pal-card__hint">As per Income Tax Portal &mdash; Section 139AA</div>
</div>
}
<!--
PAN could not be verified but a declaration is on file. Shown in full because it is the
only thing standing between this client and a PAN KYC exception — an officer reviewing
the record has to be able to see what was declared, not just that something was.
-->
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
<div class="pan-decl-card">
<div class="pan-decl-card__head">
<mat-icon class="pan-decl-card__icon">assignment_turned_in</mat-icon>
<span class="pan-decl-card__title">PAN Declaration Taken</span>
@if (panDeclarationTakenOn) {
<span class="pan-decl-card__date">{{ panDeclarationTakenOn }}</span>
}
</div>
<div class="pan-decl-card__rows">
<div class="pan-decl-card__row">
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
</div>
@if (panDeclaration?.nameOnPan) {
<div class="pan-decl-card__row">
<span class="k">Name on PAN</span>
<span class="v">{{ panDeclaration?.nameOnPan }}</span>
</div>
}
@if (panDeclaration?.dobOnPan) {
<div class="pan-decl-card__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ panDeclaration?.dobOnPan | date: 'dd MMMM yyyy' }}</span>
</div>
}
@if (panDeclaration?.fileName) {
<div class="pan-decl-card__row">
<span class="k">Declaration file</span>
<span class="v">{{ panDeclaration?.fileName }}</span>
</div>
}
</div>
<div class="pan-decl-card__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
@if (!readOnly) {
<div class="pan-decl-card__actions">
<button mat-stroked-button type="button" (click)="openPanDeclaration()">Edit Declaration</button>
<button mat-stroked-button type="button" class="danger" (click)="removePanDeclaration()">
Remove
</button>
</div>
}
</div>
}
}
<!-- Aadhaar with independent verify (Individual only) -->
@@ -175,9 +312,68 @@
@if (aadhaarVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
<span class="verify-chip declared"><fa-icon icon="receipt"></fa-icon> Receipt</span>
}
</div>
</div>
<!--
A customer who has applied for Aadhaar but not received it has no number to verify, so no
amount of retrying the OTP will clear the check. Offer the enrolment slip instead rather
than leaving the officer unable to finish the client.
-->
@if (aadhaarVerifyStatus !== 'VERIFIED' && !aadhaarReceiptTaken && !aadhaarOtpSent && !readOnly) {
<div class="aad-rcpt-offer full-width">
<mat-icon>receipt_long</mat-icon>
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Record Enrolment Receipt</button>
</div>
}
<!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only thing standing between this client and an Aadhaar KYC exception.
-->
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
<div class="aad-rcpt-card full-width">
<div class="aad-rcpt-card__head">
<mat-icon class="aad-rcpt-card__icon">receipt_long</mat-icon>
<span class="aad-rcpt-card__title">Aadhaar Enrolment Receipt Recorded</span>
@if (aadhaarReceiptTakenOn) {
<span class="aad-rcpt-card__date">{{ aadhaarReceiptTakenOn }}</span>
}
</div>
<div class="aad-rcpt-card__rows">
<div class="aad-rcpt-card__row">
<span class="k">Enrolment ID</span>
<span class="v mono">{{ aadhaarReceipt?.enrolmentId }}</span>
</div>
@if (aadhaarReceipt?.enrolmentDate) {
<div class="aad-rcpt-card__row">
<span class="k">Enrolment date</span>
<span class="v">{{ aadhaarReceipt?.enrolmentDate | date: 'dd MMMM yyyy' }}</span>
</div>
}
@if (aadhaarReceipt?.remarks) {
<div class="aad-rcpt-card__row">
<span class="k">Remarks</span>
<span class="v">{{ aadhaarReceipt?.remarks }}</span>
</div>
}
</div>
<div class="aad-rcpt-card__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
@if (!readOnly) {
<div class="aad-rcpt-card__actions">
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Edit Receipt</button>
<button mat-stroked-button type="button" class="danger" (click)="removeAadhaarReceipt()">Remove</button>
</div>
}
</div>
}
<!-- Aadhaar OTP Section -->
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
<div class="otp-card full-width">
@@ -725,51 +921,53 @@
}
<!-- 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
@if (!kycAllDone) {
<div
class="exception-flag-box"
style="
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #b45309;
display: flex;
align-items: center;
gap: 8px;
margin-top: 20px;
padding: 16px;
border: 1px solid #f59e0b;
background: #fffbeb;
border-radius: 8px;
clear: both;
"
>
<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>
<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>
}
@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>
}
</div>
<!-- ═══ SECTION: KYC Document Management ═══ -->
@@ -847,19 +1045,23 @@
<!-- Existing saved ID-proof doc — locked by default. Replace flow
unlocks automatically when the section-level Edit button toggles
`readOnly` off. -->
@if (existingKycDocuments.length > 0 && !kycDocFiles.front && readOnly) {
@if (existingKycDocSides.front && !kycDocFiles.front && readOnly) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.front?.previewUrl) {
<img
[src]="existingKycDocSides.front?.previewUrl"
alt="Saved front side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.front?.fileName }}
</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
@@ -869,8 +1071,8 @@
} @else {
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocuments.length > 0"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && existingKycDocuments.length === 0"
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocSides.front"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && !existingKycDocSides.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
@if (kycDocFiles.front) {
@@ -886,24 +1088,28 @@
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingKycDocuments.length > 0) {
} @else if (existingKycDocSides.front) {
<!-- Edit mode WITH existing doc — let the user view it OR
click anywhere to replace. -->
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.front?.previewUrl) {
<img
[src]="existingKycDocSides.front?.previewUrl"
alt="Saved front side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.front?.fileName }}
</span>
<div class="kyc-doc-dropzone__existing-actions">
<button
type="button"
class="kyc-doc-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('front')"
>
<mat-icon>visibility</mat-icon>
View
@@ -930,25 +1136,45 @@
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<!-- Hide Required pill when saved combined doc covers both sides -->
@if (!(existingKycDocuments.length > 0 && !kycDocFiles.back)) {
<!-- Hide Required pill when a back scan (or a stacked scan holding it) is on file -->
@if (!((existingKycDocSides.back || kycDocIsLegacyCombined) && !kycDocFiles.back)) {
<span class="kyc-doc-upload-req">Required</span>
}
</div>
<!-- Locked view: saved combined doc covers the back side too. -->
@if (existingKycDocuments.length > 0 && !kycDocFiles.back && readOnly) {
<!--
A stacked scan that could not be cut in two has no separate back image. Say so rather
than repeating the front slot's picture here.
-->
@if (kycDocIsLegacyCombined && !kycDocFiles.back) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
<mat-icon class="kyc-doc-dropzone__doc-ic">filter_none</mat-icon>
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">Included in the combined scan</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
<mat-icon>visibility</mat-icon>
View both sides
</button>
</div>
</div>
</div>
} @else if (existingKycDocSides.back && !kycDocFiles.back && readOnly) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (existingKycDocSides.back?.previewUrl) {
<img
[src]="existingKycDocSides.back?.previewUrl"
alt="Saved back side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.back?.fileName }}
</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('back')">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
@@ -958,8 +1184,8 @@
} @else {
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocuments.length > 0"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && existingKycDocuments.length === 0"
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocSides.back"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && !existingKycDocSides.back"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
@if (kycDocFiles.back) {
@@ -975,23 +1201,27 @@
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingKycDocuments.length > 0) {
<!-- Edit mode WITH existing combined doc — view + replace. -->
} @else if (existingKycDocSides.back) {
<!-- Edit mode WITH an existing back scan — view + replace. -->
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.back?.previewUrl) {
<img
[src]="existingKycDocSides.back?.previewUrl"
alt="Saved back side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.back?.fileName }}
</span>
<div class="kyc-doc-dropzone__existing-actions">
<button
type="button"
class="kyc-doc-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('back')"
>
<mat-icon>visibility</mat-icon>
View
@@ -1027,7 +1257,9 @@
<mat-icon>filter_none</mat-icon>
Combined preview (front + back)
</span>
<span class="kyc-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
<span class="kyc-merged__hint">
Preview only — each side is saved as its own file. Re-crop a side to update it.
</span>
</div>
<div class="kyc-merged__preview">
<img [src]="kycDocMergedPreview" alt="Merged KYC document" />
@@ -1197,27 +1429,36 @@
</div>
}
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
[placeholder]="isKycVerified ? 'Mandatory — explain why KYC is being re-edited' : ''"
></textarea>
@if (isKycVerified && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>Remarks are mandatory when updating a verified KYC record</mat-error>
}
</mat-form-field>
@if (isKycVerified) {
<div class="kyc-reedit-alert">
<mat-icon class="kyc-reedit-alert__icon">info</mat-icon>
<span
>KYC is <strong>VERIFIED</strong>. Any update will reset the status to <strong>IN PROGRESS</strong> and
require PAN re-verification.</span
>
</div>
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>
{{
isKycVerified
? 'Reason for Re-edit'
: kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Remarks'
}}
</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
[placeholder]="
isKycVerified
? 'Mandatory — explain why KYC is being re-edited'
: kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: ''
"
></textarea>
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>
Remarks are mandatory when
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
</mat-error>
}
</mat-form-field>
}
</div>
@@ -1393,6 +1634,39 @@
<mat-option value="REGULATORY">Regulatory Authority</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="freezeDatePicker.open()">
<mat-label>Freeze Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
<input matInput required [max]="today" [matDatepicker]="freezeDatePicker" formControlName="freezeDate" />
<mat-datepicker-toggle matSuffix [for]="freezeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #freezeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Freeze Amount</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>currency_rupee</mat-icon></span>
<input matInput type="number" min="0" formControlName="freezeAmount" />
<mat-hint>Leave blank for a full freeze</mat-hint>
</mat-form-field>
}
@if (kycForm.get('accountStatus')?.value === 'UNFREEZE') {
<mat-form-field appearance="outline" class="form-field" (click)="unfreezeDatePicker.open()">
<mat-label>Unfreeze Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>lock_open</mat-icon></span>
<input matInput required [max]="today" [matDatepicker]="unfreezeDatePicker" formControlName="unfreezeDate" />
<mat-datepicker-toggle matSuffix [for]="unfreezeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #unfreezeDatePicker></mat-datepicker>
</mat-form-field>
}
@if (kycForm.get('accountStatus')?.value === 'NO_DEBIT') {
<mat-form-field appearance="outline" class="form-field" (click)="noDebitDatePicker.open()">
<mat-label>No Debit From Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>money_off</mat-icon></span>
<input matInput required [matDatepicker]="noDebitDatePicker" formControlName="noDebitDate" />
<mat-datepicker-toggle matSuffix [for]="noDebitDatePicker"></mat-datepicker-toggle>
<mat-datepicker #noDebitDatePicker></mat-datepicker>
</mat-form-field>
}
</div>
</div>
@@ -1545,4 +1819,34 @@
</div>
</div>
</div>
<!-- Form Validation Summary -->
@if (kycForm && kycForm.invalid) {
<div
class="kyc-validation-summary"
style="
margin-top: 24px;
padding: 16px;
background: #fff5f5;
border: 1px solid #fee2e2;
border-radius: 12px;
display: flex;
align-items: flex-start;
gap: 12px;
clear: both;
"
>
<mat-icon style="color: #ef4444; flex-shrink: 0">error_outline</mat-icon>
<div style="display: flex; flex-direction: column; gap: 4px">
<span style="font-size: 14px; font-weight: 600; color: #991b1b"
>Please complete/correct the following fields to proceed:</span
>
<ul style="margin: 4px 0 0 16px; padding: 0; font-size: 13px; color: #b91c1c; line-height: 1.5">
@for (label of invalidFieldLabels; track label) {
<li>{{ label }}</li>
}
</ul>
</div>
</div>
}
</div>
@@ -304,6 +304,206 @@
color: #059669;
border: 1px solid #a7f3d0;
}
// Not verified, but covered by a declaration — deliberately amber rather than green so it never
// reads as a passing check.
&.declared {
background: #fffbeb;
color: #b45309;
border: 1px solid #fcd34d;
}
}
// ── PAN declaration summary ────────────────────────────────────────────────
.pan-decl-card {
margin: -4px 0 16px;
padding: 12px 14px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
}
.pan-decl-card__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.pan-decl-card__icon {
color: #b45309;
font-size: 18px;
width: 18px;
height: 18px;
}
.pan-decl-card__title {
font-size: 13px;
font-weight: 600;
color: #92400e;
}
.pan-decl-card__date {
margin-left: auto;
font-size: 11px;
color: #b45309;
}
.pan-decl-card__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.pan-decl-card__row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #92400e;
font-weight: 500;
}
.v {
color: #451a03;
word-break: break-word;
&.strong {
font-weight: 600;
}
}
}
.pan-decl-card__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #fde68a;
font-size: 11.5px;
line-height: 1.5;
color: #b45309;
}
.pan-decl-card__actions {
display: flex;
gap: 8px;
margin-top: 10px;
.danger {
color: #b91c1c;
}
}
// ── Aadhaar enrolment receipt ──────────────────────────────────────────────
// Blue rather than the declaration's amber: nothing has failed here, the customer simply does not
// hold the document yet.
.aad-rcpt-offer {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: -4px 0 16px;
padding: 10px 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
font-size: 12.5px;
color: #1e40af;
mat-icon {
color: #2563eb;
font-size: 18px;
width: 18px;
height: 18px;
}
button {
margin-left: auto;
color: #1d4ed8 !important;
border-color: #bfdbfe !important;
}
}
.aad-rcpt-card {
margin: -4px 0 16px;
padding: 12px 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.aad-rcpt-card__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.aad-rcpt-card__icon {
color: #2563eb;
font-size: 18px;
width: 18px;
height: 18px;
}
.aad-rcpt-card__title {
font-size: 13px;
font-weight: 600;
color: #1e3a8a;
}
.aad-rcpt-card__date {
margin-left: auto;
font-size: 11px;
color: #2563eb;
}
.aad-rcpt-card__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.aad-rcpt-card__row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #1e40af;
font-weight: 500;
}
.v {
color: #0f172a;
word-break: break-word;
&.mono {
font-family: monospace;
letter-spacing: 0.02em;
}
}
}
.aad-rcpt-card__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #bfdbfe;
font-size: 11.5px;
line-height: 1.5;
color: #2563eb;
}
.aad-rcpt-card__actions {
display: flex;
gap: 8px;
margin-top: 10px;
.danger {
color: #b91c1c;
}
}
.verify-icon-ok {
@@ -2431,3 +2631,358 @@
}
}
}
/* The name and DOB that PAN / Aadhaar are matched against, surfaced from the General tab so a
mismatch is visible before the check runs rather than only after it fails. */
.identity-basis {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px 14px;
margin-bottom: 4px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.identity-basis--incomplete {
border-color: #fdba74;
background: #fff7ed;
}
.identity-basis__icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
color: #1d4ed8;
.identity-basis--incomplete & {
color: #c2410c;
}
}
.identity-basis__body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.identity-basis__legend {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.identity-basis__pairs {
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
}
.identity-basis__pair {
display: flex;
align-items: baseline;
gap: 7px;
}
.identity-basis__k {
font-size: 11px;
font-weight: 600;
color: #64748b;
}
.identity-basis__v {
font-size: 13.5px;
font-weight: 600;
color: #1e293b;
}
.identity-basis__hint {
font-size: 11.5px;
color: #64748b;
line-height: 1.45;
}
.identity-basis__warn {
font-size: 11.5px;
font-weight: 600;
color: #c2410c;
line-height: 1.45;
}
/* ═══════════════════════════════════════════════════════════════
PAN ↔ Aadhaar premium status card
─── Replaces the legacy table-row treatment. Dark gradient with
offset glow circles, large gradient icon, and a state-driven
accent (green / red / blue / amber).
═══════════════════════════════════════════════════════════════ */
.pal-card {
margin-top: 16px;
width: 100%;
min-height: 88px;
padding: 14px 18px 30px;
border-radius: 18px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #111827, #0f172a);
border: 1px solid rgb(255 255 255 / 8%);
box-shadow:
0 12px 32px rgb(0 0 0 / 35%),
inset 0 1px 1px rgb(255 255 255 / 5%);
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
/* Decorative glows — colors swap with the state variant */
&::before {
content: '';
position: absolute;
width: 180px;
height: 180px;
background: #2563eb;
border-radius: 50%;
top: -110px;
right: -70px;
opacity: 0.18;
pointer-events: none;
}
&::after {
content: '';
position: absolute;
width: 120px;
height: 120px;
background: #22c55e;
border-radius: 50%;
bottom: -70px;
left: -50px;
opacity: 0.12;
pointer-events: none;
}
&__left {
display: flex;
align-items: center;
gap: 14px;
z-index: 2;
}
&__icon {
width: 54px;
height: 54px;
border-radius: 16px;
background: linear-gradient(145deg, #2563eb, #06b6d4);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22px;
position: relative;
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
flex-shrink: 0;
mat-icon {
font-size: 26px;
width: 26px;
height: 26px;
}
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 16px;
border: 1px solid rgb(255 255 255 / 20%);
}
}
&__txt {
display: flex;
flex-direction: column;
gap: 4px;
}
&__small {
font-size: 10.5px;
color: #94a3b8;
letter-spacing: 0.1em;
text-transform: uppercase;
font-weight: 700;
}
&__title {
color: #fff;
font-size: 17px;
font-weight: 700;
line-height: 1.1;
}
&__status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 4px 11px;
border-radius: 50px;
background: rgb(34 197 94 / 12%);
border: 1px solid rgb(74 222 128 / 25%);
color: #4ade80;
font-size: 11.5px;
font-weight: 600;
backdrop-filter: blur(5px);
width: fit-content;
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
}
}
&__badge {
z-index: 2;
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
flex-shrink: 0;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__hint {
position: absolute;
bottom: 8px;
right: 16px;
font-size: 10px;
color: rgb(255 255 255 / 35%);
font-style: italic;
letter-spacing: 0.02em;
z-index: 1;
}
/* ─── State variants ─── */
&--ok {
&::before {
background: #2563eb;
}
&::after {
background: #22c55e;
}
}
&--fail {
border-color: rgb(239 68 68 / 22%);
&::before {
background: #ef4444;
opacity: 0.22;
}
&::after {
background: #f87171;
opacity: 0.16;
}
.pal-card__status {
background: rgb(239 68 68 / 14%);
border-color: rgb(248 113 113 / 32%);
color: #fca5a5;
}
.pal-card__badge {
background: linear-gradient(135deg, #ef4444, #b91c1c);
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
}
.pal-card__icon {
background: linear-gradient(145deg, #ef4444, #f97316);
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
}
}
&--info {
border-color: rgb(59 130 246 / 22%);
&::before {
background: #3b82f6;
opacity: 0.22;
}
&::after {
background: #60a5fa;
opacity: 0.14;
}
.pal-card__status {
background: rgb(59 130 246 / 14%);
border-color: rgb(96 165 250 / 32%);
color: #93c5fd;
}
.pal-card__badge {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
}
}
&--pending {
border-color: rgb(245 158 11 / 22%);
&::before {
background: #f59e0b;
opacity: 0.22;
}
&::after {
background: #fbbf24;
opacity: 0.14;
}
.pal-card__status {
background: rgb(245 158 11 / 14%);
border-color: rgb(251 191 36 / 32%);
color: #fbbf24;
}
.pal-card__badge {
background: linear-gradient(135deg, #f59e0b, #b45309);
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
}
.pal-card__icon {
background: linear-gradient(145deg, #f59e0b, #d97706);
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
}
}
}
@media (width <= 540px) {
.pal-card {
flex-wrap: wrap;
gap: 10px;
padding-bottom: 36px;
&__hint {
position: static;
width: 100%;
text-align: right;
margin-top: 4px;
}
}
}
File diff suppressed because it is too large Load Diff