Same number in both fields defeats the point of having a second contact route, and re-running DIP on an identical number is pointless. Added a cross-field validator on alternateMobileNo, re-checked whenever mobileNo changes, with a matching error message and the Verify-with-DIP button disabled while the clash exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3153 lines
117 KiB
TypeScript
3153 lines
117 KiB
TypeScript
/**
|
|
* 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,
|
|
OnInit,
|
|
OnDestroy,
|
|
OnChanges,
|
|
SimpleChanges,
|
|
Input,
|
|
Output,
|
|
EventEmitter,
|
|
inject
|
|
} from '@angular/core';
|
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|
import {
|
|
UntypedFormBuilder,
|
|
UntypedFormGroup,
|
|
Validators,
|
|
UntypedFormControl,
|
|
ReactiveFormsModule,
|
|
ValidatorFn,
|
|
AbstractControl,
|
|
ValidationErrors
|
|
} from '@angular/forms';
|
|
import { Subject, Subscription, merge } from 'rxjs';
|
|
import { filter, switchMap, takeUntil } from 'rxjs/operators';
|
|
import { ClientsService } from 'app/clients/clients.service';
|
|
import { Dates } from 'app/core/utils/dates';
|
|
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
|
import { toProperNameCase } from 'app/clients/utils/name-case.util';
|
|
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
|
|
|
/** Custom Services */
|
|
import { SettingsService } from 'app/settings/settings.service';
|
|
import { MatDivider } from '@angular/material/divider';
|
|
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
|
import { MatCheckbox } from '@angular/material/checkbox';
|
|
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { MatSelectTrigger } from '@angular/material/select';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
|
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
|
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
|
import { DinVerifyDialogComponent } from './din-verify-dialog.component';
|
|
import {
|
|
ContactOtpBlockComponent,
|
|
ContactOtpState
|
|
} from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component';
|
|
import {
|
|
MINOR_AGE_THRESHOLD,
|
|
SENIOR_CITIZEN_AGE_THRESHOLD,
|
|
isMinorAge,
|
|
isSeniorCitizenAge
|
|
} from 'app/clients/shared/client-age.util';
|
|
|
|
/**
|
|
* Create Client Component
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-client-general-step',
|
|
templateUrl: './client-general-step.component.html',
|
|
styleUrls: ['./client-general-step.component.scss'],
|
|
providers: [ExternalNationalIdService],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatDivider,
|
|
CdkTextareaAutosize,
|
|
MatCheckbox,
|
|
FaIconComponent,
|
|
MatIcon,
|
|
MatTooltip,
|
|
MatSelectTrigger,
|
|
DateFormatPipe,
|
|
ContactOtpBlockComponent
|
|
]
|
|
})
|
|
export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges {
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private dateUtils = inject(Dates);
|
|
private settingsService = inject(SettingsService);
|
|
private clientService = inject(ClientsService);
|
|
externalNationalIdService = inject(ExternalNationalIdService);
|
|
private dialog = inject(MatDialog);
|
|
private sanitizer = inject(DomSanitizer);
|
|
|
|
/** Subject to trigger unsubscription on destroy */
|
|
private destroy$ = new Subject<void>();
|
|
|
|
@Output() legalFormChangeEvent = new EventEmitter<{ legalForm: number }>();
|
|
|
|
/** Expose enum to template */
|
|
readonly LegalFormId = LegalFormId;
|
|
|
|
/** Senior Citizen proof is optional; the PWD certificate is mandatory (see seniorPwdDocsComplete). Both, when
|
|
* attached, are uploaded after client creation. */
|
|
seniorCitizenProofFile: File | null = null;
|
|
pwdCertificateFile: File | null = null;
|
|
|
|
/** Previously-uploaded documents, loaded in edit mode so the certificate doesn't appear to
|
|
* "disappear" on reopening — the raw File properties above only ever hold a fresh selection. */
|
|
existingSeniorCitizenProofDoc: { id: string; name: string } | null = null;
|
|
existingPwdCertificateDoc: { id: string; name: string } | null = null;
|
|
|
|
/** Disability categories (per the Rights of Persons with Disabilities Act, 2016) — fetched from
|
|
* m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions.
|
|
* Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */
|
|
pwdCategoryOptions: any[] = [];
|
|
riskRules: any[] = [];
|
|
|
|
/** Set true after a save attempt so the UI can highlight a missing required document */
|
|
seniorPwdDocsTouched = false;
|
|
|
|
/** Senior Citizen proof is optional. The PWD certificate + disability type are mandatory
|
|
* whenever the PWD checkbox is set. */
|
|
get seniorPwdDocsComplete(): boolean {
|
|
const pwdOk =
|
|
!this.createClientForm.get('isPwd')?.value ||
|
|
((!!this.pwdCertificateFile || !!this.existingPwdCertificateDoc) &&
|
|
!!this.createClientForm.get('pwdCategoryId')?.value);
|
|
return pwdOk;
|
|
}
|
|
|
|
markSeniorPwdDocsTouched(): void {
|
|
this.seniorPwdDocsTouched = true;
|
|
}
|
|
|
|
onSeniorCitizenProofSelected(event: any): void {
|
|
const file = event.target?.files?.[0];
|
|
if (file) {
|
|
this.seniorCitizenProofFile = file;
|
|
}
|
|
}
|
|
|
|
removeSeniorCitizenProof(): void {
|
|
this.seniorCitizenProofFile = null;
|
|
}
|
|
|
|
onPwdCertificateSelected(event: any): void {
|
|
const file = event.target?.files?.[0];
|
|
if (file) {
|
|
this.pwdCertificateFile = file;
|
|
}
|
|
}
|
|
|
|
removePwdCertificate(): void {
|
|
this.pwdCertificateFile = null;
|
|
}
|
|
|
|
/** Loads previously-uploaded senior-citizen-proof / PWD-certificate documents in edit mode —
|
|
* mirrors the KYC step's loadExistingSignature() pattern so re-opening edit shows what's
|
|
* already on file instead of an empty upload prompt. */
|
|
private loadExistingSeniorPwdDocuments(): void {
|
|
const clientId = this.clientData?.id;
|
|
if (!clientId) {
|
|
return;
|
|
}
|
|
this.clientService.getClientDocuments(clientId.toString()).subscribe({
|
|
next: (documents: any[]) => {
|
|
const seniorDoc = documents
|
|
?.filter((d: any) => d.name === 'seniorCitizenProof')
|
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
|
if (seniorDoc?.id) {
|
|
this.existingSeniorCitizenProofDoc = {
|
|
id: seniorDoc.id.toString(),
|
|
name: seniorDoc.fileName || 'Senior Citizen Proof'
|
|
};
|
|
}
|
|
const pwdDoc = documents
|
|
?.filter((d: any) => d.name === 'pwdCertificate')
|
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
|
if (pwdDoc?.id) {
|
|
this.existingPwdCertificateDoc = {
|
|
id: pwdDoc.id.toString(),
|
|
name: pwdDoc.fileName || 'Disability Certificate'
|
|
};
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
viewExistingSeniorCitizenProof(): void {
|
|
const clientId = this.clientData?.id;
|
|
if (!clientId || !this.existingSeniorCitizenProofDoc) {
|
|
return;
|
|
}
|
|
this.clientService.downloadClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
|
|
next: (blob: any) =>
|
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
|
data: {
|
|
blob,
|
|
title: this.existingSeniorCitizenProofDoc?.name,
|
|
fileName: this.existingSeniorCitizenProofDoc?.name
|
|
}
|
|
}),
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
removeExistingSeniorCitizenProof(): void {
|
|
const clientId = this.clientData?.id;
|
|
if (!clientId || !this.existingSeniorCitizenProofDoc) {
|
|
return;
|
|
}
|
|
this.clientService.deleteClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
|
|
next: () => (this.existingSeniorCitizenProofDoc = null),
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
viewExistingPwdCertificate(): void {
|
|
const clientId = this.clientData?.id;
|
|
if (!clientId || !this.existingPwdCertificateDoc) {
|
|
return;
|
|
}
|
|
this.clientService.downloadClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
|
|
next: (blob: any) =>
|
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
|
data: { blob, title: this.existingPwdCertificateDoc?.name, fileName: this.existingPwdCertificateDoc?.name }
|
|
}),
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
removeExistingPwdCertificate(): void {
|
|
const clientId = this.clientData?.id;
|
|
if (!clientId || !this.existingPwdCertificateDoc) {
|
|
return;
|
|
}
|
|
this.clientService.deleteClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
|
|
next: () => (this.existingPwdCertificateDoc = null),
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
/** Minimum date allowed. */
|
|
minDate = new Date(2000, 0, 1);
|
|
/** Maximum date allowed. */
|
|
maxDate = new Date();
|
|
|
|
/** Client Template */
|
|
@Input() clientTemplate: any;
|
|
/** Existing client data for edit mode (null for create) */
|
|
@Input() clientData: any = null;
|
|
/** Pre-fill data from "Create as Client" on a manual family member */
|
|
@Input() prefillData: any = null;
|
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
|
@Input() readOnly = false;
|
|
/** Create Client Form */
|
|
createClientForm: UntypedFormGroup;
|
|
|
|
/** Office Options */
|
|
officeOptions: any;
|
|
/** Staff Options */
|
|
staffOptions: any;
|
|
/** Legal Form Options */
|
|
legalFormOptions: any;
|
|
/** Client Type Options */
|
|
clientTypeOptions: any;
|
|
/** Client Classification Options */
|
|
clientClassificationTypeOptions: any;
|
|
/** Business Line Options */
|
|
businessLineOptions: any;
|
|
/** Constitution Options */
|
|
constitutionOptions: any;
|
|
/** Enriched constitution options (API IDs + master metadata) */
|
|
enrichedConstitutionOptions: any[] = [];
|
|
/** Gender Options */
|
|
genderOptions: any;
|
|
/** Saving Product Options */
|
|
savingProductOptions: any;
|
|
/** Occupation Category Options */
|
|
occupationCategoryOptions: any;
|
|
/** Occupation Options */
|
|
occupationOptions: any;
|
|
/** Salutation Options */
|
|
salutationOptions: any;
|
|
/** Marital Status Options */
|
|
maritalStatusOptions: any[] = [];
|
|
/** Education Level Options */
|
|
educationLevelOptions: any[] = [];
|
|
/** Degree / Qualification Options */
|
|
degreeQualificationOptions: any[] = [];
|
|
/** Religion Options */
|
|
religionOptions: any[] = [];
|
|
/** Social Category Options */
|
|
socialCategoryOptions: any[] = [];
|
|
/** Nationality Options */
|
|
nationalityOptions = [
|
|
{ value: 'INDIAN', label: 'Indian', flagType: 'india', icon: '' },
|
|
{ value: 'NRI', label: 'NRI', flagType: 'none', icon: 'flight_takeoff' },
|
|
{ value: 'FOREIGN_NATIONAL', label: 'Foreign National', flagType: 'none', icon: 'public' },
|
|
{ value: 'PIO', label: 'PIO', flagType: 'none', icon: 'connecting_airports' },
|
|
{ value: 'OCI', label: 'OCI', flagType: 'none', icon: 'card_travel' }
|
|
];
|
|
/** Calculated age detail string (Y/M/D) */
|
|
calculatedAgeDetail: string | null = null;
|
|
/** Whether client is a minor — threshold from the shared age util, derived from Date of Birth */
|
|
isMinor = false;
|
|
/** Whether client is a senior citizen — threshold from the shared age util, derived from DOB */
|
|
isSeniorCitizen = false;
|
|
/** Exposed for template hints so the displayed rule can never drift from the applied rule. */
|
|
readonly minorAgeThreshold = MINOR_AGE_THRESHOLD;
|
|
readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD;
|
|
/** Age breakdown for DB storage */
|
|
ageBreakdown = { years: 0, months: 0, days: 0 };
|
|
|
|
/** Guardian search */
|
|
guardianSearchText = '';
|
|
guardianSearchResults: any[] = [];
|
|
guardianSearching = false;
|
|
selectedGuardian: any = null;
|
|
private guardianSearchTimer: any = null;
|
|
/** Signatory search (Non-Individual, multiple) */
|
|
signatorySearchText = '';
|
|
signatorySearchResults: any[] = [];
|
|
signatorySearching = false;
|
|
signatories: any[] = [];
|
|
private signatorySearchTimer: any = null;
|
|
/** Guardian linking mode (search visible) */
|
|
guardianLinking = false;
|
|
/** Guardian detail row expanded */
|
|
guardianExpanded = false;
|
|
/** Mobile number DIP verification status */
|
|
mobileVerified = false;
|
|
/** DIP lookup log ID from API */
|
|
dipLookupLogId: number | null = null;
|
|
/** DIP lookup timestamp from API */
|
|
dipTimestamp: string | null = null;
|
|
/** Alternate mobile number DIP verification status */
|
|
alternateMobileVerified = false;
|
|
/** Alternate DIP lookup log ID from API */
|
|
alternateDipLookupLogId: number | null = null;
|
|
/** Alternate DIP lookup timestamp from API */
|
|
alternateDipTimestamp: string | null = null;
|
|
/**
|
|
* DIP/MNRL status the number actually returned (CLEAN / SUSPECTED / SERVICE_UNAVAILABLE).
|
|
* Kept separately from `mobileVerified` so an unverifiable number is not indistinguishable
|
|
* from one that genuinely passed.
|
|
*/
|
|
dipStatus: string | null = null;
|
|
alternateDipStatus: string | null = null;
|
|
/** Officer rejected the number outright — it must be replaced before saving. */
|
|
dipRejected = false;
|
|
alternateDipRejected = false;
|
|
|
|
/**
|
|
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
|
|
* edit-flow dual old/new OTP panel in `EditClientComponent`, a brand-new client has no "old"
|
|
* destination to verify against — so only a single "new value" OTP-verification is needed
|
|
* per field here. Sent to the backend as `mobileNewOtpId`/`emailNewOtpId` alongside the rest
|
|
* of the create-client payload (see `CreateClientComponent.saveAndProceed()`), which requires
|
|
* both whenever `mobileNo`/`emailAddress` are present (and they always are — both are
|
|
* `Validators.required` on this form).
|
|
*/
|
|
mobileOtpVerified = false;
|
|
mobileNewOtpId: number | null = null;
|
|
emailOtpVerified = false;
|
|
emailNewOtpId: number | null = null;
|
|
|
|
/**
|
|
* Whether both mobile and email have been OTP-verified for CLIENT CREATION. Always `true`
|
|
* in edit mode (`clientData` set) — edit-time contact verification is gated separately by
|
|
* `EditClientComponent.contactVerificationComplete`, not by this component.
|
|
*/
|
|
get contactVerified(): boolean {
|
|
if (this.clientData) {
|
|
return true;
|
|
}
|
|
return this.mobileOtpVerified && this.emailOtpVerified;
|
|
}
|
|
|
|
/** Resets create-time OTP verification for mobileNo — called whenever the field's value
|
|
* changes after a prior verification, since that OTP no longer applies to the new value. */
|
|
onMobileOtpState(state: ContactOtpState): void {
|
|
this.mobileOtpVerified = state.verified;
|
|
this.mobileNewOtpId = state.otpId;
|
|
}
|
|
|
|
/** Resets create-time OTP verification for emailAddress — see `onMobileOtpState()`. */
|
|
onEmailOtpState(state: ContactOtpState): void {
|
|
this.emailOtpVerified = state.verified;
|
|
this.emailNewOtpId = state.otpId;
|
|
}
|
|
/** Constitution Types Master with KYC field metadata */
|
|
constitutionTypesMaster = [
|
|
{
|
|
value: 'PRIVATE_LIMITED',
|
|
label: 'Private Limited Company',
|
|
icon: 'business',
|
|
panLabel: 'PAN of Company',
|
|
panHint: 'PAN mandatory. CIN from MCA.',
|
|
regHint: 'Certificate of Incorporation No.',
|
|
showCin: true,
|
|
additionalField: 'Constitution as per Articles of Association',
|
|
documents: 'Certificate of Incorporation, Memorandum & Articles of Association, PAN, Board Resolution',
|
|
signatoryKyc: 'KYC of all Authorized Signatories (OVDs + PAN + Photo)',
|
|
note: 'Board Resolution to specify authorized signatories'
|
|
},
|
|
{
|
|
value: 'PUBLIC_LIMITED',
|
|
label: 'Public Limited Company',
|
|
icon: 'domain',
|
|
panLabel: 'PAN of Company',
|
|
panHint: 'PAN mandatory. CIN from MCA.',
|
|
regHint: 'Certificate of Incorporation No.',
|
|
showCin: true,
|
|
additionalField: 'Constitution as per Articles of Association',
|
|
documents: 'Certificate of Incorporation, Memorandum & Articles of Association, PAN, Board Resolution',
|
|
signatoryKyc: 'KYC of all Authorized Signatories (OVDs + PAN + Photo)',
|
|
note: 'Board Resolution to specify authorized signatories'
|
|
},
|
|
{
|
|
value: 'LLP',
|
|
label: 'Limited Liability Partnership (LLP)',
|
|
icon: 'handshake',
|
|
panLabel: 'PAN of LLP',
|
|
panHint: 'PAN mandatory. LLPIN from MCA.',
|
|
regHint: 'Certificate of Incorporation No.',
|
|
showCin: true,
|
|
additionalField: 'LLP Agreement',
|
|
documents: 'Certificate of Incorporation, LLP Agreement, PAN, Designated Partners Resolution',
|
|
signatoryKyc: 'KYC of all Designated Partners / Authorized Signatories',
|
|
note: 'LLPIN from MCA required'
|
|
},
|
|
{
|
|
value: 'PARTNERSHIP',
|
|
label: 'Partnership Firm',
|
|
icon: 'handshake',
|
|
panLabel: 'PAN of Firm',
|
|
panHint: 'PAN of Firm mandatory',
|
|
regHint: 'Registration Certificate No. (if registered)',
|
|
showCin: false,
|
|
additionalField: 'Partners Names (linked client-wise)',
|
|
documents: 'Partnership Deed, Registration Certificate (if registered), PAN of Firm',
|
|
signatoryKyc: 'KYC of all Partners / Authorized Signatories',
|
|
note: 'Registration optional but preferred'
|
|
},
|
|
{
|
|
value: 'PROPRIETORSHIP',
|
|
label: 'Proprietorship',
|
|
icon: 'person',
|
|
panLabel: 'PAN of Proprietor',
|
|
panHint: 'PAN of Proprietor (no separate entity PAN needed)',
|
|
regHint: 'GST / Shop License No.',
|
|
showCin: false,
|
|
additionalField: '',
|
|
documents: 'GST Certificate / Shop License / Utility Bill, PAN of Proprietor',
|
|
signatoryKyc: "Proprietor's individual KYC (same as individual requirements)",
|
|
note: 'At least 2 proofs of business existence required. Proprietor name = Client name.'
|
|
},
|
|
{
|
|
value: 'HUF',
|
|
label: 'HUF (Hindu Undivided Family)',
|
|
icon: 'family_restroom',
|
|
panLabel: 'PAN of HUF',
|
|
panHint: 'PAN of HUF mandatory',
|
|
regHint: 'HUF Declaration / Deed No.',
|
|
showCin: false,
|
|
additionalField: 'Karta Name',
|
|
documents: 'HUF Declaration/Deed, PAN of HUF',
|
|
signatoryKyc: 'KYC of Karta',
|
|
note: ''
|
|
},
|
|
{
|
|
value: 'TRUST',
|
|
label: 'Trust',
|
|
icon: 'verified_user',
|
|
panLabel: 'PAN of Trust',
|
|
panHint: 'PAN of Trust mandatory',
|
|
regHint: 'Trust Deed / Registration No.',
|
|
showCin: false,
|
|
additionalField: 'Purpose of Trust',
|
|
documents: 'Trust Deed, Registration Certificate, PAN of Trust',
|
|
signatoryKyc: 'KYC of Trustees / Authorized Signatories',
|
|
note: 'Foreign contribution rules apply if applicable'
|
|
},
|
|
{
|
|
value: 'SOCIETY',
|
|
label: 'Society',
|
|
icon: 'groups',
|
|
panLabel: 'PAN of Society',
|
|
panHint: 'PAN of Society mandatory',
|
|
regHint: 'Registration Certificate No.',
|
|
showCin: false,
|
|
additionalField: 'Managing Committee Details',
|
|
documents: 'Registration Certificate, PAN of Society, Resolution authorizing signatories',
|
|
signatoryKyc: 'KYC of Authorized Signatories',
|
|
note: 'Governed by Societies Registration Act'
|
|
},
|
|
{
|
|
value: 'ASSOCIATION',
|
|
label: 'Association / Club',
|
|
icon: 'diversity_3',
|
|
panLabel: 'PAN',
|
|
panHint: 'PAN mandatory',
|
|
regHint: 'Registration Certificate No.',
|
|
showCin: false,
|
|
additionalField: 'Club Constitution Document',
|
|
documents: 'Registration Certificate, PAN, Resolution authorizing signatories',
|
|
signatoryKyc: 'KYC of Authorized Signatories',
|
|
note: 'Committee approval needed for account'
|
|
},
|
|
{
|
|
value: 'GOVT_PSU',
|
|
label: 'Government Dept / PSU',
|
|
icon: 'account_balance',
|
|
panLabel: 'PAN (if applicable)',
|
|
panHint: 'PAN if applicable',
|
|
regHint: 'Govt. Notification / Order No.',
|
|
showCin: false,
|
|
additionalField: 'Gazette Notification Reference',
|
|
documents: 'Relevant Government Notification / Order, PAN (if applicable)',
|
|
signatoryKyc: 'Authorized Signatory KYC',
|
|
note: 'Special KYC norms as per RBI circular'
|
|
},
|
|
{
|
|
value: 'COOPERATIVE',
|
|
label: 'Cooperative Society / Cooperative Bank',
|
|
icon: 'hub',
|
|
panLabel: 'PAN',
|
|
panHint: 'PAN mandatory. RBI license if bank.',
|
|
regHint: 'Registration Certificate No.',
|
|
showCin: false,
|
|
additionalField: 'Bye-laws of the Cooperative',
|
|
documents: 'Registration Certificate, PAN, Resolution authorizing signatories',
|
|
signatoryKyc: 'KYC of Authorized Signatories',
|
|
note: 'Separate RBI licensing check for cooperative banks'
|
|
}
|
|
];
|
|
|
|
/** Currently selected constitution metadata */
|
|
get selectedConstitution(): any {
|
|
const val = this.createClientForm.get('clientNonPersonDetails.constitutionId')?.value;
|
|
return this.enrichedConstitutionOptions.find((c: any) => c.id === val) || null;
|
|
}
|
|
|
|
/** Sanctions verification state */
|
|
sanctionsVerified = false;
|
|
/** Sanctions match was found — blocks account opening */
|
|
sanctionsBlocked = false;
|
|
/**
|
|
* Outcome recorded against a match: 'FALSE_POSITIVE' (awaiting Principal Officer approval)
|
|
* or 'CONFIRMED_MATCH'. Null when the hit has not been reviewed yet. Both keep the client
|
|
* blocked — only the Principal Officer's approval, applied server-side, releases a name.
|
|
*/
|
|
sanctionsDisposition: string | null = null;
|
|
/**
|
|
* The name was edited after a block, so the applicant may no longer be the flagged one.
|
|
* Re-exposes the verify button; the block itself is only lifted by a CLEAR from the API.
|
|
*/
|
|
sanctionsNeedsRescreen = false;
|
|
/** Screening log ID from sanctions API */
|
|
screeningLogId: number | null = null;
|
|
|
|
/** Cites the direction actually in force — kept in step with the verification dialog. */
|
|
readonly sanctionsCitation = 'UAPA 1967 s.51-A & RBI KYC Directions, 2025 — Chapter IX';
|
|
readonly sanctionsTooltip =
|
|
'All customer names must be verified against the UN Security Council Consolidated List before account ' +
|
|
'opening and periodically thereafter. A match is not a rejection: compare the identifiers, record a ' +
|
|
'false positive with reasons, or confirm the match and escalate to the Principal Officer immediately.';
|
|
|
|
/** Badge text reflecting how far the flagged alert has progressed. */
|
|
get sanctionsStatusLabel(): string {
|
|
if (this.sanctionsDisposition === 'FALSE_POSITIVE') {
|
|
return 'Awaiting PO Approval';
|
|
}
|
|
if (this.sanctionsDisposition === 'CONFIRMED_MATCH') {
|
|
return 'Confirmed Match';
|
|
}
|
|
return 'Sanctions Match';
|
|
}
|
|
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
|
private verifiedName: string = '';
|
|
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
|
|
private suppressMonthlyAnnualLink = false;
|
|
|
|
/**
|
|
* @param {FormBuilder} formBuilder Form Builder
|
|
* @param {Dates} dateUtils Date Utils
|
|
* @param {SettingsService} settingsService Setting service
|
|
* @param {ClientsService} clientService Client service
|
|
*/
|
|
constructor() {
|
|
this.setClientForm();
|
|
}
|
|
|
|
ngOnInit() {
|
|
this.maxDate = this.settingsService.businessDate;
|
|
this.setOptions();
|
|
|
|
// Risk rules are an enhancement on top of the default low/medium-risk fallback in
|
|
// calculatedRisk — if the endpoint isn't available (e.g. not yet deployed on this
|
|
// backend), fail silently rather than surfacing a raw HTTP-error toast to the user.
|
|
this.clientService.getClientRiskRules().subscribe({
|
|
next: (rules) => {
|
|
this.riskRules = rules || [];
|
|
},
|
|
error: () => {
|
|
this.riskRules = [];
|
|
}
|
|
});
|
|
// Fetch marital status options from code values
|
|
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
|
this.maritalStatusOptions = options || [];
|
|
});
|
|
// Fetch education level options from code values
|
|
this.clientService.getCodeValuesByName('EducationLevel').subscribe((options) => {
|
|
this.educationLevelOptions = options || [];
|
|
});
|
|
// Fetch degree / qualification options from code values
|
|
this.clientService.getCodeValuesByName('DegreeQualification').subscribe((options) => {
|
|
this.degreeQualificationOptions = options || [];
|
|
});
|
|
// Fetch religion options from code values
|
|
this.clientService.getCodeValuesByName('Religion').subscribe((options) => {
|
|
this.religionOptions = options || [];
|
|
});
|
|
// Fetch social category options from code values
|
|
this.clientService.getCodeValuesByName('SocialCategory').subscribe((options) => {
|
|
this.socialCategoryOptions = options || [];
|
|
});
|
|
// Reset degree selection whenever education level changes (degree list is filtered by level)
|
|
this.createClientForm
|
|
.get('educationLevelId')
|
|
?.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
const current = this.createClientForm.get('degreeQualificationId')?.value;
|
|
if (current && !this.filteredDegreeQualificationOptions.some((d: any) => d.id === current)) {
|
|
this.createClientForm.get('degreeQualificationId')?.setValue('');
|
|
}
|
|
});
|
|
// Fetch all constitution options from code ID 17
|
|
this.clientService.getCodeValuesById(17).subscribe((options) => {
|
|
if (options?.length) {
|
|
this.constitutionOptions = options;
|
|
// Sort master by value length descending so longer/more-specific keys match first
|
|
const sortedMaster = [...this.constitutionTypesMaster].sort((a, b) => b.value.length - a.value.length);
|
|
this.enrichedConstitutionOptions = options.map((opt: any) => {
|
|
const normalized = opt.name?.toUpperCase().replace(/[\s_-]+/g, '') || '';
|
|
const master = sortedMaster.find((m) => normalized.includes(m.value.replace(/_/g, '')));
|
|
return {
|
|
id: opt.id,
|
|
value: master?.value || opt.name,
|
|
label: master?.label || opt.name,
|
|
icon: master?.icon || 'business',
|
|
panLabel: master?.panLabel || '',
|
|
panHint: master?.panHint || '',
|
|
regHint: master?.regHint || '',
|
|
showCin: master?.showCin || false,
|
|
additionalField: master?.additionalField || '',
|
|
documents: master?.documents || '',
|
|
signatoryKyc: master?.signatoryKyc || '',
|
|
note: master?.note || ''
|
|
};
|
|
});
|
|
}
|
|
});
|
|
this.buildDependencies();
|
|
if (this.clientData) {
|
|
this.prefillForEdit();
|
|
} else if (this.prefillData) {
|
|
this.prefillFromFamilyMember();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resets transient session state back to a blank-client baseline.
|
|
*
|
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps the SAME
|
|
* component instance alive across repeat visits to /clients/create — `ngOnInit`/the
|
|
* constructor never re-run for a second "create new client" session. Without this, computed
|
|
* state like `isMinor`, the linked guardian, or a prior sanctions/OTP verification would leak
|
|
* from whatever client was created in the previous session into the new one. Called by
|
|
* CreateClientComponent whenever `route.data` re-emits (which happens on every navigation,
|
|
* even a reused one).
|
|
*/
|
|
resetForNewClient(): void {
|
|
this.createClientForm.reset();
|
|
// legalFormId's raw FormControl default is '' — the buildDependencies() subscription
|
|
// treats any non-PERSON value (including '') as "non-individual" and adds fullname /
|
|
// clientNonPersonDetails. Patch it back to the same PERSON default buildDependencies()
|
|
// itself applies on first construction, so a reset lands on a blank Individual client
|
|
// instead of stuck with leftover Entity-only controls.
|
|
this.createClientForm.get('legalFormId')?.setValue(LegalFormId.PERSON);
|
|
this.isMinor = false;
|
|
this.calculatedAgeDetail = null;
|
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
|
this.selectedGuardian = null;
|
|
this.guardianSearchText = '';
|
|
this.guardianSearchResults = [];
|
|
this.guardianExpanded = false;
|
|
if (this.createClientForm.get('guardianDetails')) {
|
|
this.createClientForm.removeControl('guardianDetails');
|
|
}
|
|
// Same reasoning as the guardian above: the reused component instance would otherwise carry the
|
|
// previous entity's authorized signatories into a brand new client.
|
|
this.signatories = [];
|
|
this.signatorySearchText = '';
|
|
this.signatorySearchResults = [];
|
|
// Verification state must not carry over — a previous client's cleared sanctions
|
|
// screening or verified OTP must not silently apply to a different new client.
|
|
this.mobileVerified = false;
|
|
this.dipLookupLogId = null;
|
|
this.dipTimestamp = null;
|
|
this.dipStatus = null;
|
|
this.dipRejected = false;
|
|
this.alternateMobileVerified = false;
|
|
this.alternateDipLookupLogId = null;
|
|
this.alternateDipTimestamp = null;
|
|
this.alternateDipStatus = null;
|
|
this.alternateDipRejected = false;
|
|
this.mobileNewOtpId = null;
|
|
this.emailNewOtpId = null;
|
|
this.sanctionsVerified = false;
|
|
this.sanctionsBlocked = false;
|
|
this.sanctionsDisposition = null;
|
|
this.sanctionsNeedsRescreen = false;
|
|
this.screeningLogId = null;
|
|
this.verifiedName = '';
|
|
}
|
|
|
|
/**
|
|
* Pre-fills the General step form from a manually-entered family member
|
|
* when the user clicks "Create as Client" on the family members tab.
|
|
*/
|
|
private prefillFromFamilyMember(): void {
|
|
const d = this.prefillData;
|
|
if (!d) return;
|
|
|
|
// Parse DOB — Fineract returns [yyyy, mm, dd] array or ISO string
|
|
let dob: Date | undefined;
|
|
if (d.dateOfBirth) {
|
|
if (Array.isArray(d.dateOfBirth)) {
|
|
dob = new Date(d.dateOfBirth[0], d.dateOfBirth[1] - 1, d.dateOfBirth[2]);
|
|
} else {
|
|
const parsed = new Date(d.dateOfBirth);
|
|
if (!isNaN(parsed.getTime())) dob = parsed;
|
|
}
|
|
}
|
|
|
|
// Patch base fields first
|
|
this.createClientForm.patchValue({
|
|
mobileNo: d.mobileNo || '',
|
|
...(dob ? { dateOfBirth: dob } : {})
|
|
});
|
|
|
|
// Name controls are added dynamically by buildDependencies via legalFormId — set after tick
|
|
setTimeout(() => {
|
|
if (this.createClientForm.get('firstname') && d.firstname) {
|
|
this.createClientForm.get('firstname').setValue(d.firstname);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pre-fills the form with existing client data for edit mode.
|
|
* Called AFTER buildDependencies so name controls already exist.
|
|
*/
|
|
private prefillForEdit() {
|
|
const data = this.clientData;
|
|
this.loadExistingSeniorPwdDocuments();
|
|
|
|
// Loading an existing client's independently-saved monthly/annual values must not run
|
|
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
|
|
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
|
|
// instead of keeping the exact historical values as saved.
|
|
this.suppressMonthlyAnnualLink = true;
|
|
|
|
// Patch all base controls that exist on the form
|
|
this.createClientForm.patchValue({
|
|
officeId: data.officeId,
|
|
isStaff: data.isStaff,
|
|
active: data.active,
|
|
accountNo: data.accountNo,
|
|
genderId: data.gender?.id,
|
|
maritalStatusId: data.maritalStatus?.id || '',
|
|
educationLevelId: data.educationLevel?.id || data.educationLevelId || '',
|
|
degreeQualificationId: data.degreeQualification?.id || data.degreeQualificationId || '',
|
|
religionId: data.religion?.id || data.religionId || '',
|
|
socialCategoryId: data.socialCategory?.id || data.socialCategoryId || '',
|
|
mobileNo: data.mobileNo,
|
|
alternateMobileNo: data.alternateMobileNo || '',
|
|
emailAddress: data.emailAddress,
|
|
dateOfBirth: data.dateOfBirth
|
|
? Array.isArray(data.dateOfBirth)
|
|
? new Date(data.dateOfBirth[0], data.dateOfBirth[1] - 1, data.dateOfBirth[2])
|
|
: new Date(data.dateOfBirth)
|
|
: '',
|
|
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
|
|
occupationId: data.occupation?.id || data.occupationId || '',
|
|
monthlyIncome: data.monthlyIncome || '',
|
|
annualIncome: data.annualIncome || '',
|
|
annualTurnover: data.annualTurnover || '',
|
|
monthlyTurnover: data.monthlyTurnover || '',
|
|
isPwd: data.isPwd || false,
|
|
pwdCategoryId: data.pwdCategory?.id || '',
|
|
agricultureIncome: data.agricultureIncome || '',
|
|
nonAgricultureIncome: data.nonAgricultureIncome || '',
|
|
submittedOnDate: data.timeline?.submittedOnDate
|
|
? new Date(data.timeline.submittedOnDate)
|
|
: this.settingsService.businessDate
|
|
});
|
|
this.suppressMonthlyAnnualLink = false;
|
|
|
|
// Patch name fields (already created by buildDependencies via legalFormId subscription)
|
|
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
|
|
if (legalFormId === LegalFormId.PERSON) {
|
|
if (this.createClientForm.get('salutationId')) {
|
|
this.createClientForm.get('salutationId').setValue(data.salutation?.id || data.salutationId || '');
|
|
}
|
|
if (this.createClientForm.get('firstname')) {
|
|
this.createClientForm.get('firstname').setValue(data.firstname || '');
|
|
}
|
|
if (this.createClientForm.get('middlename')) {
|
|
this.createClientForm.get('middlename').setValue(data.middlename || '');
|
|
}
|
|
if (this.createClientForm.get('lastname')) {
|
|
this.createClientForm.get('lastname').setValue(data.lastname || '');
|
|
}
|
|
} else if (this.createClientForm.get('fullname')) {
|
|
this.createClientForm.get('fullname').setValue(data.fullname || '');
|
|
if (data.clientNonPersonDetails && this.createClientForm.get('clientNonPersonDetails')) {
|
|
this.createClientForm.get('clientNonPersonDetails').patchValue({
|
|
constitutionId: data.clientNonPersonDetails.constitution?.id || '',
|
|
mainBusinessLineId: data.clientNonPersonDetails.mainBusinessLine?.id || '',
|
|
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
|
|
remarks: data.clientNonPersonDetails.remarks || '',
|
|
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
|
|
authorizedSignatoryClientIds: data.clientNonPersonDetails.authorizedSignatoryClientIds || '',
|
|
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
|
|
});
|
|
this.restoreSignatories(
|
|
data.clientNonPersonDetails.authorizedSignatoryClientIds,
|
|
data.clientNonPersonDetails.authorizedSignatory
|
|
);
|
|
}
|
|
}
|
|
|
|
// Restore DIP / sanctions verification state from existing client data
|
|
if (data.dipVerified === true || data.dipVerified === 'true' || data.dipVerified === 1) {
|
|
this.mobileVerified = true;
|
|
}
|
|
if (data.dipLookupLogId) {
|
|
this.dipLookupLogId = data.dipLookupLogId;
|
|
}
|
|
if (data.dipTimestamp) {
|
|
this.dipTimestamp = data.dipTimestamp;
|
|
}
|
|
if (data.alternateDipVerified === true || data.alternateDipVerified === 'true' || data.alternateDipVerified === 1) {
|
|
this.alternateMobileVerified = true;
|
|
}
|
|
if (data.alternateDipLookupLogId) {
|
|
this.alternateDipLookupLogId = data.alternateDipLookupLogId;
|
|
}
|
|
if (data.alternateDipTimestamp) {
|
|
this.alternateDipTimestamp = data.alternateDipTimestamp;
|
|
}
|
|
if (data.nameVerified === true || data.nameVerified === 'true' || data.nameVerified === 1) {
|
|
this.sanctionsVerified = true;
|
|
this.verifiedName = this.currentNameSnapshot();
|
|
} else if (data.nameVerified === false || data.nameVerified === 'false' || data.nameVerified === 0) {
|
|
this.sanctionsVerified = false;
|
|
}
|
|
if (data.screeningLogId) {
|
|
this.screeningLogId = data.screeningLogId;
|
|
}
|
|
|
|
// Set activation date if client is active (activationDate control is added by active subscription)
|
|
if (data.active && data.timeline?.activatedOnDate) {
|
|
setTimeout(() => {
|
|
if (this.createClientForm.get('activationDate')) {
|
|
this.createClientForm.get('activationDate').setValue(new Date(data.timeline.activatedOnDate));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Restore linked guardian for minor clients. The `guardianDetails` control group above was
|
|
// just created synchronously by the dateOfBirth subscription (patched a few lines up), which
|
|
// also resets `selectedGuardian` to null — so this has to run after that patch, not before it.
|
|
// If a guardian is linked in the database (or the client is marked minor), we force minor state
|
|
// and explicitly ensure the form control exists so the guardian details load correctly.
|
|
if ((data.isMinor || this.isMinor || data.guardianClientId) && data.guardianClientId) {
|
|
this.isMinor = true;
|
|
if (!this.createClientForm.get('guardianDetails')) {
|
|
this.createClientForm.addControl(
|
|
'guardianDetails',
|
|
this.formBuilder.group({
|
|
guardianClientId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
guardianRelation: [
|
|
'',
|
|
Validators.required
|
|
]
|
|
})
|
|
);
|
|
}
|
|
setTimeout(() => {
|
|
this.createClientForm.get('guardianDetails')?.patchValue({
|
|
guardianClientId: data.guardianClientId,
|
|
guardianRelation: data.guardianRelation || ''
|
|
});
|
|
this.selectedGuardian = {
|
|
id: data.guardianClientId,
|
|
displayName: data.guardianClientDisplayName || '',
|
|
_loading: true
|
|
};
|
|
this.fetchClientFullDetails(data.guardianClientId, this.selectedGuardian);
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Alternate Mobile No must differ from Mobile No — the same number saved in both fields is
|
|
* meaningless (there's no second contact route) and the DIP checks would just repeat. */
|
|
private notSameAsMobileNoValidator(): ValidatorFn {
|
|
return (control: AbstractControl): ValidationErrors | null => {
|
|
const value = control.value;
|
|
const mobileNo = control.parent?.get('mobileNo')?.value;
|
|
return value && mobileNo && value === mobileNo ? { sameAsMobile: true } : null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates the client form.
|
|
*/
|
|
setClientForm() {
|
|
this.createClientForm = this.formBuilder.group({
|
|
officeId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
legalFormId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
nationalityId: ['INDIAN'],
|
|
isStaff: [false],
|
|
active: [false],
|
|
addSavings: [false],
|
|
accountNo: [''],
|
|
genderId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
maritalStatusId: [''],
|
|
educationLevelId: [''],
|
|
degreeQualificationId: [''],
|
|
religionId: [''],
|
|
socialCategoryId: [''],
|
|
mobileNo: [
|
|
'',
|
|
[
|
|
Validators.required,
|
|
Validators.pattern(/^[6-9]\d{9}$/),
|
|
Validators.maxLength(10)
|
|
]
|
|
],
|
|
alternateMobileNo: [
|
|
'',
|
|
[
|
|
Validators.pattern(/^[6-9]\d{9}$/),
|
|
Validators.maxLength(10),
|
|
this.notSameAsMobileNoValidator()
|
|
]
|
|
],
|
|
emailAddress: [
|
|
'',
|
|
[
|
|
Validators.required,
|
|
Validators.email,
|
|
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
|
|
Validators.maxLength(100)
|
|
]
|
|
],
|
|
dateOfBirth: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
occupationCategoryId: [''],
|
|
occupationId: [''],
|
|
monthlyIncome: [
|
|
'',
|
|
[
|
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
|
Validators.min(0)
|
|
]
|
|
],
|
|
annualIncome: [
|
|
'',
|
|
[
|
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
|
Validators.min(0)
|
|
]
|
|
],
|
|
annualTurnover: [
|
|
'',
|
|
[
|
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
|
Validators.min(0)
|
|
]
|
|
],
|
|
monthlyTurnover: [
|
|
'',
|
|
[
|
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
|
Validators.min(0)
|
|
]
|
|
],
|
|
isPwd: [false],
|
|
pwdCategoryId: [''],
|
|
agricultureIncome: [
|
|
'',
|
|
[
|
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
|
Validators.min(0)
|
|
]
|
|
],
|
|
nonAgricultureIncome: [
|
|
'',
|
|
[
|
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
|
Validators.min(0)
|
|
]
|
|
],
|
|
submittedOnDate: [
|
|
this.settingsService.businessDate,
|
|
Validators.required
|
|
]
|
|
});
|
|
|
|
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
|
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
|
this.linkIncomeToTurnover();
|
|
}
|
|
|
|
/**
|
|
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
|
* and annual income for annual/yearly turnover), but only if the turnover fields
|
|
* have not been manually modified or cleared.
|
|
*/
|
|
private linkIncomeToTurnover(): void {
|
|
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
|
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
|
const annualIncome = this.createClientForm.get('annualIncome');
|
|
const annualTurnover = this.createClientForm.get('annualTurnover');
|
|
|
|
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
|
|
|
|
if (monthlyIncome && monthlyTurnover && annualTurnover) {
|
|
monthlyIncome.valueChanges.subscribe((value) => {
|
|
if (this.suppressMonthlyAnnualLink) {
|
|
return;
|
|
}
|
|
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
|
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
|
if (isPristine || isTurnoverEmpty) {
|
|
const num = parseFloat(value);
|
|
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
if (annualIncome && annualTurnover && monthlyTurnover) {
|
|
annualIncome.valueChanges.subscribe((value) => {
|
|
if (this.suppressMonthlyAnnualLink) {
|
|
return;
|
|
}
|
|
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
|
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
|
if (isPristine || isTurnoverEmpty) {
|
|
const num = parseFloat(value);
|
|
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
|
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
|
* subscriptions don't trigger each other back and forth.
|
|
*/
|
|
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
|
|
private round2(n: number): string {
|
|
return (Math.round(n * 100) / 100).toString();
|
|
}
|
|
|
|
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
|
|
const monthly = this.createClientForm.get(monthlyControlName);
|
|
const annual = this.createClientForm.get(annualControlName);
|
|
if (!monthly || !annual) {
|
|
return;
|
|
}
|
|
|
|
monthly.valueChanges.subscribe((value) => {
|
|
if (this.suppressMonthlyAnnualLink) {
|
|
return;
|
|
}
|
|
const num = parseFloat(value);
|
|
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
|
|
});
|
|
|
|
annual.valueChanges.subscribe((value) => {
|
|
if (this.suppressMonthlyAnnualLink) {
|
|
return;
|
|
}
|
|
const num = parseFloat(value);
|
|
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sets select dropdown options.
|
|
*/
|
|
setOptions() {
|
|
this.officeOptions = this.clientTemplate.officeOptions;
|
|
this.staffOptions = this.clientTemplate.staffOptions;
|
|
this.legalFormOptions = this.clientTemplate.clientLegalFormOptions;
|
|
this.clientTypeOptions = this.clientTemplate.clientTypeOptions;
|
|
this.clientClassificationTypeOptions = this.clientTemplate.clientClassificationOptions;
|
|
this.businessLineOptions = this.clientTemplate.clientNonPersonMainBusinessLineOptions;
|
|
this.constitutionOptions = this.clientTemplate.clientNonPersonConstitutionOptions;
|
|
this.genderOptions = this.clientTemplate.genderOptions;
|
|
this.savingProductOptions = this.clientTemplate.savingProductOptions;
|
|
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
|
|
this.occupationOptions = this.clientTemplate.occupationOptions || [];
|
|
this.salutationOptions = this.clientTemplate.salutationOptions || [];
|
|
this.pwdCategoryOptions = this.clientTemplate.pwdCategoryOptions || [];
|
|
}
|
|
|
|
/**
|
|
* Adds controls conditionally.
|
|
*/
|
|
buildDependencies() {
|
|
this.createClientForm
|
|
.get('legalFormId')
|
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe((legalFormId: number) => {
|
|
this.legalFormChangeEvent.emit({ legalForm: legalFormId });
|
|
if (legalFormId === LegalFormId.PERSON) {
|
|
this.createClientForm.removeControl('fullname');
|
|
this.createClientForm.removeControl('clientNonPersonDetails');
|
|
// Restore DOB and Gender required for individual
|
|
this.createClientForm.get('dateOfBirth')?.setValidators(Validators.required);
|
|
this.createClientForm.get('dateOfBirth')?.updateValueAndValidity();
|
|
this.createClientForm.get('genderId')?.setValidators(Validators.required);
|
|
this.createClientForm.get('genderId')?.updateValueAndValidity();
|
|
this.createClientForm.addControl('salutationId', new UntypedFormControl(''));
|
|
this.createClientForm.addControl(
|
|
'firstname',
|
|
new UntypedFormControl('', [
|
|
Validators.required,
|
|
Validators.pattern(/^[A-Za-z][A-Za-z\s.'-]*$/),
|
|
Validators.maxLength(50)
|
|
])
|
|
);
|
|
this.createClientForm.addControl(
|
|
'middlename',
|
|
new UntypedFormControl('', [
|
|
Validators.pattern(/^[A-Za-z][A-Za-z\s.'-]*$/),
|
|
Validators.maxLength(50)
|
|
])
|
|
);
|
|
this.createClientForm.addControl(
|
|
'lastname',
|
|
new UntypedFormControl('', [
|
|
Validators.required,
|
|
Validators.pattern(/^[A-Za-z][A-Za-z\s.'-]*$/),
|
|
Validators.maxLength(50)
|
|
])
|
|
);
|
|
} else {
|
|
this.createClientForm.removeControl('salutationId');
|
|
this.createClientForm.removeControl('firstname');
|
|
this.createClientForm.removeControl('middlename');
|
|
this.createClientForm.removeControl('lastname');
|
|
// Remove DOB and Gender required for non-individual
|
|
this.createClientForm.get('dateOfBirth')?.clearValidators();
|
|
this.createClientForm.get('dateOfBirth')?.updateValueAndValidity();
|
|
this.createClientForm.get('genderId')?.clearValidators();
|
|
this.createClientForm.get('genderId')?.updateValueAndValidity();
|
|
this.createClientForm.addControl(
|
|
'fullname',
|
|
new UntypedFormControl('', [
|
|
Validators.required,
|
|
Validators.pattern(/^[A-Za-z][A-Za-z\s.&'-]*$/),
|
|
Validators.maxLength(100)
|
|
])
|
|
);
|
|
this.createClientForm.addControl(
|
|
'clientNonPersonDetails',
|
|
this.formBuilder.group({
|
|
constitutionId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
mainBusinessLineId: [''],
|
|
incorpNumber: [''],
|
|
authorizedSignatory: [''],
|
|
// Client ids behind the names above, in the same order — what lets an edit rebuild the
|
|
// signatory rows with their client links and PAN/DIN state. No input is bound to it;
|
|
// syncSignatoryNames() keeps it in step with the chips.
|
|
authorizedSignatoryClientIds: [''],
|
|
additionalInfo: [''],
|
|
remarks: ['']
|
|
})
|
|
);
|
|
}
|
|
// Re-attach name-change watchers after controls are (re)created
|
|
setTimeout(() => this.watchNameChangesForReverification());
|
|
});
|
|
// Initialize legal form — use client's existing value in edit mode, default to PERSON for create
|
|
const initialLegalForm = this.clientData?.legalForm?.id || LegalFormId.PERSON;
|
|
this.createClientForm.get('legalFormId').patchValue(initialLegalForm);
|
|
// Salutation → Gender auto-fill
|
|
this.createClientForm
|
|
.get('legalFormId')
|
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
// Re-subscribe after salutationId is added
|
|
setTimeout(() => this.watchSalutationForGender());
|
|
});
|
|
setTimeout(() => this.watchSalutationForGender());
|
|
// Age auto-calculation from DOB (years, months, days) + minor detection
|
|
this.createClientForm
|
|
.get('dateOfBirth')
|
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe((dob: any) => {
|
|
if (dob) {
|
|
const birth = new Date(dob);
|
|
const today = new Date();
|
|
let years = today.getFullYear() - birth.getFullYear();
|
|
let months = today.getMonth() - birth.getMonth();
|
|
let days = today.getDate() - birth.getDate();
|
|
if (days < 0) {
|
|
months--;
|
|
const prevMonth = new Date(today.getFullYear(), today.getMonth(), 0);
|
|
days += prevMonth.getDate();
|
|
}
|
|
if (months < 0) {
|
|
years--;
|
|
months += 12;
|
|
}
|
|
if (years >= 0) {
|
|
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
|
this.ageBreakdown = { years, months, days };
|
|
const wasMinor = this.isMinor;
|
|
this.isMinor = isMinorAge(years);
|
|
this.isSeniorCitizen = isSeniorCitizenAge(years);
|
|
// Add/remove guardian form group based on minor status
|
|
if (this.isMinor && !wasMinor) {
|
|
this.createClientForm.addControl(
|
|
'guardianDetails',
|
|
this.formBuilder.group({
|
|
guardianClientId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
guardianRelation: [
|
|
'',
|
|
Validators.required
|
|
]
|
|
})
|
|
);
|
|
this.selectedGuardian = null;
|
|
this.guardianSearchText = '';
|
|
this.guardianSearchResults = [];
|
|
} else if (!this.isMinor && wasMinor) {
|
|
this.createClientForm.removeControl('guardianDetails');
|
|
this.selectedGuardian = null;
|
|
}
|
|
} else {
|
|
this.calculatedAgeDetail = null;
|
|
this.isMinor = false;
|
|
this.isSeniorCitizen = false;
|
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
|
this.createClientForm.removeControl('guardianDetails');
|
|
}
|
|
} else {
|
|
this.calculatedAgeDetail = null;
|
|
this.isMinor = false;
|
|
this.isSeniorCitizen = false;
|
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
|
this.createClientForm.removeControl('guardianDetails');
|
|
}
|
|
});
|
|
this.createClientForm
|
|
.get('active')
|
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe((active: boolean) => {
|
|
if (active) {
|
|
this.createClientForm.addControl('activationDate', new UntypedFormControl('', Validators.required));
|
|
} else {
|
|
this.createClientForm.removeControl('activationDate');
|
|
}
|
|
});
|
|
this.createClientForm
|
|
.get('addSavings')
|
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe((active: boolean) => {
|
|
if (active) {
|
|
this.createClientForm.addControl('savingsProductId', new UntypedFormControl('', Validators.required));
|
|
} else {
|
|
this.createClientForm.removeControl('savingsProductId');
|
|
}
|
|
});
|
|
this.createClientForm
|
|
.get('officeId')
|
|
.valueChanges.pipe(
|
|
filter((officeId: number) => !!officeId),
|
|
switchMap((officeId: number) => this.clientService.getClientWithOfficeTemplate(officeId)),
|
|
takeUntil(this.destroy$)
|
|
)
|
|
.subscribe((clientTemplate: any) => {
|
|
this.staffOptions = clientTemplate.staffOptions;
|
|
});
|
|
|
|
// Reset DIP verification when mobile number changes
|
|
this.createClientForm
|
|
.get('mobileNo')
|
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
this.mobileVerified = false;
|
|
// The rejection applied to the previous number, not this one. Leaving it set would
|
|
// keep Save & Next dead with a message about a number no longer in the field.
|
|
this.dipRejected = false;
|
|
this.dipStatus = null;
|
|
this.dipLookupLogId = null;
|
|
// Also reset create-time contact-OTP verification — an OTP verified against a
|
|
// previous mobile number no longer applies once the value is edited. Belt-and-braces
|
|
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
|
|
// if the OTP block was unmounted (e.g. `*ngIf` hid it while the field was momentarily
|
|
// invalid) and its own reset never had a chance to run.
|
|
this.mobileOtpVerified = false;
|
|
this.mobileNewOtpId = null;
|
|
// Alternate Mobile No's "not same as Mobile No" validator reads mobileNo's current
|
|
// value, so re-run it here too — otherwise fixing mobileNo away from a clash leaves
|
|
// the alternate field's error stuck until the user also edits that field.
|
|
this.createClientForm.get('alternateMobileNo')?.updateValueAndValidity({ emitEvent: false });
|
|
});
|
|
|
|
this.createClientForm
|
|
.get('alternateMobileNo')
|
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
this.alternateMobileVerified = false;
|
|
this.alternateDipRejected = false;
|
|
this.alternateDipStatus = null;
|
|
this.alternateDipLookupLogId = null;
|
|
});
|
|
|
|
// Reset create-time contact-OTP verification when email address changes — see the
|
|
// mobileNo subscription above for the rationale.
|
|
this.createClientForm
|
|
.get('emailAddress')
|
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
this.emailOtpVerified = false;
|
|
this.emailNewOtpId = null;
|
|
});
|
|
}
|
|
|
|
ngOnChanges(changes: SimpleChanges) {
|
|
if (changes['readOnly'] && this.createClientForm) {
|
|
if (this.readOnly) {
|
|
this.createClientForm.disable({ emitEvent: false });
|
|
} else {
|
|
this.createClientForm.enable({ emitEvent: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
}
|
|
|
|
/**
|
|
* Verify mobile number with DIP via dialog.
|
|
*/
|
|
verifyWithDIP() {
|
|
const mobileNo = this.createClientForm.get('mobileNo')?.value || '';
|
|
if (mobileNo.length !== 10) return;
|
|
const dialogRef = this.dialog.open(DipVerifyDialogComponent, {
|
|
width: '500px',
|
|
data: { mobileNo }
|
|
});
|
|
dialogRef.afterClosed().subscribe((result: any) => {
|
|
if (!result) return;
|
|
// A flagged number the officer chose to proceed with counts as verified — the decision and
|
|
// its reason are already stored against the lookup, which is what the save gate checks.
|
|
this.mobileVerified = result.verified === true || result.decision === 'PROCEEDED';
|
|
this.dipRejected = result.decision === 'REJECTED';
|
|
this.dipStatus = result.status || null;
|
|
this.dipLookupLogId = result.lookupLogId || null;
|
|
this.dipTimestamp = result.timestamp || null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Verify alternate mobile number with DIP via dialog.
|
|
*/
|
|
verifyAlternateDIP() {
|
|
const mobileNo = this.createClientForm.get('alternateMobileNo')?.value || '';
|
|
if (mobileNo.length !== 10) return;
|
|
const dialogRef = this.dialog.open(DipVerifyDialogComponent, {
|
|
width: '500px',
|
|
data: { mobileNo }
|
|
});
|
|
dialogRef.afterClosed().subscribe((result: any) => {
|
|
if (!result) return;
|
|
this.alternateMobileVerified = result.verified === true || result.decision === 'PROCEEDED';
|
|
this.alternateDipRejected = result.decision === 'REJECTED';
|
|
this.alternateDipStatus = result.status || null;
|
|
this.alternateDipLookupLogId = result.lookupLogId || null;
|
|
this.alternateDipTimestamp = result.timestamp || null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Zoom an image (guardian photo / signature) in a dialog.
|
|
*/
|
|
zoomGuardianImage(imageUrl: string, title: string) {
|
|
this.dialog.open(GuardianImageZoomDialogComponent, {
|
|
data: { imageUrl, title },
|
|
panelClass: 'guardian-zoom-panel'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Edit guardian link — re-enters linking mode while keeping the current guardian.
|
|
*/
|
|
editGuardianLink() {
|
|
this.guardianLinking = true;
|
|
this.guardianExpanded = false;
|
|
this.removeGuardian();
|
|
}
|
|
|
|
capitalizeControl(controlName: string) {
|
|
const control = this.createClientForm.get(controlName);
|
|
if (control && control.value) {
|
|
const originalValue = control.value;
|
|
const capitalized = toProperNameCase(originalValue);
|
|
if (originalValue !== capitalized) {
|
|
control.setValue(capitalized);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify client name against sanctions lists via dialog.
|
|
* Passes the client name to the dialog for real API screening.
|
|
* If a match is found, sanctionsBlocked is set to true blocking further steps.
|
|
*/
|
|
verifySanctions() {
|
|
const name = this.currentNameSnapshot();
|
|
const dob = this.createClientForm.get('dateOfBirth')?.value;
|
|
|
|
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
|
|
width: '620px',
|
|
data: {
|
|
name,
|
|
// The dialog compares these against the listed particulars — a name-only hit cannot be
|
|
// judged without the applicant's own identifiers next to it.
|
|
dateOfBirth: dob ? new Date(dob).toLocaleDateString('en-IN') : '',
|
|
nationality: this.nationalityLabel()
|
|
}
|
|
});
|
|
dialogRef.afterClosed().subscribe((result: any) => {
|
|
if (!result) return;
|
|
this.screeningLogId = result.screeningLogId || null;
|
|
if (result.verified === true) {
|
|
// CLEAR — no match found
|
|
this.sanctionsVerified = true;
|
|
this.sanctionsBlocked = false;
|
|
this.sanctionsDisposition = null;
|
|
this.sanctionsNeedsRescreen = false;
|
|
this.verifiedName = name;
|
|
} else if (result.verified === false) {
|
|
// FLAGGED — blocked either way. A FALSE_POSITIVE is a claim awaiting the Principal
|
|
// Officer, not a clearance; only their approval lets this name through.
|
|
this.sanctionsVerified = false;
|
|
this.sanctionsBlocked = true;
|
|
this.sanctionsDisposition = result.disposition || null;
|
|
this.sanctionsNeedsRescreen = false;
|
|
this.verifiedName = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Human-readable nationality for the sanctions comparison panel. */
|
|
private nationalityLabel(): string {
|
|
return this.getSelectedNationality()?.label || '';
|
|
}
|
|
|
|
/** Concatenated current name from the form (handles individual + entity) */
|
|
private currentNameSnapshot(): string {
|
|
const firstname = this.createClientForm.get('firstname')?.value || '';
|
|
const middlename = this.createClientForm.get('middlename')?.value || '';
|
|
const lastname = this.createClientForm.get('lastname')?.value || '';
|
|
const fullname = this.createClientForm.get('fullname')?.value || '';
|
|
return (
|
|
fullname ||
|
|
[
|
|
firstname,
|
|
middlename,
|
|
lastname
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
).trim();
|
|
}
|
|
|
|
/**
|
|
* Watch name fields — once a name has been verified, any edit invalidates the
|
|
* sanctions verification badge so the user must re-verify before submitting.
|
|
*/
|
|
private nameWatchSub: Subscription | null = null;
|
|
private watchNameChangesForReverification() {
|
|
this.nameWatchSub?.unsubscribe();
|
|
const fields = [
|
|
'firstname',
|
|
'middlename',
|
|
'lastname',
|
|
'fullname'
|
|
];
|
|
const controls = fields.map((f) => this.createClientForm.get(f)).filter((c) => !!c);
|
|
if (!controls.length) return;
|
|
this.nameWatchSub = merge(...controls.map((c) => c!.valueChanges))
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
// Only invalidate if a verification was previously recorded
|
|
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
|
|
if (this.currentNameSnapshot() !== this.verifiedName) {
|
|
// Editing the name forces a fresh screening, but must NOT silently clear an existing
|
|
// block: otherwise a flagged applicant is waved through by nudging one character. The
|
|
// block is only lifted by re-running the check and getting a CLEAR back from the API.
|
|
this.sanctionsVerified = false;
|
|
this.screeningLogId = null;
|
|
if (this.sanctionsBlocked) {
|
|
this.sanctionsNeedsRescreen = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Search existing clients to select as guardian */
|
|
onGuardianSearch(text: string): void {
|
|
this.guardianSearchText = text;
|
|
clearTimeout(this.guardianSearchTimer);
|
|
|
|
if (!text || text.length < 2) {
|
|
this.guardianSearchResults = [];
|
|
return;
|
|
}
|
|
|
|
this.guardianSearching = true;
|
|
this.guardianSearchTimer = setTimeout(() => {
|
|
// Guardian picker: only natural persons can be a guardian.
|
|
this.clientService.searchByText(text, 0, 10, '', '', true, 'INDIVIDUAL').subscribe({
|
|
next: (data: any) => {
|
|
this.guardianSearchResults = data?.content || [];
|
|
this.guardianSearching = false;
|
|
this.clientService.enrichSearchResults(this.guardianSearchResults);
|
|
},
|
|
error: () => {
|
|
this.guardianSearchResults = [];
|
|
this.guardianSearching = false;
|
|
}
|
|
});
|
|
}, 400);
|
|
}
|
|
|
|
/** Select a client as guardian and fetch full details */
|
|
selectGuardian(client: any): void {
|
|
this.selectedGuardian = { ...client, _loading: true };
|
|
this.guardianSearchResults = [];
|
|
this.guardianSearchText = '';
|
|
this.guardianLinking = false;
|
|
this.createClientForm.get('guardianDetails')?.patchValue({
|
|
guardianClientId: client.id
|
|
});
|
|
this.fetchClientFullDetails(client.id, this.selectedGuardian);
|
|
}
|
|
|
|
/** Fetch full client details directly onto target object */
|
|
private fetchClientFullDetails(clientId: number, target: any): void {
|
|
const cid = clientId.toString();
|
|
|
|
this.clientService.getClientData(cid).subscribe({
|
|
next: (data: any) => {
|
|
target.cifNumber = data.cifNumber || data.accountNo;
|
|
target.mobileNo = data.mobileNo || null;
|
|
target.emailAddress = data.emailAddress || null;
|
|
target.officeName = data.officeName || null;
|
|
// Filled in only when the caller has not supplied them. A signatory restored from stored
|
|
// ids starts as a bare { id } and needs both to render its row; one picked from the search
|
|
// dropdown already carries them, and a guardian must keep whatever it was given.
|
|
target.displayName = target.displayName || data.displayName;
|
|
target.status = target.status || data.status;
|
|
target._loading = false;
|
|
},
|
|
error: () => {
|
|
target._loading = false;
|
|
}
|
|
});
|
|
|
|
this.clientService.getClientKyc(cid).subscribe({
|
|
next: (kyc: any) => {
|
|
target.panNumber = kyc?.panNumber || '-';
|
|
target.panVerified = kyc?.panVerified || false;
|
|
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
|
|
target.aadhaarVerified = kyc?.aadhaarVerified || false;
|
|
// Same reasoning as above: a DIN verified in an earlier session lives on the signatory's own
|
|
// KYC record, so re-reading it here is what stops the edit screen asking for it again.
|
|
target.dinNumber = target.dinNumber || kyc?.dinNumber || '';
|
|
target.dinVerified = target.dinVerified || kyc?.dinVerified || false;
|
|
},
|
|
error: () => {
|
|
target.panNumber = '-';
|
|
target.aadhaarMasked = '-';
|
|
}
|
|
});
|
|
|
|
this.clientService.getClientAccountData(cid).subscribe({
|
|
next: (accts: any) => {
|
|
const savings = accts?.savingsAccounts || [];
|
|
const active = savings.find((a: any) => a.status?.active) || savings[0];
|
|
target.accountType = active ? active.productName || 'Savings' : '-';
|
|
target.accountNo = active?.accountNo || '-';
|
|
},
|
|
error: () => {
|
|
target.accountType = '-';
|
|
target.accountNo = '-';
|
|
}
|
|
});
|
|
|
|
this.clientService.getClientAddressData(cid).subscribe({
|
|
next: (addresses: any[]) => {
|
|
if (addresses?.length) {
|
|
const addr = addresses[0];
|
|
const parts = [
|
|
addr.addressLine1,
|
|
addr.townVillage,
|
|
addr.city,
|
|
addr.countyDistrict,
|
|
addr.addressLine3
|
|
].filter(Boolean);
|
|
if (parts.length) {
|
|
target.fullAddress = parts.join(', ') + (addr.postalCode ? ' - ' + addr.postalCode : '');
|
|
}
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
|
|
// Fetch profile image
|
|
this.clientService.getClientProfileImage(cid).subscribe({
|
|
next: (imageData: any) => {
|
|
if (imageData) {
|
|
target.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(imageData);
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
|
|
// Fetch signature image
|
|
this.clientService.getClientDocuments(cid).subscribe({
|
|
next: (docs: any[]) => {
|
|
const sigDoc = docs?.find(
|
|
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
|
|
);
|
|
if (sigDoc) {
|
|
this.clientService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({
|
|
next: (blob: Blob) => {
|
|
target.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
/** Remove selected guardian with confirmation */
|
|
removeGuardian(): void {
|
|
const dialogRef = this.dialog.open(GuardianDeleteConfirmDialogComponent, {
|
|
width: '400px'
|
|
});
|
|
dialogRef.afterClosed().subscribe((confirmed: boolean) => {
|
|
if (confirmed) {
|
|
this.selectedGuardian = null;
|
|
this.guardianExpanded = false;
|
|
this.createClientForm.get('guardianDetails')?.patchValue({
|
|
guardianClientId: ''
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Dismiss guardian search dropdown on blur (delayed so click events still fire) */
|
|
dismissGuardianSearch(): void {
|
|
setTimeout(() => {
|
|
this.guardianSearchResults = [];
|
|
this.guardianSearching = false;
|
|
}, 200);
|
|
}
|
|
|
|
/** Dismiss signatory search dropdown on blur (delayed so click events still fire) */
|
|
dismissSignatorySearch(): void {
|
|
setTimeout(() => {
|
|
this.signatorySearchResults = [];
|
|
this.signatorySearching = false;
|
|
}, 200);
|
|
}
|
|
|
|
/** Search existing clients to add as authorized signatory */
|
|
onSignatorySearch(text: string): void {
|
|
this.signatorySearchText = text;
|
|
clearTimeout(this.signatorySearchTimer);
|
|
if (!text || text.length < 2) {
|
|
this.signatorySearchResults = [];
|
|
return;
|
|
}
|
|
this.signatorySearching = true;
|
|
this.signatorySearchTimer = setTimeout(() => {
|
|
this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({
|
|
next: (data: any) => {
|
|
this.signatorySearchResults = data?.content || [];
|
|
this.signatorySearching = false;
|
|
this.clientService.enrichSearchResults(this.signatorySearchResults);
|
|
},
|
|
error: () => {
|
|
this.signatorySearchResults = [];
|
|
this.signatorySearching = false;
|
|
}
|
|
});
|
|
}, 400);
|
|
}
|
|
|
|
addSignatory(client: any): void {
|
|
// Prevent duplicates
|
|
if (this.signatories.find((s) => s.id === client.id)) {
|
|
this.signatorySearchResults = [];
|
|
this.signatorySearchText = '';
|
|
return;
|
|
}
|
|
const sig = { ...client, _loading: true };
|
|
this.signatories.push(sig);
|
|
this.signatorySearchResults = [];
|
|
this.signatorySearchText = '';
|
|
this.syncSignatoryNames();
|
|
|
|
this.fetchClientFullDetails(client.id, sig);
|
|
this.openDinVerifyDialog(sig);
|
|
}
|
|
|
|
/** Open DIN verification dialog for a signatory */
|
|
openDinVerifyDialog(sig: any): void {
|
|
const cid = sig.id?.toString();
|
|
if (!cid) return;
|
|
|
|
const entityName = this.createClientForm.get('fullname')?.value || '';
|
|
|
|
const openDialog = (kycId: string, panFromKyc?: string) => {
|
|
const ref = this.dialog.open(DinVerifyDialogComponent, {
|
|
width: '620px',
|
|
maxWidth: '95vw',
|
|
disableClose: false,
|
|
autoFocus: true,
|
|
data: {
|
|
clientId: cid,
|
|
kycId,
|
|
signatoryName: sig.displayName || '',
|
|
cifNumber: sig.cifNumber || sig.accountNumber || cid,
|
|
panNumber: sig.panNumber || panFromKyc || '-',
|
|
entityName
|
|
}
|
|
});
|
|
ref.afterClosed().subscribe((result: any) => {
|
|
if (result && !result.skipped) {
|
|
sig.dinNumber = result.dinNumber || '';
|
|
sig.dinVerified = result.dinVerified || false;
|
|
sig.directorName = result.directorName || '';
|
|
sig.dinStatus = result.dinStatus || '';
|
|
sig.dinDesignation = result.dinDesignation || '';
|
|
sig.companyMatch = result.companyMatch ?? null;
|
|
sig.matchedCompany = result.matchedCompany || '';
|
|
}
|
|
});
|
|
};
|
|
|
|
// Fetch KYC ID first, then open dialog
|
|
this.clientService.getClientKyc(cid).subscribe({
|
|
next: (kyc: any) => openDialog(kyc?.id?.toString() || '', kyc?.panNumber),
|
|
error: () => openDialog('')
|
|
});
|
|
}
|
|
|
|
removeSignatoryAt(index: number): void {
|
|
this.signatories.splice(index, 1);
|
|
this.syncSignatoryNames();
|
|
}
|
|
|
|
viewSignatoryDetails(sig: any): void {
|
|
this.dialog.open(SignatoryDetailDialogComponent, {
|
|
width: '720px',
|
|
maxWidth: '95vw',
|
|
data: sig,
|
|
panelClass: 'signatory-detail-panel',
|
|
autoFocus: false
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rebuilds the authorized-signatory rows when an existing entity client is opened for edit.
|
|
*
|
|
* Driven by the stored client ids rather than the stored names: the ids are what make each row a
|
|
* real client again, so its CIF, PAN and DIN verification can be re-read instead of the officer
|
|
* having to re-add and re-verify signatories that were already recorded. The saved names are used
|
|
* only as the label until the lookup returns, and as the fallback for a client that can no longer
|
|
* be fetched, so a row is never silently dropped.
|
|
*
|
|
* Clients created before the ids were stored have names but no ids; those still list, as
|
|
* name-only rows, rather than showing nothing at all.
|
|
*/
|
|
private restoreSignatories(storedIds: string | null | undefined, storedNames: string | null | undefined): void {
|
|
const ids = (storedIds || '')
|
|
.split(',')
|
|
.map((id: string) => id.trim())
|
|
.filter(Boolean);
|
|
const names = (storedNames || '')
|
|
.split(',')
|
|
.map((name: string) => name.trim())
|
|
.filter(Boolean);
|
|
|
|
if (!ids.length) {
|
|
this.signatories = names.map((displayName: string) => ({ displayName }));
|
|
return;
|
|
}
|
|
|
|
this.signatories = ids.map((id: string, index: number) => ({
|
|
id: Number(id),
|
|
displayName: names[index] || '',
|
|
_loading: true
|
|
}));
|
|
this.signatories.forEach((sig: any) => this.fetchClientFullDetails(sig.id, sig));
|
|
}
|
|
|
|
private syncSignatoryNames(): void {
|
|
const names = this.signatories.map((s) => s.displayName).join(', ');
|
|
// Ids are written in the same order as the names, so the two columns line up on reload.
|
|
const clientIds = this.signatories.map((s) => s.id).join(',');
|
|
this.createClientForm.get('clientNonPersonDetails')?.patchValue({
|
|
authorizedSignatory: names,
|
|
authorizedSignatoryClientIds: clientIds
|
|
});
|
|
}
|
|
|
|
/** Watch MSME turnover & investment for auto-classification */
|
|
/** Maps salutation name to gender. Returns gender option or null. */
|
|
private salutationGenderMap: Record<string, string> = {
|
|
mr: 'male',
|
|
'mr.': 'male',
|
|
shri: 'male',
|
|
sri: 'male',
|
|
master: 'male',
|
|
mrs: 'female',
|
|
'mrs.': 'female',
|
|
ms: 'female',
|
|
'ms.': 'female',
|
|
miss: 'female',
|
|
smt: 'female',
|
|
'smt.': 'female',
|
|
kumari: 'female'
|
|
// Dr, Er, CA, Adv etc. are gender-neutral — no auto-fill
|
|
};
|
|
|
|
private salutationSub: any;
|
|
|
|
/** Watch salutation changes and auto-fill gender */
|
|
private watchSalutationForGender() {
|
|
if (this.salutationSub) {
|
|
this.salutationSub.unsubscribe();
|
|
}
|
|
const salCtrl = this.createClientForm.get('salutationId');
|
|
if (!salCtrl) return;
|
|
|
|
this.salutationSub = salCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((salutationId: any) => {
|
|
if (!salutationId || !this.salutationOptions?.length || !this.genderOptions?.length) return;
|
|
|
|
// Find the salutation name from the selected ID
|
|
const salutation = this.salutationOptions.find((s: any) => s.id === salutationId);
|
|
if (!salutation) return;
|
|
|
|
const salName = (salutation.name || '').toLowerCase().trim().replace(/\.$/, '');
|
|
const mappedGender = this.salutationGenderMap[salName] || this.salutationGenderMap[salName + '.'];
|
|
if (!mappedGender) return;
|
|
|
|
// Find matching gender option by name
|
|
const genderOption = this.genderOptions.find((g: any) => (g.name || '').toLowerCase().trim() === mappedGender);
|
|
|
|
if (genderOption) {
|
|
this.createClientForm.get('genderId')?.setValue(genderOption.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Returns capitalized full name preview from first + middle + last */
|
|
get fullNamePreview(): string {
|
|
const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '');
|
|
const salutationId = this.createClientForm?.get('salutationId')?.value;
|
|
const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || '';
|
|
const first = capitalize(this.createClientForm?.get('firstname')?.value || '');
|
|
const middle = capitalize(this.createClientForm?.get('middlename')?.value || '');
|
|
const last = capitalize(this.createClientForm?.get('lastname')?.value || '');
|
|
const full = [
|
|
first,
|
|
middle,
|
|
last
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
return salutation ? `${salutation} ${full}` : full;
|
|
}
|
|
|
|
/**
|
|
* The name PAN and Aadhaar are actually matched against.
|
|
*
|
|
* Deliberately not {@link fullNamePreview}: that prefixes the salutation, while
|
|
* ClientKycWritePlatformServiceJpaRepositoryImpl builds the verification name from firstname +
|
|
* middlename + lastname only. Values are passed through untouched rather than capitalised, so what
|
|
* the KYC step displays is exactly what goes to the provider.
|
|
*/
|
|
get verificationName(): string {
|
|
return [
|
|
this.createClientForm?.get('firstname')?.value,
|
|
this.createClientForm?.get('middlename')?.value,
|
|
this.createClientForm?.get('lastname')?.value
|
|
]
|
|
.map((part: any) => (part || '').toString().trim())
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
}
|
|
|
|
/** Date of birth as captured, sent to the provider formatted dd/MM/yyyy. */
|
|
get verificationDateOfBirth(): Date | null {
|
|
const value = this.createClientForm?.get('dateOfBirth')?.value;
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (value instanceof Date) {
|
|
return value;
|
|
}
|
|
const parsed = new Date(value);
|
|
return isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
getSelectedGender(): any {
|
|
const val = this.createClientForm.get('genderId')?.value;
|
|
return this.genderOptions?.find((g: any) => g.id === val) || null;
|
|
}
|
|
|
|
getGenderIcon(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
if (n.includes('male') && !n.includes('female')) return 'male';
|
|
if (n.includes('female')) return 'female';
|
|
if (n.includes('trans')) return 'transgender';
|
|
if (n.includes('other')) return 'wc';
|
|
return 'person';
|
|
}
|
|
|
|
getSelectedOccupationCategory(): any {
|
|
const val = this.createClientForm.get('occupationCategoryId')?.value;
|
|
return this.occupationCategoryOptions?.find((c: any) => c.id === val) || null;
|
|
}
|
|
|
|
getSelectedOccupation(): any {
|
|
const val = this.createClientForm.get('occupationId')?.value;
|
|
return this.occupationOptions?.find((o: any) => o.id === val) || null;
|
|
}
|
|
|
|
getOccupationCategoryIcon(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
if (n.includes('salaried') || n.includes('employed')) return 'badge';
|
|
if (n.includes('self') || n.includes('business')) return 'storefront';
|
|
if (n.includes('profession')) return 'work';
|
|
if (n.includes('agricultur') || n.includes('farm')) return 'agriculture';
|
|
if (n.includes('retire')) return 'elderly';
|
|
if (n.includes('student')) return 'school';
|
|
if (n.includes('home') || n.includes('house')) return 'home';
|
|
if (n.includes('government') || n.includes('govt') || n.includes('psu')) return 'account_balance';
|
|
if (n.includes('private')) return 'business';
|
|
if (n.includes('unemploy') || n.includes('not employ')) return 'person_off';
|
|
if (n.includes('other')) return 'more_horiz';
|
|
return 'work_outline';
|
|
}
|
|
|
|
getOccupationIcon(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
if (n.includes('doctor') || n.includes('medical') || n.includes('health')) return 'local_hospital';
|
|
if (n.includes('engineer')) return 'engineering';
|
|
if (n.includes('teacher') || n.includes('professor') || n.includes('education')) return 'school';
|
|
if (n.includes('lawyer') || n.includes('advocate') || n.includes('legal')) return 'gavel';
|
|
if (n.includes('account') || n.includes('ca ') || n.includes('chartered')) return 'calculate';
|
|
if (n.includes('architect')) return 'architecture';
|
|
if (n.includes('it ') || n.includes('software') || n.includes('tech') || n.includes('computer')) return 'computer';
|
|
if (n.includes('bank') || n.includes('financ') || n.includes('insurance')) return 'account_balance';
|
|
if (n.includes('police') || n.includes('army') || n.includes('defence') || n.includes('military')) return 'shield';
|
|
if (n.includes('driver') || n.includes('transport')) return 'local_shipping';
|
|
if (n.includes('shop') || n.includes('retail') || n.includes('trade')) return 'shopping_cart';
|
|
if (n.includes('farm') || n.includes('agricultur')) return 'agriculture';
|
|
if (n.includes('labour') || n.includes('worker') || n.includes('daily')) return 'construction';
|
|
if (n.includes('cook') || n.includes('chef') || n.includes('food') || n.includes('hotel')) return 'restaurant';
|
|
if (n.includes('tailor') || n.includes('textile') || n.includes('garment')) return 'checkroom';
|
|
if (n.includes('artist') || n.includes('media') || n.includes('journalist')) return 'palette';
|
|
if (n.includes('govt') || n.includes('government') || n.includes('civil serv')) return 'account_balance';
|
|
if (n.includes('retired') || n.includes('pension')) return 'elderly';
|
|
if (n.includes('student')) return 'school';
|
|
if (n.includes('housewife') || n.includes('homemaker') || n.includes('home')) return 'home';
|
|
if (n.includes('business') || n.includes('entrepreneur')) return 'storefront';
|
|
if (n.includes('other')) return 'more_horiz';
|
|
return 'work_outline';
|
|
}
|
|
|
|
getSelectedLegalFormLabel(): string {
|
|
const val = this.createClientForm.get('legalFormId')?.value;
|
|
return val === 1 ? 'Individual' : val === 2 ? 'Non-Individual' : '';
|
|
}
|
|
|
|
getSelectedNationality(): any {
|
|
const val = this.createClientForm.get('nationalityId')?.value;
|
|
return this.nationalityOptions.find((n) => n.value === val) || null;
|
|
}
|
|
|
|
getSelectedBusiness(): any {
|
|
const val = this.createClientForm.get('clientNonPersonDetails.mainBusinessLineId')?.value;
|
|
return this.businessLineOptions?.find((b: any) => b.id === val) || null;
|
|
}
|
|
|
|
getSelectedMaritalStatus(): any {
|
|
const val = this.createClientForm.get('maritalStatusId')?.value;
|
|
return this.maritalStatusOptions.find((ms: any) => ms.id === val) || null;
|
|
}
|
|
|
|
getMaritalStatusIcon(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
if (n.includes('single')) return 'person';
|
|
if (n.includes('married')) return 'favorite';
|
|
if (n.includes('unmarried')) return 'person_outline';
|
|
if (n.includes('divorced')) return 'heart_broken';
|
|
if (n.includes('widowed')) return 'sentiment_dissatisfied';
|
|
if (n.includes('separated')) return 'people_alt';
|
|
if (n.includes('annulled')) return 'remove_circle_outline';
|
|
if (n.includes('live-in') || n.includes('live in')) return 'home';
|
|
if (n.includes('not disclosed')) return 'visibility_off';
|
|
return 'help_outline';
|
|
}
|
|
|
|
getSelectedEducationLevel(): any {
|
|
const val = this.createClientForm.get('educationLevelId')?.value;
|
|
return this.educationLevelOptions.find((e: any) => e.id === val) || null;
|
|
}
|
|
|
|
getEducationLevelIcon(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
if (
|
|
n.includes('school') ||
|
|
n.includes('10th') ||
|
|
n.includes('12th') ||
|
|
n.includes('ssc') ||
|
|
n.includes('hsc') ||
|
|
n.includes('matric') ||
|
|
n.includes('intermediate')
|
|
)
|
|
return 'school';
|
|
if (n.includes('diploma') || n.includes('iti')) return 'card_membership';
|
|
if (n.includes('under') || n.includes('bachelor') || (n.includes('graduate') && !n.includes('post')))
|
|
return 'menu_book';
|
|
if (n.includes('post') || n.includes('master')) return 'auto_stories';
|
|
if (n.includes('doctor') || n.includes('phd') || n.includes('ph.d')) return 'workspace_premium';
|
|
if (n.includes('profession')) return 'workspace_premium';
|
|
if (n.includes('none') || n.includes('illiter') || n.includes('below')) return 'block';
|
|
return 'school';
|
|
}
|
|
|
|
getSelectedDegreeQualification(): any {
|
|
const val = this.createClientForm.get('degreeQualificationId')?.value;
|
|
return this.degreeQualificationOptions.find((d: any) => d.id === val) || null;
|
|
}
|
|
|
|
/**
|
|
* Classifies an education level name into one of: school | undergraduate | postgraduate | doctorate.
|
|
* Used to filter the Degree/Qualification dropdown to relevant options.
|
|
*/
|
|
getEducationLevelCategory(name: string): string {
|
|
const n = (name || '').toLowerCase().replace(/[\s_-]+/g, '');
|
|
if (n.includes('doctor') || n.includes('phd') || n.includes('profession')) return 'doctorate';
|
|
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
|
|
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
|
|
return 'undergraduate';
|
|
// Diploma is its own Education Level, distinct from School — checked before the school
|
|
// keywords below so it doesn't get swept into that bucket.
|
|
if (n.includes('diploma') || n.includes('polytechnic')) return 'diploma';
|
|
if (
|
|
n.includes('school') ||
|
|
n.includes('10th') ||
|
|
n.includes('12th') ||
|
|
n.includes('ssc') ||
|
|
n.includes('hsc') ||
|
|
n.includes('matric') ||
|
|
n.includes('intermediate') ||
|
|
n.includes('iti') ||
|
|
n.includes('below')
|
|
)
|
|
return 'school';
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Classifies a degree / qualification name into the education level it belongs to.
|
|
*/
|
|
getDegreeQualificationCategory(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
// Doctorate / professional
|
|
if (
|
|
n.includes('ph.d') ||
|
|
n.includes('phd') ||
|
|
n.includes('d.litt') ||
|
|
n.includes('d.sc') ||
|
|
n.startsWith('ca ') ||
|
|
n === 'ca' ||
|
|
n.includes('chartered accountant') ||
|
|
n.startsWith('cs ') ||
|
|
n === 'cs' ||
|
|
n.includes('company secretary') ||
|
|
n.includes('cma') ||
|
|
n.includes('cfa') ||
|
|
n.includes('doctorate')
|
|
)
|
|
return 'doctorate';
|
|
// Postgraduate — starts with M. or contains master / PG-specific
|
|
if (
|
|
n.startsWith('m.') ||
|
|
n.startsWith('m ') ||
|
|
n.includes('master') ||
|
|
n.includes('mba') ||
|
|
n.includes('mca') ||
|
|
n.includes('mds') ||
|
|
n.includes('msw') ||
|
|
n.includes('mfa') ||
|
|
n.match(/\bmd\b/) ||
|
|
n.match(/\bms\b/)
|
|
)
|
|
return 'postgraduate';
|
|
// Undergraduate — starts with B. or contains bachelor / UG-specific
|
|
if (
|
|
n.startsWith('b.') ||
|
|
n.startsWith('b ') ||
|
|
n.includes('bachelor') ||
|
|
n.includes('bba') ||
|
|
n.includes('bca') ||
|
|
n.includes('mbbs') ||
|
|
n.includes('bds') ||
|
|
n.includes('bams') ||
|
|
n.includes('bhms') ||
|
|
n.includes('bfa') ||
|
|
n.includes('bhm') ||
|
|
n.includes('llb') ||
|
|
n.includes('nursing')
|
|
)
|
|
return 'undergraduate';
|
|
// School
|
|
if (
|
|
n.includes('10th') ||
|
|
n.includes('12th') ||
|
|
n.includes('ssc') ||
|
|
n.includes('hsc') ||
|
|
n.includes('matric') ||
|
|
n.includes('intermediate') ||
|
|
n.includes('diploma') ||
|
|
n.includes('iti') ||
|
|
n.includes('below')
|
|
)
|
|
return 'school';
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Degrees filtered by the currently selected Education Level. Each DegreeQualification code
|
|
* value is tagged with its owning level in `description` (e.g. "Undergraduate") when seeded —
|
|
* match on that authoritative field first. Only fall back to the name-keyword heuristic
|
|
* (getDegreeQualificationCategory) for older/untagged rows that predate this tagging, so
|
|
* degrees never silently disappear because an admin added one without a description.
|
|
* Returns all degrees if no level is selected.
|
|
*/
|
|
get filteredDegreeQualificationOptions(): any[] {
|
|
const level = this.getSelectedEducationLevel();
|
|
if (!level) return [];
|
|
const category = this.getEducationLevelCategory(level.name);
|
|
if (!category) return this.degreeQualificationOptions;
|
|
const filtered = this.degreeQualificationOptions.filter((d: any) => {
|
|
if (d.description) {
|
|
return this.getEducationLevelCategory(d.description) === category;
|
|
}
|
|
const c = this.getDegreeQualificationCategory(d.name);
|
|
return c === category || c === '';
|
|
});
|
|
return filtered.length > 0 ? filtered : this.degreeQualificationOptions;
|
|
}
|
|
|
|
getSelectedReligion(): any {
|
|
const val = this.createClientForm.get('religionId')?.value;
|
|
return this.religionOptions.find((r: any) => r.id === val) || null;
|
|
}
|
|
|
|
getReligionIcon(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
if (n.includes('hindu')) return 'temple_hindu';
|
|
if (n.includes('muslim') || n.includes('islam')) return 'mosque';
|
|
if (n.includes('christ')) return 'church';
|
|
if (n.includes('sikh')) return 'temple_buddhist';
|
|
if (n.includes('buddh')) return 'temple_buddhist';
|
|
if (n.includes('jain')) return 'temple_hindu';
|
|
if (n.includes('jew') || n.includes('juda')) return 'synagogue';
|
|
if (n.includes('parsi') || n.includes('zoroastrian')) return 'local_fire_department';
|
|
if (n.includes('bahai') || n.includes("baha'i")) return 'auto_awesome';
|
|
if (n.includes('none') || n.includes('atheist') || n.includes('not disclosed')) return 'visibility_off';
|
|
if (n.includes('other')) return 'more_horiz';
|
|
return 'public';
|
|
}
|
|
|
|
getSelectedSocialCategory(): any {
|
|
const val = this.createClientForm.get('socialCategoryId')?.value;
|
|
return this.socialCategoryOptions.find((s: any) => s.id === val) || null;
|
|
}
|
|
|
|
getSocialCategoryIcon(name: string): string {
|
|
const n = (name || '').toLowerCase().replace(/[\s.-]+/g, '');
|
|
if (n.includes('general') || n === 'gen' || n.includes('unreserved')) return 'groups';
|
|
if (n.includes('obc') || n.includes('otherbackward')) return 'diversity_3';
|
|
if (n === 'sc' || n.includes('scheduledcaste')) return 'diversity_1';
|
|
if (n === 'st' || n.includes('scheduledtribe')) return 'forest';
|
|
if (n.includes('ews') || n.includes('economically')) return 'savings';
|
|
if (n.includes('minority')) return 'diversity_2';
|
|
if (n.includes('other')) return 'more_horiz';
|
|
if (n.includes('notdisclosed')) return 'visibility_off';
|
|
return 'groups';
|
|
}
|
|
|
|
getDegreeQualificationIcon(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
if (
|
|
n.includes('b.tech') ||
|
|
n.includes('m.tech') ||
|
|
n.includes('b.e') ||
|
|
n.includes('m.e') ||
|
|
n.includes('engineer')
|
|
)
|
|
return 'engineering';
|
|
if (
|
|
n.includes('mbbs') ||
|
|
n.includes('md') ||
|
|
n.includes('ms ') ||
|
|
n.includes('medic') ||
|
|
n.includes('bhms') ||
|
|
n.includes('bams')
|
|
)
|
|
return 'local_hospital';
|
|
if (n.includes('bds') || n.includes('mds') || n.includes('dental')) return 'medical_services';
|
|
if (n.includes('pharm')) return 'medication';
|
|
if (n.includes('b.com') || n.includes('m.com') || n.includes('commerce')) return 'trending_up';
|
|
if (n.includes('mba') || n.includes('bba') || n.includes('business')) return 'business_center';
|
|
if (n.includes('bca') || n.includes('mca') || n.includes('comput')) return 'computer';
|
|
if (n.includes('llb') || n.includes('llm') || n.includes('law')) return 'gavel';
|
|
if (n.includes('b.ed') || n.includes('m.ed') || n.includes('educat')) return 'cast_for_education';
|
|
if (n.includes('b.sc') || n.includes('m.sc') || n.includes('science')) return 'science';
|
|
if (n.includes('b.a') || n.includes('m.a') || n.includes('arts')) return 'palette';
|
|
if (n.includes('arch')) return 'architecture';
|
|
if (n.includes('nurs')) return 'medical_information';
|
|
if (n.includes('ca ') || n.includes('chartered')) return 'calculate';
|
|
if (n.includes('cs ') || n.includes('company secretary')) return 'description';
|
|
if (n.includes('cfa') || n.includes('cma')) return 'pie_chart';
|
|
if (n.includes('phd') || n.includes('ph.d') || n.includes('doctorate')) return 'workspace_premium';
|
|
if (n.includes('10th') || n.includes('ssc') || n.includes('matric')) return 'menu_book';
|
|
if (n.includes('12th') || n.includes('hsc') || n.includes('intermediate')) return 'auto_stories';
|
|
if (n.includes('diploma') || n.includes('iti')) return 'card_membership';
|
|
if (n.includes('other')) return 'more_horiz';
|
|
return 'school';
|
|
}
|
|
|
|
getBusinessIcon(name: string): string {
|
|
const n = (name || '').toLowerCase();
|
|
if (n.includes('manufactur')) return 'precision_manufacturing';
|
|
if (n.includes('trad')) return 'storefront';
|
|
if (n.includes('service')) return 'support_agent';
|
|
if (n.includes('agricult') || n.includes('farm')) return 'agriculture';
|
|
if (n.includes('construct') || n.includes('real estate')) return 'construction';
|
|
if (n.includes('retail')) return 'shopping_cart';
|
|
if (n.includes('transport') || n.includes('logistic')) return 'local_shipping';
|
|
if (n.includes('health') || n.includes('pharma') || n.includes('medical')) return 'local_hospital';
|
|
if (n.includes('education') || n.includes('school')) return 'school';
|
|
if (n.includes('tech') || n.includes('it') || n.includes('software')) return 'computer';
|
|
if (n.includes('food') || n.includes('restaurant') || n.includes('hotel')) return 'restaurant';
|
|
if (n.includes('finance') || n.includes('bank') || n.includes('insurance')) return 'account_balance';
|
|
if (n.includes('textile') || n.includes('garment')) return 'checkroom';
|
|
if (n.includes('mining') || n.includes('mineral')) return 'landscape';
|
|
if (n.includes('energy') || n.includes('power') || n.includes('electric')) return 'bolt';
|
|
if (n.includes('media') || n.includes('entertainment')) return 'movie';
|
|
if (n.includes('telecom') || n.includes('communication')) return 'cell_tower';
|
|
return 'business';
|
|
}
|
|
|
|
getDateLabel(legalFormId: number, values: string[]): string {
|
|
return legalFormId === LegalFormId.PERSON ? values[0] : values[1];
|
|
}
|
|
|
|
/**
|
|
* Client General Details
|
|
*/
|
|
get clientGeneralDetails() {
|
|
const generalDetails = this.readOnly ? this.createClientForm.getRawValue() : this.createClientForm.value;
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const locale = this.settingsService.language.code;
|
|
const isEditMode = !!this.clientData;
|
|
for (const key in generalDetails) {
|
|
if (key === 'addSavings') {
|
|
delete generalDetails[key];
|
|
continue;
|
|
}
|
|
if (generalDetails[key] === '') {
|
|
// In edit mode, preserve empty strings for fields that previously had a value so the
|
|
// backend can clear them (Fineract treats a missing field as "no change" but an empty
|
|
// string as a deliberate clear).
|
|
if (isEditMode && this.clientData[key] != null && this.clientData[key] !== '') {
|
|
continue;
|
|
}
|
|
delete generalDetails[key];
|
|
}
|
|
}
|
|
// Entity name is normalised here as well as on blur: a pasted/auto-filled value, or one the
|
|
// user never blurred out of before submitting, would otherwise reach Fineract exactly as typed.
|
|
if (generalDetails.fullname) {
|
|
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
|
}
|
|
|
|
if (generalDetails.submittedOnDate instanceof Date) {
|
|
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
|
}
|
|
if (generalDetails.activationDate instanceof Date) {
|
|
generalDetails.activationDate = this.dateUtils.formatDate(generalDetails.activationDate, dateFormat);
|
|
}
|
|
if (generalDetails.dateOfBirth instanceof Date) {
|
|
generalDetails.dateOfBirth = this.dateUtils.formatDate(generalDetails.dateOfBirth, dateFormat);
|
|
}
|
|
|
|
if (generalDetails.clientNonPersonDetails) {
|
|
generalDetails.clientNonPersonDetails = {
|
|
...generalDetails.clientNonPersonDetails,
|
|
dateFormat,
|
|
locale
|
|
};
|
|
}
|
|
// Add computed age breakdown from dateOfBirth
|
|
if (this.ageBreakdown.years > 0 || this.ageBreakdown.months > 0 || this.ageBreakdown.days > 0) {
|
|
generalDetails.ageYears = this.ageBreakdown.years;
|
|
generalDetails.ageMonths = this.ageBreakdown.months;
|
|
generalDetails.ageDays = this.ageBreakdown.days;
|
|
}
|
|
|
|
// Store extended fields separately (not yet supported by Fineract API)
|
|
// Add isMinor flag
|
|
generalDetails.isMinor = this.isMinor;
|
|
// isSeniorCitizen is auto-detected from DOB (not a form control) — add it explicitly
|
|
generalDetails.isSeniorCitizen = this.isSeniorCitizen;
|
|
|
|
// Add guardian clientId for minors
|
|
if (this.isMinor && generalDetails.guardianDetails) {
|
|
generalDetails.guardianClientId = generalDetails.guardianDetails.guardianClientId;
|
|
generalDetails.guardianRelation = generalDetails.guardianDetails.guardianRelation;
|
|
}
|
|
|
|
// Add DIP verification status
|
|
generalDetails.dipVerified = this.mobileVerified;
|
|
if (this.dipLookupLogId) {
|
|
generalDetails.dipLookupLogId = this.dipLookupLogId;
|
|
}
|
|
if (this.dipTimestamp) {
|
|
generalDetails.dipTimestamp = this.dipTimestamp;
|
|
}
|
|
|
|
// Add alternate mobile DIP verification status
|
|
generalDetails.alternateDipVerified = this.alternateMobileVerified;
|
|
if (this.alternateDipLookupLogId) {
|
|
generalDetails.alternateDipLookupLogId = this.alternateDipLookupLogId;
|
|
}
|
|
if (this.alternateDipTimestamp) {
|
|
generalDetails.alternateDipTimestamp = this.alternateDipTimestamp;
|
|
}
|
|
|
|
// Add name verification (sanctions screening) status
|
|
generalDetails.nameVerified = this.sanctionsVerified;
|
|
|
|
// Add sanctions screening log ID
|
|
if (this.screeningLogId) {
|
|
generalDetails.screeningLogId = this.screeningLogId;
|
|
}
|
|
|
|
const unsupportedFields = [
|
|
'guardianDetails',
|
|
'addressDetails',
|
|
'entityKycDetails'
|
|
];
|
|
unsupportedFields.forEach((f) => delete generalDetails[f]);
|
|
|
|
return generalDetails;
|
|
}
|
|
|
|
/**
|
|
* Turnover ladder that decides an entity's risk when no configured risk rule matches it.
|
|
*
|
|
* A company's size is the one thing about it the branch always knows at onboarding, and the
|
|
* bigger the book the more exposure it carries — so the band moves up with annual turnover
|
|
* instead of every entity landing on a flat Medium regardless of whether it turns over ten lakh
|
|
* or a hundred crore. Thresholds follow the MSME-style split the business already thinks in
|
|
* (crores), and are only a fallback: a rule configured in m_client_risk_rule with
|
|
* minAnnualTurnover / maxAnnualTurnover still wins, which is where a bank sets its own numbers.
|
|
*
|
|
* Amounts are in rupees. `upTo: null` is the open-ended top band.
|
|
*/
|
|
private static readonly ENTITY_TURNOVER_RISK_BANDS: { upTo: number | null; category: number; label: string }[] = [
|
|
{ upTo: 10000000, category: 1, label: 'up to ₹1 crore' },
|
|
{ upTo: 100000000, category: 2, label: '₹1 crore to ₹10 crore' },
|
|
{ upTo: null, category: 3, label: 'above ₹10 crore' }
|
|
];
|
|
|
|
/**
|
|
* A numeric rule bound counts as configured only when it is present and above zero.
|
|
*
|
|
* Migration 0448 replaced every unset income/turnover NULL with 0.0, so "absent" now arrives as
|
|
* either. Every comparison in calculatedRisk already pairs its check with `> 0` for that reason;
|
|
* this keeps the criteria-less test reading the columns the same way.
|
|
*/
|
|
private static isConfigured(bound: number | string | null | undefined): boolean {
|
|
return bound != null && parseFloat(String(bound)) > 0;
|
|
}
|
|
|
|
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
|
|
riskCategoryLabel(category: number): string {
|
|
if (category === 3) {
|
|
return 'High Risk';
|
|
}
|
|
return category === 2 ? 'Medium Risk' : 'Low Risk';
|
|
}
|
|
|
|
/**
|
|
* Entity risk from annual turnover, for the fallback path only.
|
|
*
|
|
* AML is deliberately left CLEAR even in the top band. A configured High-risk rule defaults to
|
|
* FLAGGED because someone decided that case warrants it; turnover on its own is a size signal,
|
|
* not a suspicion, and auto-flagging every large company would put them all in the AML queue.
|
|
*/
|
|
private entityRiskFromTurnover(annualTurnover: number): {
|
|
category: number;
|
|
reason: string;
|
|
amlCheckStatus: string;
|
|
bannedEntityMatch: string;
|
|
willfulDefaulter: string;
|
|
ecgcSalFlag: string;
|
|
dormantFlag: string;
|
|
} {
|
|
const base = {
|
|
amlCheckStatus: 'CLEAR',
|
|
bannedEntityMatch: 'NO',
|
|
willfulDefaulter: 'NO',
|
|
ecgcSalFlag: 'NO',
|
|
dormantFlag: 'ACTIVE'
|
|
};
|
|
|
|
// Nothing captured yet — the entity default stands until a turnover is entered.
|
|
if (!annualTurnover || annualTurnover <= 0) {
|
|
return {
|
|
category: 2,
|
|
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
|
...base
|
|
};
|
|
}
|
|
|
|
const band =
|
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.find((b) => b.upTo == null || annualTurnover <= b.upTo) ??
|
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS[
|
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.length - 1
|
|
];
|
|
|
|
return {
|
|
category: band.category,
|
|
reason: `Annual turnover ₹${annualTurnover.toLocaleString('en-IN')} — ${band.label} (${this.riskCategoryLabel(
|
|
band.category
|
|
)})`,
|
|
...base
|
|
};
|
|
}
|
|
|
|
/** Live risk band for the entity turnover card in the General step. */
|
|
get entityTurnoverRisk(): { category: number; reason: string } {
|
|
return this.calculatedRisk;
|
|
}
|
|
|
|
get calculatedRisk(): {
|
|
category: number;
|
|
reason: string;
|
|
amlCheckStatus?: string;
|
|
bannedEntityMatch?: string;
|
|
willfulDefaulter?: string;
|
|
ecgcSalFlag?: string;
|
|
dormantFlag?: string;
|
|
} {
|
|
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
|
|
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
|
|
const occId = this.createClientForm.get('occupationCvId')?.value;
|
|
|
|
const monthlyIncome = parseFloat(this.createClientForm.get('monthlyIncome')?.value) || 0;
|
|
const annualIncome = parseFloat(this.createClientForm.get('annualIncome')?.value) || 0;
|
|
const monthlyTurnover = parseFloat(this.createClientForm.get('monthlyTurnover')?.value) || 0;
|
|
const annualTurnover = parseFloat(this.createClientForm.get('annualTurnover')?.value) || 0;
|
|
const agricultureIncome = parseFloat(this.createClientForm.get('agricultureIncome')?.value) || 0;
|
|
const nonAgricultureIncome = parseFloat(this.createClientForm.get('nonAgricultureIncome')?.value) || 0;
|
|
|
|
const totalAnnualIncome = annualIncome + agricultureIncome + nonAgricultureIncome;
|
|
|
|
// Evaluate rules from DB (ordered by orderPosition ASC)
|
|
if (this.riskRules && this.riskRules.length > 0) {
|
|
for (const rule of this.riskRules) {
|
|
// Only KYC profile rules categorise a customer at onboarding. The same table also holds
|
|
// ~221 AML transaction-monitoring scenarios ("Large cash transaction", "Additional RFIs
|
|
// Rule - 91", ...) which describe behaviour and may only fire against posted transactions,
|
|
// as AML alerts — RBI Master Direction on KYC separates risk categorisation (Section 12)
|
|
// from transaction monitoring (Section 13). ruleKind, set on the row, is what tells them
|
|
// apart; it replaced an inference from "this rule has no criteria set", which migration
|
|
// 0448 silently broke by writing 0.0 over those NULLs. That let rule 91 match a salaried
|
|
// client on an income band it was never meant to carry and pre-flag them for AML.
|
|
if (rule.ruleKind === 'AML') {
|
|
continue;
|
|
}
|
|
|
|
// Backstop for a rule row predating rule_kind: a rule naming no occupation, no legal form
|
|
// and no income or turnover band constrains nothing, so it would match every customer.
|
|
// Treat 0 as unset — that is how the comparisons below read it, and what 0448 left behind.
|
|
if (
|
|
rule.ruleKind == null &&
|
|
rule.legalFormId == null &&
|
|
rule.occupationCategoryCvId == null &&
|
|
rule.occupationCvId == null &&
|
|
!ClientGeneralStepComponent.isConfigured(rule.minMonthlyIncome) &&
|
|
!ClientGeneralStepComponent.isConfigured(rule.maxMonthlyIncome) &&
|
|
!ClientGeneralStepComponent.isConfigured(rule.minAnnualIncome) &&
|
|
!ClientGeneralStepComponent.isConfigured(rule.maxAnnualIncome) &&
|
|
!ClientGeneralStepComponent.isConfigured(rule.minAnnualTurnover) &&
|
|
!ClientGeneralStepComponent.isConfigured(rule.maxAnnualTurnover)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// Match legal form
|
|
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
|
|
continue;
|
|
}
|
|
|
|
// Match occupation category
|
|
if (rule.occupationCategoryCvId != null && rule.occupationCategoryCvId !== occCatId) {
|
|
continue;
|
|
}
|
|
|
|
// Match occupation
|
|
if (rule.occupationCvId != null && rule.occupationCvId !== occId) {
|
|
continue;
|
|
}
|
|
|
|
// Match monthly income ranges
|
|
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
|
|
continue;
|
|
}
|
|
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
|
|
continue;
|
|
}
|
|
|
|
// Match annual income ranges (evaluated against totalAnnualIncome)
|
|
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
|
|
continue;
|
|
}
|
|
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
|
|
continue;
|
|
}
|
|
|
|
// Match turnover ranges
|
|
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
|
|
continue;
|
|
}
|
|
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
|
|
continue;
|
|
}
|
|
|
|
// All matched!
|
|
return {
|
|
category: rule.riskCategory,
|
|
reason: rule.reason,
|
|
// A profile rule sets the risk category; it does not raise an AML flag. High Risk means
|
|
// enhanced due diligence and a 2-yearly KYC refresh (RBI MD Sections 12 and 38), not
|
|
// suspicion — an alert belongs to observed transaction behaviour (Section 13), so a
|
|
// customer who has transacted nothing yet stays CLEAR. A bank that genuinely wants a
|
|
// rule to flag on match can still say so explicitly via the rule's own amlCheckStatus.
|
|
amlCheckStatus: rule.amlCheckStatus || 'CLEAR',
|
|
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
|
|
willfulDefaulter: rule.willfulDefaulter || 'NO',
|
|
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
|
|
dormantFlag: rule.dormantFlag || 'ACTIVE'
|
|
};
|
|
}
|
|
}
|
|
|
|
// Default Fallbacks
|
|
if (legalFormId !== LegalFormId.PERSON) {
|
|
return this.entityRiskFromTurnover(annualTurnover);
|
|
}
|
|
return {
|
|
category: 1,
|
|
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
|
|
amlCheckStatus: 'CLEAR',
|
|
bannedEntityMatch: 'NO',
|
|
willfulDefaulter: 'NO',
|
|
ecgcSalFlag: 'NO',
|
|
dormantFlag: 'ACTIVE'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signatory / Guardian Detail Dialog
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-signatory-detail-dialog',
|
|
template: `
|
|
<div class="sd">
|
|
<!-- Header -->
|
|
<div class="sd-header">
|
|
<div class="sd-avatar" [class.sd-avatar--photo]="data.profileImageUrl">
|
|
@if (data.profileImageUrl) {
|
|
<img [src]="data.profileImageUrl" alt="Photo" />
|
|
} @else {
|
|
<fa-icon icon="user-shield" class="sd-avatar-icon"></fa-icon>
|
|
}
|
|
</div>
|
|
<div class="sd-header-info">
|
|
<h3 class="sd-name">{{ data.displayName }}</h3>
|
|
<p class="sd-sub">CIF: {{ data.cifNumber || data.id }} · {{ data.status?.value || 'Active' }}</p>
|
|
</div>
|
|
<button mat-icon-button [mat-dialog-close]="true" class="sd-close"><mat-icon>close</mat-icon></button>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<mat-dialog-content class="sd-body">
|
|
@if (loading) {
|
|
<div class="sd-loading">
|
|
<mat-progress-spinner diameter="24" mode="indeterminate"></mat-progress-spinner>
|
|
<span>Loading details...</span>
|
|
</div>
|
|
}
|
|
<!-- Account Info -->
|
|
<div class="sd-section">
|
|
<div class="sd-row">
|
|
<div class="sd-field">
|
|
<span class="sd-label">Customer ID</span>
|
|
<span class="sd-val">{{ data.cifNumber || data.id }}</span>
|
|
</div>
|
|
<div class="sd-field">
|
|
<span class="sd-label">Account No</span>
|
|
<span class="sd-val">{{ data.accountNo || data.accountNumber || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="sd-row">
|
|
<div class="sd-field">
|
|
<span class="sd-label">Account Type</span>
|
|
<span class="sd-val">{{ data.accountType || '-' }}</span>
|
|
</div>
|
|
<div class="sd-field">
|
|
<span class="sd-label">Office</span>
|
|
<span class="sd-val">{{ data.officeName || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Address -->
|
|
<div class="sd-section">
|
|
<div class="sd-row">
|
|
<div class="sd-field sd-field--full">
|
|
<span class="sd-label"><mat-icon class="sd-icon">location_on</mat-icon> Address</span>
|
|
<span class="sd-val">{{ data.fullAddress || 'No address on record' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KYC -->
|
|
<div class="sd-section sd-section--kyc">
|
|
<div class="sd-row">
|
|
<div class="sd-field">
|
|
<span class="sd-label"><mat-icon class="sd-icon">credit_card</mat-icon> PAN</span>
|
|
<span class="sd-val sd-val--mono">
|
|
{{ data.panNumber || '-' }}
|
|
@if (data.panVerified) {
|
|
<mat-icon class="sd-tick">check_circle</mat-icon>
|
|
}
|
|
</span>
|
|
</div>
|
|
<div class="sd-field">
|
|
<span class="sd-label"><mat-icon class="sd-icon">fingerprint</mat-icon> Aadhaar</span>
|
|
<span class="sd-val sd-val--mono">
|
|
{{ data.aadhaarMasked || '-' }}
|
|
@if (data.aadhaarVerified) {
|
|
<mat-icon class="sd-tick">check_circle</mat-icon>
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contact -->
|
|
@if (data.mobileNo || data.emailAddress) {
|
|
<div class="sd-section">
|
|
<div class="sd-row">
|
|
@if (data.mobileNo) {
|
|
<div class="sd-field">
|
|
<span class="sd-label"><mat-icon class="sd-icon">phone_android</mat-icon> Mobile</span>
|
|
<span class="sd-val">{{ data.mobileNo }}</span>
|
|
</div>
|
|
}
|
|
@if (data.emailAddress) {
|
|
<div class="sd-field">
|
|
<span class="sd-label"><mat-icon class="sd-icon">email</mat-icon> Email</span>
|
|
<span class="sd-val">{{ data.emailAddress }}</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Signature -->
|
|
@if (data.signatureUrl) {
|
|
<div class="sd-section sd-section--sig">
|
|
<span class="sd-label"><mat-icon class="sd-icon">draw</mat-icon> Specimen Signature</span>
|
|
<div class="sd-sig-frame">
|
|
<img [src]="data.signatureUrl" alt="Signature" />
|
|
</div>
|
|
</div>
|
|
}
|
|
</mat-dialog-content>
|
|
</div>
|
|
`,
|
|
styles: [
|
|
`
|
|
:host {
|
|
--bg: #f0f4f8;
|
|
--surface: #ffffff;
|
|
--border: #e2e8f0;
|
|
--border-input: #cbd5e1;
|
|
--primary: #1e6bb8;
|
|
--primary-light: #ebf3ff;
|
|
--text: #1a2332;
|
|
--text-muted: #64748b;
|
|
--radius-card: 12px;
|
|
}
|
|
|
|
.sd {
|
|
background: var(--bg);
|
|
border-radius: var(--radius-card);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Header */
|
|
.sd-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 20px 24px;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.sd-avatar {
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
background: var(--primary-light);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
border: 2px solid var(--border);
|
|
}
|
|
.sd-avatar--photo {
|
|
border-color: var(--primary);
|
|
}
|
|
.sd-avatar img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.sd-avatar-icon {
|
|
font-size: 22px;
|
|
color: var(--primary);
|
|
}
|
|
.sd-header-info {
|
|
flex: 1;
|
|
}
|
|
.sd-name {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
.sd-sub {
|
|
margin: 2px 0 0;
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
}
|
|
.sd-close {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Body */
|
|
.sd-body {
|
|
padding: 0 !important;
|
|
background: var(--bg);
|
|
max-height: none !important;
|
|
overflow: visible !important;
|
|
padding-bottom: 16px !important;
|
|
}
|
|
.sd-loading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 20px 24px;
|
|
color: var(--text-muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
/* Sections */
|
|
.sd-section {
|
|
background: var(--surface);
|
|
margin: 12px 16px;
|
|
padding: 16px 20px;
|
|
border-radius: var(--radius-card);
|
|
border: 1px solid var(--border);
|
|
}
|
|
.sd-section--kyc {
|
|
background: var(--primary-light);
|
|
border-color: #c7ddf5;
|
|
}
|
|
.sd-section--sig {
|
|
padding-bottom: 20px;
|
|
}
|
|
|
|
.sd-row {
|
|
display: flex;
|
|
gap: 24px;
|
|
}
|
|
.sd-row + .sd-row {
|
|
margin-top: 14px;
|
|
}
|
|
.sd-field {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.sd-field--full {
|
|
flex: 1 1 100%;
|
|
}
|
|
|
|
.sd-label {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
color: var(--text-muted);
|
|
letter-spacing: 0.6px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.sd-val {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
.sd-val--mono {
|
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.sd-icon {
|
|
font-size: 15px !important;
|
|
width: 15px !important;
|
|
height: 15px !important;
|
|
color: var(--primary);
|
|
}
|
|
.sd-tick {
|
|
font-size: 18px !important;
|
|
width: 18px !important;
|
|
height: 18px !important;
|
|
color: var(--primary);
|
|
}
|
|
|
|
/* Signature frame */
|
|
.sd-sig-frame {
|
|
margin-top: 8px;
|
|
padding: 12px;
|
|
background: var(--surface);
|
|
border: 1px dashed var(--border-input);
|
|
border-radius: 8px;
|
|
display: inline-block;
|
|
}
|
|
.sd-sig-frame img {
|
|
max-width: 300px;
|
|
max-height: 100px;
|
|
display: block;
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.sd-row {
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
}
|
|
`
|
|
],
|
|
imports: [
|
|
STANDALONE_SHARED_IMPORTS,
|
|
MatIcon,
|
|
FaIconComponent,
|
|
MatDialogContent,
|
|
MatDialogClose,
|
|
MatProgressSpinner
|
|
]
|
|
})
|
|
export class SignatoryDetailDialogComponent {
|
|
private clientService = inject(ClientsService);
|
|
private sanitizer = inject(DomSanitizer);
|
|
private inputData = inject<any>(MAT_DIALOG_DATA);
|
|
data: any;
|
|
loading = false;
|
|
|
|
constructor() {
|
|
const inputData = this.inputData;
|
|
this.data = { ...inputData };
|
|
const cid = inputData.id?.toString();
|
|
if (!cid) return;
|
|
|
|
// Fetch profile image
|
|
this.clientService.getClientProfileImage(cid).subscribe({
|
|
next: (img: any) => {
|
|
if (img) {
|
|
this.data = { ...this.data, profileImageUrl: this.sanitizer.bypassSecurityTrustResourceUrl(img) };
|
|
}
|
|
}
|
|
});
|
|
|
|
// Fetch signature
|
|
this.clientService.getClientDocuments(cid).subscribe({
|
|
next: (docs: any[]) => {
|
|
const sigDoc = docs?.find(
|
|
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
|
|
);
|
|
if (sigDoc) {
|
|
this.clientService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({
|
|
next: (blob: Blob) => {
|
|
this.data = {
|
|
...this.data,
|
|
signatureUrl: this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob))
|
|
};
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Guardian Image Zoom Dialog
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-guardian-image-zoom-dialog',
|
|
template: `
|
|
<div class="zoom-dialog">
|
|
<div class="zoom-dialog__header">
|
|
<span class="zoom-dialog__title">{{ data.title }}</span>
|
|
<button mat-icon-button [mat-dialog-close]="true" class="zoom-dialog__close">
|
|
<mat-icon>close</mat-icon>
|
|
</button>
|
|
</div>
|
|
<div class="zoom-dialog__body">
|
|
<img [src]="data.imageUrl" alt="{{ data.title }}" class="zoom-dialog__img" />
|
|
</div>
|
|
</div>
|
|
`,
|
|
styles: [
|
|
`
|
|
.zoom-dialog {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
.zoom-dialog__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 16px;
|
|
background: #0f172a;
|
|
color: #fff;
|
|
}
|
|
.zoom-dialog__title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
.zoom-dialog__close {
|
|
color: rgba(255, 255, 255, 0.7) !important;
|
|
}
|
|
.zoom-dialog__close:hover {
|
|
color: #fff !important;
|
|
}
|
|
.zoom-dialog__body {
|
|
padding: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #f8fafc;
|
|
}
|
|
.zoom-dialog__img {
|
|
max-width: 100%;
|
|
max-height: 70vh;
|
|
object-fit: contain;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
|
}
|
|
`
|
|
],
|
|
imports: [
|
|
MatIcon,
|
|
MatDialogClose
|
|
]
|
|
})
|
|
export class GuardianImageZoomDialogComponent {
|
|
data = inject<{ imageUrl: string; title: string }>(MAT_DIALOG_DATA);
|
|
}
|
|
|
|
/**
|
|
* Guardian Delete Confirmation Dialog
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-guardian-delete-confirm-dialog',
|
|
template: `
|
|
<div class="gdc">
|
|
<div class="gdc-header">
|
|
<mat-icon class="gdc-header__icon">warning</mat-icon>
|
|
<h3 class="gdc-header__title">Remove Guardian</h3>
|
|
</div>
|
|
<div class="gdc-body">
|
|
<p>Are you sure you want to remove the linked guardian? This action cannot be undone.</p>
|
|
</div>
|
|
<div class="gdc-actions">
|
|
<button mat-stroked-button [mat-dialog-close]="false">Cancel</button>
|
|
<button mat-flat-button color="warn" [mat-dialog-close]="true">
|
|
<mat-icon>delete</mat-icon>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`,
|
|
styles: [
|
|
`
|
|
.gdc {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
.gdc-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 16px 20px;
|
|
background: #fef2f2;
|
|
border-bottom: 1px solid #fecaca;
|
|
}
|
|
.gdc-header__icon {
|
|
color: #dc2626;
|
|
font-size: 24px;
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
.gdc-header__title {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #991b1b;
|
|
}
|
|
.gdc-body {
|
|
padding: 20px;
|
|
}
|
|
.gdc-body p {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
color: #374151;
|
|
line-height: 1.5;
|
|
}
|
|
.gdc-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
padding: 12px 20px;
|
|
border-top: 1px solid #f1f5f9;
|
|
}
|
|
.gdc-actions button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
`
|
|
],
|
|
imports: [
|
|
MatIcon,
|
|
MatDialogClose
|
|
]
|
|
})
|
|
export class GuardianDeleteConfirmDialogComponent {}
|