feat: non-individual entity UI improvements — hide individual-only sections, fix legal form display, send extended fields to API
- Hide Family Members tab/section for non-individual entities in client view, create flow, and preview - Hide Financial & Classification section for non-individual entities in personal data and preview - Fix legal form column in clients list — fetch legalForm from client API for correct display - Add person/business icon before client name in list rows - Remove unsupportedNpd filter — send authorizedSignatory, additionalInfo, dateOfRegistration to API - Add registration number format hint (mat-hint) based on constitution type - Add spacing between verification row and constitution type dropdowns - Reduce table cell padding and prevent action buttons from being clipped - Add missing MPL-2.0 license headers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ab01111424
commit
b890d24bb7
@@ -1,65 +1,808 @@
|
||||
<!--
|
||||
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">
|
||||
<div class="step-header">
|
||||
<div class="step-header__left">
|
||||
<fa-icon icon="id-card" class="step-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h3 class="step-header__title">KYC & Documents</h3>
|
||||
<p class="step-header__sub">RBI-compliant KYC verification — PAN, Aadhaar & document upload.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: KYC Verification ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="fingerprint" class="section-icon"></fa-icon>
|
||||
{{ 'labels.inputs.KYC Verification' | translate }}
|
||||
KYC Verification
|
||||
<span class="kyc-badge">KYC</span>
|
||||
</h4>
|
||||
|
||||
<div class="kyc-info-hint">
|
||||
<mat-icon class="kyc-info-hint-icon">info</mat-icon>
|
||||
<span>Verification (PAN, Aadhaar, GSTIN) will be available from the KYC tab after the client is created.</span>
|
||||
<div class="form-grid">
|
||||
|
||||
<!-- ══ INDIVIDUAL KYC ══ -->
|
||||
@if (!isNonIndividual) {
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Individual PAN with inline verify -->
|
||||
@if (kycForm.get('hasPan')?.value) {
|
||||
<div class="form-field verify-field">
|
||||
<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 })" />
|
||||
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (panVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
<mat-hint>Format: ABCDE1234F</mat-hint>
|
||||
</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()">
|
||||
@if (panVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>PAN Verified Successfully</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Full Name (as per PAN)</span>
|
||||
<span class="pan-result-value name">{{ panVerifyName }}</span>
|
||||
</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>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ panVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Aadhaar with independent verify (Individual only) -->
|
||||
<div class="form-field verify-field">
|
||||
<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">
|
||||
<fa-icon [icon]="aadhaarVisible ? 'eye-slash' : 'eye'"></fa-icon>
|
||||
</button>
|
||||
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||
<mat-error>Must be 12 digits</mat-error>
|
||||
}
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
</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()">
|
||||
@if (aadhaarVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify Aadhaar
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aadhaar OTP Section -->
|
||||
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
|
||||
<div class="otp-card full-width">
|
||||
<div class="otp-card__header">
|
||||
<div class="otp-card__header-icon">
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
</div>
|
||||
<div class="otp-card__header-text">
|
||||
<span class="otp-card__title">Aadhaar OTP Verification</span>
|
||||
<span class="otp-card__subtitle">A 6-digit OTP has been sent to the Aadhaar-linked mobile number</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="otp-card__body">
|
||||
<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"
|
||||
[value]="digit"
|
||||
(input)="onOtpDigitInput($index, $event)"
|
||||
(keydown)="onOtpKeydown($index, $event)"
|
||||
autocomplete="one-time-code" />
|
||||
}
|
||||
</div>
|
||||
<div class="otp-card__actions">
|
||||
<button mat-flat-button color="primary" type="button" class="otp-card__verify-btn"
|
||||
[disabled]="readOnly || aadhaarOtp.length < 6 || aadhaarOtpVerifying"
|
||||
(click)="verifyAadhaarOtp()">
|
||||
@if (aadhaarOtpVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate" class="otp-spinner"></mat-progress-spinner>
|
||||
Verifying...
|
||||
} @else {
|
||||
<fa-icon icon="check-circle" class="otp-btn-icon"></fa-icon>
|
||||
Verify OTP
|
||||
}
|
||||
</button>
|
||||
<div class="otp-card__resend">
|
||||
@if (resendCountdown > 0) {
|
||||
<span class="otp-card__countdown">
|
||||
Resend in <strong>00:{{ resendCountdown.toString().padStart(2, '0') }}</strong>
|
||||
</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()">
|
||||
Resend OTP
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (aadhaarVerifyError) {
|
||||
<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">
|
||||
<div class="aadhaar-result-card__header">
|
||||
<fa-icon icon="check-circle" class="aadhaar-result-card__icon"></fa-icon>
|
||||
<span>Aadhaar Verified Successfully</span>
|
||||
</div>
|
||||
<div class="aadhaar-result-card__body">
|
||||
<span class="aadhaar-result-card__label">Name as per Aadhaar</span>
|
||||
<span class="aadhaar-result-card__name">{{ aadhaarVerifyName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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'">
|
||||
<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 }
|
||||
</span>
|
||||
</div>
|
||||
<span class="pan-link-hint">As per Income Tax Portal — Section 139AA</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ══ NON-INDIVIDUAL KYC ══ -->
|
||||
@if (isNonIndividual) {
|
||||
<!-- Incorporation Date -->
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
|
||||
<mat-label>Incorporation Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #incorpDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- 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 })" />
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
|
||||
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
||||
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
|
||||
<div class="form-field verify-field">
|
||||
<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 })" />
|
||||
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (panVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
<mat-hint>PAN of Proprietor (no separate entity PAN needed)</mat-hint>
|
||||
</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()">
|
||||
@if (panVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>PAN Verified Successfully</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Proprietor Name (as per PAN)</span>
|
||||
<span class="pan-result-value name">{{ panVerifyName }}</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ panVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- Non-Proprietorship: Enterprise/Company PAN -->
|
||||
<div class="form-field verify-field">
|
||||
<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 })" />
|
||||
@if (kycForm.get('enterprisePan')?.hasError('pattern') && kycForm.get('enterprisePan')?.touched) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
@if (entPanVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (entPanVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
<mat-hint>{{ constitutionMeta?.panHint || 'PAN mandatory' }}</mat-hint>
|
||||
</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()">
|
||||
@if (entPanVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (entPanVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (entPanVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>Enterprise PAN Verified</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Entity Name (as per PAN)</span>
|
||||
<span class="pan-result-value name">{{ entPanVerifyName }}</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ entPanVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- CIN (Companies & LLP only) -->
|
||||
@if (constitutionMeta?.showCin) {
|
||||
<div class="form-field verify-field">
|
||||
<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"
|
||||
style="text-transform: uppercase"
|
||||
(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>
|
||||
}
|
||||
@if (cinVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
|
||||
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
|
||||
[disabled]="readOnly || !kycForm.get('cinNumber')?.value || cinVerifying"
|
||||
(click)="verifyCin()">
|
||||
@if (cinVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (cinVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CIN verification progress -->
|
||||
@if (cinVerifying) {
|
||||
<div class="cin-progress full-width">
|
||||
<mat-progress-bar mode="determinate" [value]="cinProgressValue" color="primary"></mat-progress-bar>
|
||||
<div class="cin-progress-percent">{{ cinProgressValue }}%</div>
|
||||
<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>
|
||||
<span>{{ step.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (cinVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>CIN Verified — {{ cinCompanyName }}</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">CIN</span>
|
||||
<span class="pan-result-value name">{{ kycForm.get('cinNumber')?.value }}</span>
|
||||
</div>
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ cinVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!clientId) {
|
||||
<div class="verify-info-banner">
|
||||
<fa-icon icon="info-circle" class="verify-info-icon"></fa-icon>
|
||||
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="file-alt" class="section-icon"></fa-icon>
|
||||
KYC Document
|
||||
<span class="kyc-badge doc-badge">OVD</span>
|
||||
@if (kycTemplate?.clientTypeName) {
|
||||
<span class="kyc-client-type-chip">{{ kycTemplate.clientTypeName }}</span>
|
||||
}
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<!-- PAN -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="panNumber"
|
||||
placeholder="{{ 'labels.inputs.Enter 10-character PAN' | translate }}"
|
||||
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>{{ 'labels.inputs.Enter 10-character PAN' | translate }}</mat-error>
|
||||
<mat-label>KYC Document Type</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>description</mat-icon></span>
|
||||
<mat-select required formControlName="kycDocumentName">
|
||||
@for (doc of kycDocumentTypes; track doc) {
|
||||
<mat-option [value]="doc.name">
|
||||
{{ doc.name }}
|
||||
@if (doc.mandatory) {
|
||||
<span class="doc-mandatory-star"> *</span>
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
|
||||
<mat-error>KYC Document Type is <strong>required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Aadhaar -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="aadhaarNumber"
|
||||
placeholder="{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}"
|
||||
maxlength="12" type="password" />
|
||||
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
|
||||
<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"
|
||||
style="text-transform: uppercase"
|
||||
(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) {
|
||||
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- GSTIN -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>{{ 'labels.inputs.GSTIN Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="gstinNumber"
|
||||
placeholder="{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}"
|
||||
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>{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}</mat-error>
|
||||
}
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="docIssueDatePicker.open()">
|
||||
<mat-label>Issue Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input matInput [max]="today" [matDatepicker]="docIssueDatePicker" formControlName="kycDocIssueDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="docIssueDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #docIssueDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Remarks -->
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="3"></textarea>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="docExpiryDatePicker.open()">
|
||||
<mat-label>Expiry Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>event_busy</mat-icon></span>
|
||||
<input matInput [matDatepicker]="docExpiryDatePicker" formControlName="kycDocExpiryDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="docExpiryDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #docExpiryDatePicker></mat-datepicker>
|
||||
<mat-hint>If applicable (Passport, DL, Visa)</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Risk & KYC Review ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="shield-alt" class="section-icon"></fa-icon>
|
||||
Risk Category & KYC Review
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Risk Category</mat-label>
|
||||
<mat-select formControlName="riskCategory">
|
||||
<mat-select-trigger>
|
||||
@if (kycForm.get('riskCategory')?.value === 1) {
|
||||
<span class="risk-option"><span class="risk-meter risk-low"></span> Low Risk</span>
|
||||
} @else if (kycForm.get('riskCategory')?.value === 2) {
|
||||
<span class="risk-option"><span class="risk-meter risk-medium"></span> Medium Risk</span>
|
||||
} @else if (kycForm.get('riskCategory')?.value === 3) {
|
||||
<span class="risk-option"><span class="risk-meter risk-high"></span> High Risk</span>
|
||||
}
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>KYC Review Due Date</mat-label>
|
||||
<input matInput [value]="getKycExpiryDate(getRenewalYears())" disabled />
|
||||
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
||||
</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-form-field>
|
||||
</div>
|
||||
|
||||
<!-- KYC Expiry Alert Cards -->
|
||||
<div class="kyc-expiry-section">
|
||||
<div class="kyc-expiry-header">
|
||||
<fa-icon icon="bell" class="kyc-expiry-header-icon"></fa-icon>
|
||||
<span>KYC Expiry Alert</span>
|
||||
</div>
|
||||
<div class="kyc-expiry-body">
|
||||
<div class="kyc-expiry-cards">
|
||||
@for (rc of riskCards; track rc.value) {
|
||||
<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"
|
||||
[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"
|
||||
[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"/>
|
||||
</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>
|
||||
</div>
|
||||
@if (kycForm.get('riskCategory')?.value === rc.value) {
|
||||
<span class="kyc-expiry-card__next">Next: {{ getKycExpiryDate(rc.years) }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (kycForm.get('riskCategory')?.value) {
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: AML & Compliance Checks ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title aml-title">
|
||||
<fa-icon icon="exclamation-triangle" class="section-icon"></fa-icon>
|
||||
AML & Compliance Checks
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>AML Check Status</mat-label>
|
||||
<mat-select required formControlName="amlCheckStatus">
|
||||
<mat-option value="CLEAR">Clear</mat-option>
|
||||
<mat-option value="FLAGGED">Flagged</mat-option>
|
||||
<mat-option value="BLOCKED">Blocked</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Banned Entity List Match</mat-label>
|
||||
<mat-select required formControlName="bannedEntityMatch">
|
||||
<mat-option value="NO">No</mat-option>
|
||||
<mat-option value="YES">Yes</mat-option>
|
||||
<mat-option value="PARTIAL_MATCH">Partial Match</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Willful Defaulter Flag</mat-label>
|
||||
<mat-select formControlName="willfulDefaulter">
|
||||
<mat-option value="NO">No</mat-option>
|
||||
<mat-option value="YES">Yes</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>ECGC SAL Flag</mat-label>
|
||||
<mat-select formControlName="ecgcSalFlag">
|
||||
<mat-option value="NO">No</mat-option>
|
||||
<mat-option value="YES">Yes</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Dormant / Inoperative Flag</mat-label>
|
||||
<mat-select formControlName="dormantFlag">
|
||||
<mat-option value="ACTIVE">Active</mat-option>
|
||||
<mat-option value="DORMANT">Dormant</mat-option>
|
||||
<mat-option value="INOPERATIVE">Inoperative</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (kycForm.get('amlCheckStatus')?.value === 'BLOCKED' || kycForm.get('bannedEntityMatch')?.value === 'YES') {
|
||||
<div class="aml-block-alert">
|
||||
<fa-icon icon="ban" class="aml-block-icon"></fa-icon>
|
||||
<span><strong>BLOCKED</strong> — Account opening cannot proceed.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Freeze / Debit Controls ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title freeze-title">
|
||||
<fa-icon icon="lock" class="section-icon"></fa-icon>
|
||||
Freeze / Debit Controls
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Account Status</mat-label>
|
||||
<mat-select formControlName="accountStatus">
|
||||
<mat-option value="ACTIVE">Active</mat-option>
|
||||
<mat-option value="FROZEN">Frozen</mat-option>
|
||||
<mat-option value="UNFREEZE">Unfreeze</mat-option>
|
||||
<mat-option value="NO_DEBIT">No Debit</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (kycForm.get('accountStatus')?.value === 'FROZEN') {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Reason for Freeze</mat-label>
|
||||
<mat-select required formControlName="freezeReason">
|
||||
<mat-option value="COURT_ORDER">Court Order</mat-option>
|
||||
<mat-option value="AML">AML</mat-option>
|
||||
<mat-option value="REGULATORY">Regulatory</mat-option>
|
||||
<mat-option value="INTERNAL">Internal</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Freeze Authority</mat-label>
|
||||
<mat-select required formControlName="freezeAuthority">
|
||||
<mat-option value="BRANCH_MANAGER">Branch Manager</mat-option>
|
||||
<mat-option value="HO">Head Office</mat-option>
|
||||
<mat-option value="REGULATORY">Regulatory Authority</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Form 60/61 ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||
Form 60 / 61
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||
}
|
||||
</h4>
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<div class="kyc-info-hint warning-hint">
|
||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||
<span>Client does not have PAN. Form 60/61 is mandatory.</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form60Type">
|
||||
<mat-option value="FORM_60">Form 60</mat-option>
|
||||
<mat-option value="FORM_61">Form 61</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||
<mat-label>Declaration Date</mat-label>
|
||||
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
||||
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #form60DatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="form60-not-needed">Client has PAN — Form 60/61 not required.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Form 15G / 15H ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||
Form 15G / 15H
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<div class="form-field checkbox-field">
|
||||
<mat-checkbox formControlName="form15Applicable">Form 15G/15H Applicable</mat-checkbox>
|
||||
</div>
|
||||
@if (kycForm.get('form15Applicable')?.value) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form15Type">
|
||||
<mat-option value="15G">15G (Below 60 years)</mat-option>
|
||||
<mat-option value="15H">15H (Senior Citizen 60+)</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Financial Year</mat-label>
|
||||
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
||||
Photograph & Signature
|
||||
</h4>
|
||||
<div class="photo-sig-grid">
|
||||
<div class="upload-box" [class.upload-box--has-preview]="photographPreview">
|
||||
@if (photographPreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="photographPreview" alt="Photo" class="upload-preview__img upload-preview__img--photo" />
|
||||
<div class="upload-preview__actions">
|
||||
<label class="upload-preview__btn recrop">
|
||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||
</label>
|
||||
<button type="button" class="upload-preview__btn remove" (click)="removePhoto()">
|
||||
<fa-icon icon="trash"></fa-icon> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
|
||||
<span class="upload-box__label">Client Photograph</span>
|
||||
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||
<fa-icon icon="camera"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="upload-box" [class.upload-box--has-preview]="signaturePreview">
|
||||
@if (signaturePreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||
<div class="upload-preview__actions">
|
||||
<label class="upload-preview__btn recrop">
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||
</label>
|
||||
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
||||
<fa-icon icon="trash"></fa-icon> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
||||
<span class="upload-box__label">Specimen Signature</span>
|
||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,82 +1,805 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
|
||||
/** Custom Imports */
|
||||
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import { Inject } from '@angular/core';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatSelectTrigger } from '@angular/material/select';
|
||||
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
/**
|
||||
* Client KYC Step Component
|
||||
* Collects PAN, Aadhaar, and GSTIN details during client creation.
|
||||
* KYC data is submitted via POST /clients/{clientId}/kyc after client creation.
|
||||
* Verification happens later from the KYC tab on the client view page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-client-kyc-step',
|
||||
templateUrl: './client-kyc-step.component.html',
|
||||
styleUrls: ['./client-kyc-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
FaIconComponent
|
||||
MatCheckbox,
|
||||
MatProgressSpinner,
|
||||
FaIconComponent,
|
||||
MatSelectTrigger
|
||||
]
|
||||
})
|
||||
export class ClientKycStepComponent {
|
||||
export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private dialog = inject(MatDialog);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
/** KYC Reactive Form */
|
||||
/** Set by parent after client is created */
|
||||
@Input() clientId: string = '';
|
||||
@Input() isNonIndividual = false;
|
||||
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
|
||||
@Input() constitutionMeta: any = null;
|
||||
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
||||
@Input() entityName: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
|
||||
today = new Date();
|
||||
kycId: string = '';
|
||||
/** Whether existing KYC data has been loaded (edit mode) */
|
||||
kycLoaded = false;
|
||||
|
||||
/** 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'
|
||||
];
|
||||
/** Constitution-based KYC document types for non-individual clients */
|
||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||
'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': [
|
||||
{ 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': [
|
||||
{ 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': [
|
||||
{ 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': [
|
||||
{ name: 'Registration Certificate', mandatory: false },
|
||||
{ name: 'Partnership Deed', mandatory: true },
|
||||
{ name: 'PAN of Firm', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'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': [
|
||||
{ name: 'Registration Certificate', mandatory: true },
|
||||
{ name: 'PAN of Society', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'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': [
|
||||
{ name: 'Registration Certificate', mandatory: true },
|
||||
{ name: 'PAN', mandatory: true },
|
||||
{ name: 'Resolution authorizing signatories', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
],
|
||||
'GOVT_PSU': [
|
||||
{ name: 'Government Notification / Order', mandatory: true },
|
||||
{ name: 'PAN (if applicable)', mandatory: false },
|
||||
{ name: 'Authorized Signatory KYC', mandatory: true }
|
||||
],
|
||||
'COOPERATIVE': [
|
||||
{ name: 'Registration Certificate', mandatory: true },
|
||||
{ name: 'PAN', mandatory: true },
|
||||
{ name: 'Resolution authorizing signatories', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', mandatory: true }
|
||||
]
|
||||
};
|
||||
/** KYC template data from API */
|
||||
kycTemplate: any = null;
|
||||
kycTemplateLoaded = false;
|
||||
|
||||
/** Risk cards */
|
||||
riskCards = [
|
||||
{ value: 1, label: 'Low Risk', years: 10, color: 'green' },
|
||||
{ value: 2, label: 'Medium Risk', years: 8, color: 'amber' },
|
||||
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
||||
];
|
||||
|
||||
/** PAN verification */
|
||||
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
panVerifying = false;
|
||||
panVerifyError: string = '';
|
||||
panVerifyName: string = '';
|
||||
panVerifiedOn: string = '';
|
||||
|
||||
/** Aadhaar verification */
|
||||
aadhaarVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
aadhaarVerifying = false;
|
||||
aadhaarVerifyError: string = '';
|
||||
aadhaarVerifyName: string = '';
|
||||
aadhaarVisible = false;
|
||||
aadhaarOtpSent = false;
|
||||
aadhaarOtp = '';
|
||||
aadhaarOtpVerifying = false;
|
||||
aadhaarOtpReferenceId = '';
|
||||
|
||||
/** OTP digit boxes */
|
||||
otpDigits: string[] = ['', '', '', '', '', ''];
|
||||
/** Resend countdown */
|
||||
resendCountdown = 0;
|
||||
private resendTimer: any = null;
|
||||
|
||||
onOtpDigitInput(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const val = input.value.replace(/\D/g, '');
|
||||
this.otpDigits[index] = val.charAt(0) || '';
|
||||
input.value = this.otpDigits[index];
|
||||
|
||||
// Auto-advance to next box
|
||||
if (this.otpDigits[index] && index < 5) {
|
||||
const next = input.parentElement?.querySelectorAll('input')[index + 1];
|
||||
if (next) (next as HTMLInputElement).focus();
|
||||
}
|
||||
|
||||
this.aadhaarOtp = this.otpDigits.join('');
|
||||
}
|
||||
|
||||
onOtpKeydown(index: number, event: KeyboardEvent): void {
|
||||
if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
|
||||
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
|
||||
if (inputs) {
|
||||
this.otpDigits[index - 1] = '';
|
||||
(inputs[index - 1] as HTMLInputElement).value = '';
|
||||
(inputs[index - 1] as HTMLInputElement).focus();
|
||||
}
|
||||
this.aadhaarOtp = this.otpDigits.join('');
|
||||
}
|
||||
}
|
||||
|
||||
onOtpPaste(event: ClipboardEvent): void {
|
||||
event.preventDefault();
|
||||
const pasted = (event.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
this.otpDigits[i] = pasted.charAt(i) || '';
|
||||
}
|
||||
this.aadhaarOtp = this.otpDigits.join('');
|
||||
// Focus last filled or last box
|
||||
const inputs = (event.target as HTMLElement).closest('.otp-digits')?.querySelectorAll('input');
|
||||
if (inputs) {
|
||||
const focusIdx = Math.min(pasted.length, 5);
|
||||
(inputs[focusIdx] as HTMLInputElement).focus();
|
||||
}
|
||||
}
|
||||
|
||||
startResendCountdown(): void {
|
||||
this.resendCountdown = 30;
|
||||
if (this.resendTimer) clearInterval(this.resendTimer);
|
||||
this.resendTimer = setInterval(() => {
|
||||
this.resendCountdown--;
|
||||
if (this.resendCountdown <= 0) {
|
||||
clearInterval(this.resendTimer);
|
||||
this.resendTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
resetOtpDigits(): void {
|
||||
this.otpDigits = ['', '', '', '', '', ''];
|
||||
this.aadhaarOtp = '';
|
||||
}
|
||||
|
||||
/** PAN-Aadhaar seeding status (from API response) */
|
||||
panAadhaarSeedingStatus: string = '';
|
||||
|
||||
/** Enterprise PAN verification (non-individual) */
|
||||
entPanVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
entPanVerifying = false;
|
||||
entPanVerifyName = '';
|
||||
entPanVerifiedOn = '';
|
||||
|
||||
/** CIN verification (dummy) */
|
||||
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
cinVerifying = false;
|
||||
cinVerifiedOn = '';
|
||||
cinCompanyName = '';
|
||||
cinProgressValue = 0;
|
||||
cinCurrentStep = 0;
|
||||
cinProgressSteps = [
|
||||
{ label: 'Connecting to MCA database...', icon: 'globe' as any },
|
||||
{ label: 'Validating CIN format...', icon: 'search' as any },
|
||||
{ label: 'Fetching company details...', icon: 'database' as any },
|
||||
{ label: 'Verifying registration status...', icon: 'check-circle' as any }
|
||||
];
|
||||
private cinProgressTimer: any;
|
||||
|
||||
/** File uploads */
|
||||
photographFile: File | null = null;
|
||||
photographPreview: string | null = null;
|
||||
signatureFile: File | null = null;
|
||||
signaturePreview: string | null = null;
|
||||
|
||||
/** KYC Form */
|
||||
kycForm: UntypedFormGroup = this.formBuilder.group({
|
||||
panNumber: [
|
||||
'',
|
||||
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
],
|
||||
aadhaarNumber: [
|
||||
'',
|
||||
[Validators.pattern(/^\d{12}$/)]
|
||||
],
|
||||
gstinNumber: [
|
||||
'',
|
||||
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
||||
],
|
||||
remarks: ['']
|
||||
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)]],
|
||||
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)]],
|
||||
incorporationDate: [''],
|
||||
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],
|
||||
willfulDefaulter: ['NO'],
|
||||
ecgcSalFlag: ['NO'],
|
||||
dormantFlag: ['ACTIVE'],
|
||||
accountStatus: ['ACTIVE'],
|
||||
freezeReason: [''],
|
||||
freezeAuthority: [''],
|
||||
form60Type: ['FORM_60'],
|
||||
form60Date: [''],
|
||||
form15Applicable: [false],
|
||||
form15Type: ['15G'],
|
||||
form15FY: ['']
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the KYC payload matching the backend API contract.
|
||||
* POST /clients/{clientId}/kyc expects: kycType, riskCategory, panNumber,
|
||||
* aadhaarNumberMasked (NOT full aadhaarNumber), gstinNumber, remarks.
|
||||
*/
|
||||
get kycData(): any {
|
||||
ngOnInit(): void {
|
||||
this.loadExistingKycIfNeeded();
|
||||
this.loadKycTemplate();
|
||||
if (this.isNonIndividual && this.constitutionMeta) {
|
||||
this.updateDocTypesForConstitution();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.cinProgressTimer) clearInterval(this.cinProgressTimer);
|
||||
if (this.resendTimer) clearInterval(this.resendTimer);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['clientId'] && !changes['clientId'].firstChange) {
|
||||
this.loadExistingKycIfNeeded();
|
||||
this.loadKycTemplate();
|
||||
}
|
||||
if (changes['readOnly'] && this.kycForm) {
|
||||
if (this.readOnly) {
|
||||
this.kycForm.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.kycForm.enable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
||||
this.updateDocTypesForConstitution();
|
||||
}
|
||||
}
|
||||
|
||||
/** Update KYC document types based on selected constitution type */
|
||||
private updateDocTypesForConstitution(): void {
|
||||
const constitutionValue = this.constitutionMeta?.value;
|
||||
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
|
||||
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map(doc => ({
|
||||
name: doc.name,
|
||||
code: doc.name,
|
||||
mandatory: doc.mandatory
|
||||
}));
|
||||
// Reset selected document type since options changed
|
||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
|
||||
/** Load existing KYC data if clientId is set (edit mode) */
|
||||
private loadExistingKycIfNeeded(): void {
|
||||
if (!this.clientId || this.kycLoaded) return;
|
||||
this.clientsService.getClientKyc(this.clientId).subscribe({
|
||||
next: (data: any) => {
|
||||
this.kycLoaded = true;
|
||||
if (data) {
|
||||
this.kycId = data.id?.toString() || '';
|
||||
this.kycForm.patchValue({
|
||||
panNumber: data.panNumber || '',
|
||||
aadhaarNumber: data.aadhaarNumberMasked || '',
|
||||
gstinNumber: data.gstinNumber || '',
|
||||
riskCategory: data.riskCategory || 1,
|
||||
remarks: data.remarks || '',
|
||||
hasPan: !!data.panNumber
|
||||
});
|
||||
// Set verification statuses from existing data
|
||||
if (data.panVerified) {
|
||||
this.panVerifyStatus = 'VERIFIED';
|
||||
this.panVerifyName = data.panFullName || data.panHolderName || '';
|
||||
const rawDate = data.panVerifiedOn;
|
||||
if (Array.isArray(rawDate) && rawDate.length >= 3) {
|
||||
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
|
||||
} else if (rawDate) {
|
||||
this.panVerifiedOn = rawDate;
|
||||
}
|
||||
}
|
||||
if (data.aadhaarVerified) {
|
||||
this.aadhaarVerifyStatus = 'VERIFIED';
|
||||
this.aadhaarVerifyName = data.aadhaarHolderName || 'Aadhaar Verified';
|
||||
}
|
||||
if (data.panAadhaarSeedingStatus) {
|
||||
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// No existing KYC — that's fine for new clients
|
||||
this.kycLoaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Load KYC template to get required documents based on client type */
|
||||
private loadKycTemplate(): void {
|
||||
if (!this.clientId || this.kycTemplateLoaded) return;
|
||||
this.clientsService.getClientKycTemplate(this.clientId).subscribe({
|
||||
next: (template: any) => {
|
||||
this.kycTemplate = template;
|
||||
this.kycTemplateLoaded = true;
|
||||
if (template?.requiredDocuments?.length > 0) {
|
||||
this.kycDocumentTypes = template.requiredDocuments.map((doc: any) => ({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
mandatory: doc.mandatory ?? false
|
||||
}));
|
||||
// Update kycDocumentName validator based on mandatory docs
|
||||
const hasMandatory = this.kycDocumentTypes.some(d => d.mandatory);
|
||||
if (hasMandatory) {
|
||||
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
|
||||
}
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Fallback to defaults if template API unavailable
|
||||
this.kycTemplateLoaded = true;
|
||||
this.kycDocumentTypes = this.defaultDocumentTypes.map(name => ({
|
||||
name,
|
||||
code: name,
|
||||
mandatory: false
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 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.')
|
||||
});
|
||||
} 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.'); }
|
||||
},
|
||||
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to save KYC.')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify PAN — saves PAN-only KYC data, then verifies */
|
||||
verifyPan() {
|
||||
if (!this.clientId) {
|
||||
this.panVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const pan = this.kycForm.value.panNumber;
|
||||
if (!pan) { this.panVerifyError = 'Enter PAN number first.'; return; }
|
||||
|
||||
this.panVerifying = true;
|
||||
this.panVerifyError = '';
|
||||
this.panVerifyName = '';
|
||||
|
||||
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||
const panPayload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
panNumber: pan,
|
||||
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(' ')
|
||||
|| '';
|
||||
|
||||
// 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;
|
||||
} 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 {
|
||||
this.panVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'PAN verification failed.');
|
||||
}
|
||||
},
|
||||
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); });
|
||||
}
|
||||
|
||||
/** Verify Enterprise PAN (non-individual) — uses same verify-pan API */
|
||||
verifyEnterprisePan() {
|
||||
if (!this.clientId) return;
|
||||
const pan = this.kycForm.value.enterprisePan;
|
||||
if (!pan || this.kycForm.get('enterprisePan')?.invalid) return;
|
||||
|
||||
this.entPanVerifying = true;
|
||||
const incorpDate = this.kycForm.value.incorporationDate;
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
panNumber: pan,
|
||||
dateFormat: this.settingsService.dateFormat
|
||||
};
|
||||
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]}`;
|
||||
} else {
|
||||
const now = new Date();
|
||||
this.entPanVerifiedOn = rawDate || `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}
|
||||
} else {
|
||||
this.entPanVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(res?.changes?.panVerificationMessage || '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); });
|
||||
}
|
||||
|
||||
/** Verify CIN (dummy — no real API) */
|
||||
verifyCin() {
|
||||
const cin = this.kycForm.value.cinNumber;
|
||||
if (!cin) return;
|
||||
|
||||
this.cinVerifying = true;
|
||||
this.cinProgressValue = 0;
|
||||
this.cinCurrentStep = 0;
|
||||
|
||||
const stepDurations = [800, 1000, 1200, 800];
|
||||
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
|
||||
let elapsed = 0;
|
||||
const interval = 50;
|
||||
|
||||
this.cinProgressTimer = setInterval(() => {
|
||||
elapsed += interval;
|
||||
let accum = 0;
|
||||
for (let i = 0; i < stepDurations.length; i++) {
|
||||
accum += stepDurations[i];
|
||||
if (elapsed <= accum) { this.cinCurrentStep = i; break; }
|
||||
}
|
||||
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
|
||||
|
||||
if (elapsed >= totalDuration) {
|
||||
clearInterval(this.cinProgressTimer);
|
||||
this.cinCurrentStep = stepDurations.length;
|
||||
setTimeout(() => {
|
||||
this.cinVerifying = false;
|
||||
this.cinVerifyStatus = 'VERIFIED';
|
||||
this.cinCompanyName = 'Verified via MCA';
|
||||
const now = new Date();
|
||||
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}, 300);
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
|
||||
sendAadhaarOtp() {
|
||||
if (!this.clientId) {
|
||||
this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const aadhaar = this.kycForm.value.aadhaarNumber;
|
||||
if (!aadhaar || aadhaar.length !== 12) { this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.'; return; }
|
||||
|
||||
this.aadhaarVerifying = true;
|
||||
this.aadhaarVerifyError = '';
|
||||
|
||||
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
|
||||
const aadhaarPayload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
aadhaarNumberMasked: aadhaarMasked
|
||||
};
|
||||
const panVal = this.kycForm.value.panNumber;
|
||||
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; });
|
||||
}
|
||||
|
||||
/** Verify Aadhaar OTP */
|
||||
verifyAadhaarOtp() {
|
||||
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
this.aadhaarVerifyError = res?.changes?.aadhaarVerificationMessage || 'OTP verification failed';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.aadhaarOtpVerifying = false;
|
||||
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'OTP verification failed.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showPanErrorAlert(message: string) {
|
||||
this.panVerifyError = '';
|
||||
this.dialog.open(PanErrorDialogComponent, {
|
||||
width: '480px',
|
||||
data: { message }
|
||||
});
|
||||
}
|
||||
|
||||
getRenewalYears(): number {
|
||||
switch (this.kycForm.value.riskCategory) {
|
||||
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'];
|
||||
return `${String(d.getDate()).padStart(2, '0')} ${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
async onPhotoSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||
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; }
|
||||
});
|
||||
}
|
||||
|
||||
async onSignatureSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||
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; }
|
||||
});
|
||||
}
|
||||
|
||||
removePhoto() { this.photographFile = null; this.photographPreview = null; }
|
||||
removeSignature() { this.signatureFile = null; this.signaturePreview = null; }
|
||||
|
||||
get kycData(): any {
|
||||
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
|
||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||
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);
|
||||
|
||||
return {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
kycType: 1,
|
||||
riskCategory: 1,
|
||||
panNumber: this.kycForm.value.panNumber || null,
|
||||
riskCategory: v.riskCategory || 1,
|
||||
panNumber: v.hasPan ? (v.panNumber || null) : null,
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||
remarks: this.kycForm.value.remarks || null
|
||||
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
|
||||
gstinNumber: v.gstinNumber || null,
|
||||
enterprisePan: v.enterprisePan || null,
|
||||
cinNumber: v.cinNumber || null,
|
||||
incorporationDate: formatDate(v.incorporationDate),
|
||||
// KYC Document
|
||||
kycDocumentName: v.kycDocumentName || null,
|
||||
kycDocumentNumber: v.kycDocumentNumber || null,
|
||||
kycDocIssueDate: formatDate(v.kycDocIssueDate),
|
||||
kycDocExpiryDate: formatDate(v.kycDocExpiryDate),
|
||||
// AML & Compliance
|
||||
amlCheckStatus: v.amlCheckStatus || null,
|
||||
bannedEntityMatch: v.bannedEntityMatch || null,
|
||||
willfulDefaulter: v.willfulDefaulter || null,
|
||||
ecgcSalFlag: v.ecgcSalFlag || null,
|
||||
dormantFlag: v.dormantFlag || null,
|
||||
// Freeze / Debit Controls
|
||||
accountStatus: v.accountStatus || null,
|
||||
freezeReason: v.freezeReason || null,
|
||||
freezeAuthority: v.freezeAuthority || null,
|
||||
// Form 60 / Form 15
|
||||
form60Type: v.form60Type || null,
|
||||
form60Date: formatDate(v.form60Date),
|
||||
form15Applicable: v.form15Applicable || false,
|
||||
form15Type: v.form15Type || null,
|
||||
form15FY: v.form15FY || null,
|
||||
remarks: v.remarks || null
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns true if any KYC data has been entered */
|
||||
get hasKycData(): boolean {
|
||||
const v = this.kycForm.value;
|
||||
return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PAN Error Alert Dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pan-error-dialog',
|
||||
template: `
|
||||
<div class="pan-err-dialog">
|
||||
<div class="pan-err-header">
|
||||
<mat-icon class="pan-err-icon">error</mat-icon>
|
||||
<span>PAN Verification Failed</span>
|
||||
</div>
|
||||
<mat-dialog-content class="pan-err-body">
|
||||
<p>{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end" class="pan-err-actions">
|
||||
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
|
||||
</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],
|
||||
})
|
||||
export class PanErrorDialogComponent {
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: { message: string }
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
-->
|
||||
|
||||
<div class="crop-dialog">
|
||||
<div class="crop-header">
|
||||
<div class="crop-header__left">
|
||||
<fa-icon icon="crop" class="crop-header__icon"></fa-icon>
|
||||
<span class="crop-header__title">{{ data.title }}</span>
|
||||
</div>
|
||||
<button class="crop-header__close" (click)="dialogRef.close()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="crop-body">
|
||||
@if (!imageLoaded) {
|
||||
<div class="crop-loading">Loading image...</div>
|
||||
}
|
||||
|
||||
<div class="crop-workspace" [class.hidden]="!imageLoaded">
|
||||
<div class="crop-source">
|
||||
<canvas #sourceCanvas
|
||||
(mousedown)="onMouseDown($event)"
|
||||
(mousemove)="onMouseMove($event)"
|
||||
(mouseup)="onMouseUp()"
|
||||
(mouseleave)="onMouseUp()">
|
||||
</canvas>
|
||||
<div class="crop-hint">Drag to move. Drag corner to resize.</div>
|
||||
</div>
|
||||
|
||||
<div class="crop-preview-panel">
|
||||
<span class="crop-preview-label">Preview</span>
|
||||
<div class="crop-preview-box">
|
||||
<canvas #previewCanvas></canvas>
|
||||
</div>
|
||||
<span class="crop-preview-size">{{ data.maxWidth }}x{{ data.maxHeight }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="crop-actions">
|
||||
<button mat-stroked-button (click)="dialogRef.close()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="!imageLoaded" (click)="confirm()">
|
||||
<fa-icon icon="check" class="crop-actions__icon"></fa-icon>
|
||||
Crop & Use
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*/
|
||||
|
||||
$blue: #1353a0;
|
||||
$navy: #0f172a;
|
||||
|
||||
.crop-dialog {
|
||||
width: 620px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.crop-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 16px;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crop-body {
|
||||
padding: 20px 24px !important;
|
||||
overflow: visible !important;
|
||||
max-height: 70vh !important;
|
||||
}
|
||||
|
||||
.crop-loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.crop-workspace {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.crop-source {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
canvas {
|
||||
cursor: crosshair;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.crop-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.crop-preview-panel {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.crop-preview-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.crop-preview-box {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border: 2px solid $blue;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.crop-preview-size {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
.crop-actions {
|
||||
padding: 12px 24px 16px !important;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
justify-content: flex-end !important;
|
||||
gap: 10px !important;
|
||||
|
||||
button {
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.crop-workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.crop-preview-panel {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.crop-preview-box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*/
|
||||
|
||||
import { Component, Inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export interface ImageCropData {
|
||||
file: File;
|
||||
title: string;
|
||||
aspectRatio: number; // width/height, e.g. 1 for square, 1.5 for 3:2
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-image-crop-dialog',
|
||||
templateUrl: './image-crop-dialog.component.html',
|
||||
styleUrls: ['./image-crop-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatDialogContent,
|
||||
MatDialogActions
|
||||
]
|
||||
})
|
||||
export class ImageCropDialogComponent implements AfterViewInit {
|
||||
@ViewChild('sourceCanvas') sourceCanvas!: ElementRef<HTMLCanvasElement>;
|
||||
@ViewChild('previewCanvas') previewCanvas!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
imageLoaded = false;
|
||||
private img = new Image();
|
||||
private imgWidth = 0;
|
||||
private imgHeight = 0;
|
||||
|
||||
// Crop box (relative to displayed image)
|
||||
cropX = 0;
|
||||
cropY = 0;
|
||||
cropW = 200;
|
||||
cropH = 200;
|
||||
|
||||
// Drag state
|
||||
private dragging = false;
|
||||
private dragStartX = 0;
|
||||
private dragStartY = 0;
|
||||
private dragStartCropX = 0;
|
||||
private dragStartCropY = 0;
|
||||
|
||||
// Resize state
|
||||
private resizing = false;
|
||||
|
||||
// Canvas display dimensions
|
||||
private canvasDisplayW = 0;
|
||||
private canvasDisplayH = 0;
|
||||
private scaleX = 1;
|
||||
private scaleY = 1;
|
||||
|
||||
zoom = 1;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ImageCropDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: ImageCropData
|
||||
) {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: any) => {
|
||||
this.img.onload = () => {
|
||||
this.imageLoaded = true;
|
||||
setTimeout(() => this.initCanvas());
|
||||
};
|
||||
this.img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(this.data.file);
|
||||
}
|
||||
|
||||
private initCanvas() {
|
||||
const canvas = this.sourceCanvas.nativeElement;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const container = canvas.parentElement!;
|
||||
|
||||
const maxW = container.clientWidth || 500;
|
||||
const maxH = 400;
|
||||
|
||||
// Scale image to fit container
|
||||
const ratio = Math.min(maxW / this.img.width, maxH / this.img.height, 1);
|
||||
this.canvasDisplayW = Math.floor(this.img.width * ratio);
|
||||
this.canvasDisplayH = Math.floor(this.img.height * ratio);
|
||||
this.imgWidth = this.img.width;
|
||||
this.imgHeight = this.img.height;
|
||||
|
||||
canvas.width = this.canvasDisplayW;
|
||||
canvas.height = this.canvasDisplayH;
|
||||
this.scaleX = this.img.width / this.canvasDisplayW;
|
||||
this.scaleY = this.img.height / this.canvasDisplayH;
|
||||
|
||||
// Init crop box centered
|
||||
const cropSize = Math.min(this.canvasDisplayW, this.canvasDisplayH) * 0.6;
|
||||
if (this.data.aspectRatio >= 1) {
|
||||
this.cropW = cropSize;
|
||||
this.cropH = cropSize / this.data.aspectRatio;
|
||||
} else {
|
||||
this.cropH = cropSize;
|
||||
this.cropW = cropSize * this.data.aspectRatio;
|
||||
}
|
||||
this.cropX = (this.canvasDisplayW - this.cropW) / 2;
|
||||
this.cropY = (this.canvasDisplayH - this.cropH) / 2;
|
||||
|
||||
this.drawCanvas();
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
private drawCanvas() {
|
||||
const canvas = this.sourceCanvas.nativeElement;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(this.img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Dark overlay outside crop
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Clear crop area
|
||||
ctx.clearRect(this.cropX, this.cropY, this.cropW, this.cropH);
|
||||
ctx.drawImage(
|
||||
this.img,
|
||||
this.cropX * this.scaleX, this.cropY * this.scaleY,
|
||||
this.cropW * this.scaleX, this.cropH * this.scaleY,
|
||||
this.cropX, this.cropY, this.cropW, this.cropH
|
||||
);
|
||||
|
||||
// Crop border
|
||||
ctx.strokeStyle = '#1353a0';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(this.cropX, this.cropY, this.cropW, this.cropH);
|
||||
|
||||
// Corner handles
|
||||
const hs = 8;
|
||||
ctx.fillStyle = '#1353a0';
|
||||
const corners = [
|
||||
[this.cropX, this.cropY],
|
||||
[this.cropX + this.cropW, this.cropY],
|
||||
[this.cropX, this.cropY + this.cropH],
|
||||
[this.cropX + this.cropW, this.cropY + this.cropH]
|
||||
];
|
||||
corners.forEach(([x, y]) => {
|
||||
ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs);
|
||||
});
|
||||
}
|
||||
|
||||
private updatePreview() {
|
||||
if (!this.previewCanvas) return;
|
||||
const pCanvas = this.previewCanvas.nativeElement;
|
||||
const pCtx = pCanvas.getContext('2d')!;
|
||||
const pw = this.data.maxWidth || 150;
|
||||
const ph = this.data.maxHeight || 150;
|
||||
pCanvas.width = pw;
|
||||
pCanvas.height = ph;
|
||||
|
||||
pCtx.clearRect(0, 0, pw, ph);
|
||||
pCtx.drawImage(
|
||||
this.img,
|
||||
this.cropX * this.scaleX, this.cropY * this.scaleY,
|
||||
this.cropW * this.scaleX, this.cropH * this.scaleY,
|
||||
0, 0, pw, ph
|
||||
);
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent) {
|
||||
const rect = this.sourceCanvas.nativeElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
// Check if near bottom-right corner for resize
|
||||
const cornerX = this.cropX + this.cropW;
|
||||
const cornerY = this.cropY + this.cropH;
|
||||
if (Math.abs(x - cornerX) < 12 && Math.abs(y - cornerY) < 12) {
|
||||
this.resizing = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if inside crop box for drag
|
||||
if (x >= this.cropX && x <= this.cropX + this.cropW && y >= this.cropY && y <= this.cropY + this.cropH) {
|
||||
this.dragging = true;
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
this.dragStartCropX = this.cropX;
|
||||
this.dragStartCropY = this.cropY;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent) {
|
||||
if (!this.dragging && !this.resizing) return;
|
||||
const rect = this.sourceCanvas.nativeElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
if (this.dragging) {
|
||||
this.cropX = Math.max(0, Math.min(this.canvasDisplayW - this.cropW, this.dragStartCropX + (x - this.dragStartX)));
|
||||
this.cropY = Math.max(0, Math.min(this.canvasDisplayH - this.cropH, this.dragStartCropY + (y - this.dragStartY)));
|
||||
}
|
||||
|
||||
if (this.resizing) {
|
||||
const newW = Math.max(40, Math.min(this.canvasDisplayW - this.cropX, x - this.cropX));
|
||||
const newH = newW / this.data.aspectRatio;
|
||||
if (this.cropY + newH <= this.canvasDisplayH) {
|
||||
this.cropW = newW;
|
||||
this.cropH = newH;
|
||||
}
|
||||
}
|
||||
|
||||
this.drawCanvas();
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
this.dragging = false;
|
||||
this.resizing = false;
|
||||
}
|
||||
|
||||
confirm() {
|
||||
// Create cropped image as blob
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = this.data.maxWidth || 300;
|
||||
offscreen.height = this.data.maxHeight || 300;
|
||||
const ctx = offscreen.getContext('2d')!;
|
||||
ctx.drawImage(
|
||||
this.img,
|
||||
this.cropX * this.scaleX, this.cropY * this.scaleY,
|
||||
this.cropW * this.scaleX, this.cropH * this.scaleY,
|
||||
0, 0, offscreen.width, offscreen.height
|
||||
);
|
||||
|
||||
offscreen.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const croppedFile = new File([blob], this.data.file.name, { type: 'image/png' });
|
||||
this.dialogRef.close({ croppedFile, previewUrl: offscreen.toDataURL('image/png') });
|
||||
}
|
||||
}, 'image/png');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user