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
@@ -0,0 +1,312 @@
|
||||
<!--
|
||||
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="nom-container mat-typography">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="nom-page-header">
|
||||
<div class="nom-page-header__left">
|
||||
<mat-icon class="nom-page-header__icon">shield</mat-icon>
|
||||
<div>
|
||||
<h3 class="nom-page-header__title">Account Nominees</h3>
|
||||
<p class="nom-page-header__sub">Manage nominees for each account. Nominees are account-specific.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="nom-loading">
|
||||
<mat-progress-spinner diameter="24" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Loading accounts...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && !hasAccounts) {
|
||||
<div class="nom-no-accounts">
|
||||
<mat-icon class="nom-no-accounts__icon">account_balance_wallet</mat-icon>
|
||||
<p>No active accounts found.</p>
|
||||
<p class="nom-no-accounts__sub">Nominees can be added once an account is opened.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && hasAccounts) {
|
||||
<!-- Compact: Type chips + Account dropdown + Add button in one row -->
|
||||
<div class="nom-toolbar">
|
||||
<div class="nom-type-chips">
|
||||
@for (group of accountGroups; track group.type) {
|
||||
@if (group.accounts.length > 0) {
|
||||
<button type="button" class="nom-type-chip"
|
||||
[class.active]="selectedType === group.type"
|
||||
(click)="selectedType = group.type; onTypeChange()">
|
||||
<mat-icon>{{ group.icon }}</mat-icon>
|
||||
{{ group.label }}
|
||||
<span class="nom-type-chip__count">{{ group.accounts.length }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (selectedGroup && selectedGroup.accounts.length > 0) {
|
||||
<div class="nom-toolbar__right">
|
||||
<mat-form-field appearance="outline" class="nom-account-select">
|
||||
<mat-label>Account</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountId" (ngModelChange)="onAccountChange()">
|
||||
@for (acc of selectedGroup.accounts; track acc.id) {
|
||||
<mat-option [value]="acc.id">{{ acc.productName }} — {{ acc.accountNo }}@if (acc.accountBalance != null) { ({{ acc.accountBalance | number:'1.2-2' }}) }</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (selectedAccountId && !showAddForm) {
|
||||
<button mat-flat-button color="primary" class="nom-add-btn"
|
||||
[disabled]="totalShare >= 100" (click)="showAddForm = true">
|
||||
<mat-icon>add</mat-icon> Add Nominee
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Nominees for selected account -->
|
||||
@if (selectedAccountId) {
|
||||
@if (nomineesLoading) {
|
||||
<div class="nom-loading nom-loading--sm">
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Share bar + Nominee list -->
|
||||
@if (nominees.length > 0) {
|
||||
<div class="nom-share-bar">
|
||||
<span class="nom-share-bar__label">Share</span>
|
||||
<div class="nom-share-bar__track">
|
||||
<div class="nom-share-bar__fill"
|
||||
[style.width.%]="totalShare > 100 ? 100 : totalShare"
|
||||
[class.over]="totalShare > 100"
|
||||
[class.complete]="totalShare === 100"></div>
|
||||
</div>
|
||||
<span class="nom-share-bar__value" [class.over]="totalShare > 100">{{ totalShare }}%</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (nominee of nominees; track nominee.id; let i = $index) {
|
||||
<div class="nom-card">
|
||||
<div class="nom-card__main">
|
||||
<div class="nom-card__avatar">
|
||||
@if (nominee.profileImageUrl) {
|
||||
<img [src]="nominee.profileImageUrl" alt="" class="nom-card__photo zoomable"
|
||||
(click)="zoomImage(nominee.profileImageUrl, 'Nominee Photo')" />
|
||||
} @else {
|
||||
<mat-icon>person</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="nom-card__info">
|
||||
<span class="nom-card__name">{{ nominee.nomineeDisplayName || nominee.firstName || '—' }}</span>
|
||||
<span class="nom-card__meta">
|
||||
{{ getRelationship(nominee) }}
|
||||
@if (nominee.nomineeDateOfBirth || nominee.dateOfBirth) { · DOB: {{ (nominee.nomineeDateOfBirth || nominee.dateOfBirth) | dateFormat }} }
|
||||
@if (nominee.nomineeMobileNo || nominee.mobileNo) { · {{ nominee.nomineeMobileNo || nominee.mobileNo }} }
|
||||
</span>
|
||||
</div>
|
||||
@if (nominee.signatureUrl) {
|
||||
<div class="nom-card__sig">
|
||||
<img [src]="nominee.signatureUrl" alt="Signature" class="zoomable"
|
||||
(click)="zoomImage(nominee.signatureUrl, 'Nominee Signature')" />
|
||||
</div>
|
||||
}
|
||||
@if (nominee.nomineeIdentifier) {
|
||||
<span class="nom-card__id">{{ nominee.nomineeIdentifier }}</span>
|
||||
}
|
||||
<div class="nom-card__share">
|
||||
<span class="nom-card__share-value">{{ nominee.percentageShare || 0 }}%</span>
|
||||
</div>
|
||||
<div class="nom-card__actions">
|
||||
<button mat-icon-button type="button" matTooltip="View" (click)="viewNominee(nominee)"><mat-icon>visibility</mat-icon></button>
|
||||
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)"><mat-icon>edit</mat-icon></button>
|
||||
<button mat-icon-button type="button" matTooltip="Delete" class="nom-card__delete" (click)="deleteNominee(nominee, i)"><mat-icon>delete</mat-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
@if (nominee.nomineeIsMinor && (nominee.guardianDisplayName || nominee.guardianName)) {
|
||||
<div class="nom-card__guardian">
|
||||
<mat-icon>shield</mat-icon>
|
||||
Guardian: <strong>{{ nominee.guardianDisplayName || nominee.guardianName }}</strong>
|
||||
@if (nominee.guardianRelationshipName) { ({{ nominee.guardianRelationshipName }}) }
|
||||
</div>
|
||||
}
|
||||
<!-- Inline Edit -->
|
||||
@if (editingIndex === i) {
|
||||
<div class="nom-card__edit">
|
||||
<div class="nom-edit-row">
|
||||
<mat-form-field appearance="outline" class="nom-edit-field">
|
||||
<mat-label>Type</mat-label>
|
||||
<mat-select [(ngModel)]="editNomType">
|
||||
@for (opt of nominationTypeOptions; track opt.value) { <mat-option [value]="opt.value">{{ opt.label }}</mat-option> }
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="nom-edit-field">
|
||||
<mat-label>Relationship</mat-label>
|
||||
<mat-select [(ngModel)]="editRelId">
|
||||
@for (rel of relationshipOptions; track rel) { <mat-option [value]="rel.id">{{ rel.name }}</mat-option> }
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="nom-edit-field nom-edit-field--sm">
|
||||
<mat-label>Share %</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="editShare" min="1" [max]="maxShareForEdit(i)" />
|
||||
@if (editShare && editShare > maxShareForEdit(i)) { <mat-error>Max {{ maxShareForEdit(i) }}%</mat-error> }
|
||||
</mat-form-field>
|
||||
<button mat-flat-button color="primary" type="button" class="nom-edit-save"
|
||||
[disabled]="!editRelId || !editShare || editShare > maxShareForEdit(i)"
|
||||
(click)="saveEdit(nominee, i)"><mat-icon>check</mat-icon></button>
|
||||
<button mat-stroked-button type="button" class="nom-edit-cancel" (click)="cancelEdit()"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty -->
|
||||
@if (!nomineesLoading && nominees.length === 0 && !showAddForm) {
|
||||
<div class="nom-empty">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
<span>No nominees for this account.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ═══ ADD NOMINEE FORM ═══ -->
|
||||
@if (showAddForm) {
|
||||
<div class="nom-add-form">
|
||||
<div class="nom-add-form__title"><fa-icon icon="search"></fa-icon> Search & Link Nominee</div>
|
||||
|
||||
@if (!selectedClient) {
|
||||
<div class="nom-search-wrapper">
|
||||
<mat-form-field appearance="outline" class="nom-search-field">
|
||||
<mat-label>Search by Name, Client ID or Mobile</mat-label>
|
||||
<span matPrefix class="nom-search-prefix"><mat-icon>search</mat-icon></span>
|
||||
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" (blur)="dismissSearch()" placeholder="Type at least 2 characters..." autocomplete="off" />
|
||||
</mat-form-field>
|
||||
@if (searchResults.length > 0 || searching) {
|
||||
<div class="nom-search-results">
|
||||
@if (searching) { <div class="nom-search-loading"><mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Searching...</div> }
|
||||
@for (client of searchResults; track client.id) {
|
||||
<div class="nom-search-item" (mousedown)="selectClient(client); $event.preventDefault()">
|
||||
<mat-icon class="nom-search-item__icon">person</mat-icon>
|
||||
<div class="nom-search-item__info">
|
||||
<span class="nom-search-item__name">{{ client.displayName }}</span>
|
||||
<span class="nom-search-item__meta">ID: {{ client.id }} · {{ client.officeName }}@if (client.dateOfBirth) { · DOB: {{ client.dateOfBirth | dateFormat }} }@if (client.fatherSpouseName) { · {{ client.fatherSpouseName }} }@if (client.mobileNo) { · {{ client.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 Card -->
|
||||
<div class="nom-linked-card">
|
||||
<div class="nom-linked-card__left">
|
||||
@if (linkedProfileImage) {
|
||||
<img [src]="linkedProfileImage" class="nom-linked-card__photo zoomable" (click)="zoomImage(linkedProfileImage, 'Photo')" />
|
||||
} @else { <mat-icon class="nom-linked-card__icon">person</mat-icon> }
|
||||
</div>
|
||||
<div class="nom-linked-card__info">
|
||||
<span class="nom-linked-card__name">{{ selectedClient.displayName }}</span>
|
||||
<span class="nom-linked-card__meta">ID: {{ selectedClient.id }}@if (selectedClient.mobileNo) { · {{ selectedClient.mobileNo }} }</span>
|
||||
</div>
|
||||
@if (linkedSignatureImage) {
|
||||
<div class="nom-linked-card__sig">
|
||||
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable" (click)="zoomImage(linkedSignatureImage, 'Signature')" />
|
||||
</div>
|
||||
}
|
||||
<button mat-icon-button type="button" (click)="removeSelectedClient()"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
|
||||
@if (fetchingDetails) {
|
||||
<div class="nom-fetching-hint"><mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> Fetching details...</div>
|
||||
}
|
||||
|
||||
@if (!fetchingDetails) {
|
||||
<!-- KYC details -->
|
||||
@if (selectedClient.dateOfBirth || selectedClient.panNumber || selectedClient.aadhaarMasked) {
|
||||
<div class="nom-readonly-row">
|
||||
@if (selectedClient.dateOfBirth) { <span class="nom-ro-item"><span class="nom-ro-label">DOB</span> {{ selectedClient.dateOfBirth | dateFormat }}</span> }
|
||||
@if (selectedClient.panNumber) { <span class="nom-ro-item"><span class="nom-ro-label">PAN</span> {{ selectedClient.panNumber }} @if (linkedKycStatus?.panVerified) { <mat-icon class="nom-ro-ok">check_circle</mat-icon> }</span> }
|
||||
@if (selectedClient.aadhaarMasked) { <span class="nom-ro-item"><span class="nom-ro-label">Aadhaar</span> {{ selectedClient.aadhaarMasked }} @if (linkedKycStatus?.aadhaarVerified) { <mat-icon class="nom-ro-ok">check_circle</mat-icon> }</span> }
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Nominee fields inline -->
|
||||
<div class="nom-fields-row">
|
||||
<mat-form-field appearance="outline" class="nom-field-item">
|
||||
<mat-label>Nomination Type</mat-label>
|
||||
<mat-select [(ngModel)]="nomineeNomType">
|
||||
@for (opt of nominationTypeOptions; track opt.value) { <mat-option [value]="opt.value">{{ opt.label }}</mat-option> }
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="nom-field-item">
|
||||
<mat-label>Relationship</mat-label>
|
||||
<mat-select [(ngModel)]="nomineeRelationshipId">
|
||||
@for (rel of relationshipOptions; track rel) { <mat-option [value]="rel.id">{{ rel.name }}</mat-option> }
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="nom-field-item nom-field-item--sm">
|
||||
<mat-label>Share %</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="nomineeShare" min="1" [max]="maxAllowedShare" />
|
||||
@if (nomineeShare && nomineeShare > maxAllowedShare) { <mat-error>Max {{ maxAllowedShare }}%</mat-error> }
|
||||
</mat-form-field>
|
||||
<div class="nom-field-item nom-field-item--toggle">
|
||||
<mat-slide-toggle [(ngModel)]="nomineeIsMinor">Minor</mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (nomineeIsMinor) {
|
||||
<div class="nom-guardian-section">
|
||||
<span class="nom-guardian-label"><mat-icon>shield</mat-icon> Guardian</span>
|
||||
@if (!selectedGuardian) {
|
||||
<mat-form-field appearance="outline" class="nom-search-field">
|
||||
<input matInput [ngModel]="guardianSearchText" (ngModelChange)="onGuardianSearch($event)" placeholder="Search guardian..." autocomplete="off" />
|
||||
</mat-form-field>
|
||||
@if (guardianSearchResults.length > 0 || guardianSearching) {
|
||||
<div class="nom-search-results nom-search-results--inline">
|
||||
@if (guardianSearching) { <div class="nom-search-loading"><mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner></div> }
|
||||
@for (client of guardianSearchResults; track client.id) {
|
||||
<div class="nom-search-item" (click)="selectGuardian(client)">
|
||||
<mat-icon class="nom-search-item__icon">person</mat-icon>
|
||||
<span class="nom-search-item__name">{{ client.displayName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="nom-guardian-selected">{{ selectedGuardian.displayName }} <button mat-icon-button (click)="selectedGuardian = null"><mat-icon>close</mat-icon></button></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="nom-remarks-field">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<input matInput [(ngModel)]="nomineeRemarks" placeholder="Optional" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="nom-form-actions">
|
||||
<button mat-stroked-button type="button" (click)="resetAddForm()">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="button"
|
||||
[disabled]="!nomineeRelationshipId || !nomineeShare || nomineeSaving || (nomineeShare > maxAllowedShare) || (nomineeIsMinor && !selectedGuardian)"
|
||||
(click)="saveNominee()">
|
||||
@if (nomineeSaving) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> }
|
||||
@else { <mat-icon>check</mat-icon> Save }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
$primary: #1074b9;
|
||||
$primary-light: #e8f0fe;
|
||||
|
||||
.nom-container { padding: 16px 24px; }
|
||||
|
||||
/* Header */
|
||||
.nom-page-header {
|
||||
background: #0f172a; padding: 14px 20px; border-radius: 10px; margin-bottom: 16px;
|
||||
&__left { display: flex; align-items: center; gap: 10px; }
|
||||
&__icon { color: #60a5fa; }
|
||||
&__title { font-size: 15px; font-weight: 600; color: #fff; margin: 0; }
|
||||
&__sub { font-size: 10px; color: rgba(255,255,255,0.45); margin: 1px 0 0; }
|
||||
}
|
||||
|
||||
.nom-loading { display: flex; align-items: center; gap: 8px; padding: 24px 0; justify-content: center; font-size: 13px; color: #64748b;
|
||||
&--sm { padding: 12px 0; }
|
||||
}
|
||||
|
||||
.nom-no-accounts {
|
||||
text-align: center; padding: 36px 20px; background: #f8fafc; border-radius: 8px; border: 1px dashed #e2e8f0;
|
||||
&__icon { font-size: 40px; width: 40px; height: 40px; color: #94a3b8; margin-bottom: 8px; }
|
||||
p { color: #64748b; font-size: 13px; margin: 2px 0; }
|
||||
&__sub { font-size: 11px; color: #94a3b8; }
|
||||
}
|
||||
|
||||
/* Toolbar: chips on top, account + add below */
|
||||
.nom-toolbar {
|
||||
display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px;
|
||||
&__right { display: flex; align-items: center; gap: 10px; }
|
||||
}
|
||||
.nom-type-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.nom-type-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px;
|
||||
border: 1.5px solid #e2e8f0; border-radius: 6px; background: #fff;
|
||||
font-size: 12px; font-weight: 500; color: #64748b; cursor: pointer; transition: all 0.15s;
|
||||
mat-icon { font-size: 16px; width: 16px; height: 16px; }
|
||||
&__count { font-size: 9px; font-weight: 700; background: #f1f5f9; color: #64748b; padding: 0 5px; border-radius: 6px; }
|
||||
&.active { border-color: $primary; background: $primary-light; color: $primary;
|
||||
.nom-type-chip__count { background: $primary; color: #fff; }
|
||||
}
|
||||
&:hover:not(.active) { border-color: #94a3b8; }
|
||||
}
|
||||
.nom-account-select { flex: 1; min-width: 280px; }
|
||||
.nom-add-btn { border-radius: 6px !important; font-size: 12px !important; height: 36px !important; white-space: nowrap; }
|
||||
|
||||
/* Share bar */
|
||||
.nom-share-bar {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
|
||||
&__label { font-size: 11px; font-weight: 600; color: #64748b; white-space: nowrap; }
|
||||
&__track { flex: 1; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; }
|
||||
&__fill {
|
||||
height: 100%; border-radius: 3px; transition: width 0.3s;
|
||||
background: linear-gradient(90deg, #3b82f6, #10b981);
|
||||
&.over { background: #ef4444; } &.complete { background: linear-gradient(90deg, #10b981, #059669); }
|
||||
}
|
||||
&__value { font-size: 13px; font-weight: 700; color: #10b981; min-width: 35px; text-align: right; &.over { color: #ef4444; } }
|
||||
}
|
||||
|
||||
/* Nominee card — compact */
|
||||
.nom-card { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 6px; overflow: hidden; }
|
||||
.nom-card__main { display: flex; align-items: center; gap: 10px; padding: 8px 12px; }
|
||||
.nom-card__avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%; background: #dbeafe;
|
||||
display: flex; align-items: center; justify-content: center; color: #1565c0; flex-shrink: 0;
|
||||
mat-icon { font-size: 18px; }
|
||||
}
|
||||
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.nom-card__sig {
|
||||
flex-shrink: 0;
|
||||
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
|
||||
}
|
||||
.nom-card__info { flex: 1; min-width: 0; }
|
||||
.nom-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nom-card__meta { font-size: 11px; color: #64748b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nom-card__id {
|
||||
font-size: 10px; font-weight: 600; color: #1d4ed8; background: #eff6ff;
|
||||
padding: 2px 8px; border-radius: 4px; white-space: nowrap;
|
||||
font-family: 'Roboto Mono', monospace; letter-spacing: 0.3px; flex-shrink: 0;
|
||||
}
|
||||
.nom-card__share { text-align: center; min-width: 40px; flex-shrink: 0; }
|
||||
.nom-card__share-value { font-size: 15px; font-weight: 700; color: #1565c0; }
|
||||
.nom-card__actions { display: flex; gap: 0; flex-shrink: 0;
|
||||
button { width: 32px !important; height: 32px !important; line-height: 32px !important;
|
||||
mat-icon { font-size: 18px; }
|
||||
}
|
||||
}
|
||||
.nom-card__delete { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
|
||||
.nom-card__guardian {
|
||||
display: flex; align-items: center; gap: 5px; padding: 5px 12px; background: #fff7ed;
|
||||
font-size: 11px; color: #92400e; border-top: 1px solid #fde68a;
|
||||
mat-icon { font-size: 14px; width: 14px; height: 14px; }
|
||||
}
|
||||
|
||||
/* Inline edit */
|
||||
.nom-card__edit { padding: 8px 12px; border-top: 1px solid #e2e8f0; background: #f8fafc; }
|
||||
.nom-edit-row { display: flex; align-items: center; gap: 8px; }
|
||||
.nom-edit-field { flex: 1; &--sm { max-width: 100px; } }
|
||||
.nom-edit-save, .nom-edit-cancel { min-width: 36px !important; height: 36px !important; padding: 0 !important; }
|
||||
|
||||
/* Empty */
|
||||
.nom-empty {
|
||||
text-align: center; padding: 24px; background: #f8fafc; border-radius: 6px; border: 1px dashed #e2e8f0;
|
||||
mat-icon { font-size: 28px; width: 28px; height: 28px; color: #94a3b8; margin-bottom: 4px; display: block; margin: 0 auto 4px; }
|
||||
span { font-size: 12px; color: #94a3b8; }
|
||||
}
|
||||
|
||||
/* ═══ Add Form ═══ */
|
||||
.nom-add-form {
|
||||
border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; margin-top: 8px; background: #fafbfc;
|
||||
&__title { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 12px; }
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.nom-search-wrapper { position: relative; margin-bottom: 8px; }
|
||||
.nom-search-field { width: 100%; }
|
||||
.nom-search-prefix { color: #94a3b8; }
|
||||
.nom-search-results {
|
||||
position: absolute; top: 56px; left: 0; right: 0; z-index: 100;
|
||||
background: #fff; border: 1px solid #e2e8f0; border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.1); max-height: 220px; overflow-y: auto;
|
||||
&--inline { position: relative; top: 0; margin-top: 4px; }
|
||||
}
|
||||
.nom-search-loading { display: flex; align-items: center; gap: 6px; padding: 10px 12px; font-size: 12px; color: #64748b; }
|
||||
.nom-search-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f1f5f9;
|
||||
&:last-child { border-bottom: none; } &:hover { background: #f0f7ff; }
|
||||
&__icon { color: $primary; font-size: 18px; }
|
||||
&__info { display: flex; flex-direction: column; flex: 1; }
|
||||
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
|
||||
&__meta { font-size: 10px; color: #64748b; }
|
||||
}
|
||||
.search-no-results-hint { font-size: 11px; color: #94a3b8; text-align: center; padding: 4px 0; }
|
||||
|
||||
/* Linked card */
|
||||
.nom-linked-card {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
|
||||
&__left { flex-shrink: 0; }
|
||||
&__icon { color: $primary; }
|
||||
&__photo { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 2px solid #e2e8f0; }
|
||||
&__info { flex: 1; display: flex; flex-direction: column; }
|
||||
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
|
||||
&__meta { font-size: 11px; color: #64748b; font-family: 'Roboto Mono', monospace; }
|
||||
&__sig {
|
||||
flex-shrink: 0;
|
||||
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
|
||||
}
|
||||
}
|
||||
.nom-fetching-hint { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #64748b; margin: -4px 0 8px; }
|
||||
|
||||
/* Read-only KYC row */
|
||||
.nom-readonly-row {
|
||||
display: flex; flex-wrap: wrap; gap: 16px; padding: 8px 12px;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 10px;
|
||||
}
|
||||
.nom-ro-item { font-size: 12px; color: #1e293b; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.nom-ro-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; margin-right: 2px; }
|
||||
.nom-ro-ok { font-size: 14px !important; width: 14px !important; height: 14px !important; color: #16a34a; }
|
||||
|
||||
/* Nominee fields */
|
||||
.nom-fields-row { display: flex; flex-wrap: wrap; gap: 0 12px; align-items: center; margin-bottom: 4px; }
|
||||
.nom-field-item { flex: 1; min-width: 160px; &--sm { max-width: 100px; min-width: 80px; } &--toggle { padding-top: 8px; } }
|
||||
.nom-remarks-field { width: 100%; }
|
||||
.nom-form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
|
||||
/* Guardian */
|
||||
.nom-guardian-section { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.nom-guardian-label { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #92400e; mat-icon { font-size: 16px; } }
|
||||
.nom-guardian-selected { display: flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: #1e293b;
|
||||
button { width: 24px !important; height: 24px !important; line-height: 24px !important; mat-icon { font-size: 14px; } }
|
||||
}
|
||||
|
||||
/* Zoom */
|
||||
.zoomable { cursor: zoom-in; transition: transform 0.15s; &:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } }
|
||||
::ng-deep .image-zoom-panel { .mat-mdc-dialog-container { padding: 0 !important; border-radius: 8px !important; overflow: hidden; } }
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { DeleteDialogComponent } from '../../../shared/delete-dialog/delete-dialog.component';
|
||||
import { ImageZoomDialogComponent } from '../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
interface AccountGroup {
|
||||
type: string;
|
||||
apiType: string; // SB, CB, FD, RD, LA
|
||||
label: string;
|
||||
icon: string;
|
||||
accounts: any[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-nominee-tab',
|
||||
templateUrl: './nominee-tab.component.html',
|
||||
styleUrls: ['./nominee-tab.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTooltip,
|
||||
MatSlideToggle,
|
||||
DateFormatPipe
|
||||
]
|
||||
})
|
||||
export class NomineeTabComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
clientId: string;
|
||||
loading = true;
|
||||
accountGroups: AccountGroup[] = [];
|
||||
selectedType: string = '';
|
||||
selectedAccountId: number | null = null;
|
||||
|
||||
/** Nominees for selected account */
|
||||
nominees: any[] = [];
|
||||
nomineesLoading = false;
|
||||
|
||||
/** Add nominee form */
|
||||
showAddForm = false;
|
||||
searchText = '';
|
||||
searchResults: any[] = [];
|
||||
searching = false;
|
||||
private searchTimer: any = null;
|
||||
selectedClient: any = null;
|
||||
fetchingDetails = false;
|
||||
|
||||
/** Nominee form fields */
|
||||
nomineeRelationshipId: number | null = null;
|
||||
nomineeShare: number | null = null;
|
||||
nomineeIsMinor = false;
|
||||
nomineeSaving = false;
|
||||
nomineeRemarks = '';
|
||||
|
||||
/** Guardian */
|
||||
guardianSearchText = '';
|
||||
guardianSearchResults: any[] = [];
|
||||
guardianSearching = false;
|
||||
selectedGuardian: any = null;
|
||||
private guardianSearchTimer: any = null;
|
||||
|
||||
/** Linked client details */
|
||||
linkedProfileImage: SafeUrl | null = null;
|
||||
linkedSignatureImage: SafeUrl | null = null;
|
||||
linkedKycStatus: any = null;
|
||||
|
||||
/** Template data from API */
|
||||
relationshipOptions: any[] = [];
|
||||
|
||||
/** Nomination type options */
|
||||
nominationTypeOptions = [
|
||||
{ value: 'DA1', label: 'DA1 - Add Nominee' },
|
||||
{ value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' }
|
||||
];
|
||||
|
||||
/** Add form nomination type */
|
||||
nomineeNomType = 'DA1';
|
||||
|
||||
/** Editing state */
|
||||
editingIndex: number | null = null;
|
||||
editRelId: number | null = null;
|
||||
editShare: number | null = null;
|
||||
editNomType: string = 'DA1';
|
||||
|
||||
get hasAccounts(): boolean {
|
||||
return this.accountGroups.some(g => g.accounts.length > 0);
|
||||
}
|
||||
|
||||
get selectedGroup(): AccountGroup | undefined {
|
||||
return this.accountGroups.find(g => g.type === this.selectedType);
|
||||
}
|
||||
|
||||
get selectedAccount(): any {
|
||||
return this.selectedGroup?.accounts.find((a: any) => a.id === this.selectedAccountId);
|
||||
}
|
||||
|
||||
get totalShare(): number {
|
||||
return this.nominees.reduce((sum, n) => sum + (n.percentageShare || 0), 0);
|
||||
}
|
||||
|
||||
get maxAllowedShare(): number {
|
||||
return 100 - this.totalShare;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clientId = this.route.parent.snapshot.params['clientId'];
|
||||
this.loadAccounts();
|
||||
this.loadTemplate();
|
||||
}
|
||||
|
||||
private loadTemplate(): void {
|
||||
this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({
|
||||
next: (template: any) => {
|
||||
this.relationshipOptions = template?.relationshipOptions || [];
|
||||
},
|
||||
error: () => {
|
||||
// Fallback to family member template
|
||||
this.clientsService.getClientTemplate().subscribe((t: any) => {
|
||||
this.relationshipOptions = t?.familyMemberOptions?.relationshipIdOptions || [];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadAccounts(): void {
|
||||
this.loading = true;
|
||||
this.clientsService.getClientAccountData(this.clientId).subscribe({
|
||||
next: (data: any) => {
|
||||
this.loading = false;
|
||||
this.accountGroups = [
|
||||
{ type: 'savings', apiType: 'SB', label: 'Savings Account', icon: 'savings', accounts: this.filterActive(data.savingsAccounts) },
|
||||
{ type: 'current', apiType: 'CB', label: 'Current Account', icon: 'account_balance', accounts: this.filterActive(data.currentAccounts) },
|
||||
{ type: 'fd', apiType: 'FD', label: 'Fixed Deposit', icon: 'lock', accounts: this.filterActive(data.fixedDepositAccounts) },
|
||||
{ type: 'rd', apiType: 'RD', label: 'Recurring Deposit', icon: 'autorenew', accounts: this.filterActive(data.recurringDepositAccounts) },
|
||||
{ type: 'loan', apiType: 'LA', label: 'Loan Account', icon: 'credit_score', accounts: this.filterActive(data.loanAccounts) }
|
||||
];
|
||||
const firstWithAccounts = this.accountGroups.find(g => g.accounts.length > 0);
|
||||
if (firstWithAccounts) {
|
||||
this.selectedType = firstWithAccounts.type;
|
||||
// Auto-select if only 1 account in the group
|
||||
if (firstWithAccounts.accounts.length === 1) {
|
||||
this.selectedAccountId = firstWithAccounts.accounts[0].id;
|
||||
this.loadNominees();
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
private filterActive(accounts: any[]): any[] {
|
||||
if (!accounts) return [];
|
||||
// Include all non-closed accounts (active, disbursed, pending, etc.)
|
||||
return accounts.filter((a: any) =>
|
||||
a.status?.active ||
|
||||
a.status?.value === 'Active' ||
|
||||
!a.status?.closed
|
||||
);
|
||||
}
|
||||
|
||||
onTypeChange(): void {
|
||||
this.selectedAccountId = null;
|
||||
this.nominees = [];
|
||||
this.resetAddForm();
|
||||
// Auto-select if only 1 account
|
||||
if (this.selectedGroup?.accounts.length === 1) {
|
||||
this.selectedAccountId = this.selectedGroup.accounts[0].id;
|
||||
this.loadNominees();
|
||||
}
|
||||
}
|
||||
|
||||
onAccountChange(): void {
|
||||
this.nominees = [];
|
||||
this.resetAddForm();
|
||||
if (this.selectedAccountId) { this.loadNominees(); }
|
||||
}
|
||||
|
||||
private loadNominees(): void {
|
||||
this.nomineesLoading = true;
|
||||
const account = this.selectedAccount;
|
||||
const accountType = this.selectedGroup?.apiType || '';
|
||||
this.clientsService.getClientNominees(this.clientId, account?.accountNo, accountType).subscribe({
|
||||
next: (data: any) => {
|
||||
this.nomineesLoading = false;
|
||||
this.nominees = Array.isArray(data) ? data : [];
|
||||
// Enrich each nominee with profile image & signature
|
||||
this.nominees.forEach(n => this.enrichNomineeImages(n));
|
||||
},
|
||||
error: () => { this.nomineesLoading = false; this.nominees = []; }
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch profile image & signature for a nominee card */
|
||||
private enrichNomineeImages(nominee: any): void {
|
||||
const cid = nominee.nomineeClientId?.toString();
|
||||
if (!cid) return;
|
||||
|
||||
// Profile image
|
||||
this.clientsService.getClientProfileImage(cid).subscribe({
|
||||
next: (img: any) => {
|
||||
if (img) { nominee.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); }
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
// Signature
|
||||
this.clientsService.getClientDocuments(cid).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
const sigDoc = docs?.find((d: any) =>
|
||||
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
|
||||
);
|
||||
if (sigDoc) {
|
||||
this.clientsService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
nominee.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Search & Link ═══
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
dismissSearch(): void {
|
||||
setTimeout(() => { this.searchResults = []; this.searching = false; }, 200);
|
||||
}
|
||||
|
||||
selectClient(client: any): void {
|
||||
this.selectedClient = client;
|
||||
this.searchResults = [];
|
||||
this.searchText = '';
|
||||
this.fetchClientDetails(client.id);
|
||||
}
|
||||
|
||||
removeSelectedClient(): void {
|
||||
this.selectedClient = null;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedSignatureImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
}
|
||||
|
||||
private fetchClientDetails(clientId: number): void {
|
||||
this.fetchingDetails = true;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedSignatureImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
let pending = 4;
|
||||
const done = () => { pending--; if (pending <= 0) { this.fetchingDetails = false; } };
|
||||
|
||||
this.clientsService.getClientData(clientId.toString()).subscribe({
|
||||
next: (data: any) => {
|
||||
if (data?.dateOfBirth) { this.selectedClient = { ...this.selectedClient, dateOfBirth: data.dateOfBirth }; }
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
this.clientsService.getClientKyc(clientId.toString()).subscribe({
|
||||
next: (kyc: any) => {
|
||||
if (kyc) {
|
||||
this.selectedClient = { ...this.selectedClient, panNumber: kyc.panNumber, aadhaarMasked: kyc.aadhaarNumberMasked };
|
||||
this.linkedKycStatus = { panVerified: kyc.panVerified || false, aadhaarVerified: kyc.aadhaarVerified || false };
|
||||
}
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
|
||||
next: (img: any) => { if (img) { this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img); } done(); },
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
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) => { this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); done(); },
|
||||
error: () => done()
|
||||
});
|
||||
} else { done(); }
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ Guardian ═══
|
||||
|
||||
onGuardianSearch(text: string): void {
|
||||
this.guardianSearchText = text;
|
||||
clearTimeout(this.guardianSearchTimer);
|
||||
if (!text || text.length < 2) { this.guardianSearchResults = []; return; }
|
||||
this.guardianSearching = true;
|
||||
this.guardianSearchTimer = setTimeout(() => {
|
||||
this.clientsService.searchByText(text, 0, 8).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 = '';
|
||||
}
|
||||
|
||||
// ═══ Save Nominee ═══
|
||||
|
||||
saveNominee(): void {
|
||||
if (!this.selectedClient || !this.nomineeRelationshipId || !this.nomineeShare) return;
|
||||
if (this.totalShare + this.nomineeShare > 100) return;
|
||||
|
||||
this.nomineeSaving = true;
|
||||
const account = this.selectedAccount;
|
||||
const accountType = this.selectedGroup?.apiType || '';
|
||||
|
||||
const payload: any = {
|
||||
nomineeClientId: this.selectedClient.id,
|
||||
accountNo: account?.accountNo,
|
||||
accountType,
|
||||
nominationType: this.nomineeNomType,
|
||||
relationshipId: this.nomineeRelationshipId,
|
||||
percentageShare: this.nomineeShare,
|
||||
nomineeIsMinor: this.nomineeIsMinor,
|
||||
remarks: this.nomineeRemarks || null,
|
||||
locale: 'en'
|
||||
};
|
||||
|
||||
if (this.nomineeIsMinor && this.selectedGuardian) {
|
||||
payload.guardianClientId = this.selectedGuardian.id;
|
||||
}
|
||||
|
||||
this.clientsService.addNominee(this.clientId, payload).subscribe({
|
||||
next: (res: any) => {
|
||||
this.nomineeSaving = false;
|
||||
// Reload nominees to get the full response including nomineeIdentifier
|
||||
this.loadNominees();
|
||||
this.resetAddForm();
|
||||
},
|
||||
error: () => { this.nomineeSaving = false; }
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ View & Edit ═══
|
||||
|
||||
viewNominee(nominee: any): void {
|
||||
// Resolve the linked client ID — could be nomineeClientId or nested
|
||||
const clientId = nominee.nomineeClientId || nominee.linkedClientId || nominee.clientId;
|
||||
const displayName = nominee.nomineeDisplayName || nominee.firstName || '';
|
||||
import('../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component').then(m => {
|
||||
this.dialog.open(m.ClientFamilyMemberDialogComponent, {
|
||||
data: {
|
||||
context: 'View',
|
||||
member: {
|
||||
firstName: displayName,
|
||||
relationshipId: nominee.relationshipId,
|
||||
dateOfBirth: nominee.nomineeDateOfBirth || nominee.dateOfBirth,
|
||||
linkedClientId: clientId,
|
||||
linkedClientName: displayName,
|
||||
isAlive: true,
|
||||
isDependent: nominee.nomineeIsMinor,
|
||||
panNumber: nominee.panNumber || '',
|
||||
aadhaarNumber: nominee.aadhaarMasked || ''
|
||||
},
|
||||
options: { relationshipIdOptions: this.relationshipOptions }
|
||||
},
|
||||
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
editNominee(nominee: any, index: number): void {
|
||||
this.editingIndex = index;
|
||||
this.editRelId = nominee.relationshipId || null;
|
||||
this.editShare = nominee.percentageShare;
|
||||
this.editNomType = nominee.nominationType || 'DA1';
|
||||
}
|
||||
|
||||
cancelEdit(): void { this.editingIndex = null; }
|
||||
|
||||
maxShareForEdit(index: number): number {
|
||||
return 100 - this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
|
||||
}
|
||||
|
||||
saveEdit(nominee: any, index: number): void {
|
||||
if (!this.editRelId || !this.editShare || this.editShare > this.maxShareForEdit(index)) return;
|
||||
|
||||
const payload: any = {
|
||||
relationshipId: this.editRelId,
|
||||
percentageShare: this.editShare,
|
||||
nominationType: this.editNomType,
|
||||
locale: 'en'
|
||||
};
|
||||
|
||||
this.clientsService.updateNominee(this.clientId, nominee.id.toString(), payload).subscribe({
|
||||
next: () => {
|
||||
// Reload to get updated data
|
||||
this.loadNominees();
|
||||
this.editingIndex = null;
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
deleteNominee(nominee: any, index: number): void {
|
||||
const ref = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `nominee ${nominee.nomineeDisplayName || nominee.firstName || ''}` }
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.delete) {
|
||||
this.clientsService.deleteNominee(this.clientId, nominee.id.toString()).subscribe({
|
||||
next: () => this.nominees.splice(index, 1),
|
||||
error: () => this.nominees.splice(index, 1)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
zoomImage(imageSrc: any, title: string): void {
|
||||
this.dialog.open(ImageZoomDialogComponent, {
|
||||
data: { imageSrc, title },
|
||||
panelClass: 'image-zoom-panel',
|
||||
maxWidth: '90vw', maxHeight: '90vh'
|
||||
});
|
||||
}
|
||||
|
||||
getRelationship(member: any): string {
|
||||
if (member.relationshipName) return member.relationshipName;
|
||||
if (member.relationship) return member.relationship;
|
||||
if (typeof member.relationshipId === 'object' && member.relationshipId?.name) return member.relationshipId.name;
|
||||
const opt = this.relationshipOptions.find((r: any) => r.id === member.relationshipId);
|
||||
return opt?.name || '';
|
||||
}
|
||||
|
||||
resetAddForm(): void {
|
||||
this.showAddForm = false;
|
||||
this.selectedClient = null;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedSignatureImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
this.nomineeNomType = 'DA1';
|
||||
this.nomineeRelationshipId = null;
|
||||
this.nomineeShare = null;
|
||||
this.nomineeIsMinor = false;
|
||||
this.nomineeRemarks = '';
|
||||
this.selectedGuardian = null;
|
||||
this.searchText = '';
|
||||
this.guardianSearchText = '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user