feat: edit client — save per tab, DOB re-KYC alert, audit logs, KYC improvements

- Edit button per tab with Save & Next confirmation dialog
- DOB change triggers re-KYC alert, resets PAN verification, auto-fills remarks
- Mobile/email changes logged as audit notes via client notes API
- Redesigned confirmation dialog with icon + title, type-based theming
- KYC re-edit flow: remarks mandatory for VERIFIED records, re-edit API command
- Load existing photo & signature in edit KYC, immediate upload on change
- Populate all KYC fields (document, AML, form60/15) from API on edit
- Non-individual PAN verification uses same flow as individual
- Fix table overflow when sidebar panel is open
- Add fr unit to stylelint allowed-list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-07 15:08:07 +05:30
co-authored by Claude Opus 4.6
parent b890d24bb7
commit 79e455c4ed
14 changed files with 1699 additions and 398 deletions
@@ -1,5 +1,9 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="mat-typography" [formGroup]="kycForm">
@@ -22,7 +26,6 @@
</h4>
<div class="form-grid">
<!-- ══ INDIVIDUAL KYC ══ -->
@if (!isNonIndividual) {
<div class="form-field checkbox-field full-width">
@@ -35,9 +38,16 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>PAN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="panNumber" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
<input
matInput
formControlName="panNumber"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase"
(input)="
kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@@ -51,9 +61,16 @@
</mat-form-field>
<div class="verify-action">
@if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying"
(click)="verifyPan()">
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying
"
(click)="verifyPan()"
>
@if (panVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -79,10 +96,18 @@
</div>
<div class="pan-result-row">
<span class="pan-result-label">PAN–Aadhaar Link Status</span>
<span class="pan-result-value" [class.seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'Y'" [class.not-seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'N'">
@if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') { Linked }
@else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') { Not Linked }
@else { — }
<span
class="pan-result-value"
[class.seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'Y'"
[class.not-seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'N'"
>
@if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') {
Linked
} @else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') {
Not Linked
} @else {
—
}
</span>
</div>
<div class="pan-result-row">
@@ -99,10 +124,22 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>Aadhaar Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>fingerprint</mat-icon></span>
<input matInput formControlName="aadhaarNumber" placeholder="12-digit Aadhaar"
maxlength="12" [type]="aadhaarVisible ? 'text' : 'password'" [readonly]="aadhaarOtpSent"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
<button mat-icon-button matSuffix type="button" (click)="aadhaarVisible = !aadhaarVisible" class="toggle-vis-btn">
<input
matInput
formControlName="aadhaarNumber"
placeholder="12-digit Aadhaar"
maxlength="12"
[type]="aadhaarVisible ? 'text' : 'password'"
[readonly]="aadhaarOtpSent"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
<button
mat-icon-button
matSuffix
type="button"
(click)="aadhaarVisible = !aadhaarVisible"
class="toggle-vis-btn"
>
<fa-icon [icon]="aadhaarVisible ? 'eye-slash' : 'eye'"></fa-icon>
</button>
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
@@ -114,9 +151,19 @@
</mat-form-field>
<div class="verify-action">
@if ((aadhaarVerifyStatus === 'NONE' || aadhaarVerifyStatus === 'FAILED') && !aadhaarOtpSent) {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('aadhaarNumber')?.value || kycForm.get('aadhaarNumber')?.invalid || aadhaarVerifying"
(click)="sendAadhaarOtp()">
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('aadhaarNumber')?.value ||
kycForm.get('aadhaarNumber')?.invalid ||
aadhaarVerifying
"
(click)="sendAadhaarOtp()"
>
@if (aadhaarVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -146,18 +193,28 @@
<label class="otp-card__label">Enter OTP</label>
<div class="otp-digits" (paste)="onOtpPaste($event)">
@for (digit of otpDigits; track $index) {
<input type="text" inputmode="numeric" maxlength="1"
class="otp-digit-box" [class.filled]="digit"
<input
type="text"
inputmode="numeric"
maxlength="1"
class="otp-digit-box"
[class.filled]="digit"
[value]="digit"
(input)="onOtpDigitInput($index, $event)"
(keydown)="onOtpKeydown($index, $event)"
autocomplete="one-time-code" />
autocomplete="one-time-code"
/>
}
</div>
<div class="otp-card__actions">
<button mat-flat-button color="primary" type="button" class="otp-card__verify-btn"
<button
mat-flat-button
color="primary"
type="button"
class="otp-card__verify-btn"
[disabled]="readOnly || aadhaarOtp.length < 6 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()">
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate" class="otp-spinner"></mat-progress-spinner>
Verifying...
@@ -173,8 +230,13 @@
</span>
} @else {
<span class="otp-card__resend-text">Didn't receive the code?</span>
<button mat-button type="button" class="otp-card__resend-btn"
[disabled]="readOnly || aadhaarVerifying" (click)="sendAadhaarOtp()">
<button
mat-button
type="button"
class="otp-card__resend-btn"
[disabled]="readOnly || aadhaarVerifying"
(click)="sendAadhaarOtp()"
>
Resend OTP
</button>
}
@@ -185,7 +247,9 @@
}
@if (aadhaarVerifyError) {
<div class="verify-error full-width"><fa-icon icon="exclamation-circle"></fa-icon> {{ aadhaarVerifyError }}</div>
<div class="verify-error full-width">
<fa-icon icon="exclamation-circle"></fa-icon> {{ aadhaarVerifyError }}
</div>
}
@if (aadhaarVerifyStatus === 'VERIFIED' && aadhaarVerifyName) {
<div class="aadhaar-result-card full-width">
@@ -203,14 +267,25 @@
<!-- PAN-Aadhaar Seeding Status -->
@if (kycForm.get('hasPan')?.value && panAadhaarSeedingStatus) {
<div class="form-field pan-link-status full-width">
<div class="pan-link-box" [class.linked]="panAadhaarSeedingStatus === 'Y'" [class.not-linked]="panAadhaarSeedingStatus === 'N'" [class.exempt]="panAadhaarSeedingStatus === 'E'">
<div
class="pan-link-box"
[class.linked]="panAadhaarSeedingStatus === 'Y'"
[class.not-linked]="panAadhaarSeedingStatus === 'N'"
[class.exempt]="panAadhaarSeedingStatus === 'E'"
>
<fa-icon icon="link" class="pan-link-icon"></fa-icon>
<div class="pan-link-info">
<span class="pan-link-label">PAN–Aadhaar Link Status</span>
<span class="pan-link-value">
@if (panAadhaarSeedingStatus === 'Y') { Linked (Seeded) }
@if (panAadhaarSeedingStatus === 'N') { Not Linked }
@if (panAadhaarSeedingStatus === 'E') { Exempt }
@if (panAadhaarSeedingStatus === 'Y') {
Linked (Seeded)
}
@if (panAadhaarSeedingStatus === 'N') {
Not Linked
}
@if (panAadhaarSeedingStatus === 'E') {
Exempt
}
</span>
</div>
<span class="pan-link-hint">As per Income Tax Portal — Section 139AA</span>
@@ -233,9 +308,16 @@
<!-- GSTIN (all non-individual) -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>GSTIN Number</mat-label>
<input matInput formControlName="gstinNumber" placeholder="15-character GSTIN"
maxlength="15" style="text-transform: uppercase"
(input)="kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
<input
matInput
formControlName="gstinNumber"
placeholder="15-character GSTIN"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@@ -248,9 +330,16 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>PAN of Proprietor</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="panNumber" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
<input
matInput
formControlName="panNumber"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase"
(input)="
kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@@ -264,9 +353,20 @@
</mat-form-field>
<div class="verify-action">
@if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying || !clientId"
(click)="verifyPan()">
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('panNumber')?.value ||
kycForm.get('panNumber')?.invalid ||
panVerifying ||
!clientId
"
(click)="verifyPan()"
>
@if (panVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -303,9 +403,16 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>{{ constitutionMeta?.panLabel || 'PAN of Entity' }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="enterprisePan" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('enterprisePan')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
<input
matInput
formControlName="enterprisePan"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase"
(input)="
kycForm.get('enterprisePan')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('enterprisePan')?.hasError('pattern') && kycForm.get('enterprisePan')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@@ -319,9 +426,20 @@
</mat-form-field>
<div class="verify-action">
@if (entPanVerifyStatus === 'NONE' || entPanVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('enterprisePan')?.value || kycForm.get('enterprisePan')?.invalid || entPanVerifying || !clientId"
(click)="verifyEnterprisePan()">
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('enterprisePan')?.value ||
kycForm.get('enterprisePan')?.invalid ||
entPanVerifying ||
!clientId
"
(click)="verifyEnterprisePan()"
>
@if (entPanVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -360,9 +478,16 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>CIN (Corporate Identity Number)</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>business</mat-icon></span>
<input matInput formControlName="cinNumber" maxlength="21" placeholder="From MCA"
<input
matInput
formControlName="cinNumber"
maxlength="21"
placeholder="From MCA"
style="text-transform: uppercase"
(input)="kycForm.get('cinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
(input)="
kycForm.get('cinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('cinNumber')?.hasError('pattern') && kycForm.get('cinNumber')?.touched) {
<mat-error>Invalid CIN format (21 characters)</mat-error>
}
@@ -372,9 +497,14 @@
</mat-form-field>
<div class="verify-action">
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('cinNumber')?.value || cinVerifying"
(click)="verifyCin()">
(click)="verifyCin()"
>
@if (cinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -396,7 +526,10 @@
<div class="cin-progress-steps">
@for (step of cinProgressSteps; track step.label; let i = $index) {
<div class="cin-step" [class.active]="i === cinCurrentStep" [class.done]="i < cinCurrentStep">
<fa-icon [icon]="i < cinCurrentStep ? 'check-circle' : step.icon" [class.done-icon]="i < cinCurrentStep"></fa-icon>
<fa-icon
[icon]="i < cinCurrentStep ? 'check-circle' : step.icon"
[class.done-icon]="i < cinCurrentStep"
></fa-icon>
<span>{{ step.label }}</span>
</div>
}
@@ -467,12 +600,22 @@
<mat-form-field appearance="outline" class="form-field">
<mat-label>Document Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>tag</mat-icon></span>
<input matInput required formControlName="kycDocumentNumber" placeholder="As printed on document" maxlength="30"
<input
matInput
required
formControlName="kycDocumentNumber"
placeholder="As printed on document"
maxlength="30"
style="text-transform: uppercase"
(input)="kycForm.get('kycDocumentNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
(input)="
kycForm.get('kycDocumentNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('kycDocumentNumber')?.hasError('required') && kycForm.get('kycDocumentNumber')?.touched) {
<mat-error>Document number is <strong>required</strong></mat-error>
} @else if (kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched) {
} @else if (
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
}
</mat-form-field>
@@ -517,13 +660,20 @@
}
</mat-select-trigger>
<mat-option [value]="1">
<span class="risk-option"><span class="risk-meter risk-low"></span> Low Risk <span class="risk-hint">10 yr renewal</span></span>
<span class="risk-option"
><span class="risk-meter risk-low"></span> Low Risk <span class="risk-hint">10 yr renewal</span></span
>
</mat-option>
<mat-option [value]="2">
<span class="risk-option"><span class="risk-meter risk-medium"></span> Medium Risk <span class="risk-hint">8 yr renewal</span></span>
<span class="risk-option"
><span class="risk-meter risk-medium"></span> Medium Risk
<span class="risk-hint">8 yr renewal</span></span
>
</mat-option>
<mat-option [value]="3">
<span class="risk-option"><span class="risk-meter risk-high"></span> High Risk <span class="risk-hint">2 yr renewal</span></span>
<span class="risk-option"
><span class="risk-meter risk-high"></span> High Risk <span class="risk-hint">2 yr renewal</span></span
>
</mat-option>
</mat-select>
</mat-form-field>
@@ -535,9 +685,27 @@
</mat-form-field>
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
<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>
}
</div>
<!-- KYC Expiry Alert Cards -->
@@ -552,21 +720,38 @@
<div class="kyc-expiry-card" [class.active]="kycForm.get('riskCategory')?.value === rc.value">
<span class="risk-gauge" [class]="'risk-gauge--' + rc.color">
<svg viewBox="0 0 40 24" class="risk-gauge-svg">
<path d="M4 22 A16 16 0 0 1 36 22" fill="none" stroke="#e2e8f0" stroke-width="4" stroke-linecap="round"/>
<path d="M4 22 A16 16 0 0 1 36 22" fill="none"
<path
d="M4 22 A16 16 0 0 1 36 22"
fill="none"
stroke="#e2e8f0"
stroke-width="4"
stroke-linecap="round"
/>
<path
d="M4 22 A16 16 0 0 1 36 22"
fill="none"
[attr.stroke]="rc.color === 'green' ? '#22c55e' : rc.color === 'amber' ? '#f59e0b' : '#ef4444'"
stroke-width="4" stroke-linecap="round"
[attr.stroke-dasharray]="rc.color === 'green' ? '16 50' : rc.color === 'amber' ? '33 50' : '50 50'"/>
<line x1="20" y1="22"
stroke-width="4"
stroke-linecap="round"
[attr.stroke-dasharray]="rc.color === 'green' ? '16 50' : rc.color === 'amber' ? '33 50' : '50 50'"
/>
<line
x1="20"
y1="22"
[attr.x2]="rc.color === 'green' ? '10' : rc.color === 'amber' ? '20' : '30'"
[attr.y2]="rc.color === 'green' ? '10' : rc.color === 'amber' ? '6' : '10'"
stroke="#334155" stroke-width="2" stroke-linecap="round"/>
<circle cx="20" cy="22" r="2" fill="#334155"/>
stroke="#334155"
stroke-width="2"
stroke-linecap="round"
/>
<circle cx="20" cy="22" r="2" fill="#334155" />
</svg>
</span>
<div class="kyc-expiry-card__info">
<span class="kyc-expiry-card__label">{{ rc.label }}</span>
<span class="kyc-expiry-card__cycle">Renewal every <strong>{{ rc.years }} years</strong></span>
<span class="kyc-expiry-card__cycle"
>Renewal every <strong>{{ rc.years }} years</strong></span
>
</div>
@if (kycForm.get('riskCategory')?.value === rc.value) {
<span class="kyc-expiry-card__next">Next: {{ getKycExpiryDate(rc.years) }}</span>
@@ -575,12 +760,17 @@
}
</div>
@if (kycForm.get('riskCategory')?.value) {
<div class="kyc-expiry-alert"
<div
class="kyc-expiry-alert"
[class.kyc-expiry-alert--low]="kycForm.get('riskCategory')?.value === 1"
[class.kyc-expiry-alert--medium]="kycForm.get('riskCategory')?.value === 2"
[class.kyc-expiry-alert--high]="kycForm.get('riskCategory')?.value === 3">
[class.kyc-expiry-alert--high]="kycForm.get('riskCategory')?.value === 3"
>
<fa-icon icon="exclamation-circle" class="kyc-expiry-alert__icon"></fa-icon>
<span>System will auto-alert before KYC expiry on <strong>{{ getKycExpiryDate(getRenewalYears()) }}</strong>.</span>
<span
>System will auto-alert before KYC expiry on <strong>{{ getKycExpiryDate(getRenewalYears()) }}</strong
>.</span
>
</div>
}
</div>
@@ -17,7 +17,7 @@
font-size: 15px;
font-weight: 600;
color: #333;
margin: 0 0 16px 0;
margin: 0 0 16px;
.section-icon {
color: var(--md-sys-color-primary, #1074b9);
@@ -119,7 +119,7 @@
&.active {
border-color: #1353a0;
background: #eff6ff;
box-shadow: 0 0 0 2px rgba(19, 83, 160, 0.12);
box-shadow: 0 0 0 2px rgb(19 83 160 / 12%);
}
&__dot {
@@ -128,9 +128,17 @@
border-radius: 50%;
flex-shrink: 0;
&.green { background: #10b981; }
&.amber { background: #f59e0b; }
&.red { background: #ef4444; }
&.green {
background: #10b981;
}
&.amber {
background: #f59e0b;
}
&.red {
background: #ef4444;
}
}
&__info {
@@ -186,7 +194,9 @@
color: #065f46;
border: 1px solid #a7f3d0;
.kyc-expiry-alert__icon { color: #10b981; }
.kyc-expiry-alert__icon {
color: #10b981;
}
}
&--medium {
@@ -194,7 +204,9 @@
color: #92400e;
border: 1px solid #fde68a;
.kyc-expiry-alert__icon { color: #f59e0b; }
.kyc-expiry-alert__icon {
color: #f59e0b;
}
}
&--high {
@@ -202,11 +214,13 @@
color: #991b1b;
border: 1px solid #fecaca;
.kyc-expiry-alert__icon { color: #ef4444; }
.kyc-expiry-alert__icon {
color: #ef4444;
}
}
}
@media (max-width: 768px) {
@media (width <= 768px) {
.kyc-expiry-cards {
grid-template-columns: 1fr;
}
@@ -451,7 +465,7 @@
border: 1px solid #e0e7ff;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.08);
box-shadow: 0 2px 8px rgb(99 102 241 / 8%);
margin-bottom: 8px;
}
@@ -475,7 +489,11 @@
color: #fff;
flex-shrink: 0;
mat-icon { font-size: 16px; width: 16px; height: 16px; }
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.otp-card__header-text {
@@ -536,7 +554,7 @@
&:focus {
border-color: #1074b9;
background: #fff;
box-shadow: 0 0 0 2px rgba(16, 116, 185, 0.12);
box-shadow: 0 0 0 2px rgb(16 116 185 / 12%);
}
&.filled {
@@ -652,8 +670,10 @@
color: #065f46;
}
@media (max-width: 480px) {
.otp-digits { gap: 6px; }
@media (width <= 480px) {
.otp-digits {
gap: 6px;
}
.otp-digit-box {
width: 40px;
@@ -662,8 +682,13 @@
border-radius: 8px;
}
.otp-card__header { padding: 12px 14px; }
.otp-card__body { padding: 20px 14px; }
.otp-card__header {
padding: 12px 14px;
}
.otp-card__body {
padding: 20px 14px;
}
}
/* PAN-Aadhaar Link Status (read-only) */
@@ -683,22 +708,40 @@
&.linked {
border-color: #a7f3d0;
background: #ecfdf5;
.pan-link-value { color: #059669; }
.pan-link-icon { color: #059669; }
.pan-link-value {
color: #059669;
}
.pan-link-icon {
color: #059669;
}
}
&.not-linked {
border-color: #fecaca;
background: #fef2f2;
.pan-link-value { color: #dc2626; }
.pan-link-icon { color: #dc2626; }
.pan-link-value {
color: #dc2626;
}
.pan-link-icon {
color: #dc2626;
}
}
&.exempt {
border-color: #bfdbfe;
background: #eff6ff;
.pan-link-value { color: #2563eb; }
.pan-link-icon { color: #2563eb; }
.pan-link-value {
color: #2563eb;
}
.pan-link-icon {
color: #2563eb;
}
}
}
@@ -1037,7 +1080,7 @@
}
}
@media (max-width: 768px) {
@media (width <= 768px) {
.form-grid {
grid-template-columns: 1fr;
}
@@ -1073,7 +1116,7 @@
&__sub {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
color: rgb(255 255 255 / 50%);
margin: 2px 0 0;
}
}
@@ -1092,9 +1135,20 @@
border-radius: 50%;
flex-shrink: 0;
&.risk-low { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2); }
&.risk-medium { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2); }
&.risk-high { background: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); }
&.risk-low {
background: #22c55e;
box-shadow: 0 0 0 3px rgb(34 197 94 / 20%);
}
&.risk-medium {
background: #f59e0b;
box-shadow: 0 0 0 3px rgb(245 158 11 / 20%);
}
&.risk-high {
background: #ef4444;
box-shadow: 0 0 0 3px rgb(239 68 68 / 20%);
}
}
.risk-hint {
@@ -1133,3 +1187,30 @@
}
}
/* ─── Re-edit Info Alert ─── */
.kyc-reedit-alert {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
margin-top: 8px;
background: #eff6ff;
border: 1px solid #93c5fd;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
color: #1e40af;
&__icon {
color: #3b82f6;
font-size: 20px;
width: 20px;
height: 20px;
margin-top: 1px;
flex-shrink: 0;
}
strong {
font-weight: 600;
}
}
@@ -1,5 +1,9 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
@@ -54,77 +58,88 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycId: string = '';
/** Whether existing KYC data has been loaded (edit mode) */
kycLoaded = false;
/** KYC status from API: 100=PENDING, 200=IN_PROGRESS, 300=VERIFIED */
kycStatus: number = 0;
/** Whether the KYC record is in VERIFIED state (needs re-edit command) */
get isKycVerified(): boolean {
return this.kycStatus === 300;
}
/** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
/** Fallback document types when template API is unavailable */
private defaultDocumentTypes = [
'Aadhaar Card', 'Passport', 'PAN Card', 'Driving Licence',
'Voter ID', 'NREGA Job Card', 'National Population Register Letter'
'Aadhaar Card',
'Passport',
'PAN Card',
'Driving Licence',
'Voter ID',
'NREGA Job Card',
'National Population Register Letter'
];
/** Constitution-based KYC document types for non-individual clients */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
'HUF': [
HUF: [
{ name: 'PAN Card of HUF', mandatory: true },
{ name: 'Declaration by Karta (with name, address & HUF member details)', mandatory: true },
{ name: 'KYC of Karta (OVDs + PAN + Photo)', mandatory: true },
{ name: 'Proof of Address of HUF', mandatory: false }
],
'PRIVATE_LIMITED': [
PRIVATE_LIMITED: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'Memorandum & Articles of Association', mandatory: true },
{ name: 'PAN of Company', mandatory: true },
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'PUBLIC_LIMITED': [
PUBLIC_LIMITED: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'Memorandum & Articles of Association', mandatory: true },
{ name: 'PAN of Company', mandatory: true },
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'LLP': [
LLP: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'LLP Agreement', mandatory: true },
{ name: 'PAN of LLP', mandatory: true },
{ name: 'Designated Partners Resolution', mandatory: true },
{ name: 'KYC of Designated Partners / Authorized Signatories', mandatory: true }
],
'PARTNERSHIP': [
PARTNERSHIP: [
{ name: 'Registration Certificate', mandatory: false },
{ name: 'Partnership Deed', mandatory: true },
{ name: 'PAN of Firm', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'TRUST': [
TRUST: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'Trust Deed', mandatory: true },
{ name: 'PAN of Trust', mandatory: true },
{ name: 'KYC of Trustees / Authorized Signatories', mandatory: true }
],
'SOCIETY': [
SOCIETY: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN of Society', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'PROPRIETORSHIP': [
PROPRIETORSHIP: [
{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true },
{ name: 'PAN of Proprietor', mandatory: true },
{ name: "Proprietor's KYC (OVDs + PAN + Photo)", mandatory: true }
],
'ASSOCIATION': [
ASSOCIATION: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN', mandatory: true },
{ name: 'Resolution authorizing signatories', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'GOVT_PSU': [
GOVT_PSU: [
{ name: 'Government Notification / Order', mandatory: true },
{ name: 'PAN (if applicable)', mandatory: false },
{ name: 'Authorized Signatory KYC', mandatory: true }
],
'COOPERATIVE': [
COOPERATIVE: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN', mandatory: true },
{ name: 'Resolution authorizing signatories', mandatory: true },
@@ -161,7 +176,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarOtpReferenceId = '';
/** OTP digit boxes */
otpDigits: string[] = ['', '', '', '', '', ''];
otpDigits: string[] = [
'',
'',
'',
'',
'',
''
];
/** Resend countdown */
resendCountdown = 0;
private resendTimer: any = null;
@@ -221,7 +243,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
resetOtpDigits(): void {
this.otpDigits = ['', '', '', '', '', ''];
this.otpDigits = [
'',
'',
'',
'',
'',
''
];
this.aadhaarOtp = '';
}
@@ -254,25 +283,73 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
photographPreview: string | null = null;
signatureFile: File | null = null;
signaturePreview: string | null = null;
/** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = '';
/** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true],
panNumber: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]],
aadhaarNumber: ['', [Validators.pattern(/^\d{12}$/), Validators.maxLength(12)]],
panNumber: [
'',
[
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/),
Validators.maxLength(10)
]
],
aadhaarNumber: [
'',
[
Validators.pattern(/^\d{12}$/),
Validators.maxLength(12)
]
],
panAadhaarSeedingStatus: [''],
gstinNumber: ['', [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/), Validators.maxLength(15)]],
enterprisePan: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]],
cinNumber: ['', [Validators.pattern(/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/), Validators.maxLength(21)]],
gstinNumber: [
'',
[
Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/),
Validators.maxLength(15)
]
],
enterprisePan: [
'',
[
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/),
Validators.maxLength(10)
]
],
cinNumber: [
'',
[
Validators.pattern(/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/),
Validators.maxLength(21)
]
],
incorporationDate: [''],
kycDocumentName: ['', Validators.required],
kycDocumentNumber: ['', [Validators.required, Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/), Validators.maxLength(30)]],
kycDocumentName: [
'',
Validators.required
],
kycDocumentNumber: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30)
]
],
kycDocIssueDate: [''],
kycDocExpiryDate: [''],
riskCategory: [1],
remarks: [''],
amlCheckStatus: ['CLEAR', Validators.required],
bannedEntityMatch: ['NO', Validators.required],
amlCheckStatus: [
'CLEAR',
Validators.required
],
bannedEntityMatch: [
'NO',
Validators.required
],
willfulDefaulter: ['NO'],
ecgcSalFlag: ['NO'],
dormantFlag: ['ACTIVE'],
@@ -320,7 +397,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private updateDocTypesForConstitution(): void {
const constitutionValue = this.constitutionMeta?.value;
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map(doc => ({
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
name: doc.name,
code: doc.name,
mandatory: doc.mandatory
@@ -339,13 +416,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycLoaded = true;
if (data) {
this.kycId = data.id?.toString() || '';
this.kycStatus = data.status?.id || data.kycStatus || 0;
// Helper to parse API date arrays [y,m,d] or strings into Date objects
const parseDate = (val: any): Date | string => {
if (Array.isArray(val) && val.length >= 3) {
return new Date(val[0], val[1] - 1, val[2]);
}
if (val && typeof val === 'string') {
const d = new Date(val);
return isNaN(d.getTime()) ? '' : d;
}
return '';
};
this.kycForm.patchValue({
panNumber: data.panNumber || '',
aadhaarNumber: data.aadhaarNumberMasked || '',
gstinNumber: data.gstinNumber || '',
enterprisePan: data.enterprisePan || '',
cinNumber: data.cinNumber || '',
incorporationDate: parseDate(data.incorporationDate),
// KYC Document
kycDocumentName: data.kycDocumentName || '',
kycDocumentNumber: data.kycDocumentNumber || '',
kycDocIssueDate: parseDate(data.kycDocIssueDate),
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
// Risk & Remarks
riskCategory: data.riskCategory || 1,
remarks: data.remarks || '',
hasPan: !!data.panNumber
hasPan: !!data.panNumber,
// AML & Compliance
amlCheckStatus: data.amlCheckStatus || 'CLEAR',
bannedEntityMatch: data.bannedEntityMatch || 'NO',
willfulDefaulter: data.willfulDefaulter || 'NO',
ecgcSalFlag: data.ecgcSalFlag || 'NO',
dormantFlag: data.dormantFlag || 'ACTIVE',
// Account Status & Freeze
accountStatus: data.accountStatus || 'ACTIVE',
freezeReason: data.freezeReason || '',
freezeAuthority: data.freezeAuthority || '',
// Form 60 / Form 15
form60Type: data.form60Type || 'FORM_60',
form60Date: parseDate(data.form60Date),
form15Applicable: data.form15Applicable || false,
form15Type: data.form15Type || '15G',
form15FY: data.form15FY || ''
});
// Set verification statuses from existing data
if (data.panVerified) {
@@ -366,6 +481,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
}
}
// Load existing photo & signature
this.loadExistingPhoto();
this.loadExistingSignature();
},
error: () => {
// No existing KYC — that's fine for new clients
@@ -374,6 +492,45 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
});
}
/** Fetch existing client profile photo */
private loadExistingPhoto(): void {
if (!this.clientId) return;
this.clientsService.getClientProfileImage(this.clientId).subscribe({
next: (imageData: any) => {
if (imageData) {
this.photographPreview = imageData;
}
},
error: () => {}
});
}
/** Fetch existing client signature from documents */
private loadExistingSignature(): void {
if (!this.clientId) return;
this.clientsService.getClientDocuments(this.clientId).subscribe({
next: (documents: any[]) => {
const sigDoc = documents?.find((d: any) => d.name === 'clientSignature');
if (sigDoc?.id) {
this.existingSignatureDocId = sigDoc.id.toString();
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
next: async (blob: any) => {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
this.signaturePreview = 'data:' + blob.type + ';base64,' + btoa(binary);
},
error: () => {}
});
}
},
error: () => {}
});
}
/** Load KYC template to get required documents based on client type */
private loadKycTemplate(): void {
if (!this.clientId || this.kycTemplateLoaded) return;
@@ -388,7 +545,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
mandatory: doc.mandatory ?? false
}));
// Update kycDocumentName validator based on mandatory docs
const hasMandatory = this.kycDocumentTypes.some(d => d.mandatory);
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
if (hasMandatory) {
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
}
@@ -398,7 +555,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.defaultDocumentTypes.map(name => ({
this.kycDocumentTypes = this.defaultDocumentTypes.map((name) => ({
name,
code: name,
mandatory: false
@@ -410,19 +567,48 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Save or update KYC, then execute callback */
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
if (this.kycId) {
// KYC already exists — use PUT to update (strip create-only fields)
const { kycType, ...updatePayload } = payload;
this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => callback(),
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to update KYC.')
});
// If KYC is VERIFIED, remarks are mandatory — use re-edit command
if (this.isKycVerified) {
const remarks = this.kycForm.get('remarks')?.value?.trim();
if (!remarks) {
onError('Remarks are mandatory when updating a verified KYC record.');
this.kycForm.get('remarks')?.markAsTouched();
return;
}
updatePayload.remarks = remarks;
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => {
// Status transitions to IN_PROGRESS, PAN verification is invalidated
this.kycStatus = 200;
this.panVerifyStatus = 'NONE';
this.panVerifyName = '';
this.panVerifiedOn = '';
callback();
},
error: (err: any) =>
onError(
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Failed to re-edit KYC.'
)
});
} else {
// Normal update for non-verified records
this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => callback(),
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to update KYC.')
});
}
} else {
// First time — use POST to create
this.clientsService.saveClientKyc(this.clientId, payload).subscribe({
next: (res: any) => {
this.kycId = res?.resourceId?.toString() || '';
if (this.kycId) { callback(); }
else { onError('Failed to save KYC.'); }
if (this.kycId) {
callback();
} else {
onError('Failed to save KYC.');
}
},
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to save KYC.')
});
@@ -436,7 +622,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return;
}
const pan = this.kycForm.value.panNumber;
if (!pan) { this.panVerifyError = 'Enter PAN number first.'; return; }
if (!pan) {
this.panVerifyError = 'Enter PAN number first.';
return;
}
this.panVerifying = true;
this.panVerifyError = '';
@@ -451,107 +640,136 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
};
this.saveKycAndDo(panPayload, () => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.panVerifying = false;
if (res?.changes?.panVerified === true) {
// Full name from API
const resolvedName = res.changes?.panFullName
|| res.changes?.panHolderName
|| [res.changes?.panFirstName, res.changes?.panLastName].filter(Boolean).join(' ')
|| '';
this.saveKycAndDo(
panPayload,
() => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.panVerifying = false;
if (res?.changes?.panVerified === true) {
// Full name from API
const resolvedName =
res.changes?.panFullName ||
res.changes?.panHolderName ||
[
res.changes?.panFirstName,
res.changes?.panLastName
]
.filter(Boolean)
.join(' ') ||
'';
// If name is empty, the client name/DOB didn't match PAN holder
if (!resolvedName || !resolvedName.trim()) {
this.panVerifyStatus = 'FAILED';
this.showPanErrorAlert('PAN verification failed: Client name or date of birth does not match the PAN card holder. Please ensure the client\'s name and date of birth exactly match the PAN card details.');
return;
}
// If name is empty, the client name/DOB didn't match PAN holder
if (!resolvedName || !resolvedName.trim()) {
this.panVerifyStatus = 'FAILED';
this.showPanErrorAlert(
"PAN verification failed: Client name or date of birth does not match the PAN card holder. Please ensure the client's name and date of birth exactly match the PAN card details."
);
return;
}
this.panVerifyStatus = 'VERIFIED';
this.panVerifyName = resolvedName;
// Date — could be string or array [y,m,d]
const rawDate = res.changes?.panVerifiedOn;
if (Array.isArray(rawDate)) {
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else if (rawDate) {
this.panVerifiedOn = rawDate;
this.panVerifyStatus = 'VERIFIED';
this.panVerifyName = resolvedName;
// Date — could be string or array [y,m,d]
const rawDate = res.changes?.panVerifiedOn;
if (Array.isArray(rawDate)) {
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else if (rawDate) {
this.panVerifiedOn = rawDate;
} else {
const now = new Date();
this.panVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
// Seeding status
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
const now = new Date();
this.panVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
this.panVerifyStatus = 'FAILED';
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'PAN verification failed.');
}
// Seeding status
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
},
error: (err: any) => {
this.panVerifying = false;
this.panVerifyStatus = 'FAILED';
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'PAN verification failed.');
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'PAN verification failed.';
this.showPanErrorAlert(msg);
}
},
error: (err: any) => {
this.panVerifying = false;
this.panVerifyStatus = 'FAILED';
const msg = err?.error?.errors?.[0]?.defaultUserMessage
|| err?.error?.defaultUserMessage
|| 'PAN verification failed.';
this.showPanErrorAlert(msg);
}
});
}, (msg) => { this.panVerifying = false; this.showPanErrorAlert(msg); });
});
},
(msg) => {
this.panVerifying = false;
this.showPanErrorAlert(msg);
}
);
}
/** Verify Enterprise PAN (non-individual) — uses same verify-pan API */
/**
* Verify Enterprise PAN (non-individual) — same flow as individual PAN verification.
* Maps: Company PAN → panNumber, Company Name → client name, Date of Incorporation → DOB
*/
verifyEnterprisePan() {
if (!this.clientId) return;
const pan = this.kycForm.value.enterprisePan;
if (!pan || this.kycForm.get('enterprisePan')?.invalid) return;
this.entPanVerifying = true;
const dateFormat = this.settingsService.dateFormat;
const incorpDate = this.kycForm.value.incorporationDate;
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
panNumber: pan,
dateFormat: this.settingsService.dateFormat
panNumber: pan, // Company PAN → panNumber
dateFormat,
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
};
if (incorpDate) {
payload.dateOfIncorporation = incorpDate instanceof Date
? this.dateUtils.formatDate(incorpDate, this.settingsService.dateFormat)
: incorpDate;
}
if (this.entityName) {
payload.companyFullName = this.entityName;
}
this.saveKycAndDo(payload, () => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.entPanVerifying = false;
if (res?.changes?.panVerified === true) {
this.entPanVerifyStatus = 'VERIFIED';
this.entPanVerifyName = res.changes?.panFullName || res.changes?.panHolderName || '';
const rawDate = res.changes?.panVerifiedOn;
if (Array.isArray(rawDate)) {
this.entPanVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
this.saveKycAndDo(
payload,
() => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.entPanVerifying = false;
if (res?.changes?.panVerified === true) {
this.entPanVerifyStatus = 'VERIFIED';
this.entPanVerifyName = res.changes?.panFullName || res.changes?.panHolderName || '';
const rawDate = res.changes?.panVerifiedOn;
if (Array.isArray(rawDate)) {
this.entPanVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else {
const now = new Date();
this.entPanVerifiedOn =
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
} else {
const now = new Date();
this.entPanVerifiedOn = rawDate || `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(
res?.changes?.panVerificationMessage ||
'Enterprise PAN verification failed. Ensure company name and date of incorporation match the PAN records.'
);
}
} else {
},
error: (err: any) => {
this.entPanVerifying = false;
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'Enterprise PAN verification failed.');
this.showPanErrorAlert(
err?.error?.errors?.[0]?.defaultUserMessage || 'Enterprise PAN verification failed.'
);
}
},
error: (err: any) => {
this.entPanVerifying = false;
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(err?.error?.errors?.[0]?.defaultUserMessage || 'Enterprise PAN verification failed.');
}
});
}, (msg) => { this.entPanVerifying = false; this.showPanErrorAlert(msg); });
});
},
(msg) => {
this.entPanVerifying = false;
this.showPanErrorAlert(msg);
}
);
}
/** Verify CIN (dummy — no real API) */
@@ -563,7 +781,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.cinProgressValue = 0;
this.cinCurrentStep = 0;
const stepDurations = [800, 1000, 1200, 800];
const stepDurations = [
800,
1000,
1200,
800
];
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
let elapsed = 0;
const interval = 50;
@@ -573,7 +796,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
let accum = 0;
for (let i = 0; i < stepDurations.length; i++) {
accum += stepDurations[i];
if (elapsed <= accum) { this.cinCurrentStep = i; break; }
if (elapsed <= accum) {
this.cinCurrentStep = i;
break;
}
}
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
@@ -598,7 +824,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return;
}
const aadhaar = this.kycForm.value.aadhaarNumber;
if (!aadhaar || aadhaar.length !== 12) { this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.'; return; }
if (!aadhaar || aadhaar.length !== 12) {
this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.';
return;
}
this.aadhaarVerifying = true;
this.aadhaarVerifyError = '';
@@ -611,23 +840,32 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumberMasked: aadhaarMasked
};
const panVal = this.kycForm.value.panNumber;
if (panVal) { aadhaarPayload.panNumber = panVal; }
if (panVal) {
aadhaarPayload.panNumber = panVal;
}
this.saveKycAndDo(aadhaarPayload, () => {
this.clientsService.sendAadhaarOtp(this.clientId, this.kycId, aadhaar).subscribe({
next: (res: any) => {
this.aadhaarVerifying = false;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = res?.changes?.aadhaarRefNumber || '';
this.resetOtpDigits();
this.startResendCountdown();
},
error: (err: any) => {
this.aadhaarVerifying = false;
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'Failed to send OTP.';
}
});
}, (msg) => { this.aadhaarVerifying = false; this.aadhaarVerifyError = msg; });
this.saveKycAndDo(
aadhaarPayload,
() => {
this.clientsService.sendAadhaarOtp(this.clientId, this.kycId, aadhaar).subscribe({
next: (res: any) => {
this.aadhaarVerifying = false;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = res?.changes?.aadhaarRefNumber || '';
this.resetOtpDigits();
this.startResendCountdown();
},
error: (err: any) => {
this.aadhaarVerifying = false;
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'Failed to send OTP.';
}
});
},
(msg) => {
this.aadhaarVerifying = false;
this.aadhaarVerifyError = msg;
}
);
}
/** Verify Aadhaar OTP */
@@ -636,25 +874,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.aadhaarOtpVerifying = true;
this.aadhaarVerifyError = '';
this.clientsService.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId).subscribe({
next: (res: any) => {
this.aadhaarOtpVerifying = false;
if (res?.changes?.aadhaarVerified === true) {
this.aadhaarVerifyStatus = 'VERIFIED';
this.aadhaarVerifyName = res.changes?.aadhaarHolderName || 'Aadhaar Verified';
this.aadhaarOtpSent = false;
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
this.clientsService
.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId)
.subscribe({
next: (res: any) => {
this.aadhaarOtpVerifying = false;
if (res?.changes?.aadhaarVerified === true) {
this.aadhaarVerifyStatus = 'VERIFIED';
this.aadhaarVerifyName = res.changes?.aadhaarHolderName || 'Aadhaar Verified';
this.aadhaarOtpSent = false;
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
this.aadhaarVerifyError = res?.changes?.aadhaarVerificationMessage || 'OTP verification failed';
}
} else {
this.aadhaarVerifyError = res?.changes?.aadhaarVerificationMessage || 'OTP verification failed';
},
error: (err: any) => {
this.aadhaarOtpVerifying = false;
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'OTP verification failed.';
}
},
error: (err: any) => {
this.aadhaarOtpVerifying = false;
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'OTP verification failed.';
}
});
});
}
private showPanErrorAlert(message: string) {
@@ -667,17 +907,34 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
getRenewalYears(): number {
switch (this.kycForm.value.riskCategory) {
case 1: return 10;
case 2: return 8;
case 3: return 2;
default: return 10;
case 1:
return 10;
case 2:
return 8;
case 3:
return 2;
default:
return 10;
}
}
getKycExpiryDate(years: number): string {
const d = new Date();
d.setFullYear(d.getFullYear() + years);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
return `${String(d.getDate()).padStart(2, '0')} ${months[d.getMonth()]} ${d.getFullYear()}`;
}
@@ -688,11 +945,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
input.value = '';
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
width: '680px', maxWidth: '95vw',
width: '680px',
maxWidth: '95vw',
data: { file, title: 'Crop Client Photograph', aspectRatio: 1, maxWidth: 300, maxHeight: 300 }
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) { this.photographFile = result.croppedFile; this.photographPreview = result.previewUrl; }
if (result) {
this.photographFile = result.croppedFile;
this.photographPreview = result.previewUrl;
// In edit mode, upload immediately
if (this.clientId) {
this.clientsService
.uploadClientProfileImage(this.clientId, result.croppedFile)
.subscribe({ error: () => {} });
}
}
});
}
@@ -703,16 +970,70 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
input.value = '';
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
width: '680px', maxWidth: '95vw',
width: '680px',
maxWidth: '95vw',
data: { file, title: 'Crop Specimen Signature', aspectRatio: 2.5, maxWidth: 400, maxHeight: 160 }
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) { this.signatureFile = result.croppedFile; this.signaturePreview = result.previewUrl; }
if (result) {
this.signatureFile = result.croppedFile;
this.signaturePreview = result.previewUrl;
// In edit mode, delete old signature doc then upload new one
if (this.clientId) {
if (this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
},
error: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
});
} else {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
}
}
});
}
removePhoto() { this.photographFile = null; this.photographPreview = null; }
removeSignature() { this.signatureFile = null; this.signaturePreview = null; }
removePhoto() {
this.photographFile = null;
this.photographPreview = null;
// In edit mode, delete from server
if (this.clientId) {
this.clientsService.deleteClientProfileImage(this.clientId).subscribe({ error: () => {} });
}
}
removeSignature() {
this.signatureFile = null;
this.signaturePreview = null;
// In edit mode, delete from server
if (this.clientId && this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => {
this.existingSignatureDocId = '';
},
error: () => {}
});
}
}
get kycData(): any {
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
@@ -720,15 +1041,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
const dateFormat = this.settingsService.dateFormat;
const v = formVal;
const formatDate = (d: any) => d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : (d || null);
const formatDate = (d: any) => (d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : d || null);
return {
locale: 'en',
dateFormat,
kycType: 1,
riskCategory: v.riskCategory || 1,
panNumber: v.hasPan ? (v.panNumber || null) : null,
aadhaarNumberMasked: aadhaarMasked,
// For non-individual: Company PAN is used as panNumber for verification
panNumber: this.isNonIndividual ? v.enterprisePan || null : v.hasPan ? v.panNumber || null : null,
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
gstinNumber: v.gstinNumber || null,
enterprisePan: v.enterprisePan || null,
@@ -761,7 +1083,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
get hasKycData(): boolean {
const v = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber || v.enterprisePan || v.cinNumber || v.kycDocumentName || v.form15Applicable);
return !!(
v.panNumber ||
v.aadhaarNumber ||
v.gstinNumber ||
v.enterprisePan ||
v.cinNumber ||
v.kycDocumentName ||
v.form15Applicable
);
}
}
@@ -784,22 +1114,44 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
</mat-dialog-actions>
</div>
`,
styles: [`
.pan-err-dialog { max-width: 460px; }
.pan-err-header {
display: flex; align-items: center; gap: 10px;
padding: 16px 24px 12px; border-bottom: 1px solid #fecaca;
background: #fef2f2; font-size: 16px; font-weight: 600; color: #991b1b;
}
.pan-err-icon { color: #dc2626; }
.pan-err-body { padding: 18px 24px !important; font-size: 14px; line-height: 1.6; color: #475569; }
.pan-err-actions { padding: 10px 24px 16px !important; margin: 0 !important; }
`],
imports: [MatIcon, MatDialogContent, MatDialogActions, STANDALONE_SHARED_IMPORTS],
styles: [
`
.pan-err-dialog {
max-width: 460px;
}
.pan-err-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 24px 12px;
border-bottom: 1px solid #fecaca;
background: #fef2f2;
font-size: 16px;
font-weight: 600;
color: #991b1b;
}
.pan-err-icon {
color: #dc2626;
}
.pan-err-body {
padding: 18px 24px !important;
font-size: 14px;
line-height: 1.6;
color: #475569;
}
.pan-err-actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
}
`
],
imports: [
MatIcon,
MatDialogContent,
MatDialogActions,
STANDALONE_SHARED_IMPORTS
]
})
export class PanErrorDialogComponent {
constructor(
@Inject(MAT_DIALOG_DATA) public data: { message: string }
) {}
data = inject<{ message: string }>(MAT_DIALOG_DATA);
}