feat: non-individual entity UI improvements — hide individual-only sections, fix legal form display, send extended fields to API

- Hide Family Members tab/section for non-individual entities in client view, create flow, and preview
- Hide Financial & Classification section for non-individual entities in personal data and preview
- Fix legal form column in clients list — fetch legalForm from client API for correct display
- Add person/business icon before client name in list rows
- Remove unsupportedNpd filter — send authorizedSignatory, additionalInfo, dateOfRegistration to API
- Add registration number format hint (mat-hint) based on constitution type
- Add spacing between verification row and constitution type dropdowns
- Reduce table cell padding and prevent action buttons from being clipped
- Add missing MPL-2.0 license headers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-04 15:10:51 +05:30
co-authored by Claude Opus 4.6
parent ab01111424
commit b890d24bb7
522 changed files with 51991 additions and 21885 deletions
@@ -289,7 +289,7 @@ export class ClientAddressStepComponent {
formfields.push(
this.isFieldEnabled('countyDistrict')
? new InputBase({
controlName: 'countryDistrict',
controlName: 'countyDistrict',
label: this.translateService.instant('labels.inputs.Country District'),
value: address ? address.countyDistrict : '',
type: 'text',
@@ -0,0 +1,678 @@
<!--
Copyright since 2025 Mifos Initiative
-->
<div class="ec-page">
<!-- Header -->
<div class="step-header">
<div class="step-header__left">
<fa-icon icon="building" class="step-header__icon"></fa-icon>
<div>
<h3 class="step-header__title">Scheme &amp; Sector Classification</h3>
<p class="step-header__sub">Regulatory scheme flags, sector classification &amp; compliance identifiers</p>
</div>
</div>
</div>
<!-- Active Chips -->
<div class="ec-chips-label">Active Classifications</div>
<div class="ec-chips">
@if (enabledCount === 0) {
<span class="ec-no-chip">No classifications enabled — toggle categories below</span>
}
@for (cat of enabledCategories; track cat.id) {
<span class="ec-chip" [style.background]="cat.tagBg" [style.color]="cat.tagColor">
{{ cat.label }}
<mat-icon class="ec-chip-close" (click)="removeCategory(cat.id)">close</mat-icon>
</span>
}
</div>
<!-- ═══ CATEGORY: MSME ═══ -->
<div class="ec-card" [class.ec-card--open]="enabled['msme']">
<div class="ec-card-head" (click)="toggleExpand('msme')">
<div class="ec-card-head-left">
<span class="ec-tag" style="background:#E6F4F6;color:#0A5C6B;">Udyam / MSME</span>
<div>
<div class="ec-card-title">MSME</div>
<div class="ec-card-desc">Micro, Small &amp; Medium Enterprise — Ministry of MSME / Udyam Registration</div>
</div>
</div>
<div class="ec-card-toggle">
<mat-slide-toggle [checked]="!!enabled['msme']" (change)="toggleCategory('msme')" (click)="$event.stopPropagation()"></mat-slide-toggle>
<mat-icon class="ec-chevron" [class.open]="expanded['msme']">expand_more</mat-icon>
</div>
</div>
@if (expanded['msme']) {
<div class="ec-card-body" [formGroup]="msmeForm">
<div class="ec-sub-title">Udyam Registration</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>Udyam Registration Number</mat-label>
<input matInput formControlName="udyamRegistrationNumber" placeholder="UDYAM-MP-00-0000000"
maxlength="19" style="text-transform:uppercase"
(input)="msmeForm.get('udyamRegistrationNumber')?.setValue($any($event.target).value.toUpperCase(), {emitEvent:false})" /></mat-form-field>
<mat-form-field appearance="outline" (click)="msmeRegDatePicker.open()"><mat-label>Registration Date</mat-label>
<input matInput [matDatepicker]="msmeRegDatePicker" formControlName="registrationDate" />
<mat-datepicker-toggle matSuffix [for]="msmeRegDatePicker"></mat-datepicker-toggle><mat-datepicker #msmeRegDatePicker></mat-datepicker></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Registration Status</mat-label>
<mat-select formControlName="registrationStatus">
<mat-option value="ACTIVE">Active</mat-option>
<mat-option value="SUSPENDED">Suspended</mat-option>
<mat-option value="CANCELLED">Cancelled</mat-option>
<mat-option value="PENDING">Pending Verification</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>MSME Classification</mat-label>
<mat-select formControlName="msmeClassification">
<mat-option value="MICRO">Micro (T/O ≤ ₹5 Cr, Inv ≤ ₹1 Cr)</mat-option>
<mat-option value="SMALL">Small (T/O ≤ ₹50 Cr, Inv ≤ ₹10 Cr)</mat-option>
<mat-option value="MEDIUM">Medium (T/O ≤ ₹250 Cr, Inv ≤ ₹50 Cr)</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Enterprise Type</mat-label>
<mat-select formControlName="enterpriseType">
<mat-option value="MANUFACTURING">Manufacturing</mat-option>
<mat-option value="SERVICES">Services</mat-option>
<mat-option value="TRADING">Trading</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>NIC Activity Code</mat-label>
<input matInput formControlName="nicActivityCode" placeholder="e.g. 13111" maxlength="10" /></mat-form-field>
</div>
<div class="ec-sub-title">Financial Thresholds (as per Udyam Portal)</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>Annual Turnover — Last FY (₹ Cr)</mat-label>
<input matInput type="number" formControlName="annualTurnover" placeholder="0.00" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Investment — Plant &amp; Machinery / Equipment (₹ Cr)</mat-label>
<input matInput type="number" formControlName="investmentPlantMachinery" placeholder="0.00" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Financial Year of Last Assessment</mat-label>
<mat-select formControlName="financialYearAssessment">
<mat-option value="2025-26">2025-26</mat-option>
<mat-option value="2024-25">2024-25</mat-option>
<mat-option value="2023-24">2023-24</mat-option>
<mat-option value="2022-23">2022-23</mat-option>
<mat-option value="2021-22">2021-22</mat-option>
<mat-option value="2020-21">2020-21</mat-option>
</mat-select></mat-form-field>
</div>
<div class="ec-sub-title">Owner / Promoter Details</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>Social Category of Owner</mat-label>
<mat-select formControlName="socialCategoryOwner">
<mat-option value="GENERAL">General</mat-option>
<mat-option value="SC">Scheduled Caste (SC)</mat-option>
<mat-option value="ST">Scheduled Tribe (ST)</mat-option>
<mat-option value="OBC">Other Backward Class (OBC)</mat-option>
<mat-option value="MINORITY">Minority</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Gender of Primary Owner</mat-label>
<mat-select formControlName="genderPrimaryOwner">
<mat-option value="MALE">Male</mat-option>
<mat-option value="FEMALE">Female</mat-option>
<mat-option value="OTHER">Other</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>No. of Employees (approx.)</mat-label>
<input matInput type="number" formControlName="noOfEmployees" placeholder="0" /></mat-form-field>
</div>
<div class="ec-flags">
<div class="ec-flag-row">
<div><span class="ec-flag-label">Women-led Enterprise</span><span class="ec-flag-sub">Majority ownership / management by women entrepreneur</span></div>
<mat-slide-toggle formControlName="womenLedEnterprise"></mat-slide-toggle>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">Divyang (PWD) Owned</span><span class="ec-flag-sub">Persons with Disability — eligible for special scheme benefits</span></div>
<mat-slide-toggle formControlName="divyangPwdOwned"></mat-slide-toggle>
</div>
</div>
<div class="ec-reg-note">
<strong>MSMED Act 2006 (Revised):</strong> Classification based on composite criteria of investment in plant &amp; machinery AND annual turnover. Udyam Registration is mandatory for availing PSL benefits, government procurement preference, and CGSS coverage.
</div>
<div class="ec-card-actions">
<button mat-raised-button color="primary" [disabled]="!clientId || saving['msme']" (click)="saveCategory('msme')">
@if (saving['msme']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
@else if (saved['msme']) { <mat-icon>check</mat-icon> Saved }
@else { Save MSME }
</button>
</div>
</div>
}
</div>
<!-- ═══ CATEGORY 1: STARTUP INDIA ═══ -->
<div class="ec-card" [class.ec-card--open]="enabled['startup']">
<div class="ec-card-head" (click)="toggleExpand('startup')">
<div class="ec-card-head-left">
<span class="ec-tag" style="background:#EAF2FB;color:#1A5FAB;">DPIIT</span>
<div>
<div class="ec-card-title">Startup India Registered</div>
<div class="ec-card-desc">DPIIT recognition under Startup India initiative</div>
</div>
</div>
<div class="ec-card-toggle">
<mat-slide-toggle [checked]="!!enabled['startup']" (change)="toggleCategory('startup')" (click)="$event.stopPropagation()"></mat-slide-toggle>
<mat-icon class="ec-chevron" [class.open]="expanded['startup']">expand_more</mat-icon>
</div>
</div>
@if (expanded['startup']) {
<div class="ec-card-body" [formGroup]="startupForm">
<div class="ec-sub-title">Registration Details</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>DPIIT Recognition Number</mat-label>
<input matInput formControlName="dpiitRecognitionNumber" placeholder="DIPP12345" style="text-transform:uppercase" /></mat-form-field>
<mat-form-field appearance="outline" (click)="recDatePicker.open()"><mat-label>Recognition Date</mat-label>
<input matInput [matDatepicker]="recDatePicker" formControlName="recognitionDate" />
<mat-datepicker-toggle matSuffix [for]="recDatePicker"></mat-datepicker-toggle><mat-datepicker #recDatePicker></mat-datepicker></mat-form-field>
<mat-form-field appearance="outline" (click)="certExpPicker.open()"><mat-label>Certificate Expiry Date</mat-label>
<input matInput [matDatepicker]="certExpPicker" formControlName="certificateExpiryDate" />
<mat-datepicker-toggle matSuffix [for]="certExpPicker"></mat-datepicker-toggle><mat-datepicker #certExpPicker></mat-datepicker></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Entity Stage</mat-label>
<mat-select formControlName="entityStage">
<mat-option value="EARLY">Early (0–2 years)</mat-option>
<mat-option value="GROWTH">Growth (2–5 years)</mat-option>
<mat-option value="SCALE">Scale (5+ years)</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Sector as per DPIIT</mat-label>
<mat-select formControlName="sectorAsDpiit">
<mat-option value="Fintech">Fintech</mat-option>
<mat-option value="Agritech">Agritech</mat-option>
<mat-option value="Edtech">Edtech</mat-option>
<mat-option value="Healthtech">Healthtech</mat-option>
<mat-option value="Logistics">Logistics</mat-option>
<mat-option value="Manufacturing">Manufacturing</mat-option>
<mat-option value="SaaS / B2B Tech">SaaS / B2B Tech</mat-option>
<mat-option value="Information Technology">Information Technology</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>NIC Industry Code</mat-label>
<input matInput formControlName="nicIndustryCode" placeholder="e.g. 62013" maxlength="10" /></mat-form-field>
</div>
<div class="ec-sub-title">Government Scheme Benefits Availed</div>
<div class="ec-check-grid">
<mat-checkbox formControlName="taxExemption80iac">80IAC Tax Exemption</mat-checkbox>
<mat-checkbox formControlName="sidbiFund">SIDBI Startup Fund</mat-checkbox>
<mat-checkbox formControlName="selfCertification">Self-Certification (Labour / Env.)</mat-checkbox>
<mat-checkbox formControlName="fastTrackPatent">Fast-track Patent / IPR</mat-checkbox>
<mat-checkbox formControlName="govtTenderExemption">Government Tender Exemption</mat-checkbox>
<mat-checkbox formControlName="creditGuaranteeCgss">Credit Guarantee Scheme (CGSS)</mat-checkbox>
</div>
<div class="ec-sub-title">PSL Linkage</div>
<div class="ec-grid-2">
<mat-form-field appearance="outline"><mat-label>Loan Limit for PSL Eligibility (Cr)</mat-label>
<input matInput type="number" formControlName="pslLoanLimit" placeholder="Max 50 Cr" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>PSL Sub-category</mat-label>
<mat-select formControlName="pslSubCategory">
<mat-option value="Others (Startup India)">Others (Startup India)</mat-option>
<mat-option value="MSME - Micro">MSME — Micro</mat-option>
<mat-option value="MSME - Small">MSME — Small</mat-option>
</mat-select></mat-form-field>
</div>
<div class="ec-reg-note">
<strong>RBI Circular RBI/2023-24/62:</strong> DPIIT-recognized startups eligible for PSL classification up to ₹50 Cr. Recognition certificate to be retained in document management system.
</div>
<div class="ec-card-actions">
<button mat-raised-button color="primary" [disabled]="!clientId || saving['startup']" (click)="saveCategory('startup')">
@if (saving['startup']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
@else if (saved['startup']) { <mat-icon>check</mat-icon> Saved }
@else { Save Startup India }
</button>
</div>
</div>
}
</div>
<!-- ═══ CATEGORY 2: MUDRA BORROWER ═══ -->
<div class="ec-card" [class.ec-card--open]="enabled['mudra']">
<div class="ec-card-head" (click)="toggleExpand('mudra')">
<div class="ec-card-head-left">
<span class="ec-tag" style="background:#FEF3DC;color:#8A5A00;">PMMY</span>
<div>
<div class="ec-card-title">Mudra Borrower</div>
<div class="ec-card-desc">Pradhan Mantri Mudra Yojana — SIDBI / Mudra scheme borrower</div>
</div>
</div>
<div class="ec-card-toggle">
<mat-slide-toggle [checked]="!!enabled['mudra']" (change)="toggleCategory('mudra')" (click)="$event.stopPropagation()"></mat-slide-toggle>
<mat-icon class="ec-chevron" [class.open]="expanded['mudra']">expand_more</mat-icon>
</div>
</div>
@if (expanded['mudra']) {
<div class="ec-card-body" [formGroup]="mudraForm">
<div class="ec-sub-title">Loan Classification</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>Mudra Category</mat-label>
<mat-select formControlName="mudraCategory">
<mat-option value="SHISHU">Shishu (up to ₹50,000)</mat-option>
<mat-option value="KISHORE">Kishore (₹50,001 – ₹5,00,000)</mat-option>
<mat-option value="TARUN">Tarun (₹5,00,001 – ₹10,00,000)</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Mudra Loan Account No.</mat-label>
<input matInput formControlName="mudraLoanAccountNo" placeholder="Lender's loan account number" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Lending Institution Name</mat-label>
<input matInput formControlName="lendingInstitutionName" placeholder="Bank / NBFC / MFI name" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Sanctioned Amount (₹)</mat-label>
<input matInput type="number" formControlName="sanctionedAmount" placeholder="0.00" /></mat-form-field>
<mat-form-field appearance="outline" (click)="mudraDispPicker.open()"><mat-label>Disbursement Date</mat-label>
<input matInput [matDatepicker]="mudraDispPicker" formControlName="disbursementDate" />
<mat-datepicker-toggle matSuffix [for]="mudraDispPicker"></mat-datepicker-toggle><mat-datepicker #mudraDispPicker></mat-datepicker></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Loan Purpose</mat-label>
<mat-select formControlName="loanPurpose">
<mat-option value="Manufacturing">Manufacturing</mat-option>
<mat-option value="Trading">Trading</mat-option>
<mat-option value="Services">Services</mat-option>
<mat-option value="Agriculture Allied Activity">Agriculture Allied Activity</mat-option>
<mat-option value="Transport">Transport</mat-option>
<mat-option value="Working Capital">Working Capital</mat-option>
</mat-select></mat-form-field>
</div>
<div class="ec-sub-title">Scheme Details</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>MUDRA Card Number</mat-label>
<input matInput formControlName="mudraCardNumber" placeholder="16-digit card number" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Current Outstanding (₹)</mat-label>
<input matInput type="number" formControlName="currentOutstanding" placeholder="0.00" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Repayment Status</mat-label>
<mat-select formControlName="repaymentStatus">
<mat-option value="REGULAR">Regular</mat-option>
<mat-option value="OVERDUE_1_30">Overdue (1–30 days)</mat-option>
<mat-option value="OVERDUE_31_90">Overdue (31–90 days)</mat-option>
<mat-option value="NPA">NPA</mat-option>
</mat-select></mat-form-field>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">NPA Flag</span><span class="ec-flag-sub">Mark if account classified as Non-Performing Asset</span></div>
<mat-slide-toggle formControlName="npaFlag"></mat-slide-toggle>
</div>
<div class="ec-reg-note">
<strong>PSL Tagging:</strong> Mudra loans must be tagged at both entity and account level for FIDD PSL returns. NPA flag mandatory for RBI quarterly reporting.
</div>
<div class="ec-card-actions">
<button mat-raised-button color="primary" [disabled]="!clientId || saving['mudra']" (click)="saveCategory('mudra')">
@if (saving['mudra']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
@else if (saved['mudra']) { <mat-icon>check</mat-icon> Saved }
@else { Save Mudra Borrower }
</button>
</div>
</div>
}
</div>
<!-- ═══ CATEGORY 3: EXPORT ORIENTED UNIT ═══ -->
<div class="ec-card" [class.ec-card--open]="enabled['eou']">
<div class="ec-card-head" (click)="toggleExpand('eou')">
<div class="ec-card-head-left">
<span class="ec-tag" style="background:#E8F5EE;color:#1B7A4A;">EOU</span>
<div>
<div class="ec-card-title">Export Oriented Unit</div>
<div class="ec-card-desc">Customs-registered EOU under DGFT / Ministry of Commerce</div>
</div>
</div>
<div class="ec-card-toggle">
<mat-slide-toggle [checked]="!!enabled['eou']" (change)="toggleCategory('eou')" (click)="$event.stopPropagation()"></mat-slide-toggle>
<mat-icon class="ec-chevron" [class.open]="expanded['eou']">expand_more</mat-icon>
</div>
</div>
@if (expanded['eou']) {
<div class="ec-card-body" [formGroup]="eouForm">
<div class="ec-sub-title">EOU Registration</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>EOU Registration Number</mat-label>
<input matInput formControlName="eouRegistrationNumber" placeholder="EOU/MUM/2023/0001" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Registered DC Zone</mat-label>
<mat-select formControlName="registeredDcZone">
<mat-option value="Mumbai (SEEPZ)">Mumbai (SEEPZ)</mat-option>
<mat-option value="Delhi (NSEZ)">Delhi (NSEZ)</mat-option>
<mat-option value="Chennai (MEPZ)">Chennai (MEPZ)</mat-option>
<mat-option value="Bangalore (BEPZ)">Bangalore (BEPZ)</mat-option>
<mat-option value="Kolkata (FALTA)">Kolkata (FALTA)</mat-option>
<mat-option value="Hyderabad (VSEZ)">Hyderabad (VSEZ)</mat-option>
<mat-option value="Cochin (CSEZ)">Cochin (CSEZ)</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Bond / LUT Number</mat-label>
<input matInput formControlName="bondLutNumber" placeholder="Bond reference number" /></mat-form-field>
<mat-form-field appearance="outline" (click)="eouFromPicker.open()"><mat-label>Validity From</mat-label>
<input matInput [matDatepicker]="eouFromPicker" formControlName="validityFrom" />
<mat-datepicker-toggle matSuffix [for]="eouFromPicker"></mat-datepicker-toggle><mat-datepicker #eouFromPicker></mat-datepicker></mat-form-field>
<mat-form-field appearance="outline" (click)="eouToPicker.open()"><mat-label>Validity To</mat-label>
<input matInput [matDatepicker]="eouToPicker" formControlName="validityTo" />
<mat-datepicker-toggle matSuffix [for]="eouToPicker"></mat-datepicker-toggle><mat-datepicker #eouToPicker></mat-datepicker></mat-form-field>
<mat-form-field appearance="outline"><mat-label>GSTIN under EOU Scheme</mat-label>
<input matInput formControlName="gstinEou" placeholder="15-character GSTIN" maxlength="15" style="text-transform:uppercase" /></mat-form-field>
</div>
<div class="ec-sub-title">Export Obligation &amp; Compliance</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>Annual Export Obligation (₹ Cr)</mat-label>
<input matInput type="number" formControlName="annualExportObligation" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>NFE Target (₹ Cr)</mat-label>
<input matInput type="number" formControlName="nfeTarget" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>SoftEx Filing Status</mat-label>
<mat-select formControlName="softexFilingStatus">
<mat-option value="FILED">Current</mat-option><mat-option value="PENDING">Pending</mat-option><mat-option value="NA">Not Applicable</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>BRC Submission Status</mat-label>
<mat-select formControlName="brcSubmissionStatus">
<mat-option value="SUBMITTED">Current</mat-option><mat-option value="PENDING">Pending</mat-option><mat-option value="NA">Not Applicable</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>AD Category Bank Code</mat-label>
<input matInput formControlName="adCategoryBankCode" placeholder="AD Bank IFSC / Code" /></mat-form-field>
<mat-form-field appearance="outline" (click)="femaPicker.open()"><mat-label>FEMA Declaration Date</mat-label>
<input matInput [matDatepicker]="femaPicker" formControlName="femaDeclarationDate" />
<mat-datepicker-toggle matSuffix [for]="femaPicker"></mat-datepicker-toggle><mat-datepicker #femaPicker></mat-datepicker></mat-form-field>
</div>
<div class="ec-reg-note">
<strong>FEMA / RBI:</strong> EOU accounts must be tagged for FEMA compliance on forex receipts. AD Category-I bank declaration mandatory. EDPMS reporting required for all export proceeds.
</div>
<div class="ec-card-actions">
<button mat-raised-button color="primary" [disabled]="!clientId || saving['eou']" (click)="saveCategory('eou')">
@if (saving['eou']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
@else if (saved['eou']) { <mat-icon>check</mat-icon> Saved }
@else { Save EOU }
</button>
</div>
</div>
}
</div>
<!-- ═══ CATEGORY 4: IMPORT EXPORT ═══ -->
<div class="ec-card" [class.ec-card--open]="enabled['importexport']">
<div class="ec-card-head" (click)="toggleExpand('importexport')">
<div class="ec-card-head-left">
<span class="ec-tag" style="background:#F0EBF8;color:#5B3A8A;">DGFT / IEC</span>
<div>
<div class="ec-card-title">Import Export Business</div>
<div class="ec-card-desc">Entity with valid DGFT Importer Exporter Code</div>
</div>
</div>
<div class="ec-card-toggle">
<mat-slide-toggle [checked]="!!enabled['importexport']" (change)="toggleCategory('importexport')" (click)="$event.stopPropagation()"></mat-slide-toggle>
<mat-icon class="ec-chevron" [class.open]="expanded['importexport']">expand_more</mat-icon>
</div>
</div>
@if (expanded['importexport']) {
<div class="ec-card-body" [formGroup]="importexportForm">
<div class="ec-sub-title">IEC Registration</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>IEC Code (10-digit)</mat-label>
<input matInput formControlName="iecCode" placeholder="AABCA1234F" maxlength="10" style="text-transform:uppercase" /></mat-form-field>
<mat-form-field appearance="outline" (click)="iecDatePicker.open()"><mat-label>IEC Issue Date</mat-label>
<input matInput [matDatepicker]="iecDatePicker" formControlName="iecIssueDate" />
<mat-datepicker-toggle matSuffix [for]="iecDatePicker"></mat-datepicker-toggle><mat-datepicker #iecDatePicker></mat-datepicker></mat-form-field>
<mat-form-field appearance="outline"><mat-label>IEC Status</mat-label>
<mat-select formControlName="iecStatus">
<mat-option value="ACTIVE">Active</mat-option><mat-option value="SUSPENDED">Suspended</mat-option>
<mat-option value="CANCELLED">Cancelled</mat-option><mat-option value="DEACTIVATED">Deactivated</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Trade Type</mat-label>
<mat-select formControlName="tradeType">
<mat-option value="EXPORT">Export Only</mat-option><mat-option value="IMPORT">Import Only</mat-option>
<mat-option value="BOTH">Both Import &amp; Export</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Primary HS Code (Commodity)</mat-label>
<input matInput formControlName="primaryHsCode" placeholder="e.g. 5208 — Cotton fabrics" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Key Trading Countries</mat-label>
<input matInput formControlName="keyTradingCountries" placeholder="e.g. UAE, USA, Singapore" /></mat-form-field>
</div>
<div class="ec-sub-title">Banking &amp; Compliance</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>AD Category Bank Code</mat-label>
<input matInput formControlName="adCategoryBankCode" placeholder="AD Bank IFSC / RBI code" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>ECGC Policy Number</mat-label>
<input matInput formControlName="ecgcPolicyNumber" placeholder="ECGC policy reference" /></mat-form-field>
<mat-form-field appearance="outline" (click)="ecgcExpPicker.open()"><mat-label>ECGC Policy Expiry</mat-label>
<input matInput [matDatepicker]="ecgcExpPicker" formControlName="ecgcPolicyExpiry" />
<mat-datepicker-toggle matSuffix [for]="ecgcExpPicker"></mat-datepicker-toggle><mat-datepicker #ecgcExpPicker></mat-datepicker></mat-form-field>
</div>
<div class="ec-flags">
<div class="ec-flag-row">
<div><span class="ec-flag-label">EEFC Account Required</span><span class="ec-flag-sub">Exchange Earners Foreign Currency account</span></div>
<mat-slide-toggle formControlName="eefcAccountRequired"></mat-slide-toggle>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">PCFC Facility Required</span><span class="ec-flag-sub">Pre-shipment Credit in Foreign Currency</span></div>
<mat-slide-toggle formControlName="pcfcFacilityRequired"></mat-slide-toggle>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">FEMA Declaration Obtained</span><span class="ec-flag-sub">Form A2 / LRS declaration on file</span></div>
<mat-slide-toggle formControlName="femaDeclarationObtained"></mat-slide-toggle>
</div>
</div>
<div class="ec-reg-note">
<strong>RBI / FEMA:</strong> IEC mandatory before any import/export transaction. AD bank must report all export proceeds via EDPMS and import payments via IDPMS. ECGC cover recommended for high-risk countries.
</div>
<div class="ec-card-actions">
<button mat-raised-button color="primary" [disabled]="!clientId || saving['importexport']" (click)="saveCategory('importexport')">
@if (saving['importexport']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
@else if (saved['importexport']) { <mat-icon>check</mat-icon> Saved }
@else { Save Import/Export }
</button>
</div>
</div>
}
</div>
<!-- ═══ CATEGORY 5: PRIORITY SECTOR ═══ -->
<div class="ec-card" [class.ec-card--open]="enabled['psl']">
<div class="ec-card-head" (click)="toggleExpand('psl')">
<div class="ec-card-head-left">
<span class="ec-tag" style="background:#FCEAEA;color:#7B1F1F;">RBI PSL</span>
<div>
<div class="ec-card-title">Priority Sector Borrower</div>
<div class="ec-card-desc">Eligible under RBI Priority Sector Lending guidelines</div>
</div>
</div>
<div class="ec-card-toggle">
<mat-slide-toggle [checked]="!!enabled['psl']" (change)="toggleCategory('psl')" (click)="$event.stopPropagation()"></mat-slide-toggle>
<mat-icon class="ec-chevron" [class.open]="expanded['psl']">expand_more</mat-icon>
</div>
</div>
@if (expanded['psl']) {
<div class="ec-card-body" [formGroup]="pslForm">
<div class="ec-sub-title">PSL Classification</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>PSL Category</mat-label>
<mat-select formControlName="pslCategory">
<mat-option value="AGRICULTURE">Agriculture</mat-option>
<mat-option value="MICRO_ENTERPRISE">MSME</mat-option>
<mat-option value="HOUSING">Housing</mat-option>
<mat-option value="EDUCATION">Education</mat-option>
<mat-option value="SOCIAL_INFRA">Social Infrastructure</mat-option>
<mat-option value="RENEWABLE">Renewable Energy</mat-option>
<mat-option value="EXPORT_CREDIT">Export Credit</mat-option>
<mat-option value="WEAKER_SECTION">Weaker Section</mat-option>
<mat-option value="OTHERS">Others</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>PSL Sub-category</mat-label>
<mat-select formControlName="pslSubCategory">
<mat-option value="Direct Agriculture - Crop Loan">Direct Agriculture — Crop Loan</mat-option>
<mat-option value="Direct Agriculture - Investment">Direct Agriculture — Investment</mat-option>
<mat-option value="Allied Agriculture">Allied Agriculture</mat-option>
<mat-option value="Micro Enterprise">Micro Enterprise</mat-option>
<mat-option value="Small Enterprise">Small Enterprise</mat-option>
<mat-option value="Medium Enterprise">Medium Enterprise</mat-option>
<mat-option value="Housing">Housing (up to ₹35L)</mat-option>
<mat-option value="Education Loan">Education Loan</mat-option>
<mat-option value="Weaker Section - SC/ST">Weaker Section — SC/ST</mat-option>
<mat-option value="Weaker Section - Minorities">Weaker Section — Minorities</mat-option>
<mat-option value="Export Credit">Export Credit</mat-option>
<mat-option value="Others - Startup India">Others — Startup India</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>PSL Loan Purpose Code</mat-label>
<input matInput formControlName="pslLoanPurposeCode" placeholder="RBI purpose code e.g. 111" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>PSL Eligibility Basis</mat-label>
<input matInput formControlName="pslEligibilityBasis" placeholder="e.g. Udyam Reg / Kisan Credit Card" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>FIDD Reporting Code</mat-label>
<input matInput formControlName="fiddReportingCode" placeholder="FIDD sector code" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>PSL Sanctioned Limit (₹)</mat-label>
<input matInput type="number" formControlName="pslSanctionedLimit" /></mat-form-field>
</div>
<!-- MSME Sub-section -->
@if (pslForm.get('pslCategory')?.value === 'MICRO_ENTERPRISE') {
<div class="ec-sub-title">MSME Sub-classification</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>MSME Classification</mat-label>
<mat-select formControlName="msmeClassification">
<mat-option value="MICRO">Micro (T/O ≤ ₹5 Cr, Inv ≤ ₹1 Cr)</mat-option>
<mat-option value="SMALL">Small (T/O ≤ ₹50 Cr, Inv ≤ ₹10 Cr)</mat-option>
<mat-option value="MEDIUM">Medium (T/O ≤ ₹250 Cr, Inv ≤ ₹50 Cr)</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Udyam Registration Number</mat-label>
<input matInput formControlName="udyamRegistrationNumber" placeholder="UDYAM-MP-00-0000000" style="text-transform:uppercase" /></mat-form-field>
<mat-form-field appearance="outline" (click)="udyamDatePicker.open()"><mat-label>Udyam Registration Date</mat-label>
<input matInput [matDatepicker]="udyamDatePicker" formControlName="udyamRegistrationDate" />
<mat-datepicker-toggle matSuffix [for]="udyamDatePicker"></mat-datepicker-toggle><mat-datepicker #udyamDatePicker></mat-datepicker></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Annual Turnover (₹ Cr)</mat-label>
<input matInput type="number" formControlName="annualTurnover" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Investment — Plant &amp; Machinery (₹ Cr)</mat-label>
<input matInput type="number" formControlName="investmentPlantMachinery" /></mat-form-field>
</div>
}
<div class="ec-sub-title">Special Category Flags</div>
<div class="ec-flags">
<div class="ec-flag-row">
<div><span class="ec-flag-label">Weaker Section Flag</span><span class="ec-flag-sub">SHG, JLG, SC/ST, OBC, Minorities, PWD borrowers</span></div>
<mat-slide-toggle formControlName="weakerSectionFlag"></mat-slide-toggle>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">SC / ST Borrower Flag</span><span class="ec-flag-sub">Scheduled Caste or Scheduled Tribe classification</span></div>
<mat-slide-toggle formControlName="scStBorrowerFlag"></mat-slide-toggle>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">Women Entrepreneur Flag</span><span class="ec-flag-sub">Majority-owned / managed by women</span></div>
<mat-slide-toggle formControlName="womenEntrepreneurFlag"></mat-slide-toggle>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">Minority Community Flag</span><span class="ec-flag-sub">As defined by Ministry of Minority Affairs</span></div>
<mat-slide-toggle formControlName="minorityCommunityFlag"></mat-slide-toggle>
</div>
</div>
<div class="ec-reg-note">
<strong>RBI FIDD Circular:</strong> PSL tagging mandatory at entity AND loan account level. MSME classification must be verified from Udyam portal — self-declaration not acceptable. Weaker section loans reported separately in FIDD quarterly returns.
</div>
<div class="ec-card-actions">
<button mat-raised-button color="primary" [disabled]="!clientId || saving['psl']" (click)="saveCategory('psl')">
@if (saving['psl']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
@else if (saved['psl']) { <mat-icon>check</mat-icon> Saved }
@else { Save Priority Sector }
</button>
</div>
</div>
}
</div>
<!-- ═══ CATEGORY 6: NON-PRIORITY SECTOR ═══ -->
<div class="ec-card" [class.ec-card--open]="enabled['nps']">
<div class="ec-card-head" (click)="toggleExpand('nps')">
<div class="ec-card-head-left">
<span class="ec-tag" style="background:#F1EFE8;color:#444441;">General</span>
<div>
<div class="ec-card-title">Non-Priority Sector</div>
<div class="ec-card-desc">Standard corporate / retail borrower — no special priority sector classification</div>
</div>
</div>
<div class="ec-card-toggle">
<mat-slide-toggle [checked]="!!enabled['nps']" (change)="toggleCategory('nps')" (click)="$event.stopPropagation()"></mat-slide-toggle>
<mat-icon class="ec-chevron" [class.open]="expanded['nps']">expand_more</mat-icon>
</div>
</div>
@if (expanded['nps']) {
<div class="ec-card-body" [formGroup]="npsForm">
<div class="ec-sub-title">Sector Classification</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>Industry Sector</mat-label>
<mat-select formControlName="industrySector">
<mat-option value="Large Corporate">Large Corporate</mat-option>
<mat-option value="Mid-Market Corporate">Mid-Market Corporate</mat-option>
<mat-option value="HNI">High Net Worth Individual (HNI)</mat-option>
<mat-option value="Retail - Salaried">Retail — Salaried</mat-option>
<mat-option value="Retail - Self Employed">Retail — Self Employed</mat-option>
<mat-option value="Government / PSU">Government / PSU</mat-option>
<mat-option value="Trust / NGO / Society">Trust / NGO / Society</mat-option>
<mat-option value="Real Estate">Real Estate</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>NIC Code (5-digit)</mat-label>
<input matInput formControlName="nicCode" placeholder="e.g. 13111" maxlength="5" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Industry Description</mat-label>
<input matInput formControlName="industryDescription" placeholder="Brief sector description" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Annual Revenue Band</mat-label>
<mat-select formControlName="annualRevenueBand">
<mat-option value="BELOW_1CR">Below ₹1 Cr</mat-option>
<mat-option value="1CR_TO_10CR">₹1 Cr – ₹10 Cr</mat-option>
<mat-option value="10CR_TO_100CR">₹10 Cr – ₹100 Cr</mat-option>
<mat-option value="100CR_TO_500CR">₹100 Cr – ₹500 Cr</mat-option>
<mat-option value="ABOVE_500CR">Above ₹500 Cr</mat-option>
</mat-select></mat-form-field>
</div>
<div class="ec-sub-title">Risk Classification</div>
<div class="ec-grid-3">
<mat-form-field appearance="outline"><mat-label>Risk Category</mat-label>
<mat-select formControlName="riskCategory">
<mat-option value="LOW">Low Risk</mat-option>
<mat-option value="MEDIUM">Medium Risk</mat-option>
<mat-option value="HIGH">High Risk</mat-option>
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Customer Risk Score</mat-label>
<input matInput type="number" formControlName="customerRiskScore" placeholder="0–100" min="0" max="100" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>AML Risk Classification</mat-label>
<mat-select formControlName="amlRiskClassification">
<mat-option value="SDD">Standard Due Diligence (SDD)</mat-option>
<mat-option value="CDD">Customer Due Diligence (CDD)</mat-option>
<mat-option value="EDD">Enhanced Due Diligence (EDD)</mat-option>
</mat-select></mat-form-field>
</div>
<div class="ec-flags">
<div class="ec-flag-row">
<div><span class="ec-flag-label">PEP Flag — Politically Exposed Person</span><span class="ec-flag-sub">Requires mandatory EDD as per RBI KYC Master Circular</span></div>
<mat-slide-toggle formControlName="pepFlag"></mat-slide-toggle>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">AML Watchlist Match</span><span class="ec-flag-sub">UN Sanctions / OFAC / EU / MHA match detected</span></div>
<mat-slide-toggle formControlName="amlWatchlistMatch"></mat-slide-toggle>
</div>
<div class="ec-flag-row">
<div><span class="ec-flag-label">Adverse Media Flag</span><span class="ec-flag-sub">Negative news screening result — requires review</span></div>
<mat-slide-toggle formControlName="adverseMediaFlag"></mat-slide-toggle>
</div>
</div>
<div class="ec-reg-note">
<strong>RBI KYC Master Circular 2023:</strong> PEP flag triggers mandatory EDD regardless of transaction size. AML watchlist match must be escalated to MLRO within 24 hours. Risk score reviewed annually for High Risk customers.
</div>
<div class="ec-card-actions">
<button mat-raised-button color="primary" [disabled]="!clientId || saving['nps']" (click)="saveCategory('nps')">
@if (saving['nps']) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Saving... }
@else if (saved['nps']) { <mat-icon>check</mat-icon> Saved }
@else { Save Non-Priority Sector }
</button>
</div>
</div>
}
</div>
@if (!clientId) {
<div class="ec-info-banner">
<fa-icon icon="info-circle"></fa-icon>
<span>Saving classifications requires client to be created first. Complete General tab and click Next.</span>
</div>
}
</div>
@@ -0,0 +1,206 @@
/**
* Entity Classification — Scheme & Sector Classification
*/
.ec-page { padding: 0; }
.step-header {
background: #0f172a;
padding: 16px 24px;
border-radius: 10px 10px 0 0;
margin-bottom: 20px;
&__left { display: flex; align-items: center; gap: 12px; }
&__icon { font-size: 18px; color: #60a5fa; }
&__title { font-size: 16px; font-weight: 600; color: #fff; margin: 0; }
&__sub { font-size: 11px; color: rgba(255,255,255,0.5); margin: 2px 0 0; }
}
.ec-chips-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 8px;
}
.ec-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 20px;
min-height: 28px;
}
.ec-no-chip { font-size: 12px; color: #94a3b8; padding: 4px 0; }
.ec-chip {
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
display: flex;
align-items: center;
gap: 5px;
}
.ec-chip-close {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
cursor: pointer;
opacity: 0.6;
&:hover { opacity: 1; }
}
.ec-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 12px;
overflow: hidden;
transition: border-color 0.2s;
&--open { border-color: #93c5fd; }
}
.ec-card-head {
padding: 14px 20px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
transition: background 0.15s;
&:hover { background: #f8fafc; }
}
.ec-card-head-left { display: flex; align-items: center; gap: 12px; }
.ec-tag {
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
white-space: nowrap;
}
.ec-card-title { font-size: 14px; font-weight: 600; color: #1e293b; }
.ec-card-desc { font-size: 12px; color: #64748b; margin-top: 2px; }
.ec-card-toggle { display: flex; align-items: center; gap: 10px; }
.ec-chevron {
transition: transform 0.25s;
color: #94a3b8;
&.open { transform: rotate(180deg); }
}
.ec-card-body {
border-top: 1px solid #f1f5f9;
padding: 20px;
}
.ec-grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px 16px;
margin-bottom: 8px;
mat-form-field { width: 100%; }
}
.ec-grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
margin-bottom: 8px;
mat-form-field { width: 100%; }
}
.ec-sub-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.07em;
color: #94a3b8;
text-transform: uppercase;
margin: 16px 0 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
&:first-child { margin-top: 0; padding-top: 0; border-top: none; }
}
.ec-check-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 8px;
mat-checkbox {
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
transition: background 0.12s, border-color 0.12s;
&:hover { background: #f8fafc; border-color: #93c5fd; }
}
}
.ec-flags {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.ec-flag-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 6px;
}
.ec-flag-label { font-size: 13px; font-weight: 500; color: #1e293b; display: block; }
.ec-flag-sub { font-size: 11px; color: #94a3b8; display: block; margin-top: 1px; }
.ec-reg-note {
margin-top: 16px;
padding: 10px 14px;
background: #f0f9ff;
border-left: 3px solid #0ea5e9;
border-radius: 0 6px 6px 0;
font-size: 12px;
color: #0c4a6e;
line-height: 1.6;
strong { font-weight: 600; }
}
.ec-card-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
button { display: flex; align-items: center; gap: 6px; }
}
.ec-info-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 12px;
color: #92400e;
margin-top: 12px;
}
@media (max-width: 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) {
.ec-grid-3, .ec-grid-2 { grid-template-columns: 1fr; }
.ec-check-grid { grid-template-columns: 1fr; }
.ec-card-head { padding: 12px 14px; }
.ec-card-body { padding: 14px; }
}
@@ -0,0 +1,311 @@
/**
* Copyright since 2025 Mifos Initiative
*/
import { Component, Input, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
export interface CategoryMeta {
id: string;
label: string;
tag: string;
tagColor: string;
tagBg: string;
desc: string;
icon: string;
}
@Component({
selector: 'mifosx-client-entity-kyc-step',
templateUrl: './client-entity-kyc-step.component.html',
styleUrls: ['./client-entity-kyc-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatCheckbox,
MatIcon,
MatTooltip,
MatProgressSpinner,
MatSlideToggle
]
})
export class ClientEntityKycStepComponent implements OnInit, OnChanges {
private fb = inject(UntypedFormBuilder);
private clientService = inject(ClientsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
@Input() clientId: string = '';
@Input() constitutionType: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
today = new Date();
/** Category metadata */
categories: CategoryMeta[] = [
{ id: 'msme', label: 'MSME', tag: 'Udyam / MSME', tagColor: '#0A5C6B', tagBg: '#E6F4F6', desc: 'Micro, Small & Medium Enterprise — Ministry of MSME / Udyam Registration', icon: 'cogs' },
{ id: 'startup', label: 'Startup India Registered', tag: 'DPIIT', tagColor: '#1A5FAB', tagBg: '#EAF2FB', desc: 'DPIIT recognition under Startup India initiative', icon: 'rocket' },
{ id: 'mudra', label: 'Mudra Borrower', tag: 'PMMY', tagColor: '#8A5A00', tagBg: '#FEF3DC', desc: 'Pradhan Mantri Mudra Yojana — SIDBI / Mudra scheme', icon: 'hand-holding-usd' },
{ id: 'eou', label: 'Export Oriented Unit', tag: 'EOU', tagColor: '#1B7A4A', tagBg: '#E8F5EE', desc: 'Customs-registered EOU under DGFT / Ministry of Commerce', icon: 'ship' },
{ id: 'importexport', label: 'Import Export Business', tag: 'DGFT / IEC', tagColor: '#5B3A8A', tagBg: '#F0EBF8', desc: 'Entity with valid DGFT Importer Exporter Code', icon: 'exchange-alt' },
{ id: 'psl', label: 'Priority Sector Borrower', tag: 'RBI PSL', tagColor: '#7B1F1F', tagBg: '#FCEAEA', desc: 'Eligible under RBI Priority Sector Lending guidelines', icon: 'bullseye' },
{ id: 'nps', label: 'Non-Priority Sector', tag: 'General', tagColor: '#444441', tagBg: '#F1EFE8', desc: 'Standard corporate — no special priority sector classification', icon: 'building' }
];
/** Toggle & expand state per category */
enabled: Record<string, boolean> = {};
expanded: Record<string, boolean> = {};
/** Forms per category */
msmeForm!: UntypedFormGroup;
startupForm!: UntypedFormGroup;
mudraForm!: UntypedFormGroup;
eouForm!: UntypedFormGroup;
importexportForm!: UntypedFormGroup;
pslForm!: UntypedFormGroup;
npsForm!: UntypedFormGroup;
/** Saving state */
saving: Record<string, boolean> = {};
saved: Record<string, boolean> = {};
ngOnInit() {
this.initForms();
if (this.clientId) {
this.loadExisting();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['readOnly']) {
const forms = [this.msmeForm, this.startupForm, this.mudraForm, this.eouForm, this.importexportForm, this.pslForm, this.npsForm];
for (const form of forms) {
if (!form) continue;
if (this.readOnly) {
form.disable({ emitEvent: false });
} else {
form.enable({ emitEvent: false });
}
}
}
}
private initForms() {
this.msmeForm = this.fb.group({
udyamRegistrationNumber: [''],
registrationDate: [''],
registrationStatus: [''],
msmeClassification: [''],
enterpriseType: [''],
nicActivityCode: [''],
annualTurnover: [''],
investmentPlantMachinery: [''],
financialYearAssessment: [''],
socialCategoryOwner: [''],
genderPrimaryOwner: [''],
noOfEmployees: [''],
womenLedEnterprise: [false],
divyangPwdOwned: [false]
});
this.startupForm = this.fb.group({
dpiitRecognitionNumber: [''],
recognitionDate: [''],
certificateExpiryDate: [''],
entityStage: [''],
sectorAsDpiit: [''],
nicIndustryCode: [''],
taxExemption80iac: [false],
sidbiFund: [false],
selfCertification: [false],
fastTrackPatent: [false],
govtTenderExemption: [false],
creditGuaranteeCgss: [false],
pslLoanLimit: [''],
pslSubCategory: ['']
});
this.mudraForm = this.fb.group({
mudraCategory: [''],
mudraLoanAccountNo: [''],
lendingInstitutionName: [''],
sanctionedAmount: [''],
disbursementDate: [''],
loanPurpose: [''],
mudraCardNumber: [''],
currentOutstanding: [''],
repaymentStatus: [''],
npaFlag: [false]
});
this.eouForm = this.fb.group({
eouRegistrationNumber: [''],
registeredDcZone: [''],
bondLutNumber: [''],
validityFrom: [''],
validityTo: [''],
gstinEou: [''],
annualExportObligation: [''],
nfeTarget: [''],
softexFilingStatus: [''],
brcSubmissionStatus: [''],
adCategoryBankCode: [''],
femaDeclarationDate: ['']
});
this.importexportForm = this.fb.group({
iecCode: [''],
iecIssueDate: [''],
iecStatus: [''],
tradeType: [''],
primaryHsCode: [''],
keyTradingCountries: [''],
adCategoryBankCode: [''],
ecgcPolicyNumber: [''],
ecgcPolicyExpiry: [''],
eefcAccountRequired: [false],
pcfcFacilityRequired: [false],
femaDeclarationObtained: [false]
});
this.pslForm = this.fb.group({
pslCategory: [''],
pslSubCategory: [''],
pslLoanPurposeCode: [''],
pslEligibilityBasis: [''],
fiddReportingCode: [''],
pslSanctionedLimit: [''],
msmeClassification: [''],
udyamRegistrationNumber: [''],
udyamRegistrationDate: [''],
annualTurnover: [''],
investmentPlantMachinery: [''],
weakerSectionFlag: [false],
scStBorrowerFlag: [false],
womenEntrepreneurFlag: [false],
minorityCommunityFlag: [false]
});
this.npsForm = this.fb.group({
industrySector: [''],
nicCode: [''],
industryDescription: [''],
annualRevenueBand: [''],
riskCategory: [''],
customerRiskScore: [''],
amlRiskClassification: [''],
pepFlag: [false],
amlWatchlistMatch: [false],
adverseMediaFlag: [false]
});
}
getForm(categoryId: string): UntypedFormGroup {
switch (categoryId) {
case 'msme': return this.msmeForm;
case 'startup': return this.startupForm;
case 'mudra': return this.mudraForm;
case 'eou': return this.eouForm;
case 'importexport': return this.importexportForm;
case 'psl': return this.pslForm;
case 'nps': return this.npsForm;
default: return this.fb.group({});
}
}
toggleCategory(id: string) {
this.enabled[id] = !this.enabled[id];
if (this.enabled[id]) {
this.expanded[id] = true;
}
}
toggleExpand(id: string) {
this.expanded[id] = !this.expanded[id];
}
get enabledCategories(): CategoryMeta[] {
return this.categories.filter(c => this.enabled[c.id]);
}
get enabledCount(): number {
return this.enabledCategories.length;
}
removeCategory(id: string) {
this.enabled[id] = false;
this.expanded[id] = false;
}
/** Save a single category */
saveCategory(categoryId: string) {
if (!this.clientId) return;
this.saving[categoryId] = true;
const form = this.getForm(categoryId);
const payload: any = { ...form.value, enabled: true, locale: 'en', dateFormat: this.settingsService.dateFormat };
// Format date fields
for (const key of Object.keys(payload)) {
if (payload[key] instanceof Date) {
payload[key] = this.dateUtils.formatDate(payload[key], this.settingsService.dateFormat);
}
}
this.clientService.saveEntityClassification(this.clientId, categoryId, payload).subscribe({
next: () => {
this.saving[categoryId] = false;
this.saved[categoryId] = true;
setTimeout(() => this.saved[categoryId] = false, 3000);
},
error: () => {
this.saving[categoryId] = false;
}
});
}
/** Load existing classifications */
private loadExisting() {
this.clientService.getEntityClassifications(this.clientId).subscribe({
next: (data: any) => {
if (!data) return;
for (const cat of this.categories) {
const catData = data[cat.id];
if (catData && catData.enabled) {
this.enabled[cat.id] = true;
this.getForm(cat.id).patchValue(catData);
}
}
}
});
}
/** Get all classification data for parent */
get classificationData(): any {
const result: any = {};
for (const cat of this.categories) {
if (this.enabled[cat.id]) {
result[cat.id] = { ...this.getForm(cat.id).value, enabled: true };
}
}
return result;
}
get hasClassificationData(): boolean {
return this.enabledCount > 0;
}
// Keep old getter for backward compatibility
get entityKycData(): any { return this.classificationData; }
get hasEntityKycData(): boolean { return this.hasClassificationData; }
}
@@ -1,116 +1,358 @@
<!--
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/.
-->
<h1 mat-dialog-title>{{ data.context }} {{ 'labels.heading.Family Member' | translate }}</h1>
<div class="fm-dialog">
<div class="fm-dialog__header">
<mat-icon class="fm-dialog__icon">people</mat-icon>
<span>{{ data.context }} Family Member</span>
</div>
<form [formGroup]="familyMemberForm">
<h3>{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}</h3>
<mat-dialog-content>
<form [formGroup]="familyMemberForm">
<div class="layout-row-wrap responsive-column align-start-center">
<!-- Nomination Type -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Nomination Type' | translate }}</mat-label>
<mat-select formControlName="nominationType" required>
<mat-option value="DA1">{{ 'labels.inputs.DA1 - Add Nominee' | translate }}</mat-option>
<mat-option value="DA5">{{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<!-- ═══ VIEW MODE ═══ -->
@if (isViewMode) {
<!-- Linked client card -->
@if (entryMode === 'linked' && selectedClient) {
<div class="fm-linked-card">
<div class="fm-linked-card__left">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" class="fm-linked-card__photo zoomable" alt="Profile"
(click)="zoomImage(linkedProfileImage, 'Client Photograph')" />
} @else {
<mat-icon class="fm-linked-card__icon">person</mat-icon>
}
</div>
<div class="fm-linked-card__info">
<span class="fm-linked-card__name">{{ selectedClient.displayName }}</span>
<span class="fm-linked-card__meta">Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span>
</div>
@if (linkedSignatureImage) {
<div class="fm-linked-card__sig">
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')" />
<span>Signature</span>
</div>
}
</div>
}
<!-- Nominee Name -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Nominee Name' | translate }}</mat-label>
<input formControlName="firstName" required matInput />
@if (familyMemberForm.controls.firstName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (fetchingLinkedData) {
<div class="fm-fetching-hint">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
<span>Fetching client details...</span>
</div>
}
<!-- Relationship -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship' | translate }}</mat-label>
<mat-select formControlName="relationshipId" required>
@for (relation of data.options.relationshipIdOptions; track relation) {
<mat-option [value]="relation.id">
{{ relation.name | translateKey: 'catalogs' }}
</mat-option>
<!-- All details in read-only grid -->
<div class="fm-readonly-grid">
<div class="fm-readonly-item">
<span class="fm-readonly-label">Full Name</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('firstName')?.value || '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Relationship</span>
<span class="fm-readonly-value">{{ relationshipName }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Date of Birth</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Nationality</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('nationality')?.value || '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Aadhaar</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('aadhaarNumber')?.value || '—' }}
@if (linkedKycStatus?.aadhaarVerified) {
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (familyMemberForm.get('aadhaarNumber')?.value) {
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
}
</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">PAN</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('panNumber')?.value || '—' }}
@if (linkedKycStatus?.panVerified) {
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (familyMemberForm.get('panNumber')?.value) {
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
}
</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Status</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('isAlive')?.value !== false ? 'Alive' : 'Deceased' }}
@if (familyMemberForm.get('isDependent')?.value) { · Dependent }
</span>
</div>
@if (isSpouse && familyMemberForm.get('marriageDate')?.value) {
<div class="fm-readonly-item">
<span class="fm-readonly-label">Marriage Date</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('marriageDate')?.value | date:'dd MMM yyyy' }}</span>
</div>
}
@if (isSpouse && familyMemberForm.get('marriageAuthority')?.value) {
<div class="fm-readonly-item">
<span class="fm-readonly-label">Issuing Authority</span>
<span class="fm-readonly-value">{{ getMarriageAuthorityLabel(familyMemberForm.get('marriageAuthority')?.value) }}</span>
</div>
}
</div>
@if (familyMemberForm.get('remarks')?.value) {
<div class="fm-readonly-remarks">
<span class="fm-readonly-label">Remarks</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
</div>
}
</mat-select>
@if (familyMemberForm.controls.relationshipId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Relationship' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Date of Birth -->
<mat-form-field class="flex-50-minus-25px" (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Date Of Birth' | translate }}</mat-label>
<input
formControlName="dateOfBirth"
matInput
[max]="maxDate"
[matDatepicker]="dueDatePicker"
placeholder="{{ 'labels.text.Optional' | translate }}"
/>
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
</mat-form-field>
<!-- ═══ ADD / EDIT MODE ═══ -->
@if (!isViewMode) {
<!-- Mode Toggle -->
<div class="fm-mode-toggle">
<button type="button" class="fm-mode-btn" [class.active]="entryMode === 'manual'" (click)="setMode('manual')">
<mat-icon>edit</mat-icon> Enter Manually
</button>
<button type="button" class="fm-mode-btn" [class.active]="entryMode === 'linked'" (click)="setMode('linked')">
<mat-icon>search</mat-icon> Link Existing Client
</button>
</div>
<!-- Percentage Share -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Percentage Share' | translate }} (%)</mat-label>
<input type="number" formControlName="percentageShare" matInput min="0" max="100" />
</mat-form-field>
<!-- Linked Client Search -->
@if (entryMode === 'linked') {
@if (!selectedClient) {
<div class="fm-search-wrapper">
<mat-form-field appearance="outline" class="fm-search-field">
<mat-label>Search by Name or Client ID</mat-label>
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" autocomplete="off" />
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
@if (searching) {
<div class="fm-search-results">
<div class="fm-search-loading"><mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner> Searching...</div>
</div>
}
@if (!searching && searchResults.length > 0) {
<div class="fm-search-results">
@for (r of searchResults; track r.id) {
<div class="fm-search-item" (click)="selectClient(r)">
<mat-icon class="fm-search-item__icon">person</mat-icon>
<div class="fm-search-item__info">
<span class="fm-search-item__name">{{ r.displayName }}</span>
<span class="fm-search-item__meta">ID: {{ r.id }} · {{ r.officeName }}@if (r.dateOfBirth) { · DOB: {{ r.dateOfBirth | dateFormat }} }@if (r.fatherSpouseName) { · {{ r.fatherSpouseName }} }@if (r.mobileNo) { · {{ r.mobileNo }} }</span>
</div>
</div>
}
</div>
}
@if (!searching && searchText.length >= 2 && searchResults.length === 0) {
<div class="search-no-results-hint">No clients found</div>
}
</div>
}
@if (selectedClient) {
<!-- Linked Client Card with Profile & Signature -->
<div class="fm-linked-card">
<div class="fm-linked-card__left">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" class="fm-linked-card__photo zoomable" alt="Profile"
(click)="zoomImage(linkedProfileImage, 'Client Photograph')" />
} @else {
<mat-icon class="fm-linked-card__icon">person</mat-icon>
}
</div>
<div class="fm-linked-card__info">
<span class="fm-linked-card__name">{{ selectedClient.displayName }}</span>
<span class="fm-linked-card__meta">Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span>
</div>
@if (linkedSignatureImage) {
<div class="fm-linked-card__sig">
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')" />
<span>Signature</span>
</div>
}
<button mat-icon-button type="button" (click)="removeLinkedClient()" class="fm-linked-card__remove"><mat-icon>close</mat-icon></button>
</div>
<!-- Nominee is Minor -->
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-50-minus-25px">
{{ 'labels.inputs.Nominee is Minor' | translate }}?
</mat-checkbox>
</div>
@if (fetchingLinkedData) {
<div class="fm-fetching-hint">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
<span>Fetching client details...</span>
</div>
}
<!-- Guardian Details (shown when minor) -->
@if (familyMemberForm.get('isDependent').value) {
<h3>{{ 'labels.heading.Guardian Details (If Minor)' | translate }}</h3>
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Guardian Name' | translate }}</mat-label>
<input formControlName="guardianName" matInput />
</mat-form-field>
<!-- Read-only fetched details -->
@if (!fetchingLinkedData) {
<div class="fm-readonly-grid">
<div class="fm-readonly-item">
<span class="fm-readonly-label">Full Name</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('firstName')?.value || '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Date of Birth</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Aadhaar</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('aadhaarNumber')?.value || '—' }}
@if (linkedKycStatus?.aadhaarVerified) {
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (familyMemberForm.get('aadhaarNumber')?.value) {
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
}
</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">PAN</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('panNumber')?.value || '—' }}
@if (linkedKycStatus?.panVerified) {
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (familyMemberForm.get('panNumber')?.value) {
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
}
</span>
</div>
</div>
}
}
}
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship with Minor' | translate }}</mat-label>
<input formControlName="guardianRelationship" matInput />
</mat-form-field>
</div>
}
<!-- Core Fields -->
<div class="fm-grid">
@if (entryMode === 'manual') {
<mat-form-field appearance="outline">
<mat-label>Full Name</mat-label>
<input matInput required formControlName="firstName" placeholder="Full name of family member" />
</mat-form-field>
}
<!-- Remarks -->
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<input formControlName="remarks" matInput />
</mat-form-field>
</div>
<mat-form-field appearance="outline">
<mat-label>Relationship</mat-label>
<mat-select required formControlName="relationshipId">
@for (rel of data.options.relationshipIdOptions; track rel) {
<mat-option [value]="rel.id">{{ rel.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-dialog-actions align="center">
<button type="button" mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button
type="button"
mat-raised-button
color="primary"
[disabled]="!familyMemberForm.valid"
[mat-dialog-close]="{ member: familyMember }"
>
{{ 'labels.buttons.Confirm' | translate }}
</button>
@if (entryMode === 'manual') {
<mat-form-field appearance="outline" (click)="dobPicker.open()">
<mat-label>Date of Birth</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="dobPicker" formControlName="dateOfBirth" />
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>Nationality</mat-label>
<mat-select formControlName="nationality">
<mat-option value="INDIAN">Indian</mat-option>
<mat-option value="NRI">NRI</mat-option>
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
<mat-option value="OTHER">Other</mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- Marriage Details (Spouse only) -->
@if (isSpouse) {
<div class="fm-section-label">Marriage Details</div>
<div class="fm-grid">
<mat-form-field appearance="outline" (click)="marriagePicker.open()">
<mat-label>Marriage Date</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="marriagePicker" formControlName="marriageDate" />
<mat-datepicker-toggle matSuffix [for]="marriagePicker"></mat-datepicker-toggle>
<mat-datepicker #marriagePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Issuing Authority</mat-label>
<mat-select formControlName="marriageAuthority">
@for (auth of marriageAuthorityOptions; track auth.value) {
<mat-option [value]="auth.value">{{ auth.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Marriage Certificate Upload -->
<div class="fm-file-upload">
@if (!marriageCertFile) {
<button type="button" mat-stroked-button class="fm-file-btn" (click)="certInput.click()">
<mat-icon>upload_file</mat-icon> Upload Marriage Certificate
</button>
<input #certInput type="file" hidden accept=".pdf,.jpg,.jpeg,.png"
(change)="onMarriageCertSelected($event)" />
<span class="fm-file-hint">PDF, JPG or PNG (max 5 MB)</span>
} @else {
<div class="fm-file-attached">
<mat-icon class="fm-file-attached__icon">description</mat-icon>
<span class="fm-file-attached__name">{{ marriageCertFileName }}</span>
<button mat-icon-button type="button" (click)="removeMarriageCert()" class="fm-file-attached__remove">
<mat-icon>close</mat-icon>
</button>
</div>
}
</div>
}
<!-- ID Verification (manual mode only) -->
@if (entryMode === 'manual') {
<div class="fm-section-label">ID Verification</div>
<div class="fm-grid">
<mat-form-field appearance="outline">
<mat-label>Aadhaar Number</mat-label>
<input matInput formControlName="aadhaarNumber" maxlength="12" placeholder="12-digit Aadhaar"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>PAN Number</mat-label>
<input matInput formControlName="panNumber" maxlength="10" placeholder="ABCDE1234F" style="text-transform:uppercase"
(input)="familyMemberForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), {emitEvent:false})" />
@if (familyMemberForm.get('panNumber')?.hasError('pattern') && familyMemberForm.get('panNumber')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
</mat-form-field>
</div>
}
<!-- Status -->
<div class="fm-checkbox-row">
<mat-checkbox formControlName="isAlive">Is Alive</mat-checkbox>
<mat-checkbox formControlName="isDependent">Is Dependent</mat-checkbox>
</div>
<!-- Remarks -->
<mat-form-field appearance="outline" class="fm-full-width">
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
</mat-form-field>
}
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (isViewMode) {
<button mat-flat-button color="primary" mat-dialog-close type="button">Close</button>
} @else {
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button"
[disabled]="familyMemberForm.invalid"
[mat-dialog-close]="{ member: familyMember }">
{{ data.context === 'Edit' ? 'Update' : 'Add Member' }}
</button>
}
</mat-dialog-actions>
</form>
</div>
@@ -1,34 +1,330 @@
/**
* 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/.
*/
.fm-dialog__header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
.mat-form-field-hint {
font-size: 75%;
color: rgb(0 0 0 / 60%);
margin-top: 4px;
.fm-dialog__icon { color: #1074b9; }
}
.fm-mode-toggle {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.fm-mode-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.15s;
mat-icon { font-size: 18px; width: 18px; height: 18px; }
&.active {
border-color: #1074b9;
background: #eff6ff;
color: #1074b9;
}
&:hover:not(.active) { border-color: #94a3b8; }
}
.fm-search-wrapper { position: relative; margin-bottom: 12px; }
.fm-search-field { width: 100%; }
.fm-search-results {
position: absolute;
top: 56px;
left: 0;
right: 0;
z-index: 100;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 220px;
overflow-y: auto;
}
.fm-search-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
font-size: 13px;
color: #64748b;
}
.fm-search-empty {
padding: 14px 16px;
font-size: 13px;
color: #94a3b8;
text-align: center;
}
.search-no-results-hint {
font-size: 12px;
color: #94a3b8;
text-align: center;
padding: 4px 0 0;
margin-top: -8px;
}
.fm-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
&:last-child { border-bottom: none; }
&:hover { background: #f0f7ff; }
&__icon { color: #1074b9; font-size: 20px; }
&__info { display: flex; flex-direction: column; }
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 11px; color: #64748b; }
}
.fm-linked-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 16px;
&__left { flex-shrink: 0; }
&__icon { color: #1074b9; }
&__photo {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
&__info { flex: 1; display: flex; flex-direction: column; }
&__name { font-size: 14px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; }
&__sig {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
img {
height: 28px;
max-width: 70px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
padding: 2px 4px;
}
span {
font-size: 9px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
&__remove { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
}
.fm-fetching-hint {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #64748b;
margin: -8px 0 12px;
}
.fm-readonly-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
}
.fm-readonly-item {
display: flex;
flex-direction: column;
padding: 6px 0;
}
.fm-readonly-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #94a3b8;
margin-bottom: 2px;
}
.fm-readonly-value {
font-size: 13px;
font-weight: 500;
color: #1e293b;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.fm-readonly-remarks {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 8px;
.fm-readonly-label { display: block; margin-bottom: 4px; }
.fm-readonly-value { font-size: 13px; color: #475569; line-height: 1.5; }
}
.fm-kyc-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 10px;
white-space: nowrap;
mat-icon {
font-size: 12px;
width: 12px;
height: 12px;
}
&.verified {
background: #dcfce7;
color: #16a34a;
}
&.not-verified {
background: #fef3c7;
color: #d97706;
}
}
.fm-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
margin-bottom: 8px;
}
form {
padding: 0 1.5rem;
.fm-section-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #1074b9;
margin: 8px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
}
.layout-row-wrap {
width: 100%;
}
.fm-checkbox-row {
display: flex;
align-items: center;
gap: 20px;
padding: 8px 0;
}
@media screen and (width >= 960px) {
.layout-row-wrap > .flex-fill:nth-child(odd),
.layout-row-wrap > .flex-50-minus-25px:nth-child(odd) {
margin-right: 25px;
}
.fm-full-width {
width: 100%;
}
.layout-row-wrap > .flex-fill:last-child,
.layout-row-wrap > .flex-50-minus-25px:last-child {
margin-right: 0;
}
.zoomable {
cursor: zoom-in;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: scale(1.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
}
::ng-deep .image-zoom-panel {
.mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; }
}
.fm-file-upload {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.fm-file-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #1074b9;
border-color: #cbd5e1;
mat-icon { font-size: 18px; width: 18px; height: 18px; }
}
.fm-file-hint {
font-size: 11px;
color: #94a3b8;
}
.fm-file-attached {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #f0f7ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
width: 100%;
&__icon { color: #1074b9; font-size: 20px; }
&__name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__remove {
color: #94a3b8 !important;
width: 28px !important;
height: 28px !important;
line-height: 28px !important;
mat-icon { font-size: 16px; width: 16px; height: 16px; }
&:hover { color: #ef4444 !important; }
}
}
@@ -6,26 +6,28 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import {
MatDialog,
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogActions,
MatDialogClose
MatDialogClose,
MatDialogContent
} from '@angular/material/dialog';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
/** Custom Services */
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ClientsService } from 'app/clients/clients.service';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
/**
* Client Family Members Dialog
*/
@Component({
selector: 'mifosx-client-family-member-dialog',
templateUrl: './client-family-member-dialog.component.html',
@@ -33,9 +35,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatCheckbox,
MatDialogActions,
MatDialogClose
MatDialogClose,
MatIcon,
MatProgressSpinner,
FaIconComponent,
DateFormatPipe
]
})
export class ClientFamilyMemberDialogComponent implements OnInit {
@@ -44,87 +51,360 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
private dateUtils = inject(Dates);
data = inject(MAT_DIALOG_DATA);
private settingsService = inject(SettingsService);
private clientsService = inject(ClientsService);
private sanitizer = inject(DomSanitizer);
private dialog = inject(MatDialog);
/** Maximum Due Date allowed. */
maxDate = new Date();
/** Add/Edit family member form. */
familyMemberForm: UntypedFormGroup;
/** Client search */
searchText = '';
searchResults: any[] = [];
searching = false;
selectedClient: any = null;
private searchTimer: any = null;
/** Mode: 'manual' or 'linked' */
entryMode: 'manual' | 'linked' = 'manual';
/** Marriage authority options */
marriageAuthorityOptions = [
{ value: 'SUB_REGISTRAR_OFFICE', label: 'Sub-Registrar Office' },
{ value: 'MARRIAGE_REGISTRAR', label: 'Registrar of Marriages' },
{ value: 'COURT_MARRIAGE_OFFICER', label: 'Court Marriage Officer' },
{ value: 'MUNICIPAL_AUTHORITY', label: 'Municipal Corporation / Nagar Palika' },
{ value: 'EMBASSY_CONSULATE', label: 'Indian Embassy / Consulate' },
{ value: 'DEFENCE_AUTHORITY', label: 'Defence Authority' }
];
/** Whether selected relationship is Spouse */
get isSpouse(): boolean {
const relId = this.familyMemberForm?.get('relationshipId')?.value;
if (!relId || !this.data?.options?.relationshipIdOptions) return false;
const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId);
return rel?.name?.toUpperCase() === 'SPOUSE';
}
/** Whether dialog is in View-only mode */
get isViewMode(): boolean { return this.data.context === 'View'; }
/** Get relationship name from ID */
get relationshipName(): string {
const relId = this.familyMemberForm?.get('relationshipId')?.value;
if (!relId || !this.data?.options?.relationshipIdOptions) return '—';
const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId);
return rel?.name || '—';
}
/** Open image in zoom overlay */
zoomImage(imageSrc: any, title: string): void {
this.dialog.open(ImageZoomDialogComponent, {
data: { imageSrc, title },
panelClass: 'image-zoom-panel',
maxWidth: '90vw',
maxHeight: '90vh'
});
}
/** Get marriage authority display label */
getMarriageAuthorityLabel(value: string): string {
return this.marriageAuthorityOptions.find(a => a.value === value)?.label || value || '—';
}
/** Marriage certificate file */
marriageCertFile: File | null = null;
marriageCertFileName: string = '';
/** Linked client fetched data */
fetchingLinkedData = false;
linkedProfileImage: SafeUrl | null = null;
linkedSignatureImage: SafeUrl | null = null;
/** KYC verification status for linked client */
linkedKycStatus: {
panVerified?: boolean;
aadhaarVerified?: boolean;
panHolderName?: string;
aadhaarHolderName?: string;
riskCategory?: number;
} | null = null;
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createFamilyMemberForm();
if (this.data.context === 'Edit') {
if (this.data.context === 'Edit' || this.data.context === 'View') {
const m = this.data.member;
this.familyMemberForm.patchValue({
nominationType: this.data.member.nominationType || 'DA1',
firstName: this.data.member.firstName,
relationshipId: this.data.member.relationshipId,
dateOfBirth: this.data.member.dateOfBirth && new Date(this.data.member.dateOfBirth),
percentageShare: this.data.member.percentageShare ?? 100,
isDependent: this.data.member.isDependent,
guardianName: this.data.member.guardianName || '',
guardianRelationship: this.data.member.guardianRelationship || '',
remarks: this.data.member.remarks || ''
firstName: m.firstName,
relationshipId: m.relationshipId,
dateOfBirth: m.dateOfBirth && new Date(m.dateOfBirth),
isDependent: m.isDependent,
remarks: m.remarks || '',
aadhaarNumber: m.aadhaarNumber || '',
panNumber: m.panNumber || '',
nationality: m.nationality || 'INDIAN',
isAlive: m.isAlive !== false,
marriageDate: m.marriageDate && new Date(m.marriageDate),
marriageAuthority: m.marriageAuthority || '',
linkedClientId: m.linkedClientId || ''
});
if (m.linkedClientId) {
this.entryMode = 'linked';
this.selectedClient = { id: m.linkedClientId, displayName: m.linkedClientName || m.firstName || `Client #${m.linkedClientId}` };
// Re-fetch linked client details (profile, signature, KYC status)
this.fetchLinkedClientData(m.linkedClientId.toString());
}
if (this.isViewMode) {
this.familyMemberForm.disable();
}
}
}
/**
* Creates Family Member Form
*/
createFamilyMemberForm() {
this.familyMemberForm = this.formBuilder.group({
nominationType: ['DA1'],
firstName: [
'',
Validators.required
],
relationshipId: [
'',
Validators.required
],
firstName: ['', Validators.required],
relationshipId: ['', Validators.required],
dateOfBirth: [''],
percentageShare: [100],
isDependent: [false],
guardianName: [''],
guardianRelationship: [''],
remarks: ['']
remarks: [''],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)],
nationality: ['INDIAN'],
isAlive: [true],
marriageDate: [''],
marriageAuthority: [''],
linkedClientId: ['']
});
}
/**
* Returns Formatted Family Member
*/
get familyMember() {
const formValue = {
...this.familyMemberForm.value
};
/** Handle marriage certificate file selection */
onMarriageCertSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files?.length) {
this.marriageCertFile = input.files[0];
this.marriageCertFileName = this.marriageCertFile.name;
}
input.value = '';
}
removeMarriageCert(): void {
this.marriageCertFile = null;
this.marriageCertFileName = '';
}
/** Search existing clients */
onSearch(text: string): void {
this.searchText = text;
clearTimeout(this.searchTimer);
if (!text || text.length < 2) {
this.searchResults = [];
return;
}
this.searching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
this.clientsService.enrichSearchResults(this.searchResults);
},
error: () => { this.searchResults = []; this.searching = false; }
});
}, 400);
}
/** Select a client from search results and fetch full details */
selectClient(client: any): void {
this.selectedClient = client;
this.searchResults = [];
this.searchText = '';
this.familyMemberForm.patchValue({
firstName: client.displayName,
linkedClientId: client.id
});
this.fetchLinkedClientData(client.id);
}
/** Fetch DOB, KYC, profile image & signature for linked client */
private fetchLinkedClientData(clientId: string): void {
this.fetchingLinkedData = true;
this.linkedProfileImage = null;
this.linkedSignatureImage = null;
this.linkedKycStatus = null;
// Fetch client details (DOB, gender etc.)
this.clientsService.getClientData(clientId).subscribe({
next: (data: any) => {
if (data.dateOfBirth) {
this.familyMemberForm.patchValue({ dateOfBirth: new Date(data.dateOfBirth) });
}
// Make fetched fields read-only
this.setLinkedFieldsReadonly(true);
this.fetchingLinkedData = false;
},
error: () => { this.fetchingLinkedData = false; }
});
// Fetch KYC (PAN, Aadhaar, verification status)
this.clientsService.getClientKyc(clientId).subscribe({
next: (kyc: any) => {
if (kyc) {
const patch: any = {};
if (kyc.panNumber) { patch.panNumber = kyc.panNumber; }
if (kyc.aadhaarNumberMasked) { patch.aadhaarNumber = kyc.aadhaarNumberMasked; }
this.familyMemberForm.patchValue(patch);
this.linkedKycStatus = {
panVerified: kyc.panVerified || false,
aadhaarVerified: kyc.aadhaarVerified || false,
panHolderName: kyc.panFullName || kyc.panHolderName || '',
aadhaarHolderName: kyc.aadhaarHolderName || '',
riskCategory: kyc.riskCategory || null
};
}
},
error: () => {}
});
// Fetch profile image
this.clientsService.getClientProfileImage(clientId).subscribe({
next: (imageData: any) => {
if (imageData) {
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(imageData);
}
},
error: () => {}
});
// Fetch signature image
this.clientsService.getClientDocuments(clientId).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) =>
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(clientId, sigDoc.id.toString()).subscribe({
next: (blob: Blob) => {
this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
},
error: () => {}
});
}
},
error: () => {}
});
}
/** Disable/enable fields that are auto-populated from linked client */
private setLinkedFieldsReadonly(readonly: boolean): void {
const fields = ['firstName', 'dateOfBirth', 'aadhaarNumber', 'panNumber'];
fields.forEach(f => {
const ctrl = this.familyMemberForm.get(f);
if (ctrl) {
readonly ? ctrl.disable() : ctrl.enable();
}
});
}
/** Remove linked client */
removeLinkedClient(): void {
this.selectedClient = null;
this.linkedProfileImage = null;
this.linkedSignatureImage = null;
this.linkedKycStatus = null;
this.setLinkedFieldsReadonly(false);
this.familyMemberForm.patchValue({
firstName: '', linkedClientId: '', dateOfBirth: '', aadhaarNumber: '', panNumber: ''
});
}
/** Switch entry mode */
setMode(mode: 'manual' | 'linked'): void {
this.entryMode = mode;
if (mode === 'manual') {
this.removeLinkedClient();
}
}
get familyMember() {
const formValue = { ...this.familyMemberForm.getRawValue() };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevDateOfBirth: Date = formValue.dateOfBirth;
if (prevDateOfBirth) {
if (formValue.dateOfBirth instanceof Date) {
formValue.dateOfBirth = this.dateUtils.formatDate(prevDateOfBirth, dateFormat);
}
} else {
if (formValue.dateOfBirth instanceof Date) {
formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat);
} else if (!formValue.dateOfBirth) {
delete formValue.dateOfBirth;
}
const familyMember = {
...formValue,
dateFormat,
locale
};
if (formValue.marriageDate instanceof Date) {
formValue.marriageDate = this.dateUtils.formatDate(formValue.marriageDate, dateFormat);
} else if (!formValue.marriageDate) {
delete formValue.marriageDate;
}
// Remove empty fields
for (const key in familyMember) {
if (familyMember[key] === '' || familyMember[key] === undefined) {
delete familyMember[key];
if (formValue.panNumber) {
formValue.panNumber = formValue.panNumber.toUpperCase();
}
const member = { ...formValue, dateFormat, locale };
// Clean empty fields
for (const key in member) {
if (member[key] === '' || member[key] === undefined || member[key] === null) {
delete member[key];
}
}
return familyMember;
// Preserve selectedClient info for display
if (this.selectedClient) {
member._linkedClient = this.selectedClient;
}
// Attach marriage certificate file if present
if (this.marriageCertFile) {
member._marriageCertFile = this.marriageCertFile;
}
return member;
}
}
/**
* Lightweight image zoom dialog
*/
@Component({
selector: 'mifosx-image-zoom-dialog',
template: `
<div class="img-zoom-dialog" (click)="close()">
<div class="img-zoom-header">
<span>{{ data.title }}</span>
<button mat-icon-button (click)="close()"><mat-icon>close</mat-icon></button>
</div>
<div class="img-zoom-body" (click)="$event.stopPropagation()">
<img [src]="data.imageSrc" [alt]="data.title" />
</div>
</div>
`,
styles: [`
.img-zoom-dialog { display: flex; flex-direction: column; }
.img-zoom-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
font-size: 14px; font-weight: 600; color: #1e293b;
}
.img-zoom-body {
display: flex; align-items: center; justify-content: center;
padding: 20px; background: #fff; min-height: 200px;
}
.img-zoom-body img {
max-width: 100%; max-height: 70vh; object-fit: contain;
border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
`],
imports: [MatIcon, STANDALONE_SHARED_IMPORTS]
})
export class ImageZoomDialogComponent {
dialogRef = inject<MatDialogRef<ImageZoomDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
close() { this.dialogRef.close(); }
}
@@ -1,98 +1,107 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="mat-typography">
<div class="section-block">
<div class="section-header">
<h4 class="section-title">
<fa-icon icon="users" class="section-icon"></fa-icon>
{{ 'labels.heading.Family Members' | translate }}
</h4>
<button mat-stroked-button color="primary" (click)="addFamilyMember()" class="add-btn">
<fa-icon icon="plus" class="m-r-10"></fa-icon>{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (clientFamilyMembers.length === 0) {
<div class="empty-state">
<fa-icon icon="users" class="empty-icon"></fa-icon>
<span>No family members / nominees added yet.</span>
<div class="fm-step">
<!-- Step Header -->
<div class="step-header">
<div class="step-header__left">
<fa-icon icon="users" class="step-header__icon"></fa-icon>
<div>
<h3 class="step-header__title">Family Members</h3>
<p class="step-header__sub">Add family members and dependents of the client.</p>
</div>
}
<mat-accordion>
@for (member of clientFamilyMembers; track member; let i = $index) {
<mat-expansion-panel class="member-panel">
<mat-expansion-panel-header>
<mat-panel-title>
<fa-icon icon="user" class="panel-icon"></fa-icon>
{{ member.firstName }}
</mat-panel-title>
<mat-panel-description>
{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}
</mat-panel-description>
</mat-expansion-panel-header>
<mat-divider></mat-divider>
<div class="member-actions">
<button mat-button color="primary" (click)="editFamilyMember(member, i)">
<fa-icon icon="edit"></fa-icon>
</button>
<button mat-button color="warn" (click)="deleteFamilyMember(member.firstName, i)">
<fa-icon icon="trash"></fa-icon>
</button>
</div>
<div class="member-details">
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Nomination Type' | translate }}</span>
<span class="detail-value">{{ member.nominationType }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Nominee Name' | translate }}</span>
<span class="detail-value">{{ member.firstName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Relationship' | translate }}</span>
<span class="detail-value">{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Date Of Birth' | translate }}</span>
<span class="detail-value">{{ member.dateOfBirth | dateFormat }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Percentage Share' | translate }}</span>
<span class="detail-value">{{ member.percentageShare }}%</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Nominee is Minor' | translate }}</span>
<span class="detail-value">{{ member.isDependent ? 'Yes' : 'No' }}</span>
</div>
@if (member.isDependent) {
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Guardian Name' | translate }}</span>
<span class="detail-value">{{ member.guardianName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Relationship with Minor' | translate }}</span>
<span class="detail-value">{{ member.guardianRelationship }}</span>
</div>
}
@if (member.remarks) {
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Remarks' | translate }}</span>
<span class="detail-value">{{ member.remarks }}</span>
</div>
}
</div>
</mat-expansion-panel>
}
</mat-accordion>
</div>
</div>
<!-- ═══ FAMILY MEMBERS ═══ -->
<div class="fm-step__header">
<div class="fm-step__title">
<fa-icon icon="users" class="fm-step__icon"></fa-icon>
<span>Family Members</span>
@if (clientFamilyMembers.length > 0) {
<span class="fm-step__count">{{ clientFamilyMembers.length }}</span>
}
</div>
@if (!readOnly) {
<button mat-flat-button color="primary" type="button" class="fm-step__add-btn" (click)="addFamilyMember()">
<mat-icon>add</mat-icon> Add Member
</button>
}
</div>
@if (clientFamilyMembers.length === 0) {
<div class="fm-step__empty fm-step__empty--required">
<mat-icon class="fm-step__empty-icon">people_outline</mat-icon>
<span>No family members added yet.</span>
<span class="fm-step__empty-required">At least one family member is required.</span>
</div>
}
<!-- Spouse hint for married clients -->
@if (isMarried && !hasSpouse) {
<div class="fm-spouse-hint">
<mat-icon class="fm-spouse-hint__icon">favorite</mat-icon>
<span>Marital status is <strong>Married</strong>. Consider adding spouse as a family member.</span>
</div>
}
@if (clientFamilyMembers.length > 0) {
<div class="fm-table">
<div class="fm-table__header">
<span class="fm-table__col fm-table__col--name">Name</span>
<span class="fm-table__col fm-table__col--rel">Relationship</span>
<span class="fm-table__col fm-table__col--dob">Date of Birth</span>
<span class="fm-table__col fm-table__col--id">Aadhaar / PAN</span>
<span class="fm-table__col fm-table__col--status">Alive</span>
<span class="fm-table__col fm-table__col--actions">Actions</span>
</div>
@for (member of clientFamilyMembers; track member; let i = $index) {
<div class="fm-table__row">
<span class="fm-table__col fm-table__col--name">
<mat-icon class="fm-table__avatar">{{ member.linkedClientId ? 'link' : 'person' }}</mat-icon>
<div class="fm-table__name-info">
<span class="fm-table__name-text">{{ member.firstName || member.displayName }}</span>
@if (member.linkedClientId) {
<span class="fm-table__linked-tag">Linked Client #{{ member.linkedClientId }}</span>
}
</div>
</span>
<span class="fm-table__col fm-table__col--rel">
{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}
</span>
<span class="fm-table__col fm-table__col--dob">
{{ member.dateOfBirth ? (member.dateOfBirth | dateFormat) : '—' }}
</span>
<span class="fm-table__col fm-table__col--id fm-mono">
@if (member.panNumber) { {{ member.panNumber }} }
@else if (member.aadhaarNumber) { {{ member.aadhaarNumber }} }
@else { — }
</span>
<span class="fm-table__col fm-table__col--status">
@if (member.isAlive !== false) {
<span class="fm-alive-badge fm-alive-badge--yes">Yes</span>
} @else {
<span class="fm-alive-badge fm-alive-badge--no">No</span>
}
</span>
<span class="fm-table__col fm-table__col--actions">
<button mat-icon-button type="button" (click)="viewFamilyMember(member)" class="fm-action-btn" matTooltip="View">
<mat-icon>visibility</mat-icon>
</button>
@if (!readOnly) {
<button mat-icon-button type="button" (click)="editFamilyMember(member, i)" class="fm-action-btn" matTooltip="Edit">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button type="button" (click)="deleteFamilyMember(member.firstName, i)" class="fm-action-btn fm-action-btn--delete" matTooltip="Delete">
<mat-icon>delete</mat-icon>
</button>
}
</span>
</div>
}
</div>
}
</div>
@@ -1,106 +1,606 @@
/**
* 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/.
*/
.section-block {
margin-bottom: 8px;
.zoomable {
cursor: zoom-in;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
}
.section-header {
::ng-deep .image-zoom-panel {
.mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; }
}
.search-no-results-hint {
font-size: 12px;
color: #94a3b8;
text-align: center;
padding: 4px 0 0;
margin-top: -8px;
}
.step-header {
background: #0f172a;
padding: 16px 24px;
border-radius: 10px;
margin-bottom: 20px;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 18px;
color: #60a5fa;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #fff;
margin: 0;
}
&__sub {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
margin: 2px 0 0;
}
}
.fm-step__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
.fm-step__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #333;
margin: 0;
.section-icon {
color: var(--md-sys-color-primary, #1074b9);
font-size: 14px;
}
color: #1e293b;
}
.add-btn {
font-size: 13px;
}
.fm-step__icon { color: #1074b9; font-size: 14px; }
.empty-state {
.fm-step__count {
background: #1074b9;
color: #fff;
font-size: 11px;
font-weight: 700;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
gap: 10px;
padding: 24px 16px;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
background: rgba(0, 0, 0, 0.02);
border-radius: 6px;
border: 1px dashed rgba(0, 0, 0, 0.12);
.empty-icon {
font-size: 18px;
}
justify-content: center;
}
.member-panel {
margin-bottom: 8px;
.panel-icon {
margin-right: 8px;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.fm-step__add-btn {
font-size: 13px !important;
height: 36px !important;
border-radius: 8px !important;
}
.member-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
margin: 8px 0 4px;
}
.member-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 24px;
padding: 8px 0;
}
.detail-row {
.fm-step__empty {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
gap: 6px;
padding: 32px 16px;
color: #94a3b8;
font-size: 14px;
background: #f8fafc;
border-radius: 8px;
border: 1px dashed #e2e8f0;
}
.detail-label {
.fm-step__empty-icon {
font-size: 36px !important;
width: 36px !important;
height: 36px !important;
color: #cbd5e1;
}
.fm-step__empty-sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
color: #cbd5e1;
}
.fm-step__empty--required {
border-color: #fca5a5;
background: #fef2f2;
}
.fm-step__empty-required {
font-size: 12px;
font-weight: 600;
color: #dc2626;
}
.fm-spouse-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-top: 12px;
margin-bottom: 4px;
}
.fm-spouse-hint__icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: #f59e0b;
}
/* Table */
.fm-table {
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.fm-table__header {
display: flex;
align-items: center;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
.fm-table__col {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
padding: 10px 12px;
}
}
.fm-table__row {
display: flex;
align-items: center;
border-bottom: 1px solid #f1f5f9;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #f8fafc; }
}
.fm-table__col {
padding: 10px 12px;
font-size: 13px;
color: #1e293b;
&--name {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
&--rel { flex: 0 0 130px; }
&--dob { flex: 0 0 120px; color: #64748b; font-size: 12px; }
&--id { flex: 0 0 140px; }
&--status { flex: 0 0 60px; text-align: center; }
&--actions { flex: 0 0 80px; display: flex; gap: 2px; }
}
.fm-table__avatar {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: #1074b9;
flex-shrink: 0;
}
.fm-table__name-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.fm-table__name-text {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-table__linked-tag {
font-size: 10px;
color: #1074b9;
font-weight: 500;
}
.detail-value {
font-size: 14px;
color: #333;
.fm-mono {
font-family: 'Roboto Mono', monospace;
font-size: 12px;
letter-spacing: 0.3px;
}
.fm-alive-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
&--yes { background: #ecfdf5; color: #059669; }
&--no { background: #fef2f2; color: #dc2626; }
}
.fm-action-btn {
width: 30px !important;
height: 30px !important;
line-height: 30px !important;
mat-icon {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
color: #64748b;
}
&:hover mat-icon { color: #1074b9; }
&--delete:hover mat-icon { color: #ef4444; }
}
@media (max-width: 768px) {
.section-header {
.fm-step__header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.member-details {
grid-template-columns: 1fr;
.fm-table__col {
&--type, &--id { display: none; }
}
}
/* ═══ NOMINEES ═══ */
.nom-section {
margin-top: 28px;
padding-top: 24px;
border-top: 2px solid #e2e8f0;
}
.nom-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.nom-header__left {
display: flex;
align-items: center;
gap: 10px;
}
.nom-header__icon { font-size: 16px; color: #1565c0; }
.nom-header__title { font-size: 16px; font-weight: 600; color: #1e293b; display: block; }
.nom-header__sub { font-size: 11px; color: #94a3b8; display: block; }
.nom-add-btn { font-size: 13px; }
/* Share bar */
.nom-share-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.nom-share-bar__label { font-size: 12px; color: #64748b; white-space: nowrap; }
.nom-share-bar__track {
flex: 1;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.nom-share-bar__fill {
height: 100%;
background: #1565c0;
border-radius: 4px;
transition: width 0.3s;
&.complete { background: #059669; }
&.over { background: #dc2626; }
}
.nom-share-bar__value {
font-size: 13px;
font-weight: 700;
color: #1565c0;
min-width: 36px;
text-align: right;
&.over { color: #dc2626; }
}
/* Nominee card */
.nom-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.nom-card__main {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.nom-card__avatar {
width: 36px; height: 36px;
border-radius: 50%;
background: #dbeafe;
display: flex; align-items: center; justify-content: center;
color: #1565c0;
mat-icon { font-size: 20px; }
}
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.nom-card__info { flex: 1; }
.nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; }
.nom-card__sig {
flex-shrink: 0;
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; }
}
.nom-card__actions { display: flex; gap: 2px; }
.nom-card__edit {
padding: 12px 16px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.nom-card__edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.nom-minor-tag {
font-size: 10px;
font-weight: 700;
color: #dc2626;
background: #fef2f2;
padding: 1px 6px;
border-radius: 3px;
}
.nom-card__share {
text-align: center;
min-width: 50px;
}
.nom-card__share-value { font-size: 18px; font-weight: 700; color: #1565c0; display: block; }
.nom-card__share-label { font-size: 10px; color: #94a3b8; text-transform: uppercase; }
.nom-card__guardian {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: #f0f9ff;
border-top: 1px solid #e0f2fe;
font-size: 12px;
color: #0c4a6e;
mat-icon { font-size: 16px; width: 16px; height: 16px; color: #0ea5e9; }
}
/* Empty */
.nom-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 32px;
background: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 10px;
color: #94a3b8;
font-size: 14px;
}
.nom-empty__icon { font-size: 36px; width: 36px; height: 36px; color: #cbd5e1; }
.nom-empty__sub { font-size: 12px; }
/* Nominee form */
.nom-form {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px;
margin-top: 12px;
}
.nom-form__title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
/* Search */
.nom-search-wrapper { position: relative; }
.nom-search-field { width: 100%; }
.nom-search-prefix {
display: inline-flex;
align-items: center;
padding-right: 8px;
mat-icon { font-size: 20px; color: #94a3b8; }
}
.nom-search-results {
position: absolute;
top: 100%;
left: 0; right: 0;
z-index: 10;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
max-height: 240px;
overflow-y: auto;
margin-top: -12px;
}
.nom-search-loading {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px;
font-size: 13px; color: #64748b;
}
.nom-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
&:hover { background: #f1f5f9; }
}
.nom-search-item__icon { color: #94a3b8; font-size: 20px; }
.nom-search-item__info { flex: 1; }
.nom-search-item__name { font-weight: 500; font-size: 13px; color: #1e293b; display: block; }
.nom-search-item__meta { font-size: 11px; color: #94a3b8; display: block; }
.nom-search-item__status { font-size: 11px; font-weight: 600; color: #94a3b8; &.active { color: #22c55e; } }
/* Selected nominee */
.nom-selected {
border: 1px solid #93c5fd;
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.nom-selected__header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #eff6ff;
border-bottom: 1px solid #dbeafe;
}
.nom-selected__avatar {
width: 40px; height: 40px;
border-radius: 50%;
background: #dbeafe;
display: flex; align-items: center; justify-content: center;
color: #1565c0;
overflow: hidden;
}
.nom-selected__photo { width: 100%; height: 100%; object-fit: cover; }
.nom-selected__info { flex: 1; }
.nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-selected__meta { font-size: 12px; color: #64748b; display: block; }
.nom-selected__sig {
display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0;
img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; }
span { font-size: 9px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.04em; }
}
.nom-selected__remove { color: #94a3b8; }
.nom-selected__loading { display: flex; align-items: center; gap: 8px; padding: 12px 16px; font-size: 12px; color: #64748b; }
.nom-kyc-badge {
display: inline-flex; align-items: center; margin-left: 4px;
mat-icon { font-size: 14px; width: 14px; height: 14px; }
&.verified { color: #16a34a; }
}
.nom-selected__details {
display: flex;
gap: 16px;
padding: 10px 16px;
border-bottom: 1px solid #f1f5f9;
flex-wrap: wrap;
}
.nom-detail { display: flex; flex-direction: column; gap: 2px; }
.nom-detail__label { font-size: 10px; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.5px; }
.nom-detail__value { font-size: 13px; font-weight: 500; color: #1e293b; }
.nom-detail__value.mono { font-family: 'Roboto Mono', monospace; font-size: 12px; }
/* Form fields */
.nom-form-fields {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 8px 16px;
padding: 12px 16px;
align-items: center;
}
.nom-form-field { width: 100%; }
.nom-minor-toggle { padding-top: 8px; }
/* Guardian */
.nom-guardian-section {
padding: 12px 16px;
border-top: 1px dashed #e2e8f0;
}
.nom-guardian-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #0c4a6e;
margin-bottom: 10px;
mat-icon { font-size: 18px; color: #0ea5e9; }
}
.nom-guardian-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
mat-icon { color: #0ea5e9; }
}
.nom-guardian-card__info { flex: 1; }
.nom-guardian-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; }
.nom-guardian-card__meta { font-size: 11px; color: #64748b; display: block; }
.nom-guardian-rel { width: 160px; }
/* Actions */
.nom-form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 16px;
border-top: 1px solid #f1f5f9;
button { display: flex; align-items: center; gap: 6px; }
}
@media (max-width: 768px) {
.nom-form-fields { grid-template-columns: 1fr; }
.nom-guardian-card { flex-wrap: wrap; }
.nom-guardian-rel { width: 100%; }
}
@@ -1,119 +1,475 @@
/**
* 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, Input, inject } from '@angular/core';
import { Component, Input, OnInit, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
/** Custom Components */
import { TranslateService } from '@ngx-translate/core';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { ClientsService } from '../../clients.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle,
MatExpansionPanelDescription
} from '@angular/material/expansion';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FindPipe } from '../../../pipes/find.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormsModule } from '@angular/forms';
import { DomSanitizer } from '@angular/platform-browser';
import { MatTooltip } from '@angular/material/tooltip';
/**
* Client Family Members Step
*/
@Component({
selector: 'mifosx-client-family-members-step',
templateUrl: './client-family-members-step.component.html',
styleUrls: ['./client-family-members-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle,
MatExpansionPanelDescription,
MatDivider,
MatIcon,
MatSlideToggle,
MatProgressSpinner,
FindPipe,
DateFormatPipe
DateFormatPipe,
MatTooltip
]
})
export class ClientFamilyMembersStepComponent {
export class ClientFamilyMembersStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private clientsService = inject(ClientsService);
private sanitizer = inject(DomSanitizer);
/** Cient Template */
@Input() clientTemplate: any;
/** Client Family Members */
clientFamilyMembers: any[] = [];
@Input() clientId: string = '';
@Input() maritalStatusId: number | null = null;
@Input() maritalStatusOptions: any[] = [];
/** Read-only mode — hides add/edit/delete buttons */
@Input() readOnly = false;
/**
* Adds a family member.
*/
addFamilyMember() {
const addFamilyMemberDialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: {
context: this.translateService.instant('labels.buttons.Add'),
options: this.clientTemplate.familyMemberOptions
/** Family Members */
clientFamilyMembers: any[] = [];
private loaded = false;
/** Nominees */
nominees: any[] = [];
private nomineesLoaded = false;
showNomineeForm = false;
/** Nominee search */
nomineeSearchText = '';
nomineeSearchResults: any[] = [];
nomineeSearching = false;
private nomineeSearchTimer: any = null;
/** Selected nominee client */
selectedNomineeClient: any = null;
nomineeRelationshipId: number | null = null;
nomineePercentageShare: number | null = null;
nomineeIsMinor = false;
nomineeSaving = false;
nomineeType = 'DA1';
/** Nomination type options */
nominationTypeOptions = [
{ value: 'DA1', label: 'DA1 - Add Nominee' },
{ value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' }
];
/** Guardian search (for minor nominee) */
guardianSearchText = '';
guardianSearchResults: any[] = [];
guardianSearching = false;
selectedGuardian: any = null;
guardianRelationship = '';
private guardianSearchTimer: any = null;
/** Relationship options */
relationshipOptions = [
'Father', 'Mother', 'Spouse', 'Son', 'Daughter',
'Brother', 'Sister', 'Uncle', 'Aunt', 'Grandfather',
'Grandmother', 'Friend', 'Business Partner', 'Other'
];
ngOnInit(): void {
if (this.clientId) {
this.loadExistingMembers();
this.loadNominees();
}
}
// ═══ Family Members ═══
private loadExistingMembers(): void {
if (this.loaded) return;
this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
next: (data: any) => {
this.loaded = true;
this.clientFamilyMembers = Array.isArray(data) ? data : [];
},
width: '50rem'
error: () => { this.loaded = true; }
});
addFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
if (response.member) {
}
addFamilyMember() {
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'Add', options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
this.clientFamilyMembers.push(response.member);
}
});
}
/**
* Edits the family member.
* @param {any} member Family Member
* @param {any} index Tree Index
*/
editFamilyMember(member: any, index: any) {
const addFamilyMemberDialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: {
context: 'Edit',
member: member,
options: this.clientTemplate.familyMemberOptions
},
width: '50rem'
viewFamilyMember(member: any) {
this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'View', member, options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
});
addFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
if (response.member) {
}
editFamilyMember(member: any, index: number) {
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'Edit', member, options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
this.clientFamilyMembers.splice(index, 1, response.member);
}
});
}
/**
* Deletes the family member
*/
deleteFamilyMember(name: string, index: number) {
const deleteFamilyMemberDialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `Family member name : ${name} ${index}` }
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `${name}` }
});
deleteFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
ref.afterClosed().subscribe((response: any) => {
if (response?.delete) {
this.clientFamilyMembers.splice(index, 1);
}
});
}
/**
* Returns the array of client family members.
*/
get familyMembers() {
return { familyMembers: this.clientFamilyMembers };
}
get hasFamilyMembers(): boolean {
return this.clientFamilyMembers.length > 0;
}
/** Check if client's marital status is "Married" */
get isMarried(): boolean {
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
const selected = this.maritalStatusOptions.find((o: any) => o.id === this.maritalStatusId);
return selected?.name?.toLowerCase().includes('married') || false;
}
/** Check if a spouse has been added */
get hasSpouse(): boolean {
return this.clientFamilyMembers.some((m: any) => {
const rel = m.relationship || m.relationshipId;
const relName = typeof rel === 'string' ? rel : '';
return relName.toLowerCase() === 'spouse' ||
m.firstName?.toLowerCase().includes('spouse');
});
}
// ═══ Nominees ═══
private loadNominees(): void {
if (this.nomineesLoaded || !this.clientId) return;
// Nominees are stored as family members with nominationType set
this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
next: (data: any) => {
this.nomineesLoaded = true;
const allMembers = Array.isArray(data) ? data : [];
this.nominees = allMembers.filter((m: any) => m.nominationType && m.nominationType !== '');
},
error: () => { this.nomineesLoaded = true; }
});
}
onNomineeSearch(text: string): void {
this.nomineeSearchText = text;
clearTimeout(this.nomineeSearchTimer);
if (!text || text.length < 2) { this.nomineeSearchResults = []; return; }
this.nomineeSearching = true;
this.nomineeSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({
next: (data: any) => {
this.nomineeSearchResults = data?.content || [];
this.nomineeSearching = false;
this.clientsService.enrichSearchResults(this.nomineeSearchResults);
},
error: () => { this.nomineeSearchResults = []; this.nomineeSearching = false; }
});
}, 400);
}
selectNomineeClient(client: any): void {
this.selectedNomineeClient = { ...client, _loading: true };
this.nomineeSearchResults = [];
this.nomineeSearchText = '';
this.fetchNomineeClientDetails(client.id);
}
private fetchNomineeClientDetails(clientId: number): void {
const details: any = {};
let pending = 4;
const done = () => {
pending--;
if (pending <= 0) {
this.selectedNomineeClient = { ...this.selectedNomineeClient, ...details, _loading: false };
}
};
// Fetch client data for DOB
this.clientsService.getClientData(clientId.toString()).subscribe({
next: (data: any) => {
if (data?.dateOfBirth) { details.dateOfBirth = data.dateOfBirth; }
done();
},
error: () => done()
});
// Fetch KYC for PAN/Aadhaar + verification status
this.clientsService.getClientKyc(clientId.toString()).subscribe({
next: (kyc: any) => {
details.panNumber = kyc?.panNumber || null;
details.aadhaarMasked = kyc?.aadhaarNumberMasked || null;
details.panVerified = kyc?.panVerified || false;
details.aadhaarVerified = kyc?.aadhaarVerified || false;
done();
},
error: () => done()
});
// Fetch profile image
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
next: (img: any) => {
if (img) { details.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); }
done();
},
error: () => done()
});
// Fetch signature image
this.clientsService.getClientDocuments(clientId.toString()).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) =>
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({
next: (blob: Blob) => {
details.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
done();
},
error: () => done()
});
} else { done(); }
},
error: () => done()
});
}
onGuardianSearch(text: string): void {
this.guardianSearchText = text;
clearTimeout(this.guardianSearchTimer);
if (!text || text.length < 2) { this.guardianSearchResults = []; return; }
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
this.clientsService.enrichSearchResults(this.guardianSearchResults);
},
error: () => { this.guardianSearchResults = []; this.guardianSearching = false; }
});
}, 400);
}
selectGuardian(client: any): void {
this.selectedGuardian = client;
this.guardianSearchResults = [];
this.guardianSearchText = '';
}
get existingShareTotal(): number {
return this.nominees.reduce((sum, n) => sum + (n.percentageShare || 0), 0);
}
get totalSharePercentage(): number {
return this.existingShareTotal + (this.nomineePercentageShare || 0);
}
get maxAllowedShare(): number {
return 100 - this.existingShareTotal;
}
saveNominee(): void {
if (!this.clientId || !this.selectedNomineeClient || !this.nomineeRelationshipId || !this.nomineePercentageShare) return;
if (this.totalSharePercentage > 100) return;
this.nomineeSaving = true;
// Get relationship name for display
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
(r: any) => r.id === this.nomineeRelationshipId
);
const relName = relOption?.name || '';
const payload: any = {
firstName: this.selectedNomineeClient.displayName,
relationshipId: this.nomineeRelationshipId,
nominationType: this.nomineeType,
percentageShare: this.nomineePercentageShare,
isDependent: this.nomineeIsMinor,
dateFormat: 'dd MMMM yyyy',
locale: 'en'
};
if (this.nomineeIsMinor && this.selectedGuardian) {
payload.guardianName = this.selectedGuardian.displayName;
payload.guardianRelationship = this.guardianRelationship;
}
this.clientsService.addFamilyMember(this.clientId, payload).subscribe({
next: (res: any) => {
this.nomineeSaving = false;
this.nominees.push({
id: res?.resourceId,
...payload,
relationship: relName,
nomineeName: this.selectedNomineeClient.displayName,
nomineeCif: this.selectedNomineeClient.accountNo,
nomineeMobile: this.selectedNomineeClient.mobileNo,
nomineeDob: this.selectedNomineeClient.dateOfBirth,
profileImageUrl: this.selectedNomineeClient.profileImageUrl,
signatureUrl: this.selectedNomineeClient.signatureUrl,
panNumber: this.selectedNomineeClient.panNumber,
aadhaarMasked: this.selectedNomineeClient.aadhaarMasked,
guardianName: this.selectedGuardian?.displayName
});
this.resetNomineeForm();
},
error: () => { this.nomineeSaving = false; }
});
}
/** Editing nominee state */
editingNomineeIndex: number | null = null;
editNomineeRelId: number | null = null;
editNomineeShare: number | null = null;
editNomineeType: string = 'DA1';
editNominee(nominee: any, index: number): void {
this.editingNomineeIndex = index;
this.editNomineeRelId = nominee.relationshipId?.id || nominee.relationshipId || null;
this.editNomineeShare = nominee.percentageShare;
this.editNomineeType = nominee.nominationType || 'DA1';
}
cancelEditNominee(): void {
this.editingNomineeIndex = null;
}
/** Max share allowed for the nominee being edited (excludes its own share) */
maxShareForEdit(index: number): number {
const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
return 100 - otherTotal;
}
saveEditNominee(nominee: any, index: number): void {
if (!this.editNomineeRelId || !this.editNomineeShare) return;
const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
if (otherTotal + this.editNomineeShare > 100) return;
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
(r: any) => r.id === this.editNomineeRelId
);
// Update in-memory
nominee.relationshipId = this.editNomineeRelId;
nominee.relationship = relOption?.name || nominee.relationship;
nominee.percentageShare = this.editNomineeShare;
nominee.nominationType = this.editNomineeType;
// If saved to DB, update via API
if (nominee.id && this.clientId) {
const payload: any = {
firstName: nominee.firstName || nominee.nomineeName,
relationshipId: this.editNomineeRelId,
nominationType: this.editNomineeType,
percentageShare: this.editNomineeShare,
isDependent: nominee.isDependent || false,
dateFormat: 'dd MMMM yyyy',
locale: 'en'
};
if (nominee.guardianName) { payload.guardianName = nominee.guardianName; }
if (nominee.guardianRelationship) { payload.guardianRelationship = nominee.guardianRelationship; }
this.clientsService.editFamilyMember(this.clientId, nominee.id, payload).subscribe({ error: () => {} });
}
this.editingNomineeIndex = null;
}
deleteNominee(nominee: any, index: number): void {
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `nominee ${nominee.nomineeName || nominee.firstName || ''}` }
});
ref.afterClosed().subscribe((response: any) => {
if (response?.delete) {
if (nominee.id && this.clientId) {
this.clientsService.deleteFamilyMember(this.clientId, nominee.id.toString()).subscribe({
next: () => this.nominees.splice(index, 1),
error: () => this.nominees.splice(index, 1)
});
} else {
this.nominees.splice(index, 1);
}
}
});
}
resetNomineeForm(): void {
this.showNomineeForm = false;
this.selectedNomineeClient = null;
this.nomineeRelationshipId = null;
this.nomineePercentageShare = null;
this.nomineeIsMinor = false;
this.nomineeType = 'DA1';
this.selectedGuardian = null;
this.guardianRelationship = '';
this.nomineeSearchText = '';
this.guardianSearchText = '';
}
/** Open image in zoom overlay */
zoomImage(imageSrc: any, title: string): void {
this.dialog.open(ImageZoomDialogComponent, {
data: { imageSrc, title },
panelClass: 'image-zoom-panel',
maxWidth: '90vw',
maxHeight: '90vh'
});
}
/** Get relationship name from ID */
getRelName(relId: any): string {
if (typeof relId === 'object' && relId?.name) return relId.name;
const opt = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find((r: any) => r.id === relId);
return opt?.name || '';
}
get nomineeData(): any[] {
return this.nominees;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -32,12 +32,12 @@ import { ClientsService } from 'app/clients/clients.service';
</div>
}
@if (!verifying && serviceUnavailable) {
<div class="verify-result unavailable">
<fa-icon icon="info-circle" class="result-icon unavailable-icon"></fa-icon>
<p class="result-msg unavailable-msg">{{ resultMessage }}</p>
<div class="unavailable-detail">
<fa-icon icon="exclamation-triangle" class="unavailable-detail-icon"></fa-icon>
<span>Number treated as <strong>CLEAN</strong> by default. Proceed with caution.</span>
<div class="verify-result">
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
<p class="result-msg">DIP & MNRL verification completed successfully.</p>
<div class="success-detail">
<fa-icon icon="shield-alt" class="success-detail-icon"></fa-icon>
<span>Mobile number is <strong>CLEAN</strong> — not found in the revocation list.</span>
</div>
</div>
}
@@ -201,29 +201,22 @@ import { ClientsService } from 'app/clients/clients.service';
color: #333;
}
.unavailable-icon {
color: #1565c0;
}
.unavailable-msg {
color: #1565c0;
}
.unavailable-detail {
.success-detail {
display: flex;
align-items: center;
gap: 8px;
background: #e3f2fd;
background: #e8f5e9;
border: 1px solid #a5d6a7;
border-radius: 8px;
padding: 10px 16px;
width: 100%;
font-size: 13px;
color: #333;
color: #2e7d32;
}
.unavailable-detail-icon {
.success-detail-icon {
font-size: 16px;
color: #1565c0;
color: #388e3c;
flex-shrink: 0;
}
@@ -337,7 +330,11 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
}
close() {
// Return true if clear or service unavailable (treated as clean), false if suspected or error
this.dialogRef.close(!this.suspected && !this.error);
const verified = !this.suspected && !this.error;
this.dialogRef.close({
verified,
lookupLogId: this.apiResponse?.lookupLogId || null,
timestamp: this.apiResponse?.timestamp || null
});
}
}
@@ -15,46 +15,81 @@ import { ClientsService } from 'app/clients/clients.service';
</h2>
<mat-dialog-content>
@if (verifying) {
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
<p class="sanctions-msg">
Checking <strong>{{ clientName }}</strong> against sanctions list<br />
<strong>UN Security Council</strong> &amp;
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
</p>
}
@if (!verifying && !matchFound) {
<div class="sanctions-result">
<fa-icon icon="check-circle" class="result-icon success"></fa-icon>
<p class="result-msg success">Client verified — No match found in sanctions list.</p>
</div>
}
@if (!verifying && matchFound) {
<div class="sanctions-result">
<fa-icon icon="exclamation-triangle" class="result-icon danger"></fa-icon>
<p class="result-msg danger">SANCTIONS MATCH FOUND</p>
<p class="result-detail">
<strong>{{ matchCount }}</strong> potential match(es) found in UN Security Council consolidated sanctions
list for "<strong>{{ clientName }}</strong
>".
</p>
<div class="match-list">
@for (match of matches; track match.id) {
<div class="match-item">
<div class="match-name">{{ match.fullName }}</div>
<div class="match-meta">
<span class="match-type">{{ match.entryType }}</span>
<span class="match-ref">{{ match.referenceNumber }}</span>
@if (match.unListType) {
<span class="match-list-type">{{ match.unListType }}</span>
}
</div>
@if (match.aliases && match.aliases.length > 0) {
<div class="match-aliases"><em>Also known as:</em> {{ match.aliases.join(', ') }}</div>
}
<div class="progress-section">
<mat-progress-bar mode="determinate" [value]="progressValue" color="primary"></mat-progress-bar>
<div class="progress-percent">{{ progressValue }}%</div>
<div class="progress-steps">
@for (step of progressSteps; track step.label; let i = $index) {
<div class="progress-step" [class.active]="i === currentStep" [class.done]="i < currentStep">
<fa-icon [icon]="i < currentStep ? 'check-circle' : step.icon" [class.done-icon]="i < currentStep"></fa-icon>
<span>{{ step.label }}</span>
</div>
}
</div>
<p class="block-msg">This client cannot be onboarded. Account opening is blocked due to sanctions match.</p>
<p class="sanctions-msg">
Screening <strong>{{ clientName }}</strong> against<br />
<strong>UN Security Council</strong> &amp;
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
</p>
</div>
}
@if (!verifying && !matchFound && !errorOccurred) {
<div class="approve-card">
<div class="approve-icon-ring">
<fa-icon icon="shield-alt" class="approve-shield"></fa-icon>
</div>
<div class="approve-badge">CLEARED</div>
<h3 class="approve-title">No Sanctions Match Found</h3>
<p class="approve-detail">
<strong>{{ clientName }}</strong> has been screened against the
<strong>UN Security Council Consolidated List</strong> and
<strong>FIU-IND</strong> records. No confirmed match was found.
</p>
<div class="approve-meta">
<div class="meta-row">
<span class="meta-label">Screened On</span>
<span class="meta-value">{{ screenedDate }}</span>
</div>
<div class="meta-row">
<span class="meta-label">Status</span>
<span class="meta-value meta-clear">Customer is Clear</span>
</div>
<div class="meta-row">
<span class="meta-label">Database</span>
<span class="meta-value">UN SC Consolidated List</span>
</div>
</div>
</div>
}
@if (!verifying && matchFound) {
<div class="reject-card">
<div class="reject-icon-ring">
<fa-icon icon="ban" class="reject-shield"></fa-icon>
</div>
<div class="reject-badge">CONFIRMED MATCH / REFER</div>
<h3 class="reject-title">Sanctions Match Found</h3>
<p class="reject-detail">
<strong class="reject-name">{{ clientName }}</strong> matched against
<strong>UN SC Consolidated List</strong>.
</p>
<div class="reject-meta">
<div class="meta-row">
<span class="meta-label">Screened On</span>
<span class="meta-value">{{ screenedDate }}</span>
</div>
<div class="meta-row">
<span class="meta-label">Status</span>
<span class="meta-value meta-danger">Match Found — Blocked</span>
</div>
<div class="meta-row">
<span class="meta-label">Database</span>
<span class="meta-value">UN SC Consolidated List</span>
</div>
</div>
<div class="escalate-banner">
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
</div>
</div>
}
@if (!verifying && errorOccurred) {
@@ -67,10 +102,15 @@ import { ClientsService } from 'app/clients/clients.service';
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!verifying && !matchFound && !errorOccurred) {
<button mat-raised-button color="primary" (click)="close(true)">OK</button>
<button mat-stroked-button (click)="close(true)" class="approve-btn-secondary">Dismiss</button>
<button mat-raised-button color="primary" (click)="close(true)" class="approve-btn">
<fa-icon icon="check"></fa-icon> Mark as Clear
</button>
}
@if (!verifying && matchFound) {
<button mat-raised-button color="warn" (click)="close(false)">Close</button>
<button mat-raised-button color="warn" (click)="close(false)" class="reject-btn">
<fa-icon icon="times"></fa-icon> Flag &amp; Escalate
</button>
}
@if (!verifying && errorOccurred) {
<button mat-raised-button (click)="close(false)">Close</button>
@@ -101,17 +141,250 @@ import { ClientsService } from 'app/clients/clients.service';
mat-dialog-content {
padding: 16px 24px;
max-height: 60vh;
max-height: unset;
overflow: visible;
}
.progress-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.progress-percent {
font-size: 13px;
font-weight: 700;
color: #1074b9;
}
.progress-steps {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
padding: 8px 16px;
}
.progress-step {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9ca3af;
transition: color 0.3s ease;
}
.progress-step.active {
color: #1074b9;
font-weight: 600;
}
.progress-step.done {
color: #388e3c;
}
.done-icon {
color: #388e3c;
}
.sanctions-msg {
font-size: 14px;
color: #555;
line-height: 1.6;
margin: 16px 0 0;
margin: 4px 0 0;
text-align: center;
}
.approve-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px;
gap: 12px;
}
.approve-icon-ring {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
display: flex;
align-items: center;
justify-content: center;
border: 3px solid #a7f3d0;
}
.approve-shield {
font-size: 28px;
color: #059669;
}
.approve-badge {
display: inline-block;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.5px;
color: #059669;
background: #ecfdf5;
border: 1px solid #a7f3d0;
padding: 3px 14px;
border-radius: 20px;
}
.approve-title {
font-size: 17px;
font-weight: 700;
color: #111827;
margin: 0;
}
.approve-detail {
font-size: 13px;
color: #6b7280;
text-align: center;
line-height: 1.6;
margin: 0;
max-width: 400px;
}
.approve-meta {
width: 100%;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
margin-top: 4px;
}
.meta-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid #f3f4f6;
}
.meta-row:last-child {
border-bottom: none;
}
.meta-label {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
.meta-value {
font-size: 12px;
color: #374151;
font-weight: 600;
}
.meta-clear {
color: #059669;
}
.approve-btn {
display: flex;
align-items: center;
gap: 6px;
}
.approve-btn-secondary {
margin-right: 8px;
}
.reject-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px;
gap: 12px;
}
.reject-icon-ring {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #fef2f2, #fecaca);
display: flex;
align-items: center;
justify-content: center;
border: 3px solid #fca5a5;
}
.reject-shield {
font-size: 28px;
color: #dc2626;
}
.reject-badge {
display: inline-block;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.5px;
color: #dc2626;
background: #fef2f2;
border: 1px solid #fecaca;
padding: 3px 14px;
border-radius: 20px;
}
.reject-title {
font-size: 17px;
font-weight: 700;
color: #111827;
margin: 0;
}
.reject-detail {
font-size: 13px;
color: #6b7280;
text-align: center;
line-height: 1.6;
margin: 0;
}
.reject-name {
color: #dc2626;
}
.reject-meta {
width: 100%;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
}
.meta-danger {
color: #dc2626;
}
.escalate-banner {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: #dc2626;
color: #fff;
padding: 10px 14px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
}
.escalate-icon {
font-size: 18px;
flex-shrink: 0;
}
.reject-btn {
display: flex;
align-items: center;
gap: 6px;
}
.sanctions-result {
display: flex;
flex-direction: column;
@@ -160,8 +433,8 @@ import { ClientsService } from 'app/clients/clients.service';
.match-list {
width: 100%;
max-height: 200px;
overflow-y: auto;
max-height: unset;
overflow: visible;
margin: 8px 0;
}
@@ -239,33 +512,113 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
matches: any[] = [];
errorOccurred = false;
errorMessage = '';
screeningLogId: number | null = null;
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
progressValue = 0;
currentStep = 0;
progressSteps = [
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
];
private progressTimer: any;
private apiResult: any = null;
private apiError: any = null;
private apiDone = false;
ngOnInit() {
this.clientName = this.data?.name || '';
this.dialogRef.disableClose = true;
// Start API call immediately
this.clientsService.screenSanctions(this.clientName).subscribe({
next: (result: any) => {
this.verifying = false;
this.dialogRef.disableClose = false;
if (result.matchFound) {
this.matchFound = true;
this.matchCount = result.matchCount || 0;
this.matches = result.matches || [];
}
this.apiResult = result;
this.apiDone = true;
},
error: (err: any) => {
this.verifying = false;
this.dialogRef.disableClose = false;
this.errorOccurred = true;
this.errorMessage = err?.error?.message || err?.message || 'Unable to connect to sanctions screening service.';
this.apiError = err;
this.apiDone = true;
}
});
// Animate progress through steps
this.runProgress();
}
ngOnDestroy() {}
private runProgress() {
const stepDurations = [1200, 1500, 1800, 1000]; // ms per step
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
let elapsed = 0;
const interval = 50;
this.progressTimer = setInterval(() => {
elapsed += interval;
// Calculate which step we're on
let accum = 0;
for (let i = 0; i < stepDurations.length; i++) {
accum += stepDurations[i];
if (elapsed <= accum) {
this.currentStep = i;
break;
}
}
// Update progress bar
this.progressValue = Math.min(Math.round((elapsed / totalDuration) * 95), 95);
// After all steps are done, wait for API then finish
if (elapsed >= totalDuration) {
if (this.apiDone) {
this.finishVerification();
} else {
// API still running — hold at 95% until done
this.currentStep = stepDurations.length - 1;
this.progressValue = 95;
clearInterval(this.progressTimer);
this.progressTimer = setInterval(() => {
if (this.apiDone) {
this.finishVerification();
}
}, 100);
}
}
}, interval);
}
private finishVerification() {
clearInterval(this.progressTimer);
this.progressValue = 100;
this.currentStep = this.progressSteps.length;
// Small delay after 100% before showing result
setTimeout(() => {
this.verifying = false;
this.dialogRef.disableClose = false;
if (this.apiError) {
this.errorOccurred = true;
this.errorMessage = this.apiError?.error?.message || this.apiError?.message || 'Unable to connect to sanctions screening service.';
} else {
this.screeningLogId = this.apiResult?.screeningLogId || null;
if (this.apiResult?.matchFound) {
this.matchFound = true;
this.matchCount = this.apiResult.matchCount || 0;
this.matches = this.apiResult.matches || [];
}
}
}, 400);
}
ngOnDestroy() {
clearInterval(this.progressTimer);
}
close(verified: boolean) {
this.dialogRef.close(verified);
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
}
}
@@ -0,0 +1,454 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="addr-container">
<div class="addr-header">
<div class="addr-header__left">
<fa-icon icon="map" class="addr-header__icon"></fa-icon>
<div>
<h3 class="addr-header__title">Address Details</h3>
<p class="addr-header__sub">All address fields are India-specific. PIN auto-lookup enabled.</p>
</div>
</div>
</div>
<mat-tab-group class="addr-tabs" [(selectedIndex)]="activeTab" animationDuration="150ms">
<!-- ═══ TAB 1: PERMANENT ═══ -->
<mat-tab>
<ng-template mat-tab-label>
<span class="addr-tab-num">1</span> Permanent Address
</ng-template>
<div class="addr-panel" [formGroup]="permanentForm">
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 1</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>home</mat-icon></span>
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 2</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>signpost</mat-icon></span>
<input matInput formControlName="addressLine2" placeholder="Street, Road, Lane, Colony" />
</mat-form-field>
</div>
<div class="addr-divider"><span>Location</span></div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>PIN Code</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
(input)="onPinInput('permanent')"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
@if (permanentForm.get('pinCode')?.hasError('pattern') && permanentForm.get('pinCode')?.touched) {
<mat-error>Enter a valid 6-digit PIN code</mat-error>
}
@if (pinStatus['permanent'].loading) {
<mat-hint class="pin-loading">Looking up...</mat-hint>
}
@if (pinStatus['permanent'].found) {
<mat-hint class="pin-ok">PIN verified</mat-hint>
}
@if (pinStatus['permanent'].error) {
<mat-error>{{ pinStatus['permanent'].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Locality / Area</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>location_on</mat-icon></span>
<mat-select required formControlName="locality">
@for (loc of localityOptions['permanent']; track loc) {
<mat-option [value]="loc">{{ loc }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="addr-grid addr-grid--3">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>City / Town</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>location_city</mat-icon></span>
<input matInput formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>District</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>map</mat-icon></span>
<input matInput formControlName="district" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>State</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>flag</mat-icon></span>
<input matInput required formControlName="state" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Country</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>public</mat-icon></span>
<input matInput formControlName="country" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Landmark</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>place</mat-icon></span>
<input matInput formControlName="landmark" placeholder="Near / Opp. / Behind..." />
</mat-form-field>
</div>
<div class="addr-divider"><span>Proof of Address</span></div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Proof Type</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>verified_user</mat-icon></span>
<mat-select required formControlName="proofType">
@for (pt of proofTypes; track pt) {
<mat-option [value]="pt">{{ pt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Document Number</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>tag</mat-icon></span>
<input matInput formControlName="proofDocNumber" placeholder="e.g. XXXX XXXX XXXX" />
</mat-form-field>
</div>
</div>
</mat-tab>
<!-- ═══ TAB 2: CURRENT / RESIDENTIAL ═══ -->
<mat-tab>
<ng-template mat-tab-label>
<span class="addr-tab-num">2</span> Current / Residential
</ng-template>
<div class="addr-panel" [formGroup]="currentForm">
<div class="addr-same-row">
<mat-checkbox [checked]="sameAsPermanent" (change)="toggleSameAsPermanent($event.checked)">
Same as Permanent Address
</mat-checkbox>
</div>
@if (!sameAsPermanent) {
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 1</mat-label>
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 2</mat-label>
<input matInput formControlName="addressLine2" placeholder="Street, Road, Lane, Colony" />
</mat-form-field>
</div>
<div class="addr-divider"><span>Location</span></div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>PIN Code</mat-label>
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
(input)="onPinInput('current')"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
@if (currentForm.get('pinCode')?.hasError('pattern') && currentForm.get('pinCode')?.touched) {
<mat-error>Enter a valid 6-digit PIN code</mat-error>
}
@if (pinStatus['current'].found) {
<mat-hint class="pin-ok">PIN verified</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Locality / Area</mat-label>
<mat-select required formControlName="locality">
@for (loc of localityOptions['current']; track loc) {
<mat-option [value]="loc">{{ loc }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="addr-grid addr-grid--3">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>City / Town</mat-label>
<input matInput formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>District</mat-label>
<input matInput formControlName="district" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>State</mat-label>
<input matInput required formControlName="state" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Country</mat-label>
<input matInput formControlName="country" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Landmark</mat-label>
<input matInput formControlName="landmark" placeholder="Near / Opp. / Behind..." />
</mat-form-field>
</div>
<div class="addr-divider"><span>Residency</span></div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Residence Type</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>cottage</mat-icon></span>
<mat-select formControlName="residenceType">
@for (rt of residenceTypes; track rt) {
<mat-option [value]="rt">{{ rt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Years at This Address</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>schedule</mat-icon></span>
<input matInput type="number" formControlName="yearsAtAddress" placeholder="e.g. 3" />
</mat-form-field>
</div>
} @else {
<div class="addr-same-info">
<fa-icon icon="check-circle" class="addr-same-icon"></fa-icon>
Current address is same as Permanent address.
</div>
}
</div>
</mat-tab>
<!-- ═══ TAB 3: OFFICE / WORK ═══ -->
<mat-tab>
<ng-template mat-tab-label>
<span class="addr-tab-num">3</span> Office / Work
</ng-template>
<div class="addr-panel" [formGroup]="officeForm">
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Organisation / Company Name</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>business</mat-icon></span>
<input matInput formControlName="orgName" placeholder="Legal name of employer / business" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Department / Unit</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>corporate_fare</mat-icon></span>
<input matInput formControlName="department" placeholder="e.g. Finance, Operations" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Office Address Line 1</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>home_work</mat-icon></span>
<input matInput formControlName="addressLine1" placeholder="Office No., Floor, Building Name" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Office Address Line 2</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>signpost</mat-icon></span>
<input matInput formControlName="addressLine2" placeholder="Street, Road, Industrial Area" />
</mat-form-field>
</div>
<div class="addr-divider"><span>Location</span></div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>PIN Code</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
<input matInput formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
(input)="onPinInput('office')"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
@if (officeForm.get('pinCode')?.hasError('pattern') && officeForm.get('pinCode')?.touched) {
<mat-error>Enter a valid 6-digit PIN code</mat-error>
}
@if (pinStatus['office'].found) {
<mat-hint class="pin-ok">PIN verified</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Locality / Area</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>location_on</mat-icon></span>
<mat-select formControlName="locality">
@for (loc of localityOptions['office']; track loc) {
<mat-option [value]="loc">{{ loc }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="addr-grid addr-grid--3">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>City / Town</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>location_city</mat-icon></span>
<input matInput formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>District</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>map</mat-icon></span>
<input matInput formControlName="district" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>State</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>flag</mat-icon></span>
<input matInput formControlName="state" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Country</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>public</mat-icon></span>
<input matInput formControlName="country" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Office Phone</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>phone</mat-icon></span>
<input matInput formControlName="officePhone" placeholder="+91 XXXXX XXXXX" />
</mat-form-field>
</div>
<div class="addr-divider"><span>Employment Details</span></div>
<div class="addr-grid addr-grid--3">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Employment Type</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>work</mat-icon></span>
<mat-select formControlName="employmentType">
@for (et of employmentTypes; track et) {
<mat-option [value]="et">{{ et }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Employee / Staff ID</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>badge</mat-icon></span>
<input matInput formControlName="employeeId" placeholder="Optional" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Years with Organisation</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>schedule</mat-icon></span>
<input matInput type="number" formControlName="yearsWithOrg" placeholder="e.g. 5" />
</mat-form-field>
</div>
</div>
</mat-tab>
<!-- ═══ TAB 4: CORRESPONDENCE ═══ -->
<mat-tab>
<ng-template mat-tab-label>
<span class="addr-tab-num">4</span> Correspondence
</ng-template>
<div class="addr-panel" [formGroup]="correspondenceForm">
<div class="addr-copy-row">
<span class="addr-copy-label">Copy address from:</span>
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('permanent')">Permanent</button>
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('current')">Current</button>
<button mat-stroked-button type="button" class="addr-copy-btn" (click)="copyFrom('office')">Office</button>
</div>
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 1</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>home</mat-icon></span>
<input matInput required formControlName="addressLine1" placeholder="House / Flat No., Floor, Building Name" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--1">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Line 2</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>signpost</mat-icon></span>
<input matInput formControlName="addressLine2" placeholder="Street, Road, Lane, Colony" />
</mat-form-field>
</div>
<div class="addr-divider"><span>Location</span></div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>PIN Code</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>pin_drop</mat-icon></span>
<input matInput required formControlName="pinCode" maxlength="6" placeholder="6-digit PIN"
(input)="onPinInput('correspondence')"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
@if (correspondenceForm.get('pinCode')?.hasError('pattern') && correspondenceForm.get('pinCode')?.touched) {
<mat-error>Enter a valid 6-digit PIN code</mat-error>
}
@if (pinStatus['correspondence'].found) {
<mat-hint class="pin-ok">PIN verified</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Locality / Area</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>location_on</mat-icon></span>
<mat-select required formControlName="locality">
@for (loc of localityOptions['correspondence']; track loc) {
<mat-option [value]="loc">{{ loc }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="addr-grid addr-grid--3">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>City / Town</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>location_city</mat-icon></span>
<input matInput formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>District</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>map</mat-icon></span>
<input matInput formControlName="district" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>State</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>flag</mat-icon></span>
<input matInput required formControlName="state" />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Country</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>public</mat-icon></span>
<input matInput formControlName="country" />
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Landmark</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>place</mat-icon></span>
<input matInput formControlName="landmark" placeholder="Near / Opp. / Behind..." />
</mat-form-field>
</div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Preferred Communication Mode</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>mail</mat-icon></span>
<mat-select formControlName="commMode">
@for (cm of commModes; track cm) {
<mat-option [value]="cm">{{ cm }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Contact Number for Correspondence</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>phone_android</mat-icon></span>
<input matInput formControlName="contactNumber" placeholder="+91 XXXXX XXXXX" />
</mat-form-field>
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
@@ -0,0 +1,201 @@
/**
* Copyright since 2025 Mifos Initiative
*/
$blue: #1353a0;
$navy: #0f172a;
$border: #e2e8f0;
$muted: #64748b;
.addr-container {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.addr-header {
background: $navy;
padding: 16px 24px;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 18px;
color: #60a5fa;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #fff;
margin: 0;
}
&__sub {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
margin: 2px 0 0;
}
}
// Tabs
.addr-tabs {
::ng-deep .mat-mdc-tab-labels {
background: #f8fafc;
border-bottom: 1px solid $border;
}
}
.addr-tab-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
font-size: 10px;
font-weight: 600;
margin-right: 6px;
background: #f1f5f9;
color: $muted;
}
// Panel
.addr-panel {
padding: 24px;
}
// Grids
.addr-grid {
display: grid;
gap: 4px 16px;
margin-bottom: 4px;
&--1 { grid-template-columns: 1fr; }
&--2 { grid-template-columns: 1fr 1fr; }
&--3 { grid-template-columns: 1fr 1fr 1fr; }
}
.addr-ff {
width: 100%;
}
// Divider
.addr-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 14px;
span {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: $muted;
white-space: nowrap;
}
&::before, &::after {
content: '';
flex: 1;
height: 1px;
background: $border;
}
}
// PIN status hints
.pin-loading {
color: $muted !important;
}
.pin-ok {
color: #059669 !important;
font-weight: 600;
}
// Same as permanent
.addr-same-row {
padding: 10px 14px;
background: #f8fafc;
border: 1px solid $border;
border-radius: 6px;
margin-bottom: 18px;
}
.addr-same-info {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 18px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: #065f46;
}
.addr-same-icon {
font-size: 16px;
color: #059669;
}
// Copy row
.addr-copy-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid $border;
border-radius: 6px;
margin-bottom: 18px;
}
.addr-copy-label {
font-size: 12px;
font-weight: 500;
color: $muted;
}
.addr-copy-btn {
font-size: 12px !important;
padding: 0 12px !important;
height: 30px !important;
border-radius: 4px !important;
}
// Responsive
@media (max-width: 768px) {
.addr-grid--2, .addr-grid--3 {
grid-template-columns: 1fr;
}
.addr-panel {
padding: 16px;
}
}
.addr-prefix {
display: inline-flex;
align-items: center;
padding-left: 4px;
padding-right: 8px;
margin-right: 8px;
border-right: 1.5px solid #cbd5e1;
line-height: 1;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #1565c0;
}
}
@@ -0,0 +1,319 @@
/**
* 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/.
*/
import { Component, Input, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatTabsModule } from '@angular/material/tabs';
import { MatIcon } from '@angular/material/icon';
import { ClientsService } from '../../clients.service';
@Component({
selector: 'mifosx-client-india-address-step',
templateUrl: './client-india-address-step.component.html',
styleUrls: ['./client-india-address-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatCheckbox,
MatTabsModule,
MatIcon
]
})
export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
private fb = inject(UntypedFormBuilder);
private http = inject(HttpClient);
private clientsService = inject(ClientsService);
/** Client ID — when set, loads existing addresses for edit mode */
@Input() clientId: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
activeTab = 0;
sameAsPermanent = false;
permanentForm: UntypedFormGroup = this.fb.group({
addressLine1: ['', Validators.required],
addressLine2: [''],
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
locality: ['', Validators.required],
city: [''],
district: [''],
state: ['', Validators.required],
country: [{ value: 'India', disabled: true }],
landmark: [''],
proofType: ['', Validators.required],
proofDocNumber: ['']
});
currentForm: UntypedFormGroup = this.fb.group({
addressLine1: ['', Validators.required],
addressLine2: [''],
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
locality: ['', Validators.required],
city: [''],
district: [''],
state: ['', Validators.required],
country: [{ value: 'India', disabled: true }],
landmark: [''],
residenceType: [''],
yearsAtAddress: ['']
});
officeForm: UntypedFormGroup = this.fb.group({
orgName: [''],
department: [''],
addressLine1: [''],
addressLine2: [''],
pinCode: ['', Validators.pattern(/^\d{6}$/)],
locality: [''],
city: [''],
district: [''],
state: [''],
country: [{ value: 'India', disabled: true }],
officePhone: [''],
employmentType: [''],
employeeId: [''],
yearsWithOrg: ['']
});
correspondenceForm: UntypedFormGroup = this.fb.group({
addressLine1: ['', Validators.required],
addressLine2: [''],
pinCode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
locality: ['', Validators.required],
city: [''],
district: [''],
state: ['', Validators.required],
country: [{ value: 'India', disabled: true }],
landmark: [''],
commMode: [''],
contactNumber: ['']
});
// PIN lookup state per tab
pinStatus: Record<string, { loading: boolean; found: boolean; error: boolean; message: string }> = {
permanent: { loading: false, found: false, error: false, message: '' },
current: { loading: false, found: false, error: false, message: '' },
office: { loading: false, found: false, error: false, message: '' },
correspondence: { loading: false, found: false, error: false, message: '' }
};
localityOptions: Record<string, string[]> = {
permanent: [],
current: [],
office: [],
correspondence: []
};
proofTypes = [
'Aadhaar Card', 'Passport', 'Voter ID', 'Driving Licence',
'Utility Bill (not older than 3 months)', 'Bank Statement'
];
residenceTypes = ['Owned', 'Rented', 'Leased', 'Company Provided', 'Parental / Family Owned'];
employmentTypes = [
'Salaried (Private)', 'Salaried (Government / PSU)',
'Self-Employed Professional', 'Business Owner', 'Retired'
];
commModes = ['Post / Courier', 'Email', 'SMS', 'WhatsApp'];
private pinTimers: Record<string, any> = {};
private pinCache: Record<string, any[]> = {};
private addressLoaded = false;
/** Address type name → tab mapping for loading existing data */
private typeToTab: Record<string, string> = {};
ngOnInit(): void {
if (this.clientId) {
this.loadExistingAddresses();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['readOnly']) {
const forms = [this.permanentForm, this.currentForm, this.officeForm, this.correspondenceForm];
for (const form of forms) {
if (this.readOnly) {
form.disable({ emitEvent: false });
} else {
form.enable({ emitEvent: false });
}
}
}
}
/**
* Load existing addresses from Fineract API and populate forms.
* Maps Fineract fields back to India address fields.
*/
private loadExistingAddresses(): void {
if (this.addressLoaded) return;
this.clientsService.getClientAddressData(this.clientId).subscribe({
next: (addresses: any[]) => {
this.addressLoaded = true;
if (!addresses?.length) return;
// Build type-to-tab mapping from address type names
const tabMap: Record<string, string> = {
permanent: 'permanent', 'permanent address': 'permanent',
residential: 'current', current: 'current', 'current / residential': 'current', communication: 'current',
office: 'office', 'office / work': 'office', business: 'office', work: 'office',
correspondence: 'correspondence', mailing: 'correspondence', postal: 'correspondence'
};
for (const addr of addresses) {
const typeName = (addr.addressType || '').toLowerCase().trim();
const tab = tabMap[typeName] || (addresses.indexOf(addr) === 0 ? 'permanent' : null);
if (!tab) continue;
const form = this.getForm(tab);
form.patchValue({
addressLine1: addr.addressLine1 || '',
addressLine2: addr.addressLine2 || '',
pinCode: addr.postalCode || '',
locality: addr.townVillage || '',
city: addr.city || '',
district: addr.countyDistrict || '',
state: addr.stateName || '',
landmark: addr.street || ''
});
}
},
error: () => { this.addressLoaded = true; }
});
}
onPinInput(tab: string) {
const form = this.getForm(tab);
const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || '';
form.get('pinCode')?.setValue(pin, { emitEvent: false });
clearTimeout(this.pinTimers[tab]);
if (pin.length < 6) {
this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' };
this.clearPinFields(tab);
return;
}
if (this.pinCache[pin]) {
this.applyPinData(tab, this.pinCache[pin]);
return;
}
this.pinStatus[tab] = { loading: true, found: false, error: false, message: 'Looking up...' };
this.pinTimers[tab] = setTimeout(() => {
this.http.get<any[]>(`https://api.postalpincode.in/pincode/${pin}`).subscribe({
next: (data: any) => {
if (data[0]?.Status === 'Success') {
this.pinCache[pin] = data[0].PostOffice;
this.applyPinData(tab, data[0].PostOffice);
this.pinStatus[tab] = { loading: false, found: true, error: false, message: 'Found' };
} else {
this.clearPinFields(tab);
this.pinStatus[tab] = { loading: false, found: false, error: true, message: 'Invalid PIN' };
}
},
error: () => {
this.pinStatus[tab] = { loading: false, found: false, error: true, message: 'Network error' };
}
});
}, 550);
}
private applyPinData(tab: string, offices: any[]) {
const seen = new Set<string>();
this.localityOptions[tab] = offices.filter(o => {
if (seen.has(o.Name)) return false;
seen.add(o.Name);
return true;
}).map(o => o.Name);
const form = this.getForm(tab);
const f = offices[0];
form.patchValue({
locality: this.localityOptions[tab][0] || '',
city: f.Block || f.Division || f.District || '',
district: f.District || '',
state: f.State || ''
});
}
private clearPinFields(tab: string) {
this.localityOptions[tab] = [];
const form = this.getForm(tab);
form.patchValue({ locality: '', city: '', district: '', state: '' });
}
getForm(tab: string): UntypedFormGroup {
switch (tab) {
case 'permanent': return this.permanentForm;
case 'current': return this.currentForm;
case 'office': return this.officeForm;
case 'correspondence': return this.correspondenceForm;
default: return this.permanentForm;
}
}
toggleSameAsPermanent(checked: boolean) {
this.sameAsPermanent = checked;
if (checked) {
const pv = this.permanentForm.getRawValue();
this.currentForm.patchValue({
addressLine1: pv.addressLine1,
addressLine2: pv.addressLine2,
pinCode: pv.pinCode,
locality: pv.locality,
city: pv.city,
district: pv.district,
state: pv.state,
landmark: pv.landmark
});
this.localityOptions['current'] = [...this.localityOptions['permanent']];
this.pinStatus['current'] = { ...this.pinStatus['permanent'] };
}
}
copyFrom(source: string) {
const srcForm = this.getForm(source);
const sv = srcForm.getRawValue();
this.correspondenceForm.patchValue({
addressLine1: sv.addressLine1,
addressLine2: sv.addressLine2,
pinCode: sv.pinCode,
locality: sv.locality,
city: sv.city,
district: sv.district,
state: sv.state,
landmark: sv.landmark || ''
});
this.localityOptions['correspondence'] = [...(this.localityOptions[source] || [])];
this.pinStatus['correspondence'] = { ...this.pinStatus[source] };
}
/** Returns all address data for submission */
get addressData(): any {
return {
permanent: this.permanentForm.getRawValue(),
current: this.sameAsPermanent ? { ...this.permanentForm.getRawValue(), sameAsPermanent: true } : this.currentForm.getRawValue(),
office: this.officeForm.getRawValue(),
correspondence: this.correspondenceForm.getRawValue()
};
}
get hasAddressData(): boolean {
return this.permanentForm.get('addressLine1')?.value?.length > 0;
}
}
@@ -1,65 +1,808 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="mat-typography" [formGroup]="kycForm">
<div class="step-header">
<div class="step-header__left">
<fa-icon icon="id-card" class="step-header__icon"></fa-icon>
<div>
<h3 class="step-header__title">KYC &amp; Documents</h3>
<p class="step-header__sub">RBI-compliant KYC verification — PAN, Aadhaar &amp; document upload.</p>
</div>
</div>
</div>
<!-- ═══ SECTION: KYC Verification ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="fingerprint" class="section-icon"></fa-icon>
{{ 'labels.inputs.KYC Verification' | translate }}
KYC Verification
<span class="kyc-badge">KYC</span>
</h4>
<div class="kyc-info-hint">
<mat-icon class="kyc-info-hint-icon">info</mat-icon>
<span>Verification (PAN, Aadhaar, GSTIN) will be available from the KYC tab after the client is created.</span>
<div class="form-grid">
<!-- ══ INDIVIDUAL KYC ══ -->
@if (!isNonIndividual) {
<div class="form-field checkbox-field full-width">
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
</div>
<!-- Individual PAN with inline verify -->
@if (kycForm.get('hasPan')?.value) {
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>PAN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="panNumber" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@if (panVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (panVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
<mat-hint>Format: ABCDE1234F</mat-hint>
</mat-form-field>
<div class="verify-action">
@if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying"
(click)="verifyPan()">
@if (panVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
}
</button>
}
@if (panVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (panVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>PAN Verified Successfully</span>
</div>
<div class="pan-result-body">
<div class="pan-result-row">
<span class="pan-result-label">Full Name (as per PAN)</span>
<span class="pan-result-value name">{{ panVerifyName }}</span>
</div>
<div class="pan-result-row">
<span class="pan-result-label">PAN–Aadhaar Link Status</span>
<span class="pan-result-value" [class.seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'Y'" [class.not-seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'N'">
@if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') { Linked }
@else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') { Not Linked }
@else { — }
</span>
</div>
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ panVerifiedOn }}</span>
</div>
</div>
</div>
}
}
<!-- Aadhaar with independent verify (Individual only) -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>Aadhaar Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>fingerprint</mat-icon></span>
<input matInput formControlName="aadhaarNumber" placeholder="12-digit Aadhaar"
maxlength="12" [type]="aadhaarVisible ? 'text' : 'password'" [readonly]="aadhaarOtpSent"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
<button mat-icon-button matSuffix type="button" (click)="aadhaarVisible = !aadhaarVisible" class="toggle-vis-btn">
<fa-icon [icon]="aadhaarVisible ? 'eye-slash' : 'eye'"></fa-icon>
</button>
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
<mat-error>Must be 12 digits</mat-error>
}
@if (aadhaarVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if ((aadhaarVerifyStatus === 'NONE' || aadhaarVerifyStatus === 'FAILED') && !aadhaarOtpSent) {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('aadhaarNumber')?.value || kycForm.get('aadhaarNumber')?.invalid || aadhaarVerifying"
(click)="sendAadhaarOtp()">
@if (aadhaarVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify Aadhaar
}
</button>
}
@if (aadhaarVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
<!-- Aadhaar OTP Section -->
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
<div class="otp-card full-width">
<div class="otp-card__header">
<div class="otp-card__header-icon">
<mat-icon>verified_user</mat-icon>
</div>
<div class="otp-card__header-text">
<span class="otp-card__title">Aadhaar OTP Verification</span>
<span class="otp-card__subtitle">A 6-digit OTP has been sent to the Aadhaar-linked mobile number</span>
</div>
</div>
<div class="otp-card__body">
<label class="otp-card__label">Enter OTP</label>
<div class="otp-digits" (paste)="onOtpPaste($event)">
@for (digit of otpDigits; track $index) {
<input type="text" inputmode="numeric" maxlength="1"
class="otp-digit-box" [class.filled]="digit"
[value]="digit"
(input)="onOtpDigitInput($index, $event)"
(keydown)="onOtpKeydown($index, $event)"
autocomplete="one-time-code" />
}
</div>
<div class="otp-card__actions">
<button mat-flat-button color="primary" type="button" class="otp-card__verify-btn"
[disabled]="readOnly || aadhaarOtp.length < 6 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()">
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate" class="otp-spinner"></mat-progress-spinner>
Verifying...
} @else {
<fa-icon icon="check-circle" class="otp-btn-icon"></fa-icon>
Verify OTP
}
</button>
<div class="otp-card__resend">
@if (resendCountdown > 0) {
<span class="otp-card__countdown">
Resend in <strong>00:{{ resendCountdown.toString().padStart(2, '0') }}</strong>
</span>
} @else {
<span class="otp-card__resend-text">Didn't receive the code?</span>
<button mat-button type="button" class="otp-card__resend-btn"
[disabled]="readOnly || aadhaarVerifying" (click)="sendAadhaarOtp()">
Resend OTP
</button>
}
</div>
</div>
</div>
</div>
}
@if (aadhaarVerifyError) {
<div class="verify-error full-width"><fa-icon icon="exclamation-circle"></fa-icon> {{ aadhaarVerifyError }}</div>
}
@if (aadhaarVerifyStatus === 'VERIFIED' && aadhaarVerifyName) {
<div class="aadhaar-result-card full-width">
<div class="aadhaar-result-card__header">
<fa-icon icon="check-circle" class="aadhaar-result-card__icon"></fa-icon>
<span>Aadhaar Verified Successfully</span>
</div>
<div class="aadhaar-result-card__body">
<span class="aadhaar-result-card__label">Name as per Aadhaar</span>
<span class="aadhaar-result-card__name">{{ aadhaarVerifyName }}</span>
</div>
</div>
}
<!-- PAN-Aadhaar Seeding Status -->
@if (kycForm.get('hasPan')?.value && panAadhaarSeedingStatus) {
<div class="form-field pan-link-status full-width">
<div class="pan-link-box" [class.linked]="panAadhaarSeedingStatus === 'Y'" [class.not-linked]="panAadhaarSeedingStatus === 'N'" [class.exempt]="panAadhaarSeedingStatus === 'E'">
<fa-icon icon="link" class="pan-link-icon"></fa-icon>
<div class="pan-link-info">
<span class="pan-link-label">PAN–Aadhaar Link Status</span>
<span class="pan-link-value">
@if (panAadhaarSeedingStatus === 'Y') { Linked (Seeded) }
@if (panAadhaarSeedingStatus === 'N') { Not Linked }
@if (panAadhaarSeedingStatus === 'E') { Exempt }
</span>
</div>
<span class="pan-link-hint">As per Income Tax Portal — Section 139AA</span>
</div>
</div>
}
}
<!-- ══ NON-INDIVIDUAL KYC ══ -->
@if (isNonIndividual) {
<!-- Incorporation Date -->
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
<mat-label>Incorporation Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
<mat-datepicker #incorpDatePicker></mat-datepicker>
</mat-form-field>
<!-- GSTIN (all non-individual) -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>GSTIN Number</mat-label>
<input matInput formControlName="gstinNumber" placeholder="15-character GSTIN"
maxlength="15" style="text-transform: uppercase"
(input)="kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
</mat-form-field>
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>PAN of Proprietor</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="panNumber" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@if (panVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (panVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
<mat-hint>PAN of Proprietor (no separate entity PAN needed)</mat-hint>
</mat-form-field>
<div class="verify-action">
@if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying || !clientId"
(click)="verifyPan()">
@if (panVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
}
</button>
}
@if (panVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (panVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>PAN Verified Successfully</span>
</div>
<div class="pan-result-body">
<div class="pan-result-row">
<span class="pan-result-label">Proprietor Name (as per PAN)</span>
<span class="pan-result-value name">{{ panVerifyName }}</span>
</div>
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ panVerifiedOn }}</span>
</div>
</div>
</div>
}
} @else {
<!-- Non-Proprietorship: Enterprise/Company PAN -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>{{ constitutionMeta?.panLabel || 'PAN of Entity' }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="enterprisePan" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('enterprisePan')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
@if (kycForm.get('enterprisePan')?.hasError('pattern') && kycForm.get('enterprisePan')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@if (entPanVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (entPanVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
<mat-hint>{{ constitutionMeta?.panHint || 'PAN mandatory' }}</mat-hint>
</mat-form-field>
<div class="verify-action">
@if (entPanVerifyStatus === 'NONE' || entPanVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('enterprisePan')?.value || kycForm.get('enterprisePan')?.invalid || entPanVerifying || !clientId"
(click)="verifyEnterprisePan()">
@if (entPanVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify PAN
}
</button>
}
@if (entPanVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (entPanVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>Enterprise PAN Verified</span>
</div>
<div class="pan-result-body">
<div class="pan-result-row">
<span class="pan-result-label">Entity Name (as per PAN)</span>
<span class="pan-result-value name">{{ entPanVerifyName }}</span>
</div>
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ entPanVerifiedOn }}</span>
</div>
</div>
</div>
}
}
<!-- CIN (Companies & LLP only) -->
@if (constitutionMeta?.showCin) {
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>CIN (Corporate Identity Number)</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>business</mat-icon></span>
<input matInput formControlName="cinNumber" maxlength="21" placeholder="From MCA"
style="text-transform: uppercase"
(input)="kycForm.get('cinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
@if (kycForm.get('cinNumber')?.hasError('pattern') && kycForm.get('cinNumber')?.touched) {
<mat-error>Invalid CIN format (21 characters)</mat-error>
}
@if (cinVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('cinNumber')?.value || cinVerifying"
(click)="verifyCin()">
@if (cinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
}
</button>
}
@if (cinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
<!-- CIN verification progress -->
@if (cinVerifying) {
<div class="cin-progress full-width">
<mat-progress-bar mode="determinate" [value]="cinProgressValue" color="primary"></mat-progress-bar>
<div class="cin-progress-percent">{{ cinProgressValue }}%</div>
<div class="cin-progress-steps">
@for (step of cinProgressSteps; track step.label; let i = $index) {
<div class="cin-step" [class.active]="i === cinCurrentStep" [class.done]="i < cinCurrentStep">
<fa-icon [icon]="i < cinCurrentStep ? 'check-circle' : step.icon" [class.done-icon]="i < cinCurrentStep"></fa-icon>
<span>{{ step.label }}</span>
</div>
}
</div>
</div>
}
@if (cinVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>CIN Verified — {{ cinCompanyName }}</span>
</div>
<div class="pan-result-body">
<div class="pan-result-row">
<span class="pan-result-label">CIN</span>
<span class="pan-result-value name">{{ kycForm.get('cinNumber')?.value }}</span>
</div>
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ cinVerifiedOn }}</span>
</div>
</div>
</div>
}
}
}
</div>
@if (!clientId) {
<div class="verify-info-banner">
<fa-icon icon="info-circle" class="verify-info-icon"></fa-icon>
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
</div>
}
</div>
<!-- ═══ SECTION: KYC Document Management ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="file-alt" class="section-icon"></fa-icon>
KYC Document
<span class="kyc-badge doc-badge">OVD</span>
@if (kycTemplate?.clientTypeName) {
<span class="kyc-client-type-chip">{{ kycTemplate.clientTypeName }}</span>
}
</h4>
<div class="form-grid">
<!-- PAN -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
<input matInput formControlName="panNumber"
placeholder="{{ 'labels.inputs.Enter 10-character PAN' | translate }}"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 10-character PAN' | translate }}</mat-error>
<mat-label>KYC Document Type</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>description</mat-icon></span>
<mat-select required formControlName="kycDocumentName">
@for (doc of kycDocumentTypes; track doc) {
<mat-option [value]="doc.name">
{{ doc.name }}
@if (doc.mandatory) {
<span class="doc-mandatory-star"> *</span>
}
</mat-option>
}
</mat-select>
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
<mat-error>KYC Document Type is <strong>required</strong></mat-error>
}
</mat-form-field>
<!-- Aadhaar -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
<input matInput formControlName="aadhaarNumber"
placeholder="{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}"
maxlength="12" type="password" />
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
<mat-label>Document Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>tag</mat-icon></span>
<input matInput required formControlName="kycDocumentNumber" placeholder="As printed on document" maxlength="30"
style="text-transform: uppercase"
(input)="kycForm.get('kycDocumentNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
@if (kycForm.get('kycDocumentNumber')?.hasError('required') && kycForm.get('kycDocumentNumber')?.touched) {
<mat-error>Document number is <strong>required</strong></mat-error>
} @else if (kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched) {
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
}
</mat-form-field>
<!-- GSTIN -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>{{ 'labels.inputs.GSTIN Number' | translate }}</mat-label>
<input matInput formControlName="gstinNumber"
placeholder="{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}"
maxlength="15" style="text-transform: uppercase"
(input)="kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}</mat-error>
}
<mat-form-field appearance="outline" class="form-field" (click)="docIssueDatePicker.open()">
<mat-label>Issue Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
<input matInput [max]="today" [matDatepicker]="docIssueDatePicker" formControlName="kycDocIssueDate" />
<mat-datepicker-toggle matSuffix [for]="docIssueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #docIssueDatePicker></mat-datepicker>
</mat-form-field>
<!-- Remarks -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<textarea matInput formControlName="remarks" rows="3"></textarea>
<mat-form-field appearance="outline" class="form-field" (click)="docExpiryDatePicker.open()">
<mat-label>Expiry Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>event_busy</mat-icon></span>
<input matInput [matDatepicker]="docExpiryDatePicker" formControlName="kycDocExpiryDate" />
<mat-datepicker-toggle matSuffix [for]="docExpiryDatePicker"></mat-datepicker-toggle>
<mat-datepicker #docExpiryDatePicker></mat-datepicker>
<mat-hint>If applicable (Passport, DL, Visa)</mat-hint>
</mat-form-field>
</div>
</div>
<!-- ═══ SECTION: Risk & KYC Review ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="shield-alt" class="section-icon"></fa-icon>
Risk Category &amp; KYC Review
</h4>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Risk Category</mat-label>
<mat-select formControlName="riskCategory">
<mat-select-trigger>
@if (kycForm.get('riskCategory')?.value === 1) {
<span class="risk-option"><span class="risk-meter risk-low"></span> Low Risk</span>
} @else if (kycForm.get('riskCategory')?.value === 2) {
<span class="risk-option"><span class="risk-meter risk-medium"></span> Medium Risk</span>
} @else if (kycForm.get('riskCategory')?.value === 3) {
<span class="risk-option"><span class="risk-meter risk-high"></span> High Risk</span>
}
</mat-select-trigger>
<mat-option [value]="1">
<span class="risk-option"><span class="risk-meter risk-low"></span> Low Risk <span class="risk-hint">10 yr renewal</span></span>
</mat-option>
<mat-option [value]="2">
<span class="risk-option"><span class="risk-meter risk-medium"></span> Medium Risk <span class="risk-hint">8 yr renewal</span></span>
</mat-option>
<mat-option [value]="3">
<span class="risk-option"><span class="risk-meter risk-high"></span> High Risk <span class="risk-hint">2 yr renewal</span></span>
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>KYC Review Due Date</mat-label>
<input matInput [value]="getKycExpiryDate(getRenewalYears())" disabled />
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
</mat-form-field>
</div>
<!-- KYC Expiry Alert Cards -->
<div class="kyc-expiry-section">
<div class="kyc-expiry-header">
<fa-icon icon="bell" class="kyc-expiry-header-icon"></fa-icon>
<span>KYC Expiry Alert</span>
</div>
<div class="kyc-expiry-body">
<div class="kyc-expiry-cards">
@for (rc of riskCards; track rc.value) {
<div class="kyc-expiry-card" [class.active]="kycForm.get('riskCategory')?.value === rc.value">
<span class="risk-gauge" [class]="'risk-gauge--' + rc.color">
<svg viewBox="0 0 40 24" class="risk-gauge-svg">
<path d="M4 22 A16 16 0 0 1 36 22" fill="none" stroke="#e2e8f0" stroke-width="4" stroke-linecap="round"/>
<path d="M4 22 A16 16 0 0 1 36 22" fill="none"
[attr.stroke]="rc.color === 'green' ? '#22c55e' : rc.color === 'amber' ? '#f59e0b' : '#ef4444'"
stroke-width="4" stroke-linecap="round"
[attr.stroke-dasharray]="rc.color === 'green' ? '16 50' : rc.color === 'amber' ? '33 50' : '50 50'"/>
<line x1="20" y1="22"
[attr.x2]="rc.color === 'green' ? '10' : rc.color === 'amber' ? '20' : '30'"
[attr.y2]="rc.color === 'green' ? '10' : rc.color === 'amber' ? '6' : '10'"
stroke="#334155" stroke-width="2" stroke-linecap="round"/>
<circle cx="20" cy="22" r="2" fill="#334155"/>
</svg>
</span>
<div class="kyc-expiry-card__info">
<span class="kyc-expiry-card__label">{{ rc.label }}</span>
<span class="kyc-expiry-card__cycle">Renewal every <strong>{{ rc.years }} years</strong></span>
</div>
@if (kycForm.get('riskCategory')?.value === rc.value) {
<span class="kyc-expiry-card__next">Next: {{ getKycExpiryDate(rc.years) }}</span>
}
</div>
}
</div>
@if (kycForm.get('riskCategory')?.value) {
<div class="kyc-expiry-alert"
[class.kyc-expiry-alert--low]="kycForm.get('riskCategory')?.value === 1"
[class.kyc-expiry-alert--medium]="kycForm.get('riskCategory')?.value === 2"
[class.kyc-expiry-alert--high]="kycForm.get('riskCategory')?.value === 3">
<fa-icon icon="exclamation-circle" class="kyc-expiry-alert__icon"></fa-icon>
<span>System will auto-alert before KYC expiry on <strong>{{ getKycExpiryDate(getRenewalYears()) }}</strong>.</span>
</div>
}
</div>
</div>
</div>
<!-- ═══ SECTION: AML & Compliance Checks ═══ -->
<div class="section-block">
<h4 class="section-title aml-title">
<fa-icon icon="exclamation-triangle" class="section-icon"></fa-icon>
AML &amp; Compliance Checks
</h4>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>AML Check Status</mat-label>
<mat-select required formControlName="amlCheckStatus">
<mat-option value="CLEAR">Clear</mat-option>
<mat-option value="FLAGGED">Flagged</mat-option>
<mat-option value="BLOCKED">Blocked</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Banned Entity List Match</mat-label>
<mat-select required formControlName="bannedEntityMatch">
<mat-option value="NO">No</mat-option>
<mat-option value="YES">Yes</mat-option>
<mat-option value="PARTIAL_MATCH">Partial Match</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Willful Defaulter Flag</mat-label>
<mat-select formControlName="willfulDefaulter">
<mat-option value="NO">No</mat-option>
<mat-option value="YES">Yes</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>ECGC SAL Flag</mat-label>
<mat-select formControlName="ecgcSalFlag">
<mat-option value="NO">No</mat-option>
<mat-option value="YES">Yes</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Dormant / Inoperative Flag</mat-label>
<mat-select formControlName="dormantFlag">
<mat-option value="ACTIVE">Active</mat-option>
<mat-option value="DORMANT">Dormant</mat-option>
<mat-option value="INOPERATIVE">Inoperative</mat-option>
</mat-select>
</mat-form-field>
</div>
@if (kycForm.get('amlCheckStatus')?.value === 'BLOCKED' || kycForm.get('bannedEntityMatch')?.value === 'YES') {
<div class="aml-block-alert">
<fa-icon icon="ban" class="aml-block-icon"></fa-icon>
<span><strong>BLOCKED</strong> — Account opening cannot proceed.</span>
</div>
}
</div>
<!-- ═══ SECTION: Freeze / Debit Controls ═══ -->
<div class="section-block">
<h4 class="section-title freeze-title">
<fa-icon icon="lock" class="section-icon"></fa-icon>
Freeze / Debit Controls
</h4>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Account Status</mat-label>
<mat-select formControlName="accountStatus">
<mat-option value="ACTIVE">Active</mat-option>
<mat-option value="FROZEN">Frozen</mat-option>
<mat-option value="UNFREEZE">Unfreeze</mat-option>
<mat-option value="NO_DEBIT">No Debit</mat-option>
</mat-select>
</mat-form-field>
@if (kycForm.get('accountStatus')?.value === 'FROZEN') {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Reason for Freeze</mat-label>
<mat-select required formControlName="freezeReason">
<mat-option value="COURT_ORDER">Court Order</mat-option>
<mat-option value="AML">AML</mat-option>
<mat-option value="REGULATORY">Regulatory</mat-option>
<mat-option value="INTERNAL">Internal</mat-option>
<mat-option value="OTHER">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Freeze Authority</mat-label>
<mat-select required formControlName="freezeAuthority">
<mat-option value="BRANCH_MANAGER">Branch Manager</mat-option>
<mat-option value="HO">Head Office</mat-option>
<mat-option value="REGULATORY">Regulatory Authority</mat-option>
</mat-select>
</mat-form-field>
}
</div>
</div>
<!-- ═══ SECTION: Form 60/61 ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
Form 60 / 61
@if (!kycForm.get('hasPan')?.value) {
<span class="kyc-badge required-badge">Required — No PAN</span>
}
</h4>
@if (!kycForm.get('hasPan')?.value) {
<div class="kyc-info-hint warning-hint">
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
<span>Client does not have PAN. Form 60/61 is mandatory.</span>
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form60Type">
<mat-option value="FORM_60">Form 60</mat-option>
<mat-option value="FORM_61">Form 61</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
<mat-label>Declaration Date</mat-label>
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
<mat-datepicker #form60DatePicker></mat-datepicker>
</mat-form-field>
</div>
} @else {
<p class="form60-not-needed">Client has PAN — Form 60/61 not required.</p>
}
</div>
<!-- ═══ SECTION: Form 15G / 15H ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="receipt" class="section-icon"></fa-icon>
Form 15G / 15H
</h4>
<div class="form-grid">
<div class="form-field checkbox-field">
<mat-checkbox formControlName="form15Applicable">Form 15G/15H Applicable</mat-checkbox>
</div>
@if (kycForm.get('form15Applicable')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form15Type">
<mat-option value="15G">15G (Below 60 years)</mat-option>
<mat-option value="15H">15H (Senior Citizen 60+)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Financial Year</mat-label>
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
</mat-form-field>
}
</div>
</div>
<!-- ═══ SECTION: Photograph & Signature ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="camera" class="section-icon"></fa-icon>
Photograph &amp; Signature
</h4>
<div class="photo-sig-grid">
<div class="upload-box" [class.upload-box--has-preview]="photographPreview">
@if (photographPreview) {
<div class="upload-preview">
<img [src]="photographPreview" alt="Photo" class="upload-preview__img upload-preview__img--photo" />
<div class="upload-preview__actions">
<label class="upload-preview__btn recrop">
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
<fa-icon icon="crop"></fa-icon> Re-crop
</label>
<button type="button" class="upload-preview__btn remove" (click)="removePhoto()">
<fa-icon icon="trash"></fa-icon> Remove
</button>
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
<span class="upload-box__label">Client Photograph</span>
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
<fa-icon icon="camera"></fa-icon> Choose &amp; Crop
</label>
}
</div>
<div class="upload-box" [class.upload-box--has-preview]="signaturePreview">
@if (signaturePreview) {
<div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
<div class="upload-preview__actions">
<label class="upload-preview__btn recrop">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Re-crop
</label>
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
<fa-icon icon="trash"></fa-icon> Remove
</button>
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">Specimen Signature</span>
<span class="upload-box__hint">Scanned image, max 1MB</span>
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
}
</div>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,82 +1,805 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
/** Custom Imports */
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { Inject } from '@angular/core';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatSelectTrigger } from '@angular/material/select';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* Client KYC Step Component
* Collects PAN, Aadhaar, and GSTIN details during client creation.
* KYC data is submitted via POST /clients/{clientId}/kyc after client creation.
* Verification happens later from the KYC tab on the client view page.
*/
@Component({
selector: 'mifosx-client-kyc-step',
templateUrl: './client-kyc-step.component.html',
styleUrls: ['./client-kyc-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatDivider,
MatIcon,
FaIconComponent
MatCheckbox,
MatProgressSpinner,
FaIconComponent,
MatSelectTrigger
]
})
export class ClientKycStepComponent {
export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private dialog = inject(MatDialog);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
/** KYC Reactive Form */
/** Set by parent after client is created */
@Input() clientId: string = '';
@Input() isNonIndividual = false;
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
@Input() constitutionMeta: any = null;
/** Entity full name (non-individual) — used for enterprise PAN verification */
@Input() entityName: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
today = new Date();
kycId: string = '';
/** Whether existing KYC data has been loaded (edit mode) */
kycLoaded = false;
/** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
/** Fallback document types when template API is unavailable */
private defaultDocumentTypes = [
'Aadhaar Card', 'Passport', 'PAN Card', 'Driving Licence',
'Voter ID', 'NREGA Job Card', 'National Population Register Letter'
];
/** Constitution-based KYC document types for non-individual clients */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
'HUF': [
{ name: 'PAN Card of HUF', mandatory: true },
{ name: 'Declaration by Karta (with name, address & HUF member details)', mandatory: true },
{ name: 'KYC of Karta (OVDs + PAN + Photo)', mandatory: true },
{ name: 'Proof of Address of HUF', mandatory: false }
],
'PRIVATE_LIMITED': [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'Memorandum & Articles of Association', mandatory: true },
{ name: 'PAN of Company', mandatory: true },
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'PUBLIC_LIMITED': [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'Memorandum & Articles of Association', mandatory: true },
{ name: 'PAN of Company', mandatory: true },
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'LLP': [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'LLP Agreement', mandatory: true },
{ name: 'PAN of LLP', mandatory: true },
{ name: 'Designated Partners Resolution', mandatory: true },
{ name: 'KYC of Designated Partners / Authorized Signatories', mandatory: true }
],
'PARTNERSHIP': [
{ name: 'Registration Certificate', mandatory: false },
{ name: 'Partnership Deed', mandatory: true },
{ name: 'PAN of Firm', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'TRUST': [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'Trust Deed', mandatory: true },
{ name: 'PAN of Trust', mandatory: true },
{ name: 'KYC of Trustees / Authorized Signatories', mandatory: true }
],
'SOCIETY': [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN of Society', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'PROPRIETORSHIP': [
{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true },
{ name: 'PAN of Proprietor', mandatory: true },
{ name: "Proprietor's KYC (OVDs + PAN + Photo)", mandatory: true }
],
'ASSOCIATION': [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN', mandatory: true },
{ name: 'Resolution authorizing signatories', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'GOVT_PSU': [
{ name: 'Government Notification / Order', mandatory: true },
{ name: 'PAN (if applicable)', mandatory: false },
{ name: 'Authorized Signatory KYC', mandatory: true }
],
'COOPERATIVE': [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN', mandatory: true },
{ name: 'Resolution authorizing signatories', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
]
};
/** KYC template data from API */
kycTemplate: any = null;
kycTemplateLoaded = false;
/** Risk cards */
riskCards = [
{ value: 1, label: 'Low Risk', years: 10, color: 'green' },
{ value: 2, label: 'Medium Risk', years: 8, color: 'amber' },
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
];
/** PAN verification */
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
panVerifying = false;
panVerifyError: string = '';
panVerifyName: string = '';
panVerifiedOn: string = '';
/** Aadhaar verification */
aadhaarVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
aadhaarVerifying = false;
aadhaarVerifyError: string = '';
aadhaarVerifyName: string = '';
aadhaarVisible = false;
aadhaarOtpSent = false;
aadhaarOtp = '';
aadhaarOtpVerifying = false;
aadhaarOtpReferenceId = '';
/** OTP digit boxes */
otpDigits: string[] = ['', '', '', '', '', ''];
/** Resend countdown */
resendCountdown = 0;
private resendTimer: any = null;
onOtpDigitInput(index: number, event: Event): void {
const input = event.target as HTMLInputElement;
const val = input.value.replace(/\D/g, '');
this.otpDigits[index] = val.charAt(0) || '';
input.value = this.otpDigits[index];
// Auto-advance to next box
if (this.otpDigits[index] && index < 5) {
const next = input.parentElement?.querySelectorAll('input')[index + 1];
if (next) (next as HTMLInputElement).focus();
}
this.aadhaarOtp = this.otpDigits.join('');
}
onOtpKeydown(index: number, event: KeyboardEvent): void {
if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
if (inputs) {
this.otpDigits[index - 1] = '';
(inputs[index - 1] as HTMLInputElement).value = '';
(inputs[index - 1] as HTMLInputElement).focus();
}
this.aadhaarOtp = this.otpDigits.join('');
}
}
onOtpPaste(event: ClipboardEvent): void {
event.preventDefault();
const pasted = (event.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6);
for (let i = 0; i < 6; i++) {
this.otpDigits[i] = pasted.charAt(i) || '';
}
this.aadhaarOtp = this.otpDigits.join('');
// Focus last filled or last box
const inputs = (event.target as HTMLElement).closest('.otp-digits')?.querySelectorAll('input');
if (inputs) {
const focusIdx = Math.min(pasted.length, 5);
(inputs[focusIdx] as HTMLInputElement).focus();
}
}
startResendCountdown(): void {
this.resendCountdown = 30;
if (this.resendTimer) clearInterval(this.resendTimer);
this.resendTimer = setInterval(() => {
this.resendCountdown--;
if (this.resendCountdown <= 0) {
clearInterval(this.resendTimer);
this.resendTimer = null;
}
}, 1000);
}
resetOtpDigits(): void {
this.otpDigits = ['', '', '', '', '', ''];
this.aadhaarOtp = '';
}
/** PAN-Aadhaar seeding status (from API response) */
panAadhaarSeedingStatus: string = '';
/** Enterprise PAN verification (non-individual) */
entPanVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
entPanVerifying = false;
entPanVerifyName = '';
entPanVerifiedOn = '';
/** CIN verification (dummy) */
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
cinVerifying = false;
cinVerifiedOn = '';
cinCompanyName = '';
cinProgressValue = 0;
cinCurrentStep = 0;
cinProgressSteps = [
{ label: 'Connecting to MCA database...', icon: 'globe' as any },
{ label: 'Validating CIN format...', icon: 'search' as any },
{ label: 'Fetching company details...', icon: 'database' as any },
{ label: 'Verifying registration status...', icon: 'check-circle' as any }
];
private cinProgressTimer: any;
/** File uploads */
photographFile: File | null = null;
photographPreview: string | null = null;
signatureFile: File | null = null;
signaturePreview: string | null = null;
/** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
panNumber: [
'',
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
],
aadhaarNumber: [
'',
[Validators.pattern(/^\d{12}$/)]
],
gstinNumber: [
'',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
],
remarks: ['']
hasPan: [true],
panNumber: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]],
aadhaarNumber: ['', [Validators.pattern(/^\d{12}$/), Validators.maxLength(12)]],
panAadhaarSeedingStatus: [''],
gstinNumber: ['', [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/), Validators.maxLength(15)]],
enterprisePan: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]],
cinNumber: ['', [Validators.pattern(/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/), Validators.maxLength(21)]],
incorporationDate: [''],
kycDocumentName: ['', Validators.required],
kycDocumentNumber: ['', [Validators.required, Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/), Validators.maxLength(30)]],
kycDocIssueDate: [''],
kycDocExpiryDate: [''],
riskCategory: [1],
remarks: [''],
amlCheckStatus: ['CLEAR', Validators.required],
bannedEntityMatch: ['NO', Validators.required],
willfulDefaulter: ['NO'],
ecgcSalFlag: ['NO'],
dormantFlag: ['ACTIVE'],
accountStatus: ['ACTIVE'],
freezeReason: [''],
freezeAuthority: [''],
form60Type: ['FORM_60'],
form60Date: [''],
form15Applicable: [false],
form15Type: ['15G'],
form15FY: ['']
});
/**
* Returns the KYC payload matching the backend API contract.
* POST /clients/{clientId}/kyc expects: kycType, riskCategory, panNumber,
* aadhaarNumberMasked (NOT full aadhaarNumber), gstinNumber, remarks.
*/
get kycData(): any {
ngOnInit(): void {
this.loadExistingKycIfNeeded();
this.loadKycTemplate();
if (this.isNonIndividual && this.constitutionMeta) {
this.updateDocTypesForConstitution();
}
}
ngOnDestroy(): void {
if (this.cinProgressTimer) clearInterval(this.cinProgressTimer);
if (this.resendTimer) clearInterval(this.resendTimer);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['clientId'] && !changes['clientId'].firstChange) {
this.loadExistingKycIfNeeded();
this.loadKycTemplate();
}
if (changes['readOnly'] && this.kycForm) {
if (this.readOnly) {
this.kycForm.disable({ emitEvent: false });
} else {
this.kycForm.enable({ emitEvent: false });
}
}
if (changes['constitutionMeta'] && this.isNonIndividual) {
this.updateDocTypesForConstitution();
}
}
/** Update KYC document types based on selected constitution type */
private updateDocTypesForConstitution(): void {
const constitutionValue = this.constitutionMeta?.value;
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map(doc => ({
name: doc.name,
code: doc.name,
mandatory: doc.mandatory
}));
// Reset selected document type since options changed
this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
}
}
/** Load existing KYC data if clientId is set (edit mode) */
private loadExistingKycIfNeeded(): void {
if (!this.clientId || this.kycLoaded) return;
this.clientsService.getClientKyc(this.clientId).subscribe({
next: (data: any) => {
this.kycLoaded = true;
if (data) {
this.kycId = data.id?.toString() || '';
this.kycForm.patchValue({
panNumber: data.panNumber || '',
aadhaarNumber: data.aadhaarNumberMasked || '',
gstinNumber: data.gstinNumber || '',
riskCategory: data.riskCategory || 1,
remarks: data.remarks || '',
hasPan: !!data.panNumber
});
// Set verification statuses from existing data
if (data.panVerified) {
this.panVerifyStatus = 'VERIFIED';
this.panVerifyName = data.panFullName || data.panHolderName || '';
const rawDate = data.panVerifiedOn;
if (Array.isArray(rawDate) && rawDate.length >= 3) {
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else if (rawDate) {
this.panVerifiedOn = rawDate;
}
}
if (data.aadhaarVerified) {
this.aadhaarVerifyStatus = 'VERIFIED';
this.aadhaarVerifyName = data.aadhaarHolderName || 'Aadhaar Verified';
}
if (data.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
}
}
},
error: () => {
// No existing KYC — that's fine for new clients
this.kycLoaded = true;
}
});
}
/** Load KYC template to get required documents based on client type */
private loadKycTemplate(): void {
if (!this.clientId || this.kycTemplateLoaded) return;
this.clientsService.getClientKycTemplate(this.clientId).subscribe({
next: (template: any) => {
this.kycTemplate = template;
this.kycTemplateLoaded = true;
if (template?.requiredDocuments?.length > 0) {
this.kycDocumentTypes = template.requiredDocuments.map((doc: any) => ({
name: doc.name,
code: doc.code || doc.name,
mandatory: doc.mandatory ?? false
}));
// Update kycDocumentName validator based on mandatory docs
const hasMandatory = this.kycDocumentTypes.some(d => d.mandatory);
if (hasMandatory) {
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
}
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
}
},
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.defaultDocumentTypes.map(name => ({
name,
code: name,
mandatory: false
}));
}
});
}
/** Save or update KYC, then execute callback */
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
if (this.kycId) {
// KYC already exists — use PUT to update (strip create-only fields)
const { kycType, ...updatePayload } = payload;
this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => callback(),
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to update KYC.')
});
} else {
// First time — use POST to create
this.clientsService.saveClientKyc(this.clientId, payload).subscribe({
next: (res: any) => {
this.kycId = res?.resourceId?.toString() || '';
if (this.kycId) { callback(); }
else { onError('Failed to save KYC.'); }
},
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to save KYC.')
});
}
}
/** Verify PAN — saves PAN-only KYC data, then verifies */
verifyPan() {
if (!this.clientId) {
this.panVerifyError = 'Client must be created first. Complete General tab and click Next.';
return;
}
const pan = this.kycForm.value.panNumber;
if (!pan) { this.panVerifyError = 'Enter PAN number first.'; return; }
this.panVerifying = true;
this.panVerifyError = '';
this.panVerifyName = '';
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const panPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
panNumber: pan,
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
};
this.saveKycAndDo(panPayload, () => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.panVerifying = false;
if (res?.changes?.panVerified === true) {
// Full name from API
const resolvedName = res.changes?.panFullName
|| res.changes?.panHolderName
|| [res.changes?.panFirstName, res.changes?.panLastName].filter(Boolean).join(' ')
|| '';
// If name is empty, the client name/DOB didn't match PAN holder
if (!resolvedName || !resolvedName.trim()) {
this.panVerifyStatus = 'FAILED';
this.showPanErrorAlert('PAN verification failed: Client name or date of birth does not match the PAN card holder. Please ensure the client\'s name and date of birth exactly match the PAN card details.');
return;
}
this.panVerifyStatus = 'VERIFIED';
this.panVerifyName = resolvedName;
// Date — could be string or array [y,m,d]
const rawDate = res.changes?.panVerifiedOn;
if (Array.isArray(rawDate)) {
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else if (rawDate) {
this.panVerifiedOn = rawDate;
} else {
const now = new Date();
this.panVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
// Seeding status
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
this.panVerifyStatus = 'FAILED';
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'PAN verification failed.');
}
},
error: (err: any) => {
this.panVerifying = false;
this.panVerifyStatus = 'FAILED';
const msg = err?.error?.errors?.[0]?.defaultUserMessage
|| err?.error?.defaultUserMessage
|| 'PAN verification failed.';
this.showPanErrorAlert(msg);
}
});
}, (msg) => { this.panVerifying = false; this.showPanErrorAlert(msg); });
}
/** Verify Enterprise PAN (non-individual) — uses same verify-pan API */
verifyEnterprisePan() {
if (!this.clientId) return;
const pan = this.kycForm.value.enterprisePan;
if (!pan || this.kycForm.get('enterprisePan')?.invalid) return;
this.entPanVerifying = true;
const incorpDate = this.kycForm.value.incorporationDate;
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
panNumber: pan,
dateFormat: this.settingsService.dateFormat
};
if (incorpDate) {
payload.dateOfIncorporation = incorpDate instanceof Date
? this.dateUtils.formatDate(incorpDate, this.settingsService.dateFormat)
: incorpDate;
}
if (this.entityName) {
payload.companyFullName = this.entityName;
}
this.saveKycAndDo(payload, () => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.entPanVerifying = false;
if (res?.changes?.panVerified === true) {
this.entPanVerifyStatus = 'VERIFIED';
this.entPanVerifyName = res.changes?.panFullName || res.changes?.panHolderName || '';
const rawDate = res.changes?.panVerifiedOn;
if (Array.isArray(rawDate)) {
this.entPanVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else {
const now = new Date();
this.entPanVerifiedOn = rawDate || `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
} else {
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'Enterprise PAN verification failed.');
}
},
error: (err: any) => {
this.entPanVerifying = false;
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(err?.error?.errors?.[0]?.defaultUserMessage || 'Enterprise PAN verification failed.');
}
});
}, (msg) => { this.entPanVerifying = false; this.showPanErrorAlert(msg); });
}
/** Verify CIN (dummy — no real API) */
verifyCin() {
const cin = this.kycForm.value.cinNumber;
if (!cin) return;
this.cinVerifying = true;
this.cinProgressValue = 0;
this.cinCurrentStep = 0;
const stepDurations = [800, 1000, 1200, 800];
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
let elapsed = 0;
const interval = 50;
this.cinProgressTimer = setInterval(() => {
elapsed += interval;
let accum = 0;
for (let i = 0; i < stepDurations.length; i++) {
accum += stepDurations[i];
if (elapsed <= accum) { this.cinCurrentStep = i; break; }
}
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
if (elapsed >= totalDuration) {
clearInterval(this.cinProgressTimer);
this.cinCurrentStep = stepDurations.length;
setTimeout(() => {
this.cinVerifying = false;
this.cinVerifyStatus = 'VERIFIED';
this.cinCompanyName = 'Verified via MCA';
const now = new Date();
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}, 300);
}
}, interval);
}
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
sendAadhaarOtp() {
if (!this.clientId) {
this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.';
return;
}
const aadhaar = this.kycForm.value.aadhaarNumber;
if (!aadhaar || aadhaar.length !== 12) { this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.'; return; }
this.aadhaarVerifying = true;
this.aadhaarVerifyError = '';
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
const aadhaarPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
aadhaarNumberMasked: aadhaarMasked
};
const panVal = this.kycForm.value.panNumber;
if (panVal) { aadhaarPayload.panNumber = panVal; }
this.saveKycAndDo(aadhaarPayload, () => {
this.clientsService.sendAadhaarOtp(this.clientId, this.kycId, aadhaar).subscribe({
next: (res: any) => {
this.aadhaarVerifying = false;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = res?.changes?.aadhaarRefNumber || '';
this.resetOtpDigits();
this.startResendCountdown();
},
error: (err: any) => {
this.aadhaarVerifying = false;
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'Failed to send OTP.';
}
});
}, (msg) => { this.aadhaarVerifying = false; this.aadhaarVerifyError = msg; });
}
/** Verify Aadhaar OTP */
verifyAadhaarOtp() {
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
this.aadhaarOtpVerifying = true;
this.aadhaarVerifyError = '';
this.clientsService.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId).subscribe({
next: (res: any) => {
this.aadhaarOtpVerifying = false;
if (res?.changes?.aadhaarVerified === true) {
this.aadhaarVerifyStatus = 'VERIFIED';
this.aadhaarVerifyName = res.changes?.aadhaarHolderName || 'Aadhaar Verified';
this.aadhaarOtpSent = false;
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
this.aadhaarVerifyError = res?.changes?.aadhaarVerificationMessage || 'OTP verification failed';
}
},
error: (err: any) => {
this.aadhaarOtpVerifying = false;
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'OTP verification failed.';
}
});
}
private showPanErrorAlert(message: string) {
this.panVerifyError = '';
this.dialog.open(PanErrorDialogComponent, {
width: '480px',
data: { message }
});
}
getRenewalYears(): number {
switch (this.kycForm.value.riskCategory) {
case 1: return 10;
case 2: return 8;
case 3: return 2;
default: return 10;
}
}
getKycExpiryDate(years: number): string {
const d = new Date();
d.setFullYear(d.getFullYear() + years);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${String(d.getDate()).padStart(2, '0')} ${months[d.getMonth()]} ${d.getFullYear()}`;
}
async onPhotoSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
const file = input.files[0];
input.value = '';
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
width: '680px', maxWidth: '95vw',
data: { file, title: 'Crop Client Photograph', aspectRatio: 1, maxWidth: 300, maxHeight: 300 }
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) { this.photographFile = result.croppedFile; this.photographPreview = result.previewUrl; }
});
}
async onSignatureSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
const file = input.files[0];
input.value = '';
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
width: '680px', maxWidth: '95vw',
data: { file, title: 'Crop Specimen Signature', aspectRatio: 2.5, maxWidth: 400, maxHeight: 160 }
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) { this.signatureFile = result.croppedFile; this.signaturePreview = result.previewUrl; }
});
}
removePhoto() { this.photographFile = null; this.photographPreview = null; }
removeSignature() { this.signatureFile = null; this.signaturePreview = null; }
get kycData(): any {
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
const aadhaarRaw = formVal.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
const dateFormat = this.settingsService.dateFormat;
const v = formVal;
const formatDate = (d: any) => d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : (d || null);
return {
locale: 'en',
dateFormat,
kycType: 1,
riskCategory: 1,
panNumber: this.kycForm.value.panNumber || null,
riskCategory: v.riskCategory || 1,
panNumber: v.hasPan ? (v.panNumber || null) : null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
gstinNumber: v.gstinNumber || null,
enterprisePan: v.enterprisePan || null,
cinNumber: v.cinNumber || null,
incorporationDate: formatDate(v.incorporationDate),
// KYC Document
kycDocumentName: v.kycDocumentName || null,
kycDocumentNumber: v.kycDocumentNumber || null,
kycDocIssueDate: formatDate(v.kycDocIssueDate),
kycDocExpiryDate: formatDate(v.kycDocExpiryDate),
// AML & Compliance
amlCheckStatus: v.amlCheckStatus || null,
bannedEntityMatch: v.bannedEntityMatch || null,
willfulDefaulter: v.willfulDefaulter || null,
ecgcSalFlag: v.ecgcSalFlag || null,
dormantFlag: v.dormantFlag || null,
// Freeze / Debit Controls
accountStatus: v.accountStatus || null,
freezeReason: v.freezeReason || null,
freezeAuthority: v.freezeAuthority || null,
// Form 60 / Form 15
form60Type: v.form60Type || null,
form60Date: formatDate(v.form60Date),
form15Applicable: v.form15Applicable || false,
form15Type: v.form15Type || null,
form15FY: v.form15FY || null,
remarks: v.remarks || null
};
}
/** Returns true if any KYC data has been entered */
get hasKycData(): boolean {
const v = this.kycForm.value;
return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber);
const v = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber || v.enterprisePan || v.cinNumber || v.kycDocumentName || v.form15Applicable);
}
}
/**
* PAN Error Alert Dialog
*/
@Component({
selector: 'mifosx-pan-error-dialog',
template: `
<div class="pan-err-dialog">
<div class="pan-err-header">
<mat-icon class="pan-err-icon">error</mat-icon>
<span>PAN Verification Failed</span>
</div>
<mat-dialog-content class="pan-err-body">
<p>{{ data.message }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end" class="pan-err-actions">
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
</mat-dialog-actions>
</div>
`,
styles: [`
.pan-err-dialog { max-width: 460px; }
.pan-err-header {
display: flex; align-items: center; gap: 10px;
padding: 16px 24px 12px; border-bottom: 1px solid #fecaca;
background: #fef2f2; font-size: 16px; font-weight: 600; color: #991b1b;
}
.pan-err-icon { color: #dc2626; }
.pan-err-body { padding: 18px 24px !important; font-size: 14px; line-height: 1.6; color: #475569; }
.pan-err-actions { padding: 10px 24px 16px !important; margin: 0 !important; }
`],
imports: [MatIcon, MatDialogContent, MatDialogActions, STANDALONE_SHARED_IMPORTS],
})
export class PanErrorDialogComponent {
constructor(
@Inject(MAT_DIALOG_DATA) public data: { message: string }
) {}
}
@@ -0,0 +1,49 @@
<!--
Copyright since 2025 Mifos Initiative
-->
<div class="crop-dialog">
<div class="crop-header">
<div class="crop-header__left">
<fa-icon icon="crop" class="crop-header__icon"></fa-icon>
<span class="crop-header__title">{{ data.title }}</span>
</div>
<button class="crop-header__close" (click)="dialogRef.close()">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<mat-dialog-content class="crop-body">
@if (!imageLoaded) {
<div class="crop-loading">Loading image...</div>
}
<div class="crop-workspace" [class.hidden]="!imageLoaded">
<div class="crop-source">
<canvas #sourceCanvas
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()">
</canvas>
<div class="crop-hint">Drag to move. Drag corner to resize.</div>
</div>
<div class="crop-preview-panel">
<span class="crop-preview-label">Preview</span>
<div class="crop-preview-box">
<canvas #previewCanvas></canvas>
</div>
<span class="crop-preview-size">{{ data.maxWidth }}x{{ data.maxHeight }}px</span>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions class="crop-actions">
<button mat-stroked-button (click)="dialogRef.close()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="!imageLoaded" (click)="confirm()">
<fa-icon icon="check" class="crop-actions__icon"></fa-icon>
Crop &amp; Use
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,176 @@
/**
* Copyright since 2025 Mifos Initiative
*/
$blue: #1353a0;
$navy: #0f172a;
.crop-dialog {
width: 620px;
max-width: 95vw;
}
.crop-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px 12px;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 10px;
}
&__icon {
font-size: 16px;
color: $blue;
}
&__title {
font-size: 16px;
font-weight: 600;
color: $navy;
}
&__close {
width: 30px;
height: 30px;
border: none;
background: #f1f5f9;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
transition: all 0.15s;
&:hover {
background: #e2e8f0;
color: $navy;
}
}
}
.crop-body {
padding: 20px 24px !important;
overflow: visible !important;
max-height: 70vh !important;
}
.crop-loading {
text-align: center;
padding: 40px;
color: #94a3b8;
font-size: 14px;
}
.hidden {
display: none !important;
}
.crop-workspace {
display: flex;
gap: 20px;
align-items: flex-start;
}
.crop-source {
flex: 1;
min-width: 0;
canvas {
cursor: crosshair;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
max-width: 100%;
}
}
.crop-hint {
font-size: 11px;
color: #94a3b8;
margin-top: 6px;
text-align: center;
}
.crop-preview-panel {
width: 160px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.crop-preview-label {
font-size: 11px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.crop-preview-box {
width: 150px;
height: 150px;
border: 2px solid $blue;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
canvas {
display: block;
max-width: 100%;
max-height: 100%;
}
}
.crop-preview-size {
font-size: 10px;
color: #94a3b8;
font-family: 'Roboto Mono', monospace;
}
.crop-actions {
padding: 12px 24px 16px !important;
border-top: 1px solid #e2e8f0;
margin: 0 !important;
display: flex !important;
justify-content: flex-end !important;
gap: 10px !important;
button {
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
}
&__icon {
margin-right: 6px;
font-size: 12px;
}
}
@media (max-width: 600px) {
.crop-workspace {
flex-direction: column;
}
.crop-preview-panel {
width: 100%;
flex-direction: row;
justify-content: center;
}
.crop-preview-box {
width: 100px;
height: 100px;
}
}
@@ -0,0 +1,244 @@
/**
* Copyright since 2025 Mifos Initiative
*/
import { Component, Inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export interface ImageCropData {
file: File;
title: string;
aspectRatio: number; // width/height, e.g. 1 for square, 1.5 for 3:2
maxWidth: number;
maxHeight: number;
}
@Component({
selector: 'mifosx-image-crop-dialog',
templateUrl: './image-crop-dialog.component.html',
styleUrls: ['./image-crop-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatDialogContent,
MatDialogActions
]
})
export class ImageCropDialogComponent implements AfterViewInit {
@ViewChild('sourceCanvas') sourceCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('previewCanvas') previewCanvas!: ElementRef<HTMLCanvasElement>;
imageLoaded = false;
private img = new Image();
private imgWidth = 0;
private imgHeight = 0;
// Crop box (relative to displayed image)
cropX = 0;
cropY = 0;
cropW = 200;
cropH = 200;
// Drag state
private dragging = false;
private dragStartX = 0;
private dragStartY = 0;
private dragStartCropX = 0;
private dragStartCropY = 0;
// Resize state
private resizing = false;
// Canvas display dimensions
private canvasDisplayW = 0;
private canvasDisplayH = 0;
private scaleX = 1;
private scaleY = 1;
zoom = 1;
constructor(
public dialogRef: MatDialogRef<ImageCropDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: ImageCropData
) {}
ngAfterViewInit() {
const reader = new FileReader();
reader.onload = (e: any) => {
this.img.onload = () => {
this.imageLoaded = true;
setTimeout(() => this.initCanvas());
};
this.img.src = e.target.result;
};
reader.readAsDataURL(this.data.file);
}
private initCanvas() {
const canvas = this.sourceCanvas.nativeElement;
const ctx = canvas.getContext('2d')!;
const container = canvas.parentElement!;
const maxW = container.clientWidth || 500;
const maxH = 400;
// Scale image to fit container
const ratio = Math.min(maxW / this.img.width, maxH / this.img.height, 1);
this.canvasDisplayW = Math.floor(this.img.width * ratio);
this.canvasDisplayH = Math.floor(this.img.height * ratio);
this.imgWidth = this.img.width;
this.imgHeight = this.img.height;
canvas.width = this.canvasDisplayW;
canvas.height = this.canvasDisplayH;
this.scaleX = this.img.width / this.canvasDisplayW;
this.scaleY = this.img.height / this.canvasDisplayH;
// Init crop box centered
const cropSize = Math.min(this.canvasDisplayW, this.canvasDisplayH) * 0.6;
if (this.data.aspectRatio >= 1) {
this.cropW = cropSize;
this.cropH = cropSize / this.data.aspectRatio;
} else {
this.cropH = cropSize;
this.cropW = cropSize * this.data.aspectRatio;
}
this.cropX = (this.canvasDisplayW - this.cropW) / 2;
this.cropY = (this.canvasDisplayH - this.cropH) / 2;
this.drawCanvas();
this.updatePreview();
}
private drawCanvas() {
const canvas = this.sourceCanvas.nativeElement;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(this.img, 0, 0, canvas.width, canvas.height);
// Dark overlay outside crop
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area
ctx.clearRect(this.cropX, this.cropY, this.cropW, this.cropH);
ctx.drawImage(
this.img,
this.cropX * this.scaleX, this.cropY * this.scaleY,
this.cropW * this.scaleX, this.cropH * this.scaleY,
this.cropX, this.cropY, this.cropW, this.cropH
);
// Crop border
ctx.strokeStyle = '#1353a0';
ctx.lineWidth = 2;
ctx.strokeRect(this.cropX, this.cropY, this.cropW, this.cropH);
// Corner handles
const hs = 8;
ctx.fillStyle = '#1353a0';
const corners = [
[this.cropX, this.cropY],
[this.cropX + this.cropW, this.cropY],
[this.cropX, this.cropY + this.cropH],
[this.cropX + this.cropW, this.cropY + this.cropH]
];
corners.forEach(([x, y]) => {
ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs);
});
}
private updatePreview() {
if (!this.previewCanvas) return;
const pCanvas = this.previewCanvas.nativeElement;
const pCtx = pCanvas.getContext('2d')!;
const pw = this.data.maxWidth || 150;
const ph = this.data.maxHeight || 150;
pCanvas.width = pw;
pCanvas.height = ph;
pCtx.clearRect(0, 0, pw, ph);
pCtx.drawImage(
this.img,
this.cropX * this.scaleX, this.cropY * this.scaleY,
this.cropW * this.scaleX, this.cropH * this.scaleY,
0, 0, pw, ph
);
}
onMouseDown(event: MouseEvent) {
const rect = this.sourceCanvas.nativeElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Check if near bottom-right corner for resize
const cornerX = this.cropX + this.cropW;
const cornerY = this.cropY + this.cropH;
if (Math.abs(x - cornerX) < 12 && Math.abs(y - cornerY) < 12) {
this.resizing = true;
return;
}
// Check if inside crop box for drag
if (x >= this.cropX && x <= this.cropX + this.cropW && y >= this.cropY && y <= this.cropY + this.cropH) {
this.dragging = true;
this.dragStartX = x;
this.dragStartY = y;
this.dragStartCropX = this.cropX;
this.dragStartCropY = this.cropY;
}
}
onMouseMove(event: MouseEvent) {
if (!this.dragging && !this.resizing) return;
const rect = this.sourceCanvas.nativeElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (this.dragging) {
this.cropX = Math.max(0, Math.min(this.canvasDisplayW - this.cropW, this.dragStartCropX + (x - this.dragStartX)));
this.cropY = Math.max(0, Math.min(this.canvasDisplayH - this.cropH, this.dragStartCropY + (y - this.dragStartY)));
}
if (this.resizing) {
const newW = Math.max(40, Math.min(this.canvasDisplayW - this.cropX, x - this.cropX));
const newH = newW / this.data.aspectRatio;
if (this.cropY + newH <= this.canvasDisplayH) {
this.cropW = newW;
this.cropH = newH;
}
}
this.drawCanvas();
this.updatePreview();
}
onMouseUp() {
this.dragging = false;
this.resizing = false;
}
confirm() {
// Create cropped image as blob
const offscreen = document.createElement('canvas');
offscreen.width = this.data.maxWidth || 300;
offscreen.height = this.data.maxHeight || 300;
const ctx = offscreen.getContext('2d')!;
ctx.drawImage(
this.img,
this.cropX * this.scaleX, this.cropY * this.scaleY,
this.cropW * this.scaleX, this.cropH * this.scaleY,
0, 0, offscreen.width, offscreen.height
);
offscreen.toBlob((blob) => {
if (blob) {
const croppedFile = new File([blob], this.data.file.name, { type: 'image/png' });
this.dialogRef.close({ croppedFile, previewUrl: offscreen.toDataURL('image/png') });
}
}, 'image/png');
}
}
@@ -7,6 +7,16 @@
-->
<div class="preview-container">
<div class="step-header">
<div class="step-header__left">
<fa-icon icon="eye" class="step-header__icon"></fa-icon>
<div>
<h3 class="step-header__title">Preview &amp; Submit</h3>
<p class="step-header__sub">Review all details before submitting the client registration.</p>
</div>
</div>
</div>
<!-- Client Profile Card -->
<div class="profile-card">
<div class="profile-avatar">
@@ -30,7 +40,7 @@
}
@if (client?.legalFormId) {
<span class="meta-chip">
{{ client.legalFormId | find: clientTemplate.clientLegalFormOptions : 'id' : 'value' }}
{{ client.legalFormId === 1 ? 'Individual' : 'Non-Individual' }}
</span>
}
@if (client?.active) {
@@ -42,208 +52,799 @@
</div>
</div>
<!-- General Information Card -->
<!-- Personal Information -->
<div class="info-card">
<div class="card-header">
<fa-icon icon="user" class="card-icon"></fa-icon>
<span>{{ 'labels.heading.General' | translate }}</span>
<span>Personal Information</span>
</div>
<div class="card-body">
<table class="info-table">
<tbody>
@if (client?.dateOfBirth) {
<tr>
<td class="info-label">{{ client.legalFormId === LegalFormId.PERSON ? 'Date of Birth' : 'Incorporation Date' }}</td>
<td class="info-value">{{ client.dateOfBirth | dateFormat }}</td>
</tr>
<div class="preview-grid">
@if (client?.legalFormId === LegalFormId.PERSON) {
@if (client?.firstname) {
<div class="preview-field">
<span class="preview-label">First Name</span>
<span class="preview-value">{{ client.firstname }}</span>
</div>
}
@if (client?.externalId) {
<tr>
<td class="info-label">{{ 'labels.inputs.External Id' | translate }}</td>
<td class="info-value">
<mifosx-external-identifier externalId="{{ client.externalId }}"></mifosx-external-identifier>
</td>
</tr>
@if (client?.middlename) {
<div class="preview-field">
<span class="preview-label">Middle Name</span>
<span class="preview-value">{{ client.middlename }}</span>
</div>
}
@if (client?.staffId) {
<tr>
<td class="info-label">{{ 'labels.inputs.Staff' | translate }}</td>
<td class="info-value">{{ client.staffId | find: clientTemplate.staffOptions : 'id' : 'displayName' }}</td>
</tr>
@if (client?.lastname) {
<div class="preview-field">
<span class="preview-label">Last Name</span>
<span class="preview-value">{{ client.lastname }}</span>
</div>
}
@if (client?.legalFormId === LegalFormId.PERSON && client?.genderId) {
<tr>
<td class="info-label">{{ 'labels.inputs.Gender' | translate }}</td>
<td class="info-value">{{ client.genderId | find: clientTemplate.genderOptions : 'id' : 'name' }}</td>
</tr>
}
@if (client?.mobileNo) {
<tr>
<td class="info-label">{{ 'labels.inputs.Mobile No' | translate }}</td>
<td class="info-value">{{ client.mobileNo }}</td>
</tr>
}
@if (client?.emailAddress) {
<tr>
<td class="info-label">{{ 'labels.inputs.Email Address' | translate }}</td>
<td class="info-value">{{ client.emailAddress }}</td>
</tr>
}
@if (client?.clientTypeId) {
<tr>
<td class="info-label">{{ 'labels.inputs.Client Type' | translate }}</td>
<td class="info-value">{{ client.clientTypeId | find: clientTemplate.clientTypeOptions : 'id' : 'name' }}</td>
</tr>
}
@if (client?.clientClassificationId) {
<tr>
<td class="info-label">{{ 'labels.inputs.Client Classification' | translate }}</td>
<td class="info-value">{{ client.clientClassificationId | find: clientTemplate.clientClassificationOptions : 'id' : 'name' }}</td>
</tr>
}
@if (client?.occupationCategoryId) {
<tr>
<td class="info-label">{{ 'labels.inputs.Occupation Category' | translate }}</td>
<td class="info-value">{{ client.occupationCategoryId | find: clientTemplate.occupationCategoryOptions : 'id' : 'name' }}</td>
</tr>
}
@if (client?.occupationId) {
<tr>
<td class="info-label">{{ 'labels.inputs.Occupation' | translate }}</td>
<td class="info-value">{{ client.occupationId | find: clientTemplate.occupationOptions : 'id' : 'name' }}</td>
</tr>
}
@if (client?.submittedOnDate) {
<tr>
<td class="info-label">{{ 'labels.inputs.Submitted On Date' | translate }}</td>
<td class="info-value">{{ client.submittedOnDate | dateFormat }}</td>
</tr>
}
<tr>
<td class="info-label">{{ 'labels.inputs.Active' | translate }}?</td>
<td class="info-value">
<span [class]="client?.active ? 'badge-yes' : 'badge-no'">{{ client?.active | yesNo }}</span>
</td>
</tr>
@if (client?.activationDate) {
<tr>
<td class="info-label">{{ 'labels.inputs.Activation Date' | translate }}</td>
<td class="info-value">{{ client.activationDate | dateFormat }}</td>
</tr>
}
@if (client?.savingsProductId) {
<tr>
<td class="info-label">{{ 'labels.inputs.Savings Product' | translate }}</td>
<td class="info-value">{{ client.savingsProductId | find: clientTemplate.savingProductOptions : 'id' : 'name' }}</td>
</tr>
}
@if (client?.legalFormId === LegalFormId.PERSON) {
<tr>
<td class="info-label">{{ 'labels.inputs.Is staff' | translate }}?</td>
<td class="info-value">{{ client.isStaff | yesNo }}</td>
</tr>
}
@if (client?.legalFormId === LegalFormId.ENTITY && client?.clientNonPersonDetails) {
@if (client.clientNonPersonDetails.constitutionId) {
<tr>
<td class="info-label">{{ 'labels.inputs.Constitution' | translate }}</td>
<td class="info-value">{{ client.clientNonPersonDetails.constitutionId | find: clientTemplate.clientNonPersonConstitutionOptions : 'id' : 'name' }}</td>
</tr>
}
<tr>
<td class="info-label">{{ 'labels.inputs.Incorporation Number' | translate }}</td>
<td class="info-value">{{ client.clientNonPersonDetails.incorpNumber || 'N/A' }}</td>
</tr>
}
</tbody>
</table>
}
@if (client?.legalFormId === LegalFormId.ENTITY && client?.fullname) {
<div class="preview-field">
<span class="preview-label">Entity Name</span>
<span class="preview-value">{{ client.fullname }}</span>
</div>
}
@if (client?.dateOfBirth) {
<div class="preview-field">
<span class="preview-label">{{ client.legalFormId === LegalFormId.PERSON ? 'Date of Birth' : 'Incorporation Date' }}</span>
<span class="preview-value">{{ client.dateOfBirth | dateFormat }}</span>
</div>
}
@if (client?.legalFormId === LegalFormId.PERSON && client?.genderId) {
<div class="preview-field">
<span class="preview-label">Gender</span>
<span class="preview-value">{{ client.genderId | find: clientTemplate.genderOptions : 'id' : 'name' }}</span>
</div>
}
@if (client?.legalFormId) {
<div class="preview-field">
<span class="preview-label">Legal Form</span>
<span class="preview-value">{{ client.legalFormId === 1 ? 'Individual' : 'Non-Individual' }}</span>
</div>
}
@if (client?.officeId) {
<div class="preview-field">
<span class="preview-label">Office</span>
<span class="preview-value">{{ client.officeId | find: clientTemplate.officeOptions : 'id' : 'name' }}</span>
</div>
}
@if (client?.staffId) {
<div class="preview-field">
<span class="preview-label">Staff</span>
<span class="preview-value">{{ client.staffId | find: clientTemplate.staffOptions : 'id' : 'displayName' }}</span>
</div>
}
@if (client?.legalFormId === LegalFormId.PERSON) {
<div class="preview-field">
<span class="preview-label">Is Staff?</span>
<span class="preview-value">{{ client.isStaff | yesNo }}</span>
</div>
}
@if (client?.nationalityId) {
<div class="preview-field">
<span class="preview-label">Nationality</span>
<span class="preview-value">{{ client.nationalityId }}</span>
</div>
}
@if (client?.maritalStatusId) {
<div class="preview-field">
<span class="preview-label">Marital Status</span>
<span class="preview-value">{{ client.maritalStatusId | find: maritalStatusOptions : 'id' : 'name' }}</span>
</div>
}
@if (client?.salutationId) {
<div class="preview-field">
<span class="preview-label">Salutation</span>
<span class="preview-value">{{ client.salutationId | find: clientTemplate.salutationOptions : 'id' : 'name' }}</span>
</div>
}
@if (client?.ageYears != null) {
<div class="preview-field">
<span class="preview-label">Age</span>
<span class="preview-value">{{ client.ageYears }}y {{ client.ageMonths }}m {{ client.ageDays }}d</span>
</div>
}
@if (client?.isMinor != null) {
<div class="preview-field">
<span class="preview-label">Minor</span>
<span class="preview-value">
<span [class]="client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'">
{{ client.isMinor ? 'Yes (Age < 10)' : 'No' }}
</span>
</span>
</div>
}
</div>
</div>
</div>
<!-- KYC Details Card -->
@if (kycData?.panNumber || kycData?.aadhaarNumberMasked || kycData?.gstinNumber) {
<!-- Entity Details (Non-Individual only) -->
@if (client?.legalFormId === LegalFormId.ENTITY && client?.clientNonPersonDetails) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="building" class="card-icon"></fa-icon>
<span>Entity Details</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (client.clientNonPersonDetails.constitutionId) {
<div class="preview-field">
<span class="preview-label">Constitution</span>
<span class="preview-value">{{ client.clientNonPersonDetails.constitutionId | find: clientTemplate.clientNonPersonConstitutionOptions : 'id' : 'name' }}</span>
</div>
}
@if (client.clientNonPersonDetails.mainBusinessLineId) {
<div class="preview-field">
<span class="preview-label">Main Business Line</span>
<span class="preview-value">{{ client.clientNonPersonDetails.mainBusinessLineId | find: clientTemplate.clientNonPersonMainBusinessLineOptions : 'id' : 'name' }}</span>
</div>
}
@if (client.clientNonPersonDetails.incorpNumber) {
<div class="preview-field">
<span class="preview-label">Incorporation No</span>
<span class="preview-value mono">{{ client.clientNonPersonDetails.incorpNumber }}</span>
</div>
}
@if (client.clientNonPersonDetails.remarks) {
<div class="preview-field full-width">
<span class="preview-label">Remarks</span>
<span class="preview-value">{{ client.clientNonPersonDetails.remarks }}</span>
</div>
}
</div>
</div>
</div>
}
<!-- Contact & DIP Verification -->
<div class="info-card">
<div class="card-header">
<fa-icon icon="mobile-alt" class="card-icon"></fa-icon>
<span>Contact &amp; DIP Verification</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (client?.mobileNo) {
<div class="preview-field">
<span class="preview-label">Mobile No</span>
<span class="preview-value mono">{{ client.mobileNo }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">DIP Verification</span>
<span class="preview-value">
<span [class]="client?.dipVerified ? 'preview-badge preview-badge--verified' : 'preview-badge preview-badge--pending'">
{{ client?.dipVerified ? 'Verified' : 'Pending' }}
</span>
</span>
</div>
@if (client?.dipLookupLogId) {
<div class="preview-field">
<span class="preview-label">DIP Lookup Log ID</span>
<span class="preview-value mono">{{ client.dipLookupLogId }}</span>
</div>
}
@if (client?.alternateMobileNo) {
<div class="preview-field">
<span class="preview-label">Alternate Mobile No</span>
<span class="preview-value mono">{{ client.alternateMobileNo }}</span>
</div>
}
@if (client?.alternateMobileNo) {
<div class="preview-field">
<span class="preview-label">Alternate DIP Verification</span>
<span class="preview-value">
<span [class]="client?.alternateDipVerified ? 'preview-badge preview-badge--verified' : 'preview-badge preview-badge--pending'">
{{ client?.alternateDipVerified ? 'Verified' : 'Pending' }}
</span>
</span>
</div>
}
@if (client?.alternateDipLookupLogId) {
<div class="preview-field">
<span class="preview-label">Alternate DIP Lookup Log ID</span>
<span class="preview-value mono">{{ client.alternateDipLookupLogId }}</span>
</div>
}
@if (client?.emailAddress) {
<div class="preview-field">
<span class="preview-label">Email Address</span>
<span class="preview-value">{{ client.emailAddress }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">Name Verification (Sanctions)</span>
<span class="preview-value">
<span [class]="client?.nameVerified ? 'preview-badge preview-badge--verified' : 'preview-badge preview-badge--danger'">
{{ client?.nameVerified ? 'Verified — Clear' : (client?.nameVerified === false ? 'Sanctions Match' : 'Pending') }}
</span>
</span>
</div>
@if (client?.screeningLogId) {
<div class="preview-field">
<span class="preview-label">Screening Log ID</span>
<span class="preview-value mono">{{ client.screeningLogId }}</span>
</div>
}
</div>
</div>
</div>
<!-- Financial & Classification (Individual only) -->
@if (client?.legalFormId !== 2) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="chart-line" class="card-icon"></fa-icon>
<span>Financial &amp; Classification</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (client?.occupationCategoryId) {
<div class="preview-field">
<span class="preview-label">Occupation Category</span>
<span class="preview-value">{{ client.occupationCategoryId | find: clientTemplate.occupationCategoryOptions : 'id' : 'name' }}</span>
</div>
}
@if (client?.occupationId) {
<div class="preview-field">
<span class="preview-label">Occupation</span>
<span class="preview-value">{{ client.occupationId | find: clientTemplate.occupationOptions : 'id' : 'name' }}</span>
</div>
}
@if (client?.clientTypeId) {
<div class="preview-field">
<span class="preview-label">Client Type</span>
<span class="preview-value">{{ client.clientTypeId | find: clientTemplate.clientTypeOptions : 'id' : 'name' }}</span>
</div>
}
@if (client?.clientClassificationId) {
<div class="preview-field">
<span class="preview-label">Client Classification</span>
<span class="preview-value">{{ client.clientClassificationId | find: clientTemplate.clientClassificationOptions : 'id' : 'name' }}</span>
</div>
}
@if (client?.monthlyIncome) {
<div class="preview-field">
<span class="preview-label">Monthly Income</span>
<span class="preview-value">₹ {{ client.monthlyIncome | number }}</span>
</div>
}
@if (client?.annualTurnover) {
<div class="preview-field">
<span class="preview-label">Annual Turnover</span>
<span class="preview-value">₹ {{ client.annualTurnover | number }}</span>
</div>
}
@if (!client?.occupationCategoryId && !client?.occupationId && !client?.clientTypeId && !client?.clientClassificationId && !client?.monthlyIncome && !client?.annualTurnover) {
<div class="preview-field full-width">
<span class="preview-value no-data">No classification data provided</span>
</div>
}
</div>
</div>
</div>
}
<!-- KYC Details -->
@if (kycData?.panNumber || kycData?.aadhaarNumberMasked || kycData?.gstinNumber || kycData?.enterprisePan || kycData?.cinNumber) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="fingerprint" class="card-icon"></fa-icon>
<span>{{ 'labels.inputs.KYC' | translate }}</span>
<span>KYC Details</span>
<span class="kyc-badge">KYC</span>
</div>
<div class="card-body">
<table class="info-table">
<tbody>
@if (kycData.panNumber) {
<tr>
<td class="info-label">{{ 'labels.inputs.PAN Number' | translate }}</td>
<td class="info-value mono">{{ kycData.panNumber }}</td>
</tr>
}
@if (kycData.aadhaarNumberMasked) {
<tr>
<td class="info-label">{{ 'labels.inputs.Aadhaar Number' | translate }}</td>
<td class="info-value mono">{{ kycData.aadhaarNumberMasked }}</td>
</tr>
}
@if (kycData.gstinNumber) {
<tr>
<td class="info-label">{{ 'labels.inputs.GSTIN Number' | translate }}</td>
<td class="info-value mono">{{ kycData.gstinNumber }}</td>
</tr>
}
@if (kycData.remarks) {
<tr>
<td class="info-label">{{ 'labels.inputs.Remarks' | translate }}</td>
<td class="info-value">{{ kycData.remarks }}</td>
</tr>
}
</tbody>
</table>
<div class="preview-grid">
@if (kycData.panNumber) {
<div class="preview-field">
<span class="preview-label">PAN Number</span>
<span class="preview-value mono">{{ kycData.panNumber }}</span>
</div>
}
@if (kycData.aadhaarNumberMasked) {
<div class="preview-field">
<span class="preview-label">Aadhaar Number</span>
<span class="preview-value mono">{{ kycData.aadhaarNumberMasked }}</span>
</div>
}
@if (kycData.panAadhaarSeedingStatus) {
<div class="preview-field">
<span class="preview-label">PAN–Aadhaar Link</span>
<span class="preview-value">
@if (kycData.panAadhaarSeedingStatus === 'Y') {
<span class="preview-badge preview-badge--verified">Linked</span>
} @else if (kycData.panAadhaarSeedingStatus === 'N') {
<span class="preview-badge preview-badge--pending">Not Linked</span>
} @else {
{{ kycData.panAadhaarSeedingStatus }}
}
</span>
</div>
}
@if (kycData.enterprisePan) {
<div class="preview-field">
<span class="preview-label">Enterprise PAN</span>
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
</div>
}
@if (kycData.cinNumber) {
<div class="preview-field">
<span class="preview-label">CIN (Corporate Identity Number)</span>
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
</div>
}
@if (kycData.incorporationDate) {
<div class="preview-field">
<span class="preview-label">Incorporation Date</span>
<span class="preview-value">{{ kycData.incorporationDate | dateFormat }}</span>
</div>
}
@if (kycData.gstinNumber) {
<div class="preview-field">
<span class="preview-label">GSTIN Number</span>
<span class="preview-value mono">{{ kycData.gstinNumber }}</span>
</div>
}
@if (kycData.riskCategory) {
<div class="preview-field">
<span class="preview-label">Risk Category</span>
<span class="preview-value">
@if (kycData.riskCategory === 1) { Low Risk }
@else if (kycData.riskCategory === 2) { Medium Risk }
@else if (kycData.riskCategory === 3) { High Risk }
</span>
</div>
}
@if (kycData.remarks) {
<div class="preview-field full-width">
<span class="preview-label">Remarks</span>
<span class="preview-value">{{ kycData.remarks }}</span>
</div>
}
</div>
</div>
</div>
}
<!-- Family Members / Nominee Card -->
@if (client?.familyMembers?.length) {
<!-- Guardian Details (Minor only) -->
@if (guardian) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="users" class="card-icon"></fa-icon>
<span>{{ 'labels.heading.Family Members' | translate }} / {{ 'labels.inputs.Nominee' | translate }}</span>
<span class="count-badge">{{ client.familyMembers.length }}</span>
<fa-icon icon="user-shield" class="card-icon"></fa-icon>
<span>Guardian Details</span>
<span class="preview-badge preview-badge--info" style="margin-left:auto">Minor</span>
</div>
<div class="card-body no-padding">
<table class="data-table">
<thead>
<tr>
<th>{{ 'labels.inputs.name' | translate }}</th>
<th>{{ 'labels.inputs.Relationship' | translate }}</th>
<th>{{ 'labels.inputs.Date Of Birth' | translate }}</th>
<th>{{ 'labels.inputs.Is Dependent' | translate }}</th>
</tr>
</thead>
<tbody>
@for (member of client.familyMembers; track member) {
<tr>
<td>{{ member.firstName }} {{ member.lastName }}</td>
<td>{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}</td>
<td>{{ member.dateOfBirth | dateFormat }}</td>
<td>
<span [class]="member.isDependent ? 'badge-yes' : 'badge-no'">{{ member.isDependent ? 'Yes' : 'No' }}</span>
</td>
</tr>
}
</tbody>
</table>
<div class="card-body">
<div class="preview-grid">
<div class="preview-field">
<span class="preview-label">Guardian Name</span>
<span class="preview-value">{{ guardian.displayName }}</span>
</div>
<div class="preview-field">
<span class="preview-label">Client ID</span>
<span class="preview-value mono">{{ guardian.id }}</span>
</div>
<div class="preview-field">
<span class="preview-label">Account No</span>
<span class="preview-value mono">{{ guardian.accountNumber || '—' }}</span>
</div>
<div class="preview-field">
<span class="preview-label">Relationship</span>
<span class="preview-value">{{ guardianRelation || '—' }}</span>
</div>
@if (guardian.officeName) {
<div class="preview-field">
<span class="preview-label">Office</span>
<span class="preview-value">{{ guardian.officeName }}</span>
</div>
}
@if (guardian.mobileNo) {
<div class="preview-field">
<span class="preview-label">Mobile</span>
<span class="preview-value mono">{{ guardian.mobileNo }}</span>
</div>
}
@if (guardian.status?.value) {
<div class="preview-field">
<span class="preview-label">Status</span>
<span class="preview-value">
<span [class]="guardian.status?.value === 'Active' ? 'preview-badge preview-badge--verified' : 'preview-badge preview-badge--pending'">
{{ guardian.status.value }}
</span>
</span>
</div>
}
</div>
</div>
</div>
}
<!-- Submit Section -->
<div class="submit-section">
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" class="submit-btn" (click)="submitEvent.emit()">
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Submit' | translate }}
</button>
<!-- Address Details -->
@if (addressData) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="map" class="card-icon"></fa-icon>
<span>Address Details</span>
</div>
<div class="card-body">
@for (tab of addressTabs; track tab) {
@if (addressData[tab]?.addressLine1) {
<div class="address-block">
<div class="address-block__title">{{ addressTabLabels[tab] }}</div>
<div class="preview-grid">
<div class="preview-field">
<span class="preview-label">Address Line 1</span>
<span class="preview-value">{{ addressData[tab].addressLine1 }}</span>
</div>
@if (addressData[tab].addressLine2) {
<div class="preview-field">
<span class="preview-label">Address Line 2</span>
<span class="preview-value">{{ addressData[tab].addressLine2 }}</span>
</div>
}
@if (addressData[tab].landmark) {
<div class="preview-field">
<span class="preview-label">Landmark</span>
<span class="preview-value">{{ addressData[tab].landmark }}</span>
</div>
}
@if (addressData[tab].pinCode) {
<div class="preview-field">
<span class="preview-label">PIN Code</span>
<span class="preview-value mono">{{ addressData[tab].pinCode }}</span>
</div>
}
@if (addressData[tab].locality) {
<div class="preview-field">
<span class="preview-label">Locality</span>
<span class="preview-value">{{ addressData[tab].locality }}</span>
</div>
}
@if (addressData[tab].city) {
<div class="preview-field">
<span class="preview-label">City</span>
<span class="preview-value">{{ addressData[tab].city }}</span>
</div>
}
@if (addressData[tab].district) {
<div class="preview-field">
<span class="preview-label">District</span>
<span class="preview-value">{{ addressData[tab].district }}</span>
</div>
}
@if (addressData[tab].state) {
<div class="preview-field">
<span class="preview-label">State</span>
<span class="preview-value">{{ addressData[tab].state }}</span>
</div>
}
</div>
</div>
}
}
@if (!addressData.permanent?.addressLine1 && !addressData.current?.addressLine1 && !addressData.office?.addressLine1 && !addressData.correspondence?.addressLine1) {
<div class="preview-field full-width">
<span class="preview-value no-data">No address data provided</span>
</div>
}
</div>
</div>
}
<!-- Submission Info -->
@if (client?.submittedOnDate) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="calendar-alt" class="card-icon"></fa-icon>
<span>Submission</span>
</div>
<div class="card-body">
<div class="preview-grid">
<div class="preview-field">
<span class="preview-label">Submitted On</span>
<span class="preview-value">{{ client.submittedOnDate | dateFormat }}</span>
</div>
</div>
</div>
</div>
}
<!-- Scheme & Sector Classification (Non-Individual) -->
@if (entityClassificationData) {
@if (entityClassificationData.msme?.enabled) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="cogs" class="card-icon"></fa-icon>
<span>MSME Classification</span>
<span class="preview-badge preview-badge--verified">Enabled</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (entityClassificationData.msme.udyamRegistrationNumber) {
<div class="preview-field"><span class="preview-label">Udyam Registration No.</span><span class="preview-value mono">{{ entityClassificationData.msme.udyamRegistrationNumber }}</span></div>
}
@if (entityClassificationData.msme.msmeClassification) {
<div class="preview-field"><span class="preview-label">MSME Classification</span><span class="preview-value">{{ entityClassificationData.msme.msmeClassification }}</span></div>
}
@if (entityClassificationData.msme.enterpriseType) {
<div class="preview-field"><span class="preview-label">Enterprise Type</span><span class="preview-value">{{ entityClassificationData.msme.enterpriseType }}</span></div>
}
@if (entityClassificationData.msme.nicActivityCode) {
<div class="preview-field"><span class="preview-label">NIC Activity Code</span><span class="preview-value mono">{{ entityClassificationData.msme.nicActivityCode }}</span></div>
}
@if (entityClassificationData.msme.annualTurnover) {
<div class="preview-field"><span class="preview-label">Annual Turnover</span><span class="preview-value">₹ {{ entityClassificationData.msme.annualTurnover }} Cr</span></div>
}
@if (entityClassificationData.msme.investmentPlantMachinery) {
<div class="preview-field"><span class="preview-label">Investment (Plant &amp; Machinery)</span><span class="preview-value">₹ {{ entityClassificationData.msme.investmentPlantMachinery }} Cr</span></div>
}
@if (entityClassificationData.msme.financialYearAssessment) {
<div class="preview-field"><span class="preview-label">FY of Assessment</span><span class="preview-value">{{ entityClassificationData.msme.financialYearAssessment }}</span></div>
}
@if (entityClassificationData.msme.socialCategoryOwner) {
<div class="preview-field"><span class="preview-label">Social Category</span><span class="preview-value">{{ entityClassificationData.msme.socialCategoryOwner }}</span></div>
}
@if (entityClassificationData.msme.genderPrimaryOwner) {
<div class="preview-field"><span class="preview-label">Gender of Owner</span><span class="preview-value">{{ entityClassificationData.msme.genderPrimaryOwner }}</span></div>
}
@if (entityClassificationData.msme.noOfEmployees) {
<div class="preview-field"><span class="preview-label">No. of Employees</span><span class="preview-value">{{ entityClassificationData.msme.noOfEmployees }}</span></div>
}
@if (entityClassificationData.msme.womenLedEnterprise) {
<div class="preview-field"><span class="preview-label">Women-led Enterprise</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
}
@if (entityClassificationData.msme.divyangPwdOwned) {
<div class="preview-field"><span class="preview-label">Divyang (PWD) Owned</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
}
</div>
</div>
</div>
}
@if (entityClassificationData.startup?.enabled) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="rocket" class="card-icon"></fa-icon>
<span>Startup India (DPIIT)</span>
<span class="preview-badge preview-badge--verified">Enabled</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (entityClassificationData.startup.dpiitRecognitionNumber) {
<div class="preview-field"><span class="preview-label">DPIIT Recognition No.</span><span class="preview-value mono">{{ entityClassificationData.startup.dpiitRecognitionNumber }}</span></div>
}
@if (entityClassificationData.startup.entityStage) {
<div class="preview-field"><span class="preview-label">Entity Stage</span><span class="preview-value">{{ entityClassificationData.startup.entityStage }}</span></div>
}
@if (entityClassificationData.startup.sectorAsDpiit) {
<div class="preview-field"><span class="preview-label">Sector (DPIIT)</span><span class="preview-value">{{ entityClassificationData.startup.sectorAsDpiit }}</span></div>
}
@if (entityClassificationData.startup.taxExemption80iac) {
<div class="preview-field"><span class="preview-label">80IAC Tax Exemption</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
}
@if (entityClassificationData.startup.pslLoanLimit) {
<div class="preview-field"><span class="preview-label">PSL Loan Limit</span><span class="preview-value">₹ {{ entityClassificationData.startup.pslLoanLimit | number }} Cr</span></div>
}
</div>
</div>
</div>
}
@if (entityClassificationData.mudra?.enabled) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="hand-holding-usd" class="card-icon"></fa-icon>
<span>Mudra Borrower (PMMY)</span>
<span class="preview-badge preview-badge--verified">Enabled</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (entityClassificationData.mudra.mudraCategory) {
<div class="preview-field"><span class="preview-label">Mudra Category</span><span class="preview-value">{{ entityClassificationData.mudra.mudraCategory }}</span></div>
}
@if (entityClassificationData.mudra.mudraLoanAccountNo) {
<div class="preview-field"><span class="preview-label">Loan Account No.</span><span class="preview-value mono">{{ entityClassificationData.mudra.mudraLoanAccountNo }}</span></div>
}
@if (entityClassificationData.mudra.sanctionedAmount) {
<div class="preview-field"><span class="preview-label">Sanctioned Amount</span><span class="preview-value">₹ {{ entityClassificationData.mudra.sanctionedAmount | number }}</span></div>
}
@if (entityClassificationData.mudra.lendingInstitutionName) {
<div class="preview-field"><span class="preview-label">Lending Institution</span><span class="preview-value">{{ entityClassificationData.mudra.lendingInstitutionName }}</span></div>
}
@if (entityClassificationData.mudra.repaymentStatus) {
<div class="preview-field"><span class="preview-label">Repayment Status</span><span class="preview-value">{{ entityClassificationData.mudra.repaymentStatus }}</span></div>
}
@if (entityClassificationData.mudra.npaFlag) {
<div class="preview-field"><span class="preview-label">NPA Flag</span><span class="preview-value"><span class="preview-badge preview-badge--danger">NPA</span></span></div>
}
</div>
</div>
</div>
}
@if (entityClassificationData.eou?.enabled) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="ship" class="card-icon"></fa-icon>
<span>Export Oriented Unit (EOU)</span>
<span class="preview-badge preview-badge--verified">Enabled</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (entityClassificationData.eou.eouRegistrationNumber) {
<div class="preview-field"><span class="preview-label">EOU Registration No.</span><span class="preview-value mono">{{ entityClassificationData.eou.eouRegistrationNumber }}</span></div>
}
@if (entityClassificationData.eou.registeredDcZone) {
<div class="preview-field"><span class="preview-label">Registered DC Zone</span><span class="preview-value">{{ entityClassificationData.eou.registeredDcZone }}</span></div>
}
@if (entityClassificationData.eou.gstinEou) {
<div class="preview-field"><span class="preview-label">GSTIN (EOU)</span><span class="preview-value mono">{{ entityClassificationData.eou.gstinEou }}</span></div>
}
@if (entityClassificationData.eou.annualExportObligation) {
<div class="preview-field"><span class="preview-label">Export Obligation</span><span class="preview-value">₹ {{ entityClassificationData.eou.annualExportObligation | number }} Cr</span></div>
}
@if (entityClassificationData.eou.adCategoryBankCode) {
<div class="preview-field"><span class="preview-label">AD Bank Code</span><span class="preview-value mono">{{ entityClassificationData.eou.adCategoryBankCode }}</span></div>
}
</div>
</div>
</div>
}
@if (entityClassificationData.importexport?.enabled) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="exchange-alt" class="card-icon"></fa-icon>
<span>Import Export (IEC/DGFT)</span>
<span class="preview-badge preview-badge--verified">Enabled</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (entityClassificationData.importexport.iecCode) {
<div class="preview-field"><span class="preview-label">IEC Code</span><span class="preview-value mono">{{ entityClassificationData.importexport.iecCode }}</span></div>
}
@if (entityClassificationData.importexport.iecStatus) {
<div class="preview-field"><span class="preview-label">IEC Status</span><span class="preview-value">{{ entityClassificationData.importexport.iecStatus }}</span></div>
}
@if (entityClassificationData.importexport.tradeType) {
<div class="preview-field"><span class="preview-label">Trade Type</span><span class="preview-value">{{ entityClassificationData.importexport.tradeType }}</span></div>
}
@if (entityClassificationData.importexport.primaryHsCode) {
<div class="preview-field"><span class="preview-label">Primary HS Code</span><span class="preview-value mono">{{ entityClassificationData.importexport.primaryHsCode }}</span></div>
}
@if (entityClassificationData.importexport.eefcAccountRequired) {
<div class="preview-field"><span class="preview-label">EEFC Account</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Required</span></span></div>
}
@if (entityClassificationData.importexport.femaDeclarationObtained) {
<div class="preview-field"><span class="preview-label">FEMA Declaration</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Obtained</span></span></div>
}
</div>
</div>
</div>
}
@if (entityClassificationData.psl?.enabled) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="bullseye" class="card-icon"></fa-icon>
<span>Priority Sector (PSL)</span>
<span class="preview-badge preview-badge--verified">Enabled</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (entityClassificationData.psl.pslCategory) {
<div class="preview-field"><span class="preview-label">PSL Category</span><span class="preview-value">{{ entityClassificationData.psl.pslCategory }}</span></div>
}
@if (entityClassificationData.psl.pslSubCategory) {
<div class="preview-field"><span class="preview-label">PSL Sub-category</span><span class="preview-value">{{ entityClassificationData.psl.pslSubCategory }}</span></div>
}
@if (entityClassificationData.psl.pslSanctionedLimit) {
<div class="preview-field"><span class="preview-label">Sanctioned Limit</span><span class="preview-value">₹ {{ entityClassificationData.psl.pslSanctionedLimit | number }}</span></div>
}
@if (entityClassificationData.psl.fiddReportingCode) {
<div class="preview-field"><span class="preview-label">FIDD Reporting Code</span><span class="preview-value mono">{{ entityClassificationData.psl.fiddReportingCode }}</span></div>
}
@if (entityClassificationData.psl.weakerSectionFlag) {
<div class="preview-field"><span class="preview-label">Weaker Section</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
}
@if (entityClassificationData.psl.womenEntrepreneurFlag) {
<div class="preview-field"><span class="preview-label">Women Entrepreneur</span><span class="preview-value"><span class="preview-badge preview-badge--verified">Yes</span></span></div>
}
</div>
</div>
</div>
}
@if (entityClassificationData.nps?.enabled) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="building" class="card-icon"></fa-icon>
<span>Non-Priority Sector</span>
<span class="preview-badge preview-badge--verified">Enabled</span>
</div>
<div class="card-body">
<div class="preview-grid">
@if (entityClassificationData.nps.industrySector) {
<div class="preview-field"><span class="preview-label">Industry Sector</span><span class="preview-value">{{ entityClassificationData.nps.industrySector }}</span></div>
}
@if (entityClassificationData.nps.nicCode) {
<div class="preview-field"><span class="preview-label">NIC Code</span><span class="preview-value mono">{{ entityClassificationData.nps.nicCode }}</span></div>
}
@if (entityClassificationData.nps.annualRevenueBand) {
<div class="preview-field"><span class="preview-label">Annual Revenue Band</span><span class="preview-value">{{ entityClassificationData.nps.annualRevenueBand }}</span></div>
}
@if (entityClassificationData.nps.riskCategory) {
<div class="preview-field"><span class="preview-label">Risk Category</span><span class="preview-value">{{ entityClassificationData.nps.riskCategory }}</span></div>
}
@if (entityClassificationData.nps.amlRiskClassification) {
<div class="preview-field"><span class="preview-label">AML Risk</span><span class="preview-value">{{ entityClassificationData.nps.amlRiskClassification }}</span></div>
}
@if (entityClassificationData.nps.pepFlag) {
<div class="preview-field"><span class="preview-label">PEP Flag</span><span class="preview-value"><span class="preview-badge preview-badge--danger">PEP</span></span></div>
}
@if (entityClassificationData.nps.amlWatchlistMatch) {
<div class="preview-field"><span class="preview-label">AML Watchlist</span><span class="preview-value"><span class="preview-badge preview-badge--danger">Match Found</span></span></div>
}
</div>
</div>
</div>
}
}
<!-- Family Members (Individual only) -->
@if (client?.legalFormId !== 2) {
<div class="info-card">
<div class="card-header">
<fa-icon icon="users" class="card-icon"></fa-icon>
<span>Family Members</span>
@if (familyMembers.length > 0) {
<span class="count-badge">{{ familyMembers.length }}</span>
}
</div>
<div class="card-body">
@if (familyMembers.length === 0) {
<div class="preview-empty preview-empty--warn">
<mat-icon class="preview-empty__icon">warning</mat-icon>
<span>No family members added. At least one is required.</span>
</div>
} @else {
<div class="fm-preview-list">
@for (member of familyMembers; track member; let i = $index) {
<div class="fm-preview-row">
<div class="fm-preview-row__sno">{{ i + 1 }}</div>
<div class="fm-preview-row__avatar">
<mat-icon>person</mat-icon>
</div>
<div class="fm-preview-row__info">
<span class="fm-preview-row__name">{{ member.firstName || member.displayName }}</span>
<span class="fm-preview-row__meta">
@if (member.relationship) { {{ member.relationship }} }
@if (member.dateOfBirth) { &middot; DOB: {{ member.dateOfBirth }} }
</span>
</div>
@if (member.aadhaarNumber || member.panNumber) {
<div class="fm-preview-row__ids">
@if (member.panNumber) { <span class="fm-preview-id">PAN: {{ member.panNumber }}</span> }
@if (member.aadhaarNumber) { <span class="fm-preview-id">Aadhaar: {{ member.aadhaarNumber }}</span> }
</div>
}
<div class="fm-preview-row__status">
@if (member.isDependent) {
<span class="preview-badge preview-badge--info">Dependent</span>
}
@if (member.linkedClientId) {
<span class="preview-badge preview-badge--verified">Linked</span>
}
</div>
</div>
}
</div>
}
</div>
</div>
}
</div>
@@ -6,6 +6,37 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.step-header {
background: #0f172a;
padding: 16px 24px;
border-radius: 10px 10px 0 0;
margin-bottom: 20px;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 18px;
color: #60a5fa;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #fff;
margin: 0;
}
&__sub {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
margin: 2px 0 0;
}
}
.preview-container {
display: flex;
flex-direction: column;
@@ -134,7 +165,75 @@
justify-content: center;
}
/* ── Info Table (Key-Value pairs) ── */
/* ── Preview Grid (replaces info-table) ── */
.preview-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0;
}
.preview-field {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-right: 1px solid rgba(0, 0, 0, 0.04);
&:nth-child(3n) {
border-right: none;
}
&.full-width {
grid-column: 1 / -1;
border-right: none;
}
}
.preview-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(0, 0, 0, 0.45);
}
.preview-value {
font-size: 14px;
font-weight: 500;
color: #1a1a1a;
&.no-data {
color: rgba(0, 0, 0, 0.3);
font-style: italic;
font-weight: 400;
}
}
/* ── Preview Badges ── */
.preview-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
padding: 2px 10px;
border-radius: 12px;
&--verified {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
&--pending {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
}
/* ── Legacy Info Table (keep for backward compat) ── */
.info-table {
width: 100%;
border-collapse: collapse;
@@ -246,6 +345,36 @@
letter-spacing: 1px;
}
/* ── Address Blocks ── */
.address-block {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
}
.address-block__title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #1074b9;
margin-bottom: 10px;
padding-left: 2px;
}
/* ── Info badge ── */
.preview-badge--info {
background: #eff6ff;
color: #1074b9;
border: 1px solid #bfdbfe;
}
/* ── Submit Section ── */
.submit-section {
display: flex;
@@ -270,6 +399,17 @@
}
/* ── Responsive ── */
@media (max-width: 960px) {
.preview-grid {
grid-template-columns: 1fr 1fr;
}
.preview-field {
&:nth-child(3n) { border-right: 1px solid rgba(0, 0, 0, 0.04); }
&:nth-child(2n) { border-right: none; }
}
}
@media (max-width: 768px) {
.profile-card {
flex-direction: column;
@@ -281,10 +421,12 @@
justify-content: center;
}
.info-table {
.info-label {
width: 45%;
}
.preview-grid {
grid-template-columns: 1fr;
}
.preview-field {
border-right: none !important;
}
.data-table {
@@ -294,4 +436,124 @@
font-size: 12px;
}
}
.fm-preview-row {
flex-wrap: wrap;
}
}
/* Empty state with warning */
.preview-empty {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
font-size: 13px;
color: #64748b;
background: #f8fafc;
border: 1px dashed #e2e8f0;
border-radius: 8px;
}
.preview-empty--warn {
background: #fef2f2;
border-color: #fca5a5;
color: #991b1b;
}
.preview-empty__icon {
font-size: 20px !important;
width: 20px !important;
height: 20px !important;
color: #dc2626;
}
/* Family member preview rows */
.fm-preview-list {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.fm-preview-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #f8fafc; }
}
.fm-preview-row__sno {
flex: 0 0 24px;
text-align: center;
font-size: 11px;
font-weight: 600;
color: #94a3b8;
}
.fm-preview-row__avatar {
flex: 0 0 32px;
width: 32px;
height: 32px;
border-radius: 50%;
background: #dbeafe;
display: flex;
align-items: center;
justify-content: center;
color: #1565c0;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.fm-preview-row__info {
flex: 1;
min-width: 0;
}
.fm-preview-row__name {
display: block;
font-size: 13px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fm-preview-row__meta {
display: block;
font-size: 11px;
color: #64748b;
}
.fm-preview-row__ids {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.fm-preview-id {
font-size: 11px;
font-family: 'Roboto Mono', monospace;
color: #475569;
padding: 2px 8px;
background: #f1f5f9;
border-radius: 4px;
}
.fm-preview-row__status {
display: flex;
gap: 4px;
flex-shrink: 0;
}
@@ -18,6 +18,7 @@ import {
MatExpansionPanelDescription
} from '@angular/material/expansion';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatIcon } from '@angular/material/icon';
import { FindPipe } from '../../../pipes/find.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { YesnoPipe } from '../../../pipes/yesno.pipe';
@@ -41,6 +42,7 @@ import { LegalFormId } from 'app/clients/models/legal-form.enum';
MatExpansionPanelTitle,
MatExpansionPanelDescription,
FaIconComponent,
MatIcon,
FindPipe,
DateFormatPipe,
YesnoPipe
@@ -55,6 +57,31 @@ export class ClientPreviewStepComponent {
@Input() client: any;
/** KYC Data */
@Input() kycData: any;
/** Address data from India address step */
@Input() addressData: any;
/** Selected guardian client (for minors) */
@Input() guardian: any;
/** Guardian relationship */
@Input() guardianRelation: string = '';
/** Entity Classification data (non-individual) */
@Input() entityClassificationData: any;
/** Nominee data */
@Input() nominees: any[] = [];
/** Family members from family step */
@Input() familyMembers: any[] = [];
/** Marital Status ID from general step */
@Input() maritalStatusId: number | null = null;
/** Marital Status Options */
@Input() maritalStatusOptions: any[] = [];
/** Address tab labels */
addressTabs = ['permanent', 'current', 'office', 'correspondence'];
addressTabLabels: Record<string, string> = {
permanent: 'Permanent Address',
current: 'Current / Residential',
office: 'Office / Work',
correspondence: 'Correspondence'
};
/** Form submission event */
@Output() submitEvent = new EventEmitter();