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:
Aditya
2026-04-04 15:10:51 +05:30
co-authored by Claude Opus 4.6
parent ab01111424
commit b890d24bb7
522 changed files with 51991 additions and 21885 deletions
@@ -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 }
) {}
}