/** * 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 } 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 { 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'; /** * 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(); @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[] = []; /** 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 (age < 18) */ isMinor = false; /** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */ isSeniorCitizen = false; /** 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; /** * 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; /** Screening log ID from sanctions API */ screeningLogId: number | null = null; /** Snapshot of the name that was last verified — used to invalidate verification when name changes */ private verifiedName: string = ''; /** * @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(); // Fetch marital status options from code values this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => { this.maritalStatusOptions = options || []; }); // Fetch education level options from code values this.clientService.getCodeValuesByName('EducationLevel').subscribe((options) => { this.educationLevelOptions = options || []; }); // Fetch degree / qualification options from code values this.clientService.getCodeValuesByName('DegreeQualification').subscribe((options) => { this.degreeQualificationOptions = options || []; }); // Fetch religion options from code values this.clientService.getCodeValuesByName('Religion').subscribe((options) => { this.religionOptions = options || []; }); // Fetch social category options from code values this.clientService.getCodeValuesByName('SocialCategory').subscribe((options) => { this.socialCategoryOptions = options || []; }); // Reset degree selection whenever education level changes (degree list is filtered by level) this.createClientForm .get('educationLevelId') ?.valueChanges.pipe(takeUntil(this.destroy$)) .subscribe(() => { const current = this.createClientForm.get('degreeQualificationId')?.value; if (current && !this.filteredDegreeQualificationOptions.some((d: any) => d.id === current)) { this.createClientForm.get('degreeQualificationId')?.setValue(''); } }); // Fetch all constitution options from code ID 17 this.clientService.getCodeValuesById(17).subscribe((options) => { if (options?.length) { this.constitutionOptions = options; // Sort master by value length descending so longer/more-specific keys match first const sortedMaster = [...this.constitutionTypesMaster].sort((a, b) => b.value.length - a.value.length); this.enrichedConstitutionOptions = options.map((opt: any) => { const normalized = opt.name?.toUpperCase().replace(/[\s_-]+/g, '') || ''; const master = sortedMaster.find((m) => normalized.includes(m.value.replace(/_/g, ''))); return { id: opt.id, value: master?.value || opt.name, label: master?.label || opt.name, icon: master?.icon || 'business', panLabel: master?.panLabel || '', panHint: master?.panHint || '', regHint: master?.regHint || '', showCin: master?.showCin || false, additionalField: master?.additionalField || '', documents: master?.documents || '', signatoryKyc: master?.signatoryKyc || '', note: master?.note || '' }; }); } }); this.buildDependencies(); if (this.clientData) { this.prefillForEdit(); } else if (this.prefillData) { this.prefillFromFamilyMember(); } } /** * Resets transient session state back to a blank-client baseline. * * This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps the SAME * component instance alive across repeat visits to /clients/create — `ngOnInit`/the * constructor never re-run for a second "create new client" session. Without this, computed * state like `isMinor`, the linked guardian, or a prior sanctions/OTP verification would leak * from whatever client was created in the previous session into the new one. Called by * CreateClientComponent whenever `route.data` re-emits (which happens on every navigation, * even a reused one). */ resetForNewClient(): void { this.createClientForm.reset(); // legalFormId's raw FormControl default is '' — the buildDependencies() subscription // treats any non-PERSON value (including '') as "non-individual" and adds fullname / // clientNonPersonDetails. Patch it back to the same PERSON default buildDependencies() // itself applies on first construction, so a reset lands on a blank Individual client // instead of stuck with leftover Entity-only controls. this.createClientForm.get('legalFormId')?.setValue(LegalFormId.PERSON); this.isMinor = false; this.calculatedAgeDetail = null; this.ageBreakdown = { years: 0, months: 0, days: 0 }; this.selectedGuardian = null; this.guardianSearchText = ''; this.guardianSearchResults = []; this.guardianExpanded = false; if (this.createClientForm.get('guardianDetails')) { this.createClientForm.removeControl('guardianDetails'); } // 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.alternateMobileVerified = false; this.alternateDipLookupLogId = null; this.alternateDipTimestamp = null; this.mobileNewOtpId = null; this.emailNewOtpId = null; this.sanctionsVerified = false; this.sanctionsBlocked = 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(); // 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 }); // 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 || '', additionalInfo: data.clientNonPersonDetails.additionalInfo || '' }); } } // 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); }); } } /** * 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) ] ], 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 ] }); } /** * 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: [''], 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 = years < 18; this.isSeniorCitizen = years >= 60; // 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; // 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; }); this.createClientForm .get('alternateMobileNo') .valueChanges.pipe(takeUntil(this.destroy$)) .subscribe(() => { this.alternateMobileVerified = false; }); // 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; if (result.verified) { this.mobileVerified = true; } 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; if (result.verified) { this.alternateMobileVerified = true; } 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(); } /** * 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 dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, { width: '550px', data: { name } }); 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.verifiedName = name; } else if (result.verified === false) { // FLAGGED — match found, block account opening this.sanctionsVerified = false; this.sanctionsBlocked = true; this.verifiedName = ''; } }); } /** 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) { this.sanctionsVerified = false; this.sanctionsBlocked = false; this.screeningLogId = null; } }); } /** 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; 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; }, 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 }); } private syncSignatoryNames(): void { const names = this.signatories.map((s) => s.displayName).join(', '); this.createClientForm.get('clientNonPersonDetails')?.patchValue({ authorizedSignatory: names }); } /** Watch MSME turnover & investment for auto-classification */ /** Maps salutation name to gender. Returns gender option or null. */ private salutationGenderMap: Record = { 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; } 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]; } } 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]); // Format dateOfRegistration if present if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) { generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate( generalDetails.clientNonPersonDetails.dateOfRegistration, dateFormat ); } return generalDetails; } } /** * Signatory / Guardian Detail Dialog */ @Component({ selector: 'mifosx-signatory-detail-dialog', template: `
@if (data.profileImageUrl) { Photo } @else { }

{{ data.displayName }}

CIF: {{ data.cifNumber || data.id }} · {{ data.status?.value || 'Active' }}

@if (loading) {
Loading details...
}
Customer ID {{ data.cifNumber || data.id }}
Account No {{ data.accountNo || data.accountNumber || '-' }}
Account Type {{ data.accountType || '-' }}
Office {{ data.officeName || '-' }}
location_on Address {{ data.fullAddress || 'No address on record' }}
credit_card PAN {{ data.panNumber || '-' }} @if (data.panVerified) { check_circle }
fingerprint Aadhaar {{ data.aadhaarMasked || '-' }} @if (data.aadhaarVerified) { check_circle }
@if (data.mobileNo || data.emailAddress) {
@if (data.mobileNo) {
phone_android Mobile {{ data.mobileNo }}
} @if (data.emailAddress) {
email Email {{ data.emailAddress }}
}
} @if (data.signatureUrl) {
draw Specimen Signature
Signature
}
`, 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(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: `
{{ data.title }}
{{ data.title }}
`, 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: `
warning

Remove Guardian

Are you sure you want to remove the linked guardian? This action cannot be undone.

`, 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 {}