update rd,fd,saving and current product update validation end date and and this all account nominee facility code

This commit is contained in:
Nitesh Yadav
2026-05-15 12:53:47 +05:30
parent 8758b9222f
commit 52e216fbe5
62 changed files with 3069 additions and 886 deletions
@@ -9,271 +9,268 @@
<div class="nom-section">
<!-- Header -->
<div class="nom-header">
<div class="nom-header__left">
<i class="fas fa-user-shield"></i>
<span class="nom-header__title">Nomination Facility</span>
</div>
<div class="nom-mode-tabs">
<button type="button" class="nom-tab" [class.nom-tab--active]="mode === 'manual'" (click)="switchMode('manual')">
<i class="fas fa-pencil-alt"></i> Enter Manually
</button>
<button type="button" class="nom-tab" [class.nom-tab--active]="mode === 'linked'" (click)="switchMode('linked')">
<i class="fas fa-search"></i> Link Existing Client
</button>
</div>
<mat-icon class="nom-header__icon">search</mat-icon>
<span class="nom-header__title">Search &amp; Link Nominee</span>
</div>
<div class="nom-body">
<!-- ══════════════ MANUAL MODE ══════════════ -->
@if (mode === 'manual') {
<form [formGroup]="manualForm" class="nom-form">
<!-- Basic Info -->
<div class="nom-row">
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Full Name *</mat-label>
<input matInput formControlName="fullName" placeholder="Nominee full name" />
<mat-error>Full name is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Relationship *</mat-label>
<mat-select formControlName="relationship">
@for (rel of relationships; track rel) {
<mat-option [value]="rel">{{ rel }}</mat-option>
}
</mat-select>
<mat-error>Relationship is required</mat-error>
</mat-form-field>
</div>
<div class="nom-row">
<mat-form-field appearance="outline" class="nom-field" (click)="dobPicker.open()">
<mat-label>Date of Birth</mat-label>
<input
matInput
[matDatepicker]="dobPicker"
[min]="minDate"
[max]="maxDate"
formControlName="dateOfBirth"
readonly
/>
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field">
<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-select>
</mat-form-field>
</div>
<!-- ID Verification -->
<div class="nom-section-label"><i class="fas fa-id-card"></i> ID Verification</div>
<div class="nom-row">
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Aadhaar Number</mat-label>
<input matInput formControlName="aadhaarNumber" maxlength="12" placeholder="12-digit Aadhaar" />
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field">
<mat-label>PAN Number</mat-label>
<input
matInput
formControlName="panNumber"
maxlength="10"
placeholder="ABCDE1234F"
style="text-transform: uppercase"
/>
</mat-form-field>
</div>
<div class="nom-row">
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Mobile Number</mat-label>
<input matInput formControlName="mobileNo" maxlength="10" placeholder="10-digit mobile" />
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Nominee Share (%)</mat-label>
<input matInput type="number" formControlName="nomineeShare" readonly />
</mat-form-field>
</div>
<!-- Guardian (shown if minor / not alive) -->
<div class="nom-section-label">
<i class="fas fa-user-friends"></i> Guardian Details
<span class="nom-hint">(if nominee is a minor)</span>
</div>
<div class="nom-row">
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Guardian Name</mat-label>
<input matInput formControlName="guardianName" />
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Guardian Relationship</mat-label>
<mat-select formControlName="guardianRelationship">
@for (rel of relationships; track rel) {
<mat-option [value]="rel">{{ rel }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Flags -->
<div class="nom-flags">
<mat-checkbox formControlName="isAlive" color="primary">Is Alive</mat-checkbox>
<mat-checkbox formControlName="isDependent" color="primary">Is Dependent</mat-checkbox>
</div>
<!-- Remarks -->
<mat-form-field appearance="outline" class="nom-field nom-field--full">
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
<!-- Search box -->
@if (!selectedClient) {
<div class="nom-search-wrap">
<mat-form-field appearance="outline" class="nom-field--full">
<mat-label>Search by Name, Client ID or Mobile</mat-label>
<mat-icon matPrefix class="nom-search-icon">search</mat-icon>
<input
matInput
[value]="searchText"
(input)="onSearch($any($event.target).value)"
(blur)="dismissSearch()"
placeholder="Type at least 2 characters..."
autocomplete="off"
/>
</mat-form-field>
</form>
}
<!-- ══════════════ LINK EXISTING CLIENT MODE ══════════════ -->
@if (mode === 'linked') {
@if (!selectedClient) {
<!-- Search -->
<div class="nom-search-wrap">
<mat-form-field appearance="outline" class="nom-field--full">
<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="nom-results">
@if (searching || searchResults.length > 0) {
<div class="nom-results">
@if (searching) {
<div class="nom-results__loading">
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
<span>Searching...</span>
</div>
</div>
}
@if (!searching && searchResults.length > 0) {
<div class="nom-results">
@for (r of searchResults; track r.id) {
<div class="nom-result-item" (click)="selectClient(r)">
<mat-icon class="nom-result-item__icon">person</mat-icon>
<div class="nom-result-item__info">
<span class="nom-result-item__name">{{ r.displayName }}</span>
<span class="nom-result-item__meta">
ID: {{ r.id }}
@if (r.officeName) {
· {{ r.officeName }}
}
@if (r.mobileNo) {
· {{ r.mobileNo }}
}
</span>
</div>
}
@for (r of searchResults; track r.id) {
<div
class="nom-result-item"
[class.nom-result-item--disabled]="isExcludedNominee(r)"
(mousedown)="selectClient(r); $event.preventDefault()"
>
<mat-icon class="nom-result-item__icon">person</mat-icon>
<div class="nom-result-item__info">
<span class="nom-result-item__name">{{ r.displayName }}</span>
<span class="nom-result-item__meta">
ID: {{ r.id }}
@if (r.officeName) {
· {{ r.officeName }}
}
@if (r.mobileNo) {
· {{ r.mobileNo }}
}
</span>
</div>
}
</div>
}
@if (isExcludedNominee(r)) {
<span class="nom-self-tag">Cannot select (self)</span>
}
</div>
}
@if (!searching && searchText.length >= 2 && searchResults.length === 0) {
<div class="nom-no-results">No clients found</div>
}
</div>
}
</div>
}
@if (!searching && searchText.length >= 2 && searchResults.length === 0) {
<div class="nom-no-results">No clients found</div>
<!-- Selected client card -->
@if (selectedClient) {
<div class="nom-client-card">
<div class="nom-client-card__avatar">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" alt="" class="nom-client-card__photo" />
} @else {
<mat-icon class="nom-client-card__avatar-icon">person</mat-icon>
}
</div>
<div class="nom-client-card__info">
<span class="nom-client-card__name">{{ selectedClient.displayName }}</span>
<span class="nom-client-card__meta">
ID: {{ selectedClient.id }}
@if (selectedClient.mobileNo) {
· {{ selectedClient.mobileNo }}
}
</span>
</div>
<button mat-icon-button type="button" class="nom-client-card__remove" (click)="removeClient()">
<mat-icon>close</mat-icon>
</button>
</div>
@if (fetchingDetails) {
<div class="nom-fetching">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
<span>Fetching details...</span>
</div>
}
@if (selectedClient) {
<!-- Selected Client Card -->
<div class="nom-client-card">
<div class="nom-client-card__avatar">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" alt="Profile" class="nom-client-card__photo" />
} @else {
<mat-icon class="nom-client-card__avatar-icon">person</mat-icon>
@if (!fetchingDetails) {
<!-- KYC row -->
@if (clientDob || clientPan || clientAadhaar) {
<div class="nom-kyc-row">
@if (clientDob) {
<span class="nom-kyc-item">
<span class="nom-kyc-label">DOB</span>
<span class="nom-kyc-value">{{ clientDob | dateFormat }}</span>
@if (isMinorByDob) {
<span class="nom-minor-badge">Minor</span>
}
</span>
}
@if (clientPan) {
<span class="nom-kyc-item">
<span class="nom-kyc-label">PAN</span>
<span class="nom-kyc-value">
{{ clientPan }}
@if (linkedKycStatus?.panVerified) {
<mat-icon class="nom-kyc-ok">check_circle</mat-icon>
}
</span>
</span>
}
@if (clientAadhaar) {
<span class="nom-kyc-item">
<span class="nom-kyc-label">AADHAAR</span>
<span class="nom-kyc-value">
{{ clientAadhaar }}
@if (linkedKycStatus?.aadhaarVerified) {
<mat-icon class="nom-kyc-ok">check_circle</mat-icon>
}
</span>
</span>
}
</div>
<div class="nom-client-card__info">
<span class="nom-client-card__name">{{ selectedClient.displayName }}</span>
<span class="nom-client-card__meta">
Client ID: {{ selectedClient.id }}
@if (selectedClient.accountNumber) {
· A/C: {{ selectedClient.accountNumber }}
}
</span>
</div>
<button
mat-icon-button
type="button"
(click)="removeLinked()"
class="nom-client-card__remove"
matTooltip="Remove"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (fetchingLinkedData) {
<div class="nom-fetching">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
<span>Fetching nominee details...</span>
</div>
}
@if (!fetchingLinkedData) {
<div class="nom-details-grid">
<div class="nom-detail">
<span class="nom-detail__label">Full Name</span>
<span class="nom-detail__value">{{ selectedClient.displayName }}</span>
</div>
<div class="nom-detail">
<span class="nom-detail__label">Date of Birth</span>
<span class="nom-detail__value">{{ clientDob || '—' }}</span>
</div>
@if (clientAadhaar) {
<div class="nom-detail">
<span class="nom-detail__label">Aadhaar</span>
<span class="nom-detail__value">
{{ clientAadhaar }}
@if (linkedKycStatus?.aadhaarVerified) {
<span class="nom-verified"><mat-icon>verified</mat-icon> Verified</span>
}
</span>
</div>
<!-- Fields row -->
<div class="nom-fields-row">
<mat-form-field appearance="outline" class="nom-field-item">
<mat-label>Nomination Type</mat-label>
<mat-select [(ngModel)]="nominationType">
@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)]="relationshipId">
@for (rel of relationshipOptions; track rel.id) {
<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)]="percentageShare" min="1" max="100" />
@if (percentageShare && (percentageShare < 1 || percentageShare > 100)) {
<mat-error>1–100</mat-error>
}
@if (clientPan) {
<div class="nom-detail">
<span class="nom-detail__label">PAN</span>
<span class="nom-detail__value">
{{ clientPan }}
@if (linkedKycStatus?.panVerified) {
<span class="nom-verified"><mat-icon>verified</mat-icon> Verified</span>
}
</span>
</mat-form-field>
<div class="nom-field-item nom-minor-toggle">
<mat-slide-toggle [(ngModel)]="nomineeIsMinor" [disabled]="minorToggleLocked">Minor</mat-slide-toggle>
</div>
</div>
<!-- Guardian section — shown only when minor is ON -->
@if (nomineeIsMinor) {
<div class="nom-guardian-section">
<div class="nom-guardian-header">
<mat-icon class="nom-guardian-icon">shield</mat-icon>
<span class="nom-guardian-title">Guardian</span>
<span class="nom-guardian-hint">(Required for minor nominee)</span>
</div>
@if (!selectedGuardian) {
<div class="nom-search-wrap">
<mat-form-field appearance="outline" class="nom-field--full">
<mat-label>Search Guardian by Name or Client ID</mat-label>
<mat-icon matPrefix class="nom-search-icon">search</mat-icon>
<input
matInput
[value]="guardianSearchText"
(input)="onGuardianSearch($any($event.target).value)"
(blur)="dismissGuardianSearch()"
placeholder="Type at least 2 characters..."
autocomplete="off"
/>
</mat-form-field>
@if (guardianSearching || guardianSearchResults.length > 0) {
<div class="nom-results">
@if (guardianSearching) {
<div class="nom-results__loading">
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
<span>Searching...</span>
</div>
}
@for (r of guardianSearchResults; track r.id) {
<div
class="nom-result-item"
[class.nom-result-item--disabled]="selectedClient && r.id === selectedClient.id"
(mousedown)="selectGuardian(r); $event.preventDefault()"
>
<mat-icon class="nom-result-item__icon">person</mat-icon>
<div class="nom-result-item__info">
<span class="nom-result-item__name">{{ r.displayName }}</span>
<span class="nom-result-item__meta"
>ID: {{ r.id }}
@if (r.mobileNo) {
· {{ r.mobileNo }}
}
</span>
</div>
@if (selectedClient && r.id === selectedClient.id) {
<span class="nom-result-item__conflict">Same as nominee</span>
}
</div>
}
@if (!guardianSearching && guardianSearchText.length >= 2 && guardianSearchResults.length === 0) {
<div class="nom-no-results">No clients found</div>
}
</div>
}
</div>
} @else {
<!-- Selected guardian card -->
<div class="nom-guardian-card" [class.nom-guardian-card--error]="guardianSameAsNominee">
<mat-icon class="nom-guardian-card__icon">person</mat-icon>
<div class="nom-guardian-card__info">
<span class="nom-guardian-card__name">{{ selectedGuardian.displayName }}</span>
<span class="nom-guardian-card__meta"
>ID: {{ selectedGuardian.id }}
@if (selectedGuardian.mobileNo) {
· {{ selectedGuardian.mobileNo }}
}
</span>
</div>
<button mat-icon-button type="button" (click)="removeGuardian()">
<mat-icon>close</mat-icon>
</button>
</div>
@if (guardianSameAsNominee) {
<div class="nom-conflict-error">
<mat-icon>error_outline</mat-icon>
Guardian and nominee cannot be the same client (same Client ID / CIF).
</div>
}
}
</div>
}
<!-- Relationship picker for linked client -->
<div class="nom-linked-extras">
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Relationship *</mat-label>
<mat-select [(ngModel)]="linkedRelationship" (ngModelChange)="onLinkedRelationshipChange($event)">
@for (rel of relationships; track rel) {
<mat-option [value]="rel">{{ rel }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Remarks -->
<mat-form-field appearance="outline" class="nom-field--full">
<mat-label>Remarks</mat-label>
<input matInput [(ngModel)]="remarks" placeholder="Optional" />
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field">
<mat-label>Nominee Share (%)</mat-label>
<input matInput type="number" [value]="100" readonly />
</mat-form-field>
<!-- Actions -->
@if (showActions) {
<div class="nom-actions">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" type="button" [disabled]="!isValid" (click)="save()">
<mat-icon>check</mat-icon> Save
</button>
</div>
}
}
@@ -6,194 +6,93 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'sass:color';
$blue: #2563eb;
$blue-light: #eff6ff;
$green: #16a34a;
$green-light: #dcfce7;
$border: #e2e8f0;
$text: #1e293b;
$muted: #64748b;
$gray-bg: #f8fafc;
$purple: #7c3aed;
$purple-light: #f5f3ff;
$blue: #1074b9;
$blue-light: #e8f0fe;
$green: #16a34a;
:host {
display: block;
}
// ─── Wrapper ───────────────────────────────────────────────────
.nom-section {
border: 1px solid $border;
border-radius: 14px;
border-radius: 10px;
background: #fff;
margin-top: 20px;
margin-top: 16px;
overflow: visible;
position: relative;
z-index: 10;
}
// ─── Header ────────────────────────────────────────────────────
// Header
.nom-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: $gray-bg;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid $border;
border-radius: 14px 14px 0 0;
background: $gray-bg;
border-radius: 10px 10px 0 0;
&__left {
display: flex;
align-items: center;
gap: 10px;
i {
color: $purple;
font-size: 15px;
}
&__icon {
color: $blue;
font-size: 18px;
width: 18px;
height: 18px;
}
&__title {
font-size: 14px;
font-weight: 700;
font-size: 13px;
font-weight: 600;
color: $text;
}
}
// ─── Mode toggle tabs ──────────────────────────────────────────
.nom-mode-tabs {
display: flex;
gap: 8px;
}
.nom-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1.5px solid $border;
background: #fff;
color: $muted;
transition: all 0.18s;
i {
font-size: 11px;
}
&:hover {
border-color: $blue;
color: $blue;
background: $blue-light;
}
&--active {
border-color: $blue;
background: $blue;
color: #fff;
&:hover {
background: color.adjust(#2563eb, $lightness: -5%);
}
}
}
// ─── Body ──────────────────────────────────────────────────────
// Body
.nom-body {
padding: 20px;
padding: 16px;
overflow: visible;
position: relative;
z-index: 10;
}
// ─── Manual form grid ──────────────────────────────────────────
.nom-form {
display: flex;
flex-direction: column;
gap: 0;
}
.nom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.nom-field {
.nom-field--full {
width: 100%;
&--full {
width: 100%;
display: block;
}
display: block;
}
.nom-section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: $blue;
margin: 8px 0 4px;
i {
font-size: 12px;
}
}
.nom-hint {
font-size: 10px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
.nom-search-icon {
color: $muted;
margin-left: 2px;
font-size: 18px;
}
.nom-flags {
display: flex;
align-items: center;
gap: 24px;
margin: 8px 0 12px;
}
// ─── Search (link mode) ────────────────────────────────────────
// Search results dropdown
.nom-search-wrap {
position: relative;
z-index: 1000;
overflow: visible;
}
.nom-results {
margin-top: 4px;
border: 1px solid $border;
border-radius: 8px;
position: absolute;
top: 56px;
left: 0;
right: 0;
z-index: 1001;
background: #fff;
box-shadow: 0 4px 20px rgb(0 0 0 / 10%);
overflow: hidden;
max-height: 260px;
border: 1px solid $border;
border-radius: 6px;
box-shadow: 0 6px 20px rgb(0 0 0 / 10%);
max-height: 240px;
overflow-y: auto;
&__loading {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
font-size: 13px;
padding: 12px 14px;
font-size: 12px;
color: $muted;
}
}
@@ -201,11 +100,11 @@ $purple-light: #f5f3ff;
.nom-result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid $border;
transition: background 0.12s;
&:last-child {
border-bottom: none;
@@ -218,15 +117,12 @@ $purple-light: #f5f3ff;
&__icon {
color: $muted;
font-size: 20px;
width: 20px;
height: 20px;
flex: 0 0 auto;
flex-shrink: 0;
}
&__info {
display: flex;
flex-direction: column;
gap: 2px;
}
&__name {
@@ -242,32 +138,32 @@ $purple-light: #f5f3ff;
}
.nom-no-results {
padding: 12px 0;
font-size: 13px;
padding: 10px;
font-size: 12px;
color: $muted;
text-align: center;
}
// ─── Selected client card ──────────────────────────────────────
// Client card
.nom-client-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
gap: 12px;
padding: 10px 14px;
border: 1px solid $border;
border-radius: 10px;
border-radius: 8px;
background: $gray-bg;
margin-bottom: 14px;
margin-bottom: 10px;
&__avatar {
width: 46px;
height: 46px;
width: 38px;
height: 38px;
border-radius: 50%;
background: $blue-light;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
flex-shrink: 0;
overflow: hidden;
}
@@ -279,16 +175,16 @@ $purple-light: #f5f3ff;
&__avatar-icon {
color: $blue;
font-size: 26px;
width: 26px;
height: 26px;
font-size: 22px;
width: 22px;
height: 22px;
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
}
&__name {
@@ -303,8 +199,8 @@ $purple-light: #f5f3ff;
}
&__remove {
flex: 0 0 auto;
color: $muted;
flex-shrink: 0;
}
}
@@ -314,74 +210,200 @@ $purple-light: #f5f3ff;
gap: 8px;
font-size: 12px;
color: $muted;
padding: 6px 0;
padding: 4px 0 8px;
}
// ─── Linked client details grid ────────────────────────────────
.nom-details-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px 24px;
padding: 14px 16px;
border: 1px solid $border;
border-radius: 10px;
background: #fff;
margin-bottom: 14px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.nom-detail {
// KYC row
.nom-kyc-row {
display: flex;
flex-direction: column;
gap: 4px;
&__label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
color: $muted;
text-transform: uppercase;
}
&__value {
font-size: 13px;
font-weight: 600;
color: $text;
display: flex;
align-items: center;
gap: 6px;
}
flex-wrap: wrap;
gap: 6px 20px;
padding: 8px 12px;
background: $gray-bg;
border: 1px solid $border;
border-radius: 6px;
margin-bottom: 12px;
}
.nom-verified {
.nom-kyc-item {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
}
.nom-kyc-label {
font-size: 10px;
font-weight: 700;
color: $muted;
text-transform: uppercase;
}
.nom-kyc-value {
font-size: 12px;
font-weight: 600;
color: $text;
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: 600;
}
.nom-kyc-ok {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: $green;
background: $green-light;
border-radius: 999px;
padding: 2px 8px;
}
// Fields row
.nom-fields-row {
display: flex;
flex-wrap: wrap;
gap: 0 12px;
align-items: center;
margin-bottom: 8px;
}
.nom-field-item {
flex: 1;
min-width: 160px;
&--sm {
flex: 0 0 100px;
min-width: 80px;
}
}
.nom-minor-toggle {
display: flex;
align-items: center;
padding-top: 8px;
flex: 0 0 auto;
}
// Minor badge in KYC row
.nom-minor-badge {
font-size: 10px;
font-weight: 700;
color: #92400e;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 4px;
padding: 1px 6px;
}
// Guardian section
.nom-guardian-section {
border: 1px solid #fde68a;
border-radius: 8px;
background: #fffbeb;
padding: 12px 14px;
margin-bottom: 12px;
}
.nom-guardian-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
}
.nom-guardian-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #92400e;
}
.nom-guardian-title {
font-size: 13px;
font-weight: 700;
color: #92400e;
}
.nom-guardian-hint {
font-size: 11px;
color: #b45309;
}
.nom-guardian-card {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #fff;
border: 1px solid #fcd34d;
border-radius: 6px;
&--error {
border-color: #ef4444;
background: #fef2f2;
}
&__icon {
color: #92400e;
font-size: 20px;
flex-shrink: 0;
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
}
&__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
&__meta {
font-size: 11px;
color: #64748b;
}
}
.nom-conflict-error {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 12px;
font-weight: 500;
color: #dc2626;
mat-icon {
font-size: 12px;
width: 12px;
height: 12px;
font-size: 16px;
width: 16px;
height: 16px;
}
}
// ─── Extras for linked (relationship + share) ──────────────────
.nom-linked-extras {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 4px;
.nom-result-item--disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
@media (width <= 600px) {
grid-template-columns: 1fr;
}
.nom-result-item__conflict,
.nom-self-tag {
font-size: 10px;
font-weight: 600;
color: #dc2626;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 4px;
padding: 2px 6px;
white-space: nowrap;
flex-shrink: 0;
margin-left: auto;
}
// Actions
.nom-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
@@ -6,59 +6,35 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Output, EventEmitter, inject, OnInit } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { DomSanitizer } from '@angular/platform-browser';
import { Component, Input, Output, EventEmitter, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { ClientsService } from 'app/clients/clients.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
export interface NomineeData {
mode: 'manual' | 'linked';
// Manual fields
fullName?: string;
relationship?: string;
dateOfBirth?: string;
nationality?: string;
aadhaarNumber?: string;
panNumber?: string;
mobileNo?: string;
nomineeShare?: number;
isAlive?: boolean;
isDependent?: boolean;
guardianName?: string;
guardianRelationship?: string;
remarks?: string;
// Linked client fields
clientId?: number;
displayName?: string;
cifNumber?: string;
nomineeClientId: number;
nomineeDisplayName: string;
nominationType: 'DA1' | 'DA5';
relationshipId: number;
relationshipName: string;
percentageShare: number;
nomineeIsMinor: boolean;
guardianClientId?: number;
guardianDisplayName?: string;
remarks: string | null;
locale: 'en';
// preview extras
nomineeDateOfBirth?: any;
nomineeMobileNo?: string;
nomineePan?: string;
nomineeAadhaar?: string;
}
const RELATIONSHIPS = [
'Spouse',
'Son',
'Daughter',
'Father',
'Mother',
'Brother',
'Sister',
'Grandfather',
'Grandmother',
'Grandson',
'Granddaughter',
'Uncle',
'Aunt',
'Nephew',
'Niece',
'Friend',
'Other'
];
@Component({
selector: 'mifosx-nominee-form',
templateUrl: './nominee-form.component.html',
@@ -67,91 +43,71 @@ const RELATIONSHIPS = [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatProgressSpinner,
MatIconButton,
MatIcon,
MatCheckbox,
MatDatepickerModule
MatSlideToggle,
DateFormatPipe
]
})
export class NomineeFormComponent implements OnInit {
private fb = inject(UntypedFormBuilder);
private clientsService = inject(ClientsService);
private sanitizer = inject(DomSanitizer);
@Input() primaryClientId: number | null = null;
@Input() showActions = true;
@Output() nomineeChange = new EventEmitter<NomineeData | null>();
mode: 'manual' | 'linked' = 'manual';
relationships = RELATIONSHIPS;
relationshipOptions: any[] = [];
// Manual form
manualForm!: UntypedFormGroup;
minDate = new Date(1900, 0, 1);
maxDate = new Date();
nominationTypeOptions = [
{ value: 'DA1', label: 'DA1 - Add Nominee' },
{ value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' }
];
// Link mode
/** Nominee search */
searchText = '';
searching = false;
searchResults: any[] = [];
selectedClient: any = null;
fetchingLinkedData = false;
linkedProfileImage: any = null;
linkedKycStatus: any = null;
clientDob = '';
clientAadhaar = '';
clientPan = '';
clientCifNumber = '';
linkedRelationship = '';
linkedShare = 100;
private searchTimer: any;
/** Selected nominee client */
selectedClient: any = null;
fetchingDetails = false;
linkedProfileImage: SafeUrl | null = null;
linkedKycStatus: any = null;
clientDob: any = null;
clientAadhaar = '';
clientPan = '';
/** Whether the nominee is confirmed to be a minor based on DOB */
isMinorByDob = false;
/** Whether minor toggle is manually lockable */
minorToggleLocked = false;
/** Guardian search */
guardianSearchText = '';
guardianSearching = false;
guardianSearchResults: any[] = [];
selectedGuardian: any = null;
private guardianSearchTimer: any;
/** Form fields */
nominationType: 'DA1' | 'DA5' = 'DA1';
relationshipId: number | null = null;
percentageShare: number | null = null;
nomineeIsMinor = false;
remarks = '';
ngOnInit(): void {
this.manualForm = this.fb.group({
fullName: [
'',
Validators.required
],
relationship: [
'',
Validators.required
],
dateOfBirth: [''],
nationality: ['Indian'],
aadhaarNumber: [''],
panNumber: [''],
mobileNo: [''],
nomineeShare: [100],
isAlive: [true],
isDependent: [false],
guardianName: [''],
guardianRelationship: [''],
remarks: ['']
});
this.manualForm.valueChanges.subscribe(() => {
if (this.mode === 'manual' && this.manualForm.valid) {
this.emitManual();
}
this.clientsService.getClientTemplate().subscribe({
next: (t: any) => {
this.relationshipOptions = t?.familyMemberOptions?.relationshipIdOptions || [];
},
error: () => {}
});
}
switchMode(m: 'manual' | 'linked'): void {
this.mode = m;
if (m === 'manual') {
this.clearLinked();
} else {
this.manualForm.reset({ nationality: 'Indian', isAlive: true, isDependent: false, nomineeShare: 100 });
this.nomineeChange.emit(null);
}
}
// ─── Nominee Search ──────────────────────────────────────────
// ─── Manual ──────────────────────────────────────
private emitManual(): void {
const v = this.manualForm.value;
this.nomineeChange.emit({ mode: 'manual', ...v });
}
// ─── Link existing client ─────────────────────────
onSearch(text: string): void {
this.searchText = text;
clearTimeout(this.searchTimer);
@@ -161,10 +117,11 @@ export class NomineeFormComponent implements OnInit {
}
this.searching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({
this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
this.clientsService.enrichSearchResults(this.searchResults);
},
error: () => {
this.searchResults = [];
@@ -174,70 +131,61 @@ export class NomineeFormComponent implements OnInit {
}, 400);
}
dismissSearch(): void {
setTimeout(() => {
this.searchResults = [];
}, 200);
}
isExcludedNominee(client: any): boolean {
return this.primaryClientId != null && client.id === this.primaryClientId;
}
selectClient(client: any): void {
if (this.isExcludedNominee(client)) return;
this.selectedClient = client;
this.searchResults = [];
this.searchText = '';
this.fetchLinkedClientData(client.id);
this.nomineeChange.emit({
mode: 'linked',
clientId: client.id,
displayName: client.displayName,
cifNumber: client.cifNumber || client.cifNo || ''
});
this.fetchClientDetails(client.id);
}
removeLinked(): void {
this.clearLinked();
this.nomineeChange.emit(null);
}
onLinkedRelationshipChange(rel: string): void {
if (this.selectedClient) {
this.nomineeChange.emit({
mode: 'linked',
clientId: this.selectedClient.id,
displayName: this.selectedClient.displayName,
cifNumber: this.selectedClient.accountNo || this.selectedClient.cifNumber || '',
relationship: rel,
nomineeShare: this.linkedShare
});
}
}
private clearLinked(): void {
removeClient(): void {
this.selectedClient = null;
this.linkedProfileImage = null;
this.linkedKycStatus = null;
this.clientDob = '';
this.clientDob = null;
this.clientAadhaar = '';
this.clientPan = '';
this.clientCifNumber = '';
this.searchText = '';
this.searchResults = [];
this.linkedRelationship = '';
this.linkedShare = 100;
this.isMinorByDob = false;
this.minorToggleLocked = false;
this.nomineeIsMinor = false;
this.selectedGuardian = null;
this.relationshipId = null;
this.percentageShare = null;
this.remarks = '';
this.nomineeChange.emit(null);
}
private fetchLinkedClientData(clientId: string): void {
this.fetchingLinkedData = true;
private fetchClientDetails(clientId: number): void {
this.fetchingDetails = true;
let pending = 3;
const done = () => {
pending--;
if (pending <= 0) this.fetchingDetails = false;
};
this.clientsService.getClientData(clientId).subscribe({
this.clientsService.getClientData(clientId.toString()).subscribe({
next: (data: any) => {
if (data.dateOfBirth) {
const d = Array.isArray(data.dateOfBirth)
? new Date(data.dateOfBirth[0], data.dateOfBirth[1] - 1, data.dateOfBirth[2])
: new Date(data.dateOfBirth);
this.clientDob = d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
if (data?.dateOfBirth) {
this.clientDob = data.dateOfBirth;
this.checkMinorFromDob(data.dateOfBirth);
}
this.fetchingLinkedData = false;
done();
},
error: () => {
this.fetchingLinkedData = false;
}
error: () => done()
});
this.clientsService.getClientKyc(clientId).subscribe({
this.clientsService.getClientKyc(clientId.toString()).subscribe({
next: (kyc: any) => {
if (kyc) {
this.clientAadhaar = kyc.aadhaarNumberMasked || '';
@@ -247,17 +195,159 @@ export class NomineeFormComponent implements OnInit {
aadhaarVerified: kyc.aadhaarVerified || false
};
}
done();
},
error: () => {}
error: () => done()
});
this.clientsService.getClientProfileImage(clientId).subscribe({
next: (imageData: any) => {
if (imageData) {
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(imageData);
}
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
next: (img: any) => {
if (img) this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img);
done();
},
error: () => {}
error: () => done()
});
}
/** Auto-set minor toggle based on DOB. Lock toggle if minor (must be minor). */
private checkMinorFromDob(dob: any): void {
let birthDate: Date;
if (Array.isArray(dob)) {
birthDate = new Date(dob[0], dob[1] - 1, dob[2]);
} else {
birthDate = new Date(dob);
}
if (isNaN(birthDate.getTime())) return;
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--;
if (age < 18) {
// Confirmed minor — auto-enable and lock
this.nomineeIsMinor = true;
this.isMinorByDob = true;
this.minorToggleLocked = true;
} else {
// Not a minor — disable and lock OFF
this.nomineeIsMinor = false;
this.isMinorByDob = false;
this.minorToggleLocked = true;
}
}
// ─── Guardian Search ─────────────────────────────────────────
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, '', '', true).subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
this.clientsService.enrichSearchResults(this.guardianSearchResults);
},
error: () => {
this.guardianSearchResults = [];
this.guardianSearching = false;
}
});
}, 400);
}
dismissGuardianSearch(): void {
setTimeout(() => {
this.guardianSearchResults = [];
}, 200);
}
selectGuardian(client: any): void {
if (this.selectedClient && client.id === this.selectedClient.id) {
return; // nominee and guardian cannot be the same
}
this.selectedGuardian = client;
this.guardianSearchResults = [];
this.guardianSearchText = '';
}
get guardianSameAsNominee(): boolean {
return !!(this.selectedGuardian && this.selectedClient && this.selectedGuardian.id === this.selectedClient.id);
}
removeGuardian(): void {
this.selectedGuardian = null;
this.guardianSearchText = '';
}
// ─── Live preview data (before Save) ─────────────────────────
get currentNomineeData(): NomineeData | null {
if (!this.selectedClient) return null;
const relName = this.relationshipOptions.find((r: any) => r.id === this.relationshipId)?.name || '';
return {
nomineeClientId: this.selectedClient.id,
nomineeDisplayName: this.selectedClient.displayName || '',
nominationType: this.nominationType,
relationshipId: this.relationshipId || 0,
relationshipName: relName,
percentageShare: this.percentageShare || 0,
nomineeIsMinor: this.nomineeIsMinor,
guardianClientId: this.nomineeIsMinor && this.selectedGuardian ? this.selectedGuardian.id : undefined,
guardianDisplayName: this.nomineeIsMinor && this.selectedGuardian ? this.selectedGuardian.displayName : undefined,
remarks: this.remarks || null,
locale: 'en',
nomineeDateOfBirth: this.clientDob || null,
nomineeMobileNo: this.selectedClient.mobileNo || '',
nomineePan: this.clientPan || '',
nomineeAadhaar: this.clientAadhaar || ''
};
}
// ─── Validation ───────────────────────────────────────────────
get isValid(): boolean {
if (!this.selectedClient || !this.relationshipId) return false;
if (!this.percentageShare || this.percentageShare < 1 || this.percentageShare > 100) return false;
if (this.nomineeIsMinor && !this.selectedGuardian) return false;
if (this.guardianSameAsNominee) return false;
return true;
}
// ─── Save ─────────────────────────────────────────────────────
save(): void {
if (!this.isValid) return;
const relName = this.relationshipOptions.find((r: any) => r.id === this.relationshipId)?.name || '';
const payload: NomineeData = {
nomineeClientId: this.selectedClient.id,
nomineeDisplayName: this.selectedClient.displayName || '',
nominationType: this.nominationType,
relationshipId: this.relationshipId!,
relationshipName: relName,
percentageShare: this.percentageShare!,
nomineeIsMinor: this.nomineeIsMinor,
remarks: this.remarks || null,
locale: 'en',
nomineeDateOfBirth: this.clientDob || null,
nomineeMobileNo: this.selectedClient.mobileNo || '',
nomineePan: this.clientPan || '',
nomineeAadhaar: this.clientAadhaar || ''
};
if (this.nomineeIsMinor && this.selectedGuardian) {
payload.guardianClientId = this.selectedGuardian.id;
payload.guardianDisplayName = this.selectedGuardian.displayName || '';
}
this.nomineeChange.emit(payload);
}
cancel(): void {
this.removeClient();
}
}