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:
co-authored by
Claude Opus 4.6
parent
ab01111424
commit
b890d24bb7
+340
-98
@@ -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>
|
||||
|
||||
+321
-25
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+341
-61
@@ -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(); }
|
||||
}
|
||||
|
||||
+100
-91
@@ -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>
|
||||
|
||||
+566
-66
@@ -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%; }
|
||||
}
|
||||
|
||||
+423
-67
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user