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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-04 15:10:51 +05:30
co-authored by Claude Opus 4.6
parent ab01111424
commit b890d24bb7
522 changed files with 51991 additions and 21885 deletions
@@ -1,116 +1,358 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h1 mat-dialog-title>{{ data.context }} {{ 'labels.heading.Family Member' | translate }}</h1>
<div class="fm-dialog">
<div class="fm-dialog__header">
<mat-icon class="fm-dialog__icon">people</mat-icon>
<span>{{ data.context }} Family Member</span>
</div>
<form [formGroup]="familyMemberForm">
<h3>{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}</h3>
<mat-dialog-content>
<form [formGroup]="familyMemberForm">
<div class="layout-row-wrap responsive-column align-start-center">
<!-- Nomination Type -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Nomination Type' | translate }}</mat-label>
<mat-select formControlName="nominationType" required>
<mat-option value="DA1">{{ 'labels.inputs.DA1 - Add Nominee' | translate }}</mat-option>
<mat-option value="DA5">{{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<!-- ═══ VIEW MODE ═══ -->
@if (isViewMode) {
<!-- Linked client card -->
@if (entryMode === 'linked' && selectedClient) {
<div class="fm-linked-card">
<div class="fm-linked-card__left">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" class="fm-linked-card__photo zoomable" alt="Profile"
(click)="zoomImage(linkedProfileImage, 'Client Photograph')" />
} @else {
<mat-icon class="fm-linked-card__icon">person</mat-icon>
}
</div>
<div class="fm-linked-card__info">
<span class="fm-linked-card__name">{{ selectedClient.displayName }}</span>
<span class="fm-linked-card__meta">Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span>
</div>
@if (linkedSignatureImage) {
<div class="fm-linked-card__sig">
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')" />
<span>Signature</span>
</div>
}
</div>
}
<!-- Nominee Name -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Nominee Name' | translate }}</mat-label>
<input formControlName="firstName" required matInput />
@if (familyMemberForm.controls.firstName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (fetchingLinkedData) {
<div class="fm-fetching-hint">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
<span>Fetching client details...</span>
</div>
}
<!-- Relationship -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship' | translate }}</mat-label>
<mat-select formControlName="relationshipId" required>
@for (relation of data.options.relationshipIdOptions; track relation) {
<mat-option [value]="relation.id">
{{ relation.name | translateKey: 'catalogs' }}
</mat-option>
<!-- All details in read-only grid -->
<div class="fm-readonly-grid">
<div class="fm-readonly-item">
<span class="fm-readonly-label">Full Name</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('firstName')?.value || '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Relationship</span>
<span class="fm-readonly-value">{{ relationshipName }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Date of Birth</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Nationality</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('nationality')?.value || '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Aadhaar</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('aadhaarNumber')?.value || '—' }}
@if (linkedKycStatus?.aadhaarVerified) {
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (familyMemberForm.get('aadhaarNumber')?.value) {
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
}
</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">PAN</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('panNumber')?.value || '—' }}
@if (linkedKycStatus?.panVerified) {
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (familyMemberForm.get('panNumber')?.value) {
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
}
</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Status</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('isAlive')?.value !== false ? 'Alive' : 'Deceased' }}
@if (familyMemberForm.get('isDependent')?.value) { · Dependent }
</span>
</div>
@if (isSpouse && familyMemberForm.get('marriageDate')?.value) {
<div class="fm-readonly-item">
<span class="fm-readonly-label">Marriage Date</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('marriageDate')?.value | date:'dd MMM yyyy' }}</span>
</div>
}
@if (isSpouse && familyMemberForm.get('marriageAuthority')?.value) {
<div class="fm-readonly-item">
<span class="fm-readonly-label">Issuing Authority</span>
<span class="fm-readonly-value">{{ getMarriageAuthorityLabel(familyMemberForm.get('marriageAuthority')?.value) }}</span>
</div>
}
</div>
@if (familyMemberForm.get('remarks')?.value) {
<div class="fm-readonly-remarks">
<span class="fm-readonly-label">Remarks</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
</div>
}
</mat-select>
@if (familyMemberForm.controls.relationshipId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Relationship' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Date of Birth -->
<mat-form-field class="flex-50-minus-25px" (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Date Of Birth' | translate }}</mat-label>
<input
formControlName="dateOfBirth"
matInput
[max]="maxDate"
[matDatepicker]="dueDatePicker"
placeholder="{{ 'labels.text.Optional' | translate }}"
/>
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
</mat-form-field>
<!-- ═══ ADD / EDIT MODE ═══ -->
@if (!isViewMode) {
<!-- Mode Toggle -->
<div class="fm-mode-toggle">
<button type="button" class="fm-mode-btn" [class.active]="entryMode === 'manual'" (click)="setMode('manual')">
<mat-icon>edit</mat-icon> Enter Manually
</button>
<button type="button" class="fm-mode-btn" [class.active]="entryMode === 'linked'" (click)="setMode('linked')">
<mat-icon>search</mat-icon> Link Existing Client
</button>
</div>
<!-- Percentage Share -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Percentage Share' | translate }} (%)</mat-label>
<input type="number" formControlName="percentageShare" matInput min="0" max="100" />
</mat-form-field>
<!-- Linked Client Search -->
@if (entryMode === 'linked') {
@if (!selectedClient) {
<div class="fm-search-wrapper">
<mat-form-field appearance="outline" class="fm-search-field">
<mat-label>Search by Name or Client ID</mat-label>
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" autocomplete="off" />
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
@if (searching) {
<div class="fm-search-results">
<div class="fm-search-loading"><mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner> Searching...</div>
</div>
}
@if (!searching && searchResults.length > 0) {
<div class="fm-search-results">
@for (r of searchResults; track r.id) {
<div class="fm-search-item" (click)="selectClient(r)">
<mat-icon class="fm-search-item__icon">person</mat-icon>
<div class="fm-search-item__info">
<span class="fm-search-item__name">{{ r.displayName }}</span>
<span class="fm-search-item__meta">ID: {{ r.id }} · {{ r.officeName }}@if (r.dateOfBirth) { · DOB: {{ r.dateOfBirth | dateFormat }} }@if (r.fatherSpouseName) { · {{ r.fatherSpouseName }} }@if (r.mobileNo) { · {{ r.mobileNo }} }</span>
</div>
</div>
}
</div>
}
@if (!searching && searchText.length >= 2 && searchResults.length === 0) {
<div class="search-no-results-hint">No clients found</div>
}
</div>
}
@if (selectedClient) {
<!-- Linked Client Card with Profile & Signature -->
<div class="fm-linked-card">
<div class="fm-linked-card__left">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" class="fm-linked-card__photo zoomable" alt="Profile"
(click)="zoomImage(linkedProfileImage, 'Client Photograph')" />
} @else {
<mat-icon class="fm-linked-card__icon">person</mat-icon>
}
</div>
<div class="fm-linked-card__info">
<span class="fm-linked-card__name">{{ selectedClient.displayName }}</span>
<span class="fm-linked-card__meta">Client ID: {{ selectedClient.id }}{{ selectedClient.accountNumber ? ' · A/C: ' + selectedClient.accountNumber : '' }}</span>
</div>
@if (linkedSignatureImage) {
<div class="fm-linked-card__sig">
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Specimen Signature')" />
<span>Signature</span>
</div>
}
<button mat-icon-button type="button" (click)="removeLinkedClient()" class="fm-linked-card__remove"><mat-icon>close</mat-icon></button>
</div>
<!-- Nominee is Minor -->
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-50-minus-25px">
{{ 'labels.inputs.Nominee is Minor' | translate }}?
</mat-checkbox>
</div>
@if (fetchingLinkedData) {
<div class="fm-fetching-hint">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
<span>Fetching client details...</span>
</div>
}
<!-- Guardian Details (shown when minor) -->
@if (familyMemberForm.get('isDependent').value) {
<h3>{{ 'labels.heading.Guardian Details (If Minor)' | translate }}</h3>
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Guardian Name' | translate }}</mat-label>
<input formControlName="guardianName" matInput />
</mat-form-field>
<!-- Read-only fetched details -->
@if (!fetchingLinkedData) {
<div class="fm-readonly-grid">
<div class="fm-readonly-item">
<span class="fm-readonly-label">Full Name</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('firstName')?.value || '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Date of Birth</span>
<span class="fm-readonly-value">{{ familyMemberForm.get('dateOfBirth')?.value ? (familyMemberForm.get('dateOfBirth')?.value | date:'dd MMM yyyy') : '—' }}</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">Aadhaar</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('aadhaarNumber')?.value || '—' }}
@if (linkedKycStatus?.aadhaarVerified) {
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (familyMemberForm.get('aadhaarNumber')?.value) {
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
}
</span>
</div>
<div class="fm-readonly-item">
<span class="fm-readonly-label">PAN</span>
<span class="fm-readonly-value">
{{ familyMemberForm.get('panNumber')?.value || '—' }}
@if (linkedKycStatus?.panVerified) {
<span class="fm-kyc-badge verified"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (familyMemberForm.get('panNumber')?.value) {
<span class="fm-kyc-badge not-verified"><mat-icon>cancel</mat-icon> Not Verified</span>
}
</span>
</div>
</div>
}
}
}
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship with Minor' | translate }}</mat-label>
<input formControlName="guardianRelationship" matInput />
</mat-form-field>
</div>
}
<!-- Core Fields -->
<div class="fm-grid">
@if (entryMode === 'manual') {
<mat-form-field appearance="outline">
<mat-label>Full Name</mat-label>
<input matInput required formControlName="firstName" placeholder="Full name of family member" />
</mat-form-field>
}
<!-- Remarks -->
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<input formControlName="remarks" matInput />
</mat-form-field>
</div>
<mat-form-field appearance="outline">
<mat-label>Relationship</mat-label>
<mat-select required formControlName="relationshipId">
@for (rel of data.options.relationshipIdOptions; track rel) {
<mat-option [value]="rel.id">{{ rel.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-dialog-actions align="center">
<button type="button" mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button
type="button"
mat-raised-button
color="primary"
[disabled]="!familyMemberForm.valid"
[mat-dialog-close]="{ member: familyMember }"
>
{{ 'labels.buttons.Confirm' | translate }}
</button>
@if (entryMode === 'manual') {
<mat-form-field appearance="outline" (click)="dobPicker.open()">
<mat-label>Date of Birth</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="dobPicker" formControlName="dateOfBirth" />
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>Nationality</mat-label>
<mat-select formControlName="nationality">
<mat-option value="INDIAN">Indian</mat-option>
<mat-option value="NRI">NRI</mat-option>
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
<mat-option value="OTHER">Other</mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- Marriage Details (Spouse only) -->
@if (isSpouse) {
<div class="fm-section-label">Marriage Details</div>
<div class="fm-grid">
<mat-form-field appearance="outline" (click)="marriagePicker.open()">
<mat-label>Marriage Date</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="marriagePicker" formControlName="marriageDate" />
<mat-datepicker-toggle matSuffix [for]="marriagePicker"></mat-datepicker-toggle>
<mat-datepicker #marriagePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Issuing Authority</mat-label>
<mat-select formControlName="marriageAuthority">
@for (auth of marriageAuthorityOptions; track auth.value) {
<mat-option [value]="auth.value">{{ auth.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Marriage Certificate Upload -->
<div class="fm-file-upload">
@if (!marriageCertFile) {
<button type="button" mat-stroked-button class="fm-file-btn" (click)="certInput.click()">
<mat-icon>upload_file</mat-icon> Upload Marriage Certificate
</button>
<input #certInput type="file" hidden accept=".pdf,.jpg,.jpeg,.png"
(change)="onMarriageCertSelected($event)" />
<span class="fm-file-hint">PDF, JPG or PNG (max 5 MB)</span>
} @else {
<div class="fm-file-attached">
<mat-icon class="fm-file-attached__icon">description</mat-icon>
<span class="fm-file-attached__name">{{ marriageCertFileName }}</span>
<button mat-icon-button type="button" (click)="removeMarriageCert()" class="fm-file-attached__remove">
<mat-icon>close</mat-icon>
</button>
</div>
}
</div>
}
<!-- ID Verification (manual mode only) -->
@if (entryMode === 'manual') {
<div class="fm-section-label">ID Verification</div>
<div class="fm-grid">
<mat-form-field appearance="outline">
<mat-label>Aadhaar Number</mat-label>
<input matInput formControlName="aadhaarNumber" maxlength="12" placeholder="12-digit Aadhaar"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>PAN Number</mat-label>
<input matInput formControlName="panNumber" maxlength="10" placeholder="ABCDE1234F" style="text-transform:uppercase"
(input)="familyMemberForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), {emitEvent:false})" />
@if (familyMemberForm.get('panNumber')?.hasError('pattern') && familyMemberForm.get('panNumber')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
</mat-form-field>
</div>
}
<!-- Status -->
<div class="fm-checkbox-row">
<mat-checkbox formControlName="isAlive">Is Alive</mat-checkbox>
<mat-checkbox formControlName="isDependent">Is Dependent</mat-checkbox>
</div>
<!-- Remarks -->
<mat-form-field appearance="outline" class="fm-full-width">
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
</mat-form-field>
}
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (isViewMode) {
<button mat-flat-button color="primary" mat-dialog-close type="button">Close</button>
} @else {
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button"
[disabled]="familyMemberForm.invalid"
[mat-dialog-close]="{ member: familyMember }">
{{ data.context === 'Edit' ? 'Update' : 'Add Member' }}
</button>
}
</mat-dialog-actions>
</form>
</div>
@@ -1,34 +1,330 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.fm-dialog__header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
.mat-form-field-hint {
font-size: 75%;
color: rgb(0 0 0 / 60%);
margin-top: 4px;
.fm-dialog__icon { color: #1074b9; }
}
.fm-mode-toggle {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.fm-mode-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.15s;
mat-icon { font-size: 18px; width: 18px; height: 18px; }
&.active {
border-color: #1074b9;
background: #eff6ff;
color: #1074b9;
}
&:hover:not(.active) { border-color: #94a3b8; }
}
.fm-search-wrapper { position: relative; margin-bottom: 12px; }
.fm-search-field { width: 100%; }
.fm-search-results {
position: absolute;
top: 56px;
left: 0;
right: 0;
z-index: 100;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 220px;
overflow-y: auto;
}
.fm-search-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
font-size: 13px;
color: #64748b;
}
.fm-search-empty {
padding: 14px 16px;
font-size: 13px;
color: #94a3b8;
text-align: center;
}
.search-no-results-hint {
font-size: 12px;
color: #94a3b8;
text-align: center;
padding: 4px 0 0;
margin-top: -8px;
}
.fm-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
&:last-child { border-bottom: none; }
&:hover { background: #f0f7ff; }
&__icon { color: #1074b9; font-size: 20px; }
&__info { display: flex; flex-direction: column; }
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 11px; color: #64748b; }
}
.fm-linked-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 16px;
&__left { flex-shrink: 0; }
&__icon { color: #1074b9; }
&__photo {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
&__info { flex: 1; display: flex; flex-direction: column; }
&__name { font-size: 14px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; }
&__sig {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
img {
height: 28px;
max-width: 70px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
padding: 2px 4px;
}
span {
font-size: 9px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
&__remove { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
}
.fm-fetching-hint {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #64748b;
margin: -8px 0 12px;
}
.fm-readonly-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
}
.fm-readonly-item {
display: flex;
flex-direction: column;
padding: 6px 0;
}
.fm-readonly-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #94a3b8;
margin-bottom: 2px;
}
.fm-readonly-value {
font-size: 13px;
font-weight: 500;
color: #1e293b;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.fm-readonly-remarks {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 8px;
.fm-readonly-label { display: block; margin-bottom: 4px; }
.fm-readonly-value { font-size: 13px; color: #475569; line-height: 1.5; }
}
.fm-kyc-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 10px;
white-space: nowrap;
mat-icon {
font-size: 12px;
width: 12px;
height: 12px;
}
&.verified {
background: #dcfce7;
color: #16a34a;
}
&.not-verified {
background: #fef3c7;
color: #d97706;
}
}
.fm-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
margin-bottom: 8px;
}
form {
padding: 0 1.5rem;
.fm-section-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #1074b9;
margin: 8px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
}
.layout-row-wrap {
width: 100%;
}
.fm-checkbox-row {
display: flex;
align-items: center;
gap: 20px;
padding: 8px 0;
}
@media screen and (width >= 960px) {
.layout-row-wrap > .flex-fill:nth-child(odd),
.layout-row-wrap > .flex-50-minus-25px:nth-child(odd) {
margin-right: 25px;
}
.fm-full-width {
width: 100%;
}
.layout-row-wrap > .flex-fill:last-child,
.layout-row-wrap > .flex-50-minus-25px:last-child {
margin-right: 0;
}
.zoomable {
cursor: zoom-in;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: scale(1.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
}
::ng-deep .image-zoom-panel {
.mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; }
}
.fm-file-upload {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.fm-file-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #1074b9;
border-color: #cbd5e1;
mat-icon { font-size: 18px; width: 18px; height: 18px; }
}
.fm-file-hint {
font-size: 11px;
color: #94a3b8;
}
.fm-file-attached {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #f0f7ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
width: 100%;
&__icon { color: #1074b9; font-size: 20px; }
&__name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__remove {
color: #94a3b8 !important;
width: 28px !important;
height: 28px !important;
line-height: 28px !important;
mat-icon { font-size: 16px; width: 16px; height: 16px; }
&:hover { color: #ef4444 !important; }
}
}
@@ -6,26 +6,28 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import {
MatDialog,
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogActions,
MatDialogClose
MatDialogClose,
MatDialogContent
} from '@angular/material/dialog';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
/** Custom Services */
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ClientsService } from 'app/clients/clients.service';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
/**
* Client Family Members Dialog
*/
@Component({
selector: 'mifosx-client-family-member-dialog',
templateUrl: './client-family-member-dialog.component.html',
@@ -33,9 +35,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatCheckbox,
MatDialogActions,
MatDialogClose
MatDialogClose,
MatIcon,
MatProgressSpinner,
FaIconComponent,
DateFormatPipe
]
})
export class ClientFamilyMemberDialogComponent implements OnInit {
@@ -44,87 +51,360 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
private dateUtils = inject(Dates);
data = inject(MAT_DIALOG_DATA);
private settingsService = inject(SettingsService);
private clientsService = inject(ClientsService);
private sanitizer = inject(DomSanitizer);
private dialog = inject(MatDialog);
/** Maximum Due Date allowed. */
maxDate = new Date();
/** Add/Edit family member form. */
familyMemberForm: UntypedFormGroup;
/** Client search */
searchText = '';
searchResults: any[] = [];
searching = false;
selectedClient: any = null;
private searchTimer: any = null;
/** Mode: 'manual' or 'linked' */
entryMode: 'manual' | 'linked' = 'manual';
/** Marriage authority options */
marriageAuthorityOptions = [
{ value: 'SUB_REGISTRAR_OFFICE', label: 'Sub-Registrar Office' },
{ value: 'MARRIAGE_REGISTRAR', label: 'Registrar of Marriages' },
{ value: 'COURT_MARRIAGE_OFFICER', label: 'Court Marriage Officer' },
{ value: 'MUNICIPAL_AUTHORITY', label: 'Municipal Corporation / Nagar Palika' },
{ value: 'EMBASSY_CONSULATE', label: 'Indian Embassy / Consulate' },
{ value: 'DEFENCE_AUTHORITY', label: 'Defence Authority' }
];
/** Whether selected relationship is Spouse */
get isSpouse(): boolean {
const relId = this.familyMemberForm?.get('relationshipId')?.value;
if (!relId || !this.data?.options?.relationshipIdOptions) return false;
const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId);
return rel?.name?.toUpperCase() === 'SPOUSE';
}
/** Whether dialog is in View-only mode */
get isViewMode(): boolean { return this.data.context === 'View'; }
/** Get relationship name from ID */
get relationshipName(): string {
const relId = this.familyMemberForm?.get('relationshipId')?.value;
if (!relId || !this.data?.options?.relationshipIdOptions) return '—';
const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId);
return rel?.name || '—';
}
/** Open image in zoom overlay */
zoomImage(imageSrc: any, title: string): void {
this.dialog.open(ImageZoomDialogComponent, {
data: { imageSrc, title },
panelClass: 'image-zoom-panel',
maxWidth: '90vw',
maxHeight: '90vh'
});
}
/** Get marriage authority display label */
getMarriageAuthorityLabel(value: string): string {
return this.marriageAuthorityOptions.find(a => a.value === value)?.label || value || '—';
}
/** Marriage certificate file */
marriageCertFile: File | null = null;
marriageCertFileName: string = '';
/** Linked client fetched data */
fetchingLinkedData = false;
linkedProfileImage: SafeUrl | null = null;
linkedSignatureImage: SafeUrl | null = null;
/** KYC verification status for linked client */
linkedKycStatus: {
panVerified?: boolean;
aadhaarVerified?: boolean;
panHolderName?: string;
aadhaarHolderName?: string;
riskCategory?: number;
} | null = null;
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createFamilyMemberForm();
if (this.data.context === 'Edit') {
if (this.data.context === 'Edit' || this.data.context === 'View') {
const m = this.data.member;
this.familyMemberForm.patchValue({
nominationType: this.data.member.nominationType || 'DA1',
firstName: this.data.member.firstName,
relationshipId: this.data.member.relationshipId,
dateOfBirth: this.data.member.dateOfBirth && new Date(this.data.member.dateOfBirth),
percentageShare: this.data.member.percentageShare ?? 100,
isDependent: this.data.member.isDependent,
guardianName: this.data.member.guardianName || '',
guardianRelationship: this.data.member.guardianRelationship || '',
remarks: this.data.member.remarks || ''
firstName: m.firstName,
relationshipId: m.relationshipId,
dateOfBirth: m.dateOfBirth && new Date(m.dateOfBirth),
isDependent: m.isDependent,
remarks: m.remarks || '',
aadhaarNumber: m.aadhaarNumber || '',
panNumber: m.panNumber || '',
nationality: m.nationality || 'INDIAN',
isAlive: m.isAlive !== false,
marriageDate: m.marriageDate && new Date(m.marriageDate),
marriageAuthority: m.marriageAuthority || '',
linkedClientId: m.linkedClientId || ''
});
if (m.linkedClientId) {
this.entryMode = 'linked';
this.selectedClient = { id: m.linkedClientId, displayName: m.linkedClientName || m.firstName || `Client #${m.linkedClientId}` };
// Re-fetch linked client details (profile, signature, KYC status)
this.fetchLinkedClientData(m.linkedClientId.toString());
}
if (this.isViewMode) {
this.familyMemberForm.disable();
}
}
}
/**
* Creates Family Member Form
*/
createFamilyMemberForm() {
this.familyMemberForm = this.formBuilder.group({
nominationType: ['DA1'],
firstName: [
'',
Validators.required
],
relationshipId: [
'',
Validators.required
],
firstName: ['', Validators.required],
relationshipId: ['', Validators.required],
dateOfBirth: [''],
percentageShare: [100],
isDependent: [false],
guardianName: [''],
guardianRelationship: [''],
remarks: ['']
remarks: [''],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)],
nationality: ['INDIAN'],
isAlive: [true],
marriageDate: [''],
marriageAuthority: [''],
linkedClientId: ['']
});
}
/**
* Returns Formatted Family Member
*/
get familyMember() {
const formValue = {
...this.familyMemberForm.value
};
/** Handle marriage certificate file selection */
onMarriageCertSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files?.length) {
this.marriageCertFile = input.files[0];
this.marriageCertFileName = this.marriageCertFile.name;
}
input.value = '';
}
removeMarriageCert(): void {
this.marriageCertFile = null;
this.marriageCertFileName = '';
}
/** Search existing clients */
onSearch(text: string): void {
this.searchText = text;
clearTimeout(this.searchTimer);
if (!text || text.length < 2) {
this.searchResults = [];
return;
}
this.searching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
this.clientsService.enrichSearchResults(this.searchResults);
},
error: () => { this.searchResults = []; this.searching = false; }
});
}, 400);
}
/** Select a client from search results and fetch full details */
selectClient(client: any): void {
this.selectedClient = client;
this.searchResults = [];
this.searchText = '';
this.familyMemberForm.patchValue({
firstName: client.displayName,
linkedClientId: client.id
});
this.fetchLinkedClientData(client.id);
}
/** Fetch DOB, KYC, profile image & signature for linked client */
private fetchLinkedClientData(clientId: string): void {
this.fetchingLinkedData = true;
this.linkedProfileImage = null;
this.linkedSignatureImage = null;
this.linkedKycStatus = null;
// Fetch client details (DOB, gender etc.)
this.clientsService.getClientData(clientId).subscribe({
next: (data: any) => {
if (data.dateOfBirth) {
this.familyMemberForm.patchValue({ dateOfBirth: new Date(data.dateOfBirth) });
}
// Make fetched fields read-only
this.setLinkedFieldsReadonly(true);
this.fetchingLinkedData = false;
},
error: () => { this.fetchingLinkedData = false; }
});
// Fetch KYC (PAN, Aadhaar, verification status)
this.clientsService.getClientKyc(clientId).subscribe({
next: (kyc: any) => {
if (kyc) {
const patch: any = {};
if (kyc.panNumber) { patch.panNumber = kyc.panNumber; }
if (kyc.aadhaarNumberMasked) { patch.aadhaarNumber = kyc.aadhaarNumberMasked; }
this.familyMemberForm.patchValue(patch);
this.linkedKycStatus = {
panVerified: kyc.panVerified || false,
aadhaarVerified: kyc.aadhaarVerified || false,
panHolderName: kyc.panFullName || kyc.panHolderName || '',
aadhaarHolderName: kyc.aadhaarHolderName || '',
riskCategory: kyc.riskCategory || null
};
}
},
error: () => {}
});
// Fetch profile image
this.clientsService.getClientProfileImage(clientId).subscribe({
next: (imageData: any) => {
if (imageData) {
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(imageData);
}
},
error: () => {}
});
// Fetch signature image
this.clientsService.getClientDocuments(clientId).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) =>
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(clientId, sigDoc.id.toString()).subscribe({
next: (blob: Blob) => {
this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
},
error: () => {}
});
}
},
error: () => {}
});
}
/** Disable/enable fields that are auto-populated from linked client */
private setLinkedFieldsReadonly(readonly: boolean): void {
const fields = ['firstName', 'dateOfBirth', 'aadhaarNumber', 'panNumber'];
fields.forEach(f => {
const ctrl = this.familyMemberForm.get(f);
if (ctrl) {
readonly ? ctrl.disable() : ctrl.enable();
}
});
}
/** Remove linked client */
removeLinkedClient(): void {
this.selectedClient = null;
this.linkedProfileImage = null;
this.linkedSignatureImage = null;
this.linkedKycStatus = null;
this.setLinkedFieldsReadonly(false);
this.familyMemberForm.patchValue({
firstName: '', linkedClientId: '', dateOfBirth: '', aadhaarNumber: '', panNumber: ''
});
}
/** Switch entry mode */
setMode(mode: 'manual' | 'linked'): void {
this.entryMode = mode;
if (mode === 'manual') {
this.removeLinkedClient();
}
}
get familyMember() {
const formValue = { ...this.familyMemberForm.getRawValue() };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevDateOfBirth: Date = formValue.dateOfBirth;
if (prevDateOfBirth) {
if (formValue.dateOfBirth instanceof Date) {
formValue.dateOfBirth = this.dateUtils.formatDate(prevDateOfBirth, dateFormat);
}
} else {
if (formValue.dateOfBirth instanceof Date) {
formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat);
} else if (!formValue.dateOfBirth) {
delete formValue.dateOfBirth;
}
const familyMember = {
...formValue,
dateFormat,
locale
};
if (formValue.marriageDate instanceof Date) {
formValue.marriageDate = this.dateUtils.formatDate(formValue.marriageDate, dateFormat);
} else if (!formValue.marriageDate) {
delete formValue.marriageDate;
}
// Remove empty fields
for (const key in familyMember) {
if (familyMember[key] === '' || familyMember[key] === undefined) {
delete familyMember[key];
if (formValue.panNumber) {
formValue.panNumber = formValue.panNumber.toUpperCase();
}
const member = { ...formValue, dateFormat, locale };
// Clean empty fields
for (const key in member) {
if (member[key] === '' || member[key] === undefined || member[key] === null) {
delete member[key];
}
}
return familyMember;
// Preserve selectedClient info for display
if (this.selectedClient) {
member._linkedClient = this.selectedClient;
}
// Attach marriage certificate file if present
if (this.marriageCertFile) {
member._marriageCertFile = this.marriageCertFile;
}
return member;
}
}
/**
* Lightweight image zoom dialog
*/
@Component({
selector: 'mifosx-image-zoom-dialog',
template: `
<div class="img-zoom-dialog" (click)="close()">
<div class="img-zoom-header">
<span>{{ data.title }}</span>
<button mat-icon-button (click)="close()"><mat-icon>close</mat-icon></button>
</div>
<div class="img-zoom-body" (click)="$event.stopPropagation()">
<img [src]="data.imageSrc" [alt]="data.title" />
</div>
</div>
`,
styles: [`
.img-zoom-dialog { display: flex; flex-direction: column; }
.img-zoom-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
font-size: 14px; font-weight: 600; color: #1e293b;
}
.img-zoom-body {
display: flex; align-items: center; justify-content: center;
padding: 20px; background: #fff; min-height: 200px;
}
.img-zoom-body img {
max-width: 100%; max-height: 70vh; object-fit: contain;
border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
`],
imports: [MatIcon, STANDALONE_SHARED_IMPORTS]
})
export class ImageZoomDialogComponent {
dialogRef = inject<MatDialogRef<ImageZoomDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
close() { this.dialogRef.close(); }
}
@@ -1,98 +1,107 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="mat-typography">
<div class="section-block">
<div class="section-header">
<h4 class="section-title">
<fa-icon icon="users" class="section-icon"></fa-icon>
{{ 'labels.heading.Family Members' | translate }}
</h4>
<button mat-stroked-button color="primary" (click)="addFamilyMember()" class="add-btn">
<fa-icon icon="plus" class="m-r-10"></fa-icon>{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (clientFamilyMembers.length === 0) {
<div class="empty-state">
<fa-icon icon="users" class="empty-icon"></fa-icon>
<span>No family members / nominees added yet.</span>
<div class="fm-step">
<!-- Step Header -->
<div class="step-header">
<div class="step-header__left">
<fa-icon icon="users" class="step-header__icon"></fa-icon>
<div>
<h3 class="step-header__title">Family Members</h3>
<p class="step-header__sub">Add family members and dependents of the client.</p>
</div>
}
<mat-accordion>
@for (member of clientFamilyMembers; track member; let i = $index) {
<mat-expansion-panel class="member-panel">
<mat-expansion-panel-header>
<mat-panel-title>
<fa-icon icon="user" class="panel-icon"></fa-icon>
{{ member.firstName }}
</mat-panel-title>
<mat-panel-description>
{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}
</mat-panel-description>
</mat-expansion-panel-header>
<mat-divider></mat-divider>
<div class="member-actions">
<button mat-button color="primary" (click)="editFamilyMember(member, i)">
<fa-icon icon="edit"></fa-icon>
</button>
<button mat-button color="warn" (click)="deleteFamilyMember(member.firstName, i)">
<fa-icon icon="trash"></fa-icon>
</button>
</div>
<div class="member-details">
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Nomination Type' | translate }}</span>
<span class="detail-value">{{ member.nominationType }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Nominee Name' | translate }}</span>
<span class="detail-value">{{ member.firstName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Relationship' | translate }}</span>
<span class="detail-value">{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Date Of Birth' | translate }}</span>
<span class="detail-value">{{ member.dateOfBirth | dateFormat }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Percentage Share' | translate }}</span>
<span class="detail-value">{{ member.percentageShare }}%</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Nominee is Minor' | translate }}</span>
<span class="detail-value">{{ member.isDependent ? 'Yes' : 'No' }}</span>
</div>
@if (member.isDependent) {
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Guardian Name' | translate }}</span>
<span class="detail-value">{{ member.guardianName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Relationship with Minor' | translate }}</span>
<span class="detail-value">{{ member.guardianRelationship }}</span>
</div>
}
@if (member.remarks) {
<div class="detail-row">
<span class="detail-label">{{ 'labels.inputs.Remarks' | translate }}</span>
<span class="detail-value">{{ member.remarks }}</span>
</div>
}
</div>
</mat-expansion-panel>
}
</mat-accordion>
</div>
</div>
<!-- ═══ FAMILY MEMBERS ═══ -->
<div class="fm-step__header">
<div class="fm-step__title">
<fa-icon icon="users" class="fm-step__icon"></fa-icon>
<span>Family Members</span>
@if (clientFamilyMembers.length > 0) {
<span class="fm-step__count">{{ clientFamilyMembers.length }}</span>
}
</div>
@if (!readOnly) {
<button mat-flat-button color="primary" type="button" class="fm-step__add-btn" (click)="addFamilyMember()">
<mat-icon>add</mat-icon> Add Member
</button>
}
</div>
@if (clientFamilyMembers.length === 0) {
<div class="fm-step__empty fm-step__empty--required">
<mat-icon class="fm-step__empty-icon">people_outline</mat-icon>
<span>No family members added yet.</span>
<span class="fm-step__empty-required">At least one family member is required.</span>
</div>
}
<!-- Spouse hint for married clients -->
@if (isMarried && !hasSpouse) {
<div class="fm-spouse-hint">
<mat-icon class="fm-spouse-hint__icon">favorite</mat-icon>
<span>Marital status is <strong>Married</strong>. Consider adding spouse as a family member.</span>
</div>
}
@if (clientFamilyMembers.length > 0) {
<div class="fm-table">
<div class="fm-table__header">
<span class="fm-table__col fm-table__col--name">Name</span>
<span class="fm-table__col fm-table__col--rel">Relationship</span>
<span class="fm-table__col fm-table__col--dob">Date of Birth</span>
<span class="fm-table__col fm-table__col--id">Aadhaar / PAN</span>
<span class="fm-table__col fm-table__col--status">Alive</span>
<span class="fm-table__col fm-table__col--actions">Actions</span>
</div>
@for (member of clientFamilyMembers; track member; let i = $index) {
<div class="fm-table__row">
<span class="fm-table__col fm-table__col--name">
<mat-icon class="fm-table__avatar">{{ member.linkedClientId ? 'link' : 'person' }}</mat-icon>
<div class="fm-table__name-info">
<span class="fm-table__name-text">{{ member.firstName || member.displayName }}</span>
@if (member.linkedClientId) {
<span class="fm-table__linked-tag">Linked Client #{{ member.linkedClientId }}</span>
}
</div>
</span>
<span class="fm-table__col fm-table__col--rel">
{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}
</span>
<span class="fm-table__col fm-table__col--dob">
{{ member.dateOfBirth ? (member.dateOfBirth | dateFormat) : '—' }}
</span>
<span class="fm-table__col fm-table__col--id fm-mono">
@if (member.panNumber) { {{ member.panNumber }} }
@else if (member.aadhaarNumber) { {{ member.aadhaarNumber }} }
@else { — }
</span>
<span class="fm-table__col fm-table__col--status">
@if (member.isAlive !== false) {
<span class="fm-alive-badge fm-alive-badge--yes">Yes</span>
} @else {
<span class="fm-alive-badge fm-alive-badge--no">No</span>
}
</span>
<span class="fm-table__col fm-table__col--actions">
<button mat-icon-button type="button" (click)="viewFamilyMember(member)" class="fm-action-btn" matTooltip="View">
<mat-icon>visibility</mat-icon>
</button>
@if (!readOnly) {
<button mat-icon-button type="button" (click)="editFamilyMember(member, i)" class="fm-action-btn" matTooltip="Edit">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button type="button" (click)="deleteFamilyMember(member.firstName, i)" class="fm-action-btn fm-action-btn--delete" matTooltip="Delete">
<mat-icon>delete</mat-icon>
</button>
}
</span>
</div>
}
</div>
}
</div>
@@ -1,106 +1,606 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.section-block {
margin-bottom: 8px;
.zoomable {
cursor: zoom-in;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
}
.section-header {
::ng-deep .image-zoom-panel {
.mat-mdc-dialog-container { padding: 0 !important; border-radius: 10px !important; overflow: hidden; }
}
.search-no-results-hint {
font-size: 12px;
color: #94a3b8;
text-align: center;
padding: 4px 0 0;
margin-top: -8px;
}
.step-header {
background: #0f172a;
padding: 16px 24px;
border-radius: 10px;
margin-bottom: 20px;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 18px;
color: #60a5fa;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #fff;
margin: 0;
}
&__sub {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
margin: 2px 0 0;
}
}
.fm-step__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
.fm-step__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #333;
margin: 0;
.section-icon {
color: var(--md-sys-color-primary, #1074b9);
font-size: 14px;
}
color: #1e293b;
}
.add-btn {
font-size: 13px;
}
.fm-step__icon { color: #1074b9; font-size: 14px; }
.empty-state {
.fm-step__count {
background: #1074b9;
color: #fff;
font-size: 11px;
font-weight: 700;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
gap: 10px;
padding: 24px 16px;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
background: rgba(0, 0, 0, 0.02);
border-radius: 6px;
border: 1px dashed rgba(0, 0, 0, 0.12);
.empty-icon {
font-size: 18px;
}
justify-content: center;
}
.member-panel {
margin-bottom: 8px;
.panel-icon {
margin-right: 8px;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.fm-step__add-btn {
font-size: 13px !important;
height: 36px !important;
border-radius: 8px !important;
}
.member-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
margin: 8px 0 4px;
}
.member-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 24px;
padding: 8px 0;
}
.detail-row {
.fm-step__empty {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
gap: 6px;
padding: 32px 16px;
color: #94a3b8;
font-size: 14px;
background: #f8fafc;
border-radius: 8px;
border: 1px dashed #e2e8f0;
}
.detail-label {
.fm-step__empty-icon {
font-size: 36px !important;
width: 36px !important;
height: 36px !important;
color: #cbd5e1;
}
.fm-step__empty-sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
color: #cbd5e1;
}
.fm-step__empty--required {
border-color: #fca5a5;
background: #fef2f2;
}
.fm-step__empty-required {
font-size: 12px;
font-weight: 600;
color: #dc2626;
}
.fm-spouse-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-top: 12px;
margin-bottom: 4px;
}
.fm-spouse-hint__icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: #f59e0b;
}
/* Table */
.fm-table {
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.fm-table__header {
display: flex;
align-items: center;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
.fm-table__col {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
padding: 10px 12px;
}
}
.fm-table__row {
display: flex;
align-items: center;
border-bottom: 1px solid #f1f5f9;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #f8fafc; }
}
.fm-table__col {
padding: 10px 12px;
font-size: 13px;
color: #1e293b;
&--name {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
&--rel { flex: 0 0 130px; }
&--dob { flex: 0 0 120px; color: #64748b; font-size: 12px; }
&--id { flex: 0 0 140px; }
&--status { flex: 0 0 60px; text-align: center; }
&--actions { flex: 0 0 80px; display: flex; gap: 2px; }
}
.fm-table__avatar {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: #1074b9;
flex-shrink: 0;
}
.fm-table__name-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.fm-table__name-text {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-table__linked-tag {
font-size: 10px;
color: #1074b9;
font-weight: 500;
}
.detail-value {
font-size: 14px;
color: #333;
.fm-mono {
font-family: 'Roboto Mono', monospace;
font-size: 12px;
letter-spacing: 0.3px;
}
.fm-alive-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
&--yes { background: #ecfdf5; color: #059669; }
&--no { background: #fef2f2; color: #dc2626; }
}
.fm-action-btn {
width: 30px !important;
height: 30px !important;
line-height: 30px !important;
mat-icon {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
color: #64748b;
}
&:hover mat-icon { color: #1074b9; }
&--delete:hover mat-icon { color: #ef4444; }
}
@media (max-width: 768px) {
.section-header {
.fm-step__header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.member-details {
grid-template-columns: 1fr;
.fm-table__col {
&--type, &--id { display: none; }
}
}
/* ═══ NOMINEES ═══ */
.nom-section {
margin-top: 28px;
padding-top: 24px;
border-top: 2px solid #e2e8f0;
}
.nom-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.nom-header__left {
display: flex;
align-items: center;
gap: 10px;
}
.nom-header__icon { font-size: 16px; color: #1565c0; }
.nom-header__title { font-size: 16px; font-weight: 600; color: #1e293b; display: block; }
.nom-header__sub { font-size: 11px; color: #94a3b8; display: block; }
.nom-add-btn { font-size: 13px; }
/* Share bar */
.nom-share-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.nom-share-bar__label { font-size: 12px; color: #64748b; white-space: nowrap; }
.nom-share-bar__track {
flex: 1;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.nom-share-bar__fill {
height: 100%;
background: #1565c0;
border-radius: 4px;
transition: width 0.3s;
&.complete { background: #059669; }
&.over { background: #dc2626; }
}
.nom-share-bar__value {
font-size: 13px;
font-weight: 700;
color: #1565c0;
min-width: 36px;
text-align: right;
&.over { color: #dc2626; }
}
/* Nominee card */
.nom-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.nom-card__main {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.nom-card__avatar {
width: 36px; height: 36px;
border-radius: 50%;
background: #dbeafe;
display: flex; align-items: center; justify-content: center;
color: #1565c0;
mat-icon { font-size: 20px; }
}
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.nom-card__info { flex: 1; }
.nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; }
.nom-card__sig {
flex-shrink: 0;
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; }
}
.nom-card__actions { display: flex; gap: 2px; }
.nom-card__edit {
padding: 12px 16px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.nom-card__edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.nom-minor-tag {
font-size: 10px;
font-weight: 700;
color: #dc2626;
background: #fef2f2;
padding: 1px 6px;
border-radius: 3px;
}
.nom-card__share {
text-align: center;
min-width: 50px;
}
.nom-card__share-value { font-size: 18px; font-weight: 700; color: #1565c0; display: block; }
.nom-card__share-label { font-size: 10px; color: #94a3b8; text-transform: uppercase; }
.nom-card__guardian {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: #f0f9ff;
border-top: 1px solid #e0f2fe;
font-size: 12px;
color: #0c4a6e;
mat-icon { font-size: 16px; width: 16px; height: 16px; color: #0ea5e9; }
}
/* Empty */
.nom-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 32px;
background: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 10px;
color: #94a3b8;
font-size: 14px;
}
.nom-empty__icon { font-size: 36px; width: 36px; height: 36px; color: #cbd5e1; }
.nom-empty__sub { font-size: 12px; }
/* Nominee form */
.nom-form {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px;
margin-top: 12px;
}
.nom-form__title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
/* Search */
.nom-search-wrapper { position: relative; }
.nom-search-field { width: 100%; }
.nom-search-prefix {
display: inline-flex;
align-items: center;
padding-right: 8px;
mat-icon { font-size: 20px; color: #94a3b8; }
}
.nom-search-results {
position: absolute;
top: 100%;
left: 0; right: 0;
z-index: 10;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
max-height: 240px;
overflow-y: auto;
margin-top: -12px;
}
.nom-search-loading {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px;
font-size: 13px; color: #64748b;
}
.nom-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
&:hover { background: #f1f5f9; }
}
.nom-search-item__icon { color: #94a3b8; font-size: 20px; }
.nom-search-item__info { flex: 1; }
.nom-search-item__name { font-weight: 500; font-size: 13px; color: #1e293b; display: block; }
.nom-search-item__meta { font-size: 11px; color: #94a3b8; display: block; }
.nom-search-item__status { font-size: 11px; font-weight: 600; color: #94a3b8; &.active { color: #22c55e; } }
/* Selected nominee */
.nom-selected {
border: 1px solid #93c5fd;
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.nom-selected__header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #eff6ff;
border-bottom: 1px solid #dbeafe;
}
.nom-selected__avatar {
width: 40px; height: 40px;
border-radius: 50%;
background: #dbeafe;
display: flex; align-items: center; justify-content: center;
color: #1565c0;
overflow: hidden;
}
.nom-selected__photo { width: 100%; height: 100%; object-fit: cover; }
.nom-selected__info { flex: 1; }
.nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-selected__meta { font-size: 12px; color: #64748b; display: block; }
.nom-selected__sig {
display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0;
img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; }
span { font-size: 9px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.04em; }
}
.nom-selected__remove { color: #94a3b8; }
.nom-selected__loading { display: flex; align-items: center; gap: 8px; padding: 12px 16px; font-size: 12px; color: #64748b; }
.nom-kyc-badge {
display: inline-flex; align-items: center; margin-left: 4px;
mat-icon { font-size: 14px; width: 14px; height: 14px; }
&.verified { color: #16a34a; }
}
.nom-selected__details {
display: flex;
gap: 16px;
padding: 10px 16px;
border-bottom: 1px solid #f1f5f9;
flex-wrap: wrap;
}
.nom-detail { display: flex; flex-direction: column; gap: 2px; }
.nom-detail__label { font-size: 10px; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.5px; }
.nom-detail__value { font-size: 13px; font-weight: 500; color: #1e293b; }
.nom-detail__value.mono { font-family: 'Roboto Mono', monospace; font-size: 12px; }
/* Form fields */
.nom-form-fields {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 8px 16px;
padding: 12px 16px;
align-items: center;
}
.nom-form-field { width: 100%; }
.nom-minor-toggle { padding-top: 8px; }
/* Guardian */
.nom-guardian-section {
padding: 12px 16px;
border-top: 1px dashed #e2e8f0;
}
.nom-guardian-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #0c4a6e;
margin-bottom: 10px;
mat-icon { font-size: 18px; color: #0ea5e9; }
}
.nom-guardian-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
mat-icon { color: #0ea5e9; }
}
.nom-guardian-card__info { flex: 1; }
.nom-guardian-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; }
.nom-guardian-card__meta { font-size: 11px; color: #64748b; display: block; }
.nom-guardian-rel { width: 160px; }
/* Actions */
.nom-form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 16px;
border-top: 1px solid #f1f5f9;
button { display: flex; align-items: center; gap: 6px; }
}
@media (max-width: 768px) {
.nom-form-fields { grid-template-columns: 1fr; }
.nom-guardian-card { flex-wrap: wrap; }
.nom-guardian-rel { width: 100%; }
}
@@ -1,119 +1,475 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, Input, inject } from '@angular/core';
import { Component, Input, OnInit, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
/** Custom Components */
import { TranslateService } from '@ngx-translate/core';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { ClientsService } from '../../clients.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle,
MatExpansionPanelDescription
} from '@angular/material/expansion';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FindPipe } from '../../../pipes/find.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormsModule } from '@angular/forms';
import { DomSanitizer } from '@angular/platform-browser';
import { MatTooltip } from '@angular/material/tooltip';
/**
* Client Family Members Step
*/
@Component({
selector: 'mifosx-client-family-members-step',
templateUrl: './client-family-members-step.component.html',
styleUrls: ['./client-family-members-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle,
MatExpansionPanelDescription,
MatDivider,
MatIcon,
MatSlideToggle,
MatProgressSpinner,
FindPipe,
DateFormatPipe
DateFormatPipe,
MatTooltip
]
})
export class ClientFamilyMembersStepComponent {
export class ClientFamilyMembersStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private clientsService = inject(ClientsService);
private sanitizer = inject(DomSanitizer);
/** Cient Template */
@Input() clientTemplate: any;
/** Client Family Members */
clientFamilyMembers: any[] = [];
@Input() clientId: string = '';
@Input() maritalStatusId: number | null = null;
@Input() maritalStatusOptions: any[] = [];
/** Read-only mode — hides add/edit/delete buttons */
@Input() readOnly = false;
/**
* Adds a family member.
*/
addFamilyMember() {
const addFamilyMemberDialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: {
context: this.translateService.instant('labels.buttons.Add'),
options: this.clientTemplate.familyMemberOptions
/** Family Members */
clientFamilyMembers: any[] = [];
private loaded = false;
/** Nominees */
nominees: any[] = [];
private nomineesLoaded = false;
showNomineeForm = false;
/** Nominee search */
nomineeSearchText = '';
nomineeSearchResults: any[] = [];
nomineeSearching = false;
private nomineeSearchTimer: any = null;
/** Selected nominee client */
selectedNomineeClient: any = null;
nomineeRelationshipId: number | null = null;
nomineePercentageShare: number | null = null;
nomineeIsMinor = false;
nomineeSaving = false;
nomineeType = 'DA1';
/** Nomination type options */
nominationTypeOptions = [
{ value: 'DA1', label: 'DA1 - Add Nominee' },
{ value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' }
];
/** Guardian search (for minor nominee) */
guardianSearchText = '';
guardianSearchResults: any[] = [];
guardianSearching = false;
selectedGuardian: any = null;
guardianRelationship = '';
private guardianSearchTimer: any = null;
/** Relationship options */
relationshipOptions = [
'Father', 'Mother', 'Spouse', 'Son', 'Daughter',
'Brother', 'Sister', 'Uncle', 'Aunt', 'Grandfather',
'Grandmother', 'Friend', 'Business Partner', 'Other'
];
ngOnInit(): void {
if (this.clientId) {
this.loadExistingMembers();
this.loadNominees();
}
}
// ═══ Family Members ═══
private loadExistingMembers(): void {
if (this.loaded) return;
this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
next: (data: any) => {
this.loaded = true;
this.clientFamilyMembers = Array.isArray(data) ? data : [];
},
width: '50rem'
error: () => { this.loaded = true; }
});
addFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
if (response.member) {
}
addFamilyMember() {
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'Add', options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
this.clientFamilyMembers.push(response.member);
}
});
}
/**
* Edits the family member.
* @param {any} member Family Member
* @param {any} index Tree Index
*/
editFamilyMember(member: any, index: any) {
const addFamilyMemberDialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: {
context: 'Edit',
member: member,
options: this.clientTemplate.familyMemberOptions
},
width: '50rem'
viewFamilyMember(member: any) {
this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'View', member, options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
});
addFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
if (response.member) {
}
editFamilyMember(member: any, index: number) {
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: { context: 'Edit', member, options: this.clientTemplate.familyMemberOptions },
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
this.clientFamilyMembers.splice(index, 1, response.member);
}
});
}
/**
* Deletes the family member
*/
deleteFamilyMember(name: string, index: number) {
const deleteFamilyMemberDialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `Family member name : ${name} ${index}` }
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `${name}` }
});
deleteFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
ref.afterClosed().subscribe((response: any) => {
if (response?.delete) {
this.clientFamilyMembers.splice(index, 1);
}
});
}
/**
* Returns the array of client family members.
*/
get familyMembers() {
return { familyMembers: this.clientFamilyMembers };
}
get hasFamilyMembers(): boolean {
return this.clientFamilyMembers.length > 0;
}
/** Check if client's marital status is "Married" */
get isMarried(): boolean {
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
const selected = this.maritalStatusOptions.find((o: any) => o.id === this.maritalStatusId);
return selected?.name?.toLowerCase().includes('married') || false;
}
/** Check if a spouse has been added */
get hasSpouse(): boolean {
return this.clientFamilyMembers.some((m: any) => {
const rel = m.relationship || m.relationshipId;
const relName = typeof rel === 'string' ? rel : '';
return relName.toLowerCase() === 'spouse' ||
m.firstName?.toLowerCase().includes('spouse');
});
}
// ═══ Nominees ═══
private loadNominees(): void {
if (this.nomineesLoaded || !this.clientId) return;
// Nominees are stored as family members with nominationType set
this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
next: (data: any) => {
this.nomineesLoaded = true;
const allMembers = Array.isArray(data) ? data : [];
this.nominees = allMembers.filter((m: any) => m.nominationType && m.nominationType !== '');
},
error: () => { this.nomineesLoaded = true; }
});
}
onNomineeSearch(text: string): void {
this.nomineeSearchText = text;
clearTimeout(this.nomineeSearchTimer);
if (!text || text.length < 2) { this.nomineeSearchResults = []; return; }
this.nomineeSearching = true;
this.nomineeSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({
next: (data: any) => {
this.nomineeSearchResults = data?.content || [];
this.nomineeSearching = false;
this.clientsService.enrichSearchResults(this.nomineeSearchResults);
},
error: () => { this.nomineeSearchResults = []; this.nomineeSearching = false; }
});
}, 400);
}
selectNomineeClient(client: any): void {
this.selectedNomineeClient = { ...client, _loading: true };
this.nomineeSearchResults = [];
this.nomineeSearchText = '';
this.fetchNomineeClientDetails(client.id);
}
private fetchNomineeClientDetails(clientId: number): void {
const details: any = {};
let pending = 4;
const done = () => {
pending--;
if (pending <= 0) {
this.selectedNomineeClient = { ...this.selectedNomineeClient, ...details, _loading: false };
}
};
// Fetch client data for DOB
this.clientsService.getClientData(clientId.toString()).subscribe({
next: (data: any) => {
if (data?.dateOfBirth) { details.dateOfBirth = data.dateOfBirth; }
done();
},
error: () => done()
});
// Fetch KYC for PAN/Aadhaar + verification status
this.clientsService.getClientKyc(clientId.toString()).subscribe({
next: (kyc: any) => {
details.panNumber = kyc?.panNumber || null;
details.aadhaarMasked = kyc?.aadhaarNumberMasked || null;
details.panVerified = kyc?.panVerified || false;
details.aadhaarVerified = kyc?.aadhaarVerified || false;
done();
},
error: () => done()
});
// Fetch profile image
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
next: (img: any) => {
if (img) { details.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); }
done();
},
error: () => done()
});
// Fetch signature image
this.clientsService.getClientDocuments(clientId.toString()).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) =>
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({
next: (blob: Blob) => {
details.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
done();
},
error: () => done()
});
} else { done(); }
},
error: () => done()
});
}
onGuardianSearch(text: string): void {
this.guardianSearchText = text;
clearTimeout(this.guardianSearchTimer);
if (!text || text.length < 2) { this.guardianSearchResults = []; return; }
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
this.clientsService.enrichSearchResults(this.guardianSearchResults);
},
error: () => { this.guardianSearchResults = []; this.guardianSearching = false; }
});
}, 400);
}
selectGuardian(client: any): void {
this.selectedGuardian = client;
this.guardianSearchResults = [];
this.guardianSearchText = '';
}
get existingShareTotal(): number {
return this.nominees.reduce((sum, n) => sum + (n.percentageShare || 0), 0);
}
get totalSharePercentage(): number {
return this.existingShareTotal + (this.nomineePercentageShare || 0);
}
get maxAllowedShare(): number {
return 100 - this.existingShareTotal;
}
saveNominee(): void {
if (!this.clientId || !this.selectedNomineeClient || !this.nomineeRelationshipId || !this.nomineePercentageShare) return;
if (this.totalSharePercentage > 100) return;
this.nomineeSaving = true;
// Get relationship name for display
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
(r: any) => r.id === this.nomineeRelationshipId
);
const relName = relOption?.name || '';
const payload: any = {
firstName: this.selectedNomineeClient.displayName,
relationshipId: this.nomineeRelationshipId,
nominationType: this.nomineeType,
percentageShare: this.nomineePercentageShare,
isDependent: this.nomineeIsMinor,
dateFormat: 'dd MMMM yyyy',
locale: 'en'
};
if (this.nomineeIsMinor && this.selectedGuardian) {
payload.guardianName = this.selectedGuardian.displayName;
payload.guardianRelationship = this.guardianRelationship;
}
this.clientsService.addFamilyMember(this.clientId, payload).subscribe({
next: (res: any) => {
this.nomineeSaving = false;
this.nominees.push({
id: res?.resourceId,
...payload,
relationship: relName,
nomineeName: this.selectedNomineeClient.displayName,
nomineeCif: this.selectedNomineeClient.accountNo,
nomineeMobile: this.selectedNomineeClient.mobileNo,
nomineeDob: this.selectedNomineeClient.dateOfBirth,
profileImageUrl: this.selectedNomineeClient.profileImageUrl,
signatureUrl: this.selectedNomineeClient.signatureUrl,
panNumber: this.selectedNomineeClient.panNumber,
aadhaarMasked: this.selectedNomineeClient.aadhaarMasked,
guardianName: this.selectedGuardian?.displayName
});
this.resetNomineeForm();
},
error: () => { this.nomineeSaving = false; }
});
}
/** Editing nominee state */
editingNomineeIndex: number | null = null;
editNomineeRelId: number | null = null;
editNomineeShare: number | null = null;
editNomineeType: string = 'DA1';
editNominee(nominee: any, index: number): void {
this.editingNomineeIndex = index;
this.editNomineeRelId = nominee.relationshipId?.id || nominee.relationshipId || null;
this.editNomineeShare = nominee.percentageShare;
this.editNomineeType = nominee.nominationType || 'DA1';
}
cancelEditNominee(): void {
this.editingNomineeIndex = null;
}
/** Max share allowed for the nominee being edited (excludes its own share) */
maxShareForEdit(index: number): number {
const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
return 100 - otherTotal;
}
saveEditNominee(nominee: any, index: number): void {
if (!this.editNomineeRelId || !this.editNomineeShare) return;
const otherTotal = this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
if (otherTotal + this.editNomineeShare > 100) return;
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
(r: any) => r.id === this.editNomineeRelId
);
// Update in-memory
nominee.relationshipId = this.editNomineeRelId;
nominee.relationship = relOption?.name || nominee.relationship;
nominee.percentageShare = this.editNomineeShare;
nominee.nominationType = this.editNomineeType;
// If saved to DB, update via API
if (nominee.id && this.clientId) {
const payload: any = {
firstName: nominee.firstName || nominee.nomineeName,
relationshipId: this.editNomineeRelId,
nominationType: this.editNomineeType,
percentageShare: this.editNomineeShare,
isDependent: nominee.isDependent || false,
dateFormat: 'dd MMMM yyyy',
locale: 'en'
};
if (nominee.guardianName) { payload.guardianName = nominee.guardianName; }
if (nominee.guardianRelationship) { payload.guardianRelationship = nominee.guardianRelationship; }
this.clientsService.editFamilyMember(this.clientId, nominee.id, payload).subscribe({ error: () => {} });
}
this.editingNomineeIndex = null;
}
deleteNominee(nominee: any, index: number): void {
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `nominee ${nominee.nomineeName || nominee.firstName || ''}` }
});
ref.afterClosed().subscribe((response: any) => {
if (response?.delete) {
if (nominee.id && this.clientId) {
this.clientsService.deleteFamilyMember(this.clientId, nominee.id.toString()).subscribe({
next: () => this.nominees.splice(index, 1),
error: () => this.nominees.splice(index, 1)
});
} else {
this.nominees.splice(index, 1);
}
}
});
}
resetNomineeForm(): void {
this.showNomineeForm = false;
this.selectedNomineeClient = null;
this.nomineeRelationshipId = null;
this.nomineePercentageShare = null;
this.nomineeIsMinor = false;
this.nomineeType = 'DA1';
this.selectedGuardian = null;
this.guardianRelationship = '';
this.nomineeSearchText = '';
this.guardianSearchText = '';
}
/** Open image in zoom overlay */
zoomImage(imageSrc: any, title: string): void {
this.dialog.open(ImageZoomDialogComponent, {
data: { imageSrc, title },
panelClass: 'image-zoom-panel',
maxWidth: '90vw',
maxHeight: '90vh'
});
}
/** Get relationship name from ID */
getRelName(relId: any): string {
if (typeof relId === 'object' && relId?.name) return relId.name;
const opt = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find((r: any) => r.id === relId);
return opt?.name || '';
}
get nomineeData(): any[] {
return this.nominees;
}
}