Files
cbsnexora/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
T

3154 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('');
}
});
// Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty
// ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty.
this.clientService.getCodeValuesByName('Constitution').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 }} &middot; {{ 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 {}