Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
460 lines
19 KiB
HTML
460 lines
19 KiB
HTML
<!--
|
|
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>
|