update code account saving,current,RD
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
@if (shouldShow) {
|
||||
<div class="dap" [class]="'dap--' + risk">
|
||||
<!-- ══ HEADER ════════════════════════════════════════════════════ -->
|
||||
<div class="dap__header">
|
||||
<div class="dap__header-left">
|
||||
<div class="dap__icon-wrap">
|
||||
<i [class]="isProductMode ? 'fas fa-shield-alt' : 'fas fa-exclamation-triangle'"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="dap__title">
|
||||
{{ isProductMode ? 'Dormancy Monitoring Configuration' : 'Dormancy Risk Monitor' }}
|
||||
</div>
|
||||
<div class="dap__subtitle">
|
||||
@if (isProductMode) {
|
||||
Product-level dormancy thresholds applied to this account
|
||||
} @else {
|
||||
Real-time dormancy tracking · Based on last customer transaction
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Risk severity badge -->
|
||||
<div class="dap__badges">
|
||||
<span
|
||||
class="dap__risk-badge"
|
||||
[class]="'dap__risk-badge--' + risk"
|
||||
title="Dormancy risk level based on transaction inactivity"
|
||||
>
|
||||
<span class="dap__risk-dot"></span>
|
||||
@if (isProductMode) {
|
||||
Monitoring: {{ riskSeverity }}
|
||||
} @else {
|
||||
Dormancy Risk: {{ riskSeverity }}
|
||||
}
|
||||
</span>
|
||||
@if (!isProductMode && isMonitoringActive) {
|
||||
<span class="dap__monitor-badge"> <i class="fas fa-satellite-dish"></i> Monitoring Active </span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ METRIC CARDS ════════════════════════════════════════════ -->
|
||||
<div class="dap__cards" [class.dap__cards--product]="isProductMode">
|
||||
@if (isProductMode) {
|
||||
<!-- ── PRODUCT MODE CARDS ─────────────────────────────────── -->
|
||||
|
||||
<!-- Account Status -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">Account Status</span>
|
||||
<span class="dap__pill dap__pill--green"> <i class="fas fa-check-circle"></i> New Account </span>
|
||||
<span class="dap__card-hint">No transactions yet</span>
|
||||
</div>
|
||||
|
||||
<!-- Monitoring Status -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">
|
||||
Dormancy Monitoring
|
||||
<i
|
||||
class="fas fa-info-circle dap__help"
|
||||
title="When enabled, the system tracks transaction activity and alerts on inactivity"
|
||||
></i>
|
||||
</span>
|
||||
<span class="dap__pill" [class]="isMonitoringActive ? 'dap__pill--green' : 'dap__pill--gray'">
|
||||
<i [class]="isMonitoringActive ? 'fas fa-satellite-dish' : 'fas fa-ban'"></i>
|
||||
{{ isMonitoringActive ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
<span class="dap__card-hint">{{
|
||||
isMonitoringActive ? 'Transaction activity tracked' : 'Not configured'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Alert Notification -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">
|
||||
Alert Notifications
|
||||
<i
|
||||
class="fas fa-info-circle dap__help"
|
||||
title="Sends alerts to branch officers when account approaches dormancy threshold"
|
||||
></i>
|
||||
</span>
|
||||
<span class="dap__pill" [class]="isAlertEnabled ? 'dap__pill--green' : 'dap__pill--gray'">
|
||||
<i [class]="isAlertEnabled ? 'fas fa-bell' : 'fas fa-bell-slash'"></i>
|
||||
{{ isAlertEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
<span class="dap__card-hint">{{ isAlertEnabled ? 'Branch will be notified' : 'No alerts configured' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Inactive Threshold -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">
|
||||
Inactive Threshold
|
||||
<i
|
||||
class="fas fa-info-circle dap__help"
|
||||
title="Account is flagged inactive after this many days of no customer-initiated transaction"
|
||||
></i>
|
||||
</span>
|
||||
@if (daysToInactive !== null) {
|
||||
<span class="dap__number"> {{ daysToInactive }}<small>days</small> </span>
|
||||
<span class="dap__card-hint">No transaction for {{ daysToInactive }} days</span>
|
||||
} @else {
|
||||
<span class="dap__na">Not configured</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Dormancy Threshold -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">
|
||||
Dormancy Threshold
|
||||
<i
|
||||
class="fas fa-info-circle dap__help"
|
||||
title="Account is classified dormant after this many additional days following inactive status"
|
||||
></i>
|
||||
</span>
|
||||
@if (daysToDormancy !== null) {
|
||||
<span class="dap__number">{{ daysToDormancy }}<small>days</small></span>
|
||||
<span class="dap__card-hint">{{ daysToDormancy }} days after inactive</span>
|
||||
} @else {
|
||||
<span class="dap__na">Not configured</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Escheat Threshold -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">
|
||||
Escheat Threshold
|
||||
<i
|
||||
class="fas fa-info-circle dap__help"
|
||||
title="Unclaimed funds transferred to government/regulator after this many days of dormancy"
|
||||
></i>
|
||||
</span>
|
||||
@if (daysToEscheat !== null) {
|
||||
<span class="dap__number">{{ daysToEscheat }}<small>days</small></span>
|
||||
<span class="dap__card-hint">{{ daysToEscheat }} days after dormant</span>
|
||||
} @else {
|
||||
<span class="dap__na">Not configured</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── ACCOUNT VIEW MODE CARDS ────────────────────────────── -->
|
||||
|
||||
<!-- Account Status -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">Account Status</span>
|
||||
<span class="dap__pill" [class]="'dap__pill--' + risk">
|
||||
<i class="fas fa-circle"></i> {{ statusLabel }}
|
||||
</span>
|
||||
<span class="dap__card-hint">
|
||||
@if (statusId === 0) {
|
||||
Currently active
|
||||
}
|
||||
@if (statusId === 100) {
|
||||
No recent transactions
|
||||
}
|
||||
@if (statusId === 200) {
|
||||
Classified as dormant
|
||||
}
|
||||
@if (statusId === 300) {
|
||||
Pending regulatory transfer
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Last Transaction -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">Last Transaction</span>
|
||||
@if (lastTxnDate) {
|
||||
<span class="dap__date"><i class="fas fa-calendar-check"></i> {{ lastTxnDate }}</span>
|
||||
<span class="dap__card-hint">Most recent customer activity</span>
|
||||
} @else if (accountOpenedDate) {
|
||||
<span class="dap__date dap__date--muted"><i class="fas fa-calendar-plus"></i> {{ accountOpenedDate }}</span>
|
||||
<span class="dap__card-hint">Account opened · No transactions yet</span>
|
||||
} @else {
|
||||
<span class="dap__na">No transactions yet</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Days to Inactive -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">
|
||||
Days to Inactive
|
||||
<i
|
||||
class="fas fa-info-circle dap__help"
|
||||
title="Account will be flagged inactive after no customer-initiated transaction for this duration"
|
||||
></i>
|
||||
</span>
|
||||
@if (statusId >= 100) {
|
||||
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
||||
<span class="dap__card-hint">Account is inactive</span>
|
||||
} @else if (daysToInactive !== null) {
|
||||
<span class="dap__number" [class.dap__number--warn]="daysToInactive <= 30">
|
||||
{{ daysToInactive }}<small>days</small>
|
||||
</span>
|
||||
<span class="dap__card-hint">{{ inactiveSubtext() }}</span>
|
||||
} @else {
|
||||
<span class="dap__na">—</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Days to Dormant -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">
|
||||
Days to Dormant
|
||||
<i
|
||||
class="fas fa-info-circle dap__help"
|
||||
title="Dormant classification per RBI guidelines after prolonged inactivity"
|
||||
></i>
|
||||
</span>
|
||||
@if (statusId >= 200) {
|
||||
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
||||
<span class="dap__card-hint">Account is dormant</span>
|
||||
} @else if (daysToDormancy !== null) {
|
||||
<span class="dap__number" [class.dap__number--warn]="daysToDormancy <= 30">
|
||||
{{ daysToDormancy }}<small>days</small>
|
||||
</span>
|
||||
<span class="dap__card-hint">{{ dormancySubtext() }}</span>
|
||||
} @else {
|
||||
<span class="dap__na">—</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Days to Escheat -->
|
||||
<div class="dap__card">
|
||||
<span class="dap__card-label">
|
||||
Days to Escheat
|
||||
<i
|
||||
class="fas fa-info-circle dap__help"
|
||||
title="Unclaimed funds transferred to government if account remains dormant beyond this period"
|
||||
></i>
|
||||
</span>
|
||||
@if (statusId >= 300) {
|
||||
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
||||
<span class="dap__card-hint">Pending regulatory transfer</span>
|
||||
} @else if (daysToEscheat !== null) {
|
||||
<span class="dap__number" [class.dap__number--warn]="daysToEscheat <= 30">
|
||||
{{ daysToEscheat }}<small>days</small>
|
||||
</span>
|
||||
<span class="dap__card-hint">{{ escheatSubtext() }}</span>
|
||||
} @else {
|
||||
<span class="dap__na">—</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ══ DORMANCY PROGRESSION TIMELINE ══════════════════════════ -->
|
||||
<div class="dap__timeline">
|
||||
<div class="dap__tl-header">
|
||||
<span class="dap__tl-label">Dormancy Progression</span>
|
||||
@if (!isProductMode) {
|
||||
<span class="dap__tl-note">Timeline based on last customer transaction</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dap__tl-track">
|
||||
<!-- Active node (always reached) -->
|
||||
<div class="dap__tl-node" [class.dap__tl-node--current]="statusId === 0" [class.dap__tl-node--reached]="true">
|
||||
<div class="dap__tl-circle dap__tl-circle--done">
|
||||
<i class="fas fa-check"></i>
|
||||
</div>
|
||||
<span class="dap__tl-name">Active</span>
|
||||
<span class="dap__tl-sub">Normal</span>
|
||||
</div>
|
||||
|
||||
@for (step of timelineSteps; track step.label) {
|
||||
<div class="dap__tl-line" [class.dap__tl-line--done]="step.reached"></div>
|
||||
<div
|
||||
class="dap__tl-node"
|
||||
[class.dap__tl-node--current]="step.current"
|
||||
[class.dap__tl-node--reached]="step.reached"
|
||||
>
|
||||
<div
|
||||
class="dap__tl-circle"
|
||||
[class.dap__tl-circle--done]="step.reached && !step.current"
|
||||
[class.dap__tl-circle--current]="step.current"
|
||||
[class.dap__tl-circle--pending]="!step.reached"
|
||||
>
|
||||
@if (step.reached && !step.current) {
|
||||
<i class="fas fa-check"></i>
|
||||
} @else if (step.current) {
|
||||
<i class="fas fa-circle"></i>
|
||||
} @else {
|
||||
<i class="far fa-circle"></i>
|
||||
}
|
||||
</div>
|
||||
<span class="dap__tl-name" [title]="step.tooltip">
|
||||
{{ step.label }}
|
||||
<i class="fas fa-info-circle dap__tl-info"></i>
|
||||
</span>
|
||||
@if (!step.reached && step.days !== null) {
|
||||
<span class="dap__tl-days">{{ step.days }}d</span>
|
||||
} @else if (step.reached) {
|
||||
<span class="dap__tl-reached-tag">Reached</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
@use 'sass:color';
|
||||
|
||||
// ── Design Tokens ──────────────────────────────────────────────────────────
|
||||
// Risk colours
|
||||
$c-safe: #16a34a;
|
||||
$bg-safe: #f0fdf4;
|
||||
$bd-safe: #bbf7d0;
|
||||
$c-low: #0284c7;
|
||||
$bg-low: #f0f9ff;
|
||||
$bd-low: #bae6fd;
|
||||
$c-medium: #d97706;
|
||||
$bg-medium: #fffbeb;
|
||||
$bd-medium: #fde68a;
|
||||
$c-high: #dc2626;
|
||||
$bg-high: #fff5f5;
|
||||
$bd-high: #fecaca;
|
||||
$c-critical: #b91c1c;
|
||||
$bg-critical: #fef2f2;
|
||||
$bd-critical: #fca5a5;
|
||||
$c-escheat: #7c3aed;
|
||||
$bg-escheat: #f5f3ff;
|
||||
$bd-escheat: #ddd6fe;
|
||||
|
||||
// Neutral
|
||||
$text: #1e293b;
|
||||
$muted: #64748b;
|
||||
$subtle: #94a3b8;
|
||||
$border: #e2e8f0;
|
||||
$surface: #f8fafc;
|
||||
|
||||
// ── Host ───────────────────────────────────────────────────────────────────
|
||||
:host {
|
||||
display: block;
|
||||
margin: 14px 0 18px;
|
||||
}
|
||||
|
||||
// ── Root wrapper ───────────────────────────────────────────────────────────
|
||||
.dap {
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid $border;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
// Risk-tinted border
|
||||
&--safe {
|
||||
border-color: $bd-safe;
|
||||
}
|
||||
|
||||
&--low {
|
||||
border-color: $bd-low;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
border-color: $bd-medium;
|
||||
}
|
||||
|
||||
&--high {
|
||||
border-color: $bd-high;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-color: $bd-critical;
|
||||
}
|
||||
|
||||
&--escheat {
|
||||
border-color: $bd-escheat;
|
||||
}
|
||||
|
||||
// ── HEADER ───────────────────────────────────────────────────────
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
background: $surface;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
&__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid $border;
|
||||
|
||||
i {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dap--safe & {
|
||||
background: $bg-safe;
|
||||
border-color: $bd-safe;
|
||||
|
||||
i {
|
||||
color: $c-safe;
|
||||
}
|
||||
}
|
||||
|
||||
.dap--low & {
|
||||
background: $bg-low;
|
||||
border-color: $bd-low;
|
||||
|
||||
i {
|
||||
color: $c-low;
|
||||
}
|
||||
}
|
||||
|
||||
.dap--medium & {
|
||||
background: $bg-medium;
|
||||
border-color: $bd-medium;
|
||||
|
||||
i {
|
||||
color: $c-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.dap--high & {
|
||||
background: $bg-high;
|
||||
border-color: $bd-high;
|
||||
|
||||
i {
|
||||
color: $c-high;
|
||||
}
|
||||
}
|
||||
|
||||
.dap--critical & {
|
||||
background: $bg-critical;
|
||||
border-color: $bd-critical;
|
||||
|
||||
i {
|
||||
color: $c-critical;
|
||||
}
|
||||
}
|
||||
|
||||
.dap--escheat & {
|
||||
background: $bg-escheat;
|
||||
border-color: $bd-escheat;
|
||||
|
||||
i {
|
||||
color: $c-escheat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: $text;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: 11.5px;
|
||||
color: $muted;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// ── Badge group ──────────────────────────────────────────────────
|
||||
&__badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__risk-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 13px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: default;
|
||||
|
||||
.dap--safe & {
|
||||
background: $bg-safe;
|
||||
color: $c-safe;
|
||||
border: 1.5px solid $bd-safe;
|
||||
}
|
||||
|
||||
.dap--low & {
|
||||
background: $bg-low;
|
||||
color: $c-low;
|
||||
border: 1.5px solid $bd-low;
|
||||
}
|
||||
|
||||
.dap--medium & {
|
||||
background: $bg-medium;
|
||||
color: $c-medium;
|
||||
border: 1.5px solid $bd-medium;
|
||||
}
|
||||
|
||||
.dap--high & {
|
||||
background: $bg-high;
|
||||
color: $c-high;
|
||||
border: 1.5px solid $bd-high;
|
||||
}
|
||||
|
||||
.dap--critical & {
|
||||
background: $bg-critical;
|
||||
color: $c-critical;
|
||||
border: 1.5px solid $bd-critical;
|
||||
}
|
||||
|
||||
.dap--escheat & {
|
||||
background: $bg-escheat;
|
||||
color: $c-escheat;
|
||||
border: 1.5px solid $bd-escheat;
|
||||
}
|
||||
}
|
||||
|
||||
&__risk-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
|
||||
.dap--safe & {
|
||||
background: $c-safe;
|
||||
}
|
||||
|
||||
.dap--low & {
|
||||
background: $c-low;
|
||||
}
|
||||
|
||||
.dap--medium & {
|
||||
background: $c-medium;
|
||||
}
|
||||
|
||||
.dap--high & {
|
||||
background: $c-high;
|
||||
}
|
||||
|
||||
.dap--critical & {
|
||||
background: $c-critical;
|
||||
}
|
||||
|
||||
.dap--escheat & {
|
||||
background: $c-escheat;
|
||||
}
|
||||
}
|
||||
|
||||
&__monitor-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: $bg-low;
|
||||
color: $c-low;
|
||||
border: 1.5px solid $bd-low;
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Metric Cards ─────────────────────────────────────────────────
|
||||
&__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
border-bottom: 1px solid $border;
|
||||
|
||||
&--product {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
|
||||
.dap__card:nth-child(3n) {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
|
||||
.dap__card:nth-child(2n) {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
padding: 14px 16px;
|
||||
border-right: 1px solid $border;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $surface;
|
||||
}
|
||||
}
|
||||
|
||||
&__card-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: $muted;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__card-hint {
|
||||
font-size: 10.5px;
|
||||
color: $subtle;
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__help {
|
||||
font-size: 9px;
|
||||
color: $subtle;
|
||||
cursor: help;
|
||||
|
||||
&:hover {
|
||||
color: $c-low;
|
||||
}
|
||||
}
|
||||
|
||||
// Status pill
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
|
||||
i {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: $bg-safe;
|
||||
color: $c-safe;
|
||||
border: 1px solid $bd-safe;
|
||||
}
|
||||
|
||||
&--gray {
|
||||
background: #f1f5f9;
|
||||
color: $muted;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
&--safe {
|
||||
background: $bg-safe;
|
||||
color: $c-safe;
|
||||
border: 1px solid $bd-safe;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: $bg-low;
|
||||
color: $c-low;
|
||||
border: 1px solid $bd-low;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: $bg-medium;
|
||||
color: $c-medium;
|
||||
border: 1px solid $bd-medium;
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: $bg-high;
|
||||
color: $c-high;
|
||||
border: 1px solid $bd-high;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: $bg-critical;
|
||||
color: $c-critical;
|
||||
border: 1px solid $bd-critical;
|
||||
}
|
||||
|
||||
&--escheat {
|
||||
background: $bg-escheat;
|
||||
color: $c-escheat;
|
||||
border: 1px solid $bd-escheat;
|
||||
}
|
||||
}
|
||||
|
||||
// Number display
|
||||
&__number {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: $text;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
color: $c-high;
|
||||
|
||||
small {
|
||||
color: color.adjust($c-high, $lightness: 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
i {
|
||||
color: $muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--muted {
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
&__reached {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $c-high;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
i {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&__na {
|
||||
font-size: 12px;
|
||||
color: $subtle;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ── Timeline ─────────────────────────────────────────────────────
|
||||
&__timeline {
|
||||
padding: 16px 18px 18px;
|
||||
background: $surface;
|
||||
border-top: 1px solid $border;
|
||||
}
|
||||
|
||||
&__tl-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__tl-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
&__tl-note {
|
||||
font-size: 10px;
|
||||
color: $subtle;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&__tl-track {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&__tl-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 68px;
|
||||
}
|
||||
|
||||
&__tl-circle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid $border;
|
||||
background: #fff;
|
||||
transition: all 0.2s;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&--done {
|
||||
background: $c-safe;
|
||||
border-color: $c-safe;
|
||||
box-shadow: 0 0 0 3px rgba($c-safe, 0.15);
|
||||
|
||||
i {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--current {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px rgb(0 0 0 / 8%);
|
||||
|
||||
.dap--safe &,
|
||||
.dap--low & {
|
||||
border-color: $c-safe;
|
||||
box-shadow: 0 0 0 3px rgba($c-safe, 0.15);
|
||||
|
||||
i {
|
||||
color: $c-safe;
|
||||
}
|
||||
}
|
||||
|
||||
.dap--medium & {
|
||||
border-color: $c-medium;
|
||||
box-shadow: 0 0 0 3px rgba($c-medium, 0.15);
|
||||
|
||||
i {
|
||||
color: $c-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.dap--high &,
|
||||
.dap--critical & {
|
||||
border-color: $c-high;
|
||||
box-shadow: 0 0 0 3px rgba($c-high, 0.15);
|
||||
|
||||
i {
|
||||
color: $c-high;
|
||||
}
|
||||
}
|
||||
|
||||
.dap--escheat & {
|
||||
border-color: $c-escheat;
|
||||
box-shadow: 0 0 0 3px rgba($c-escheat, 0.15);
|
||||
|
||||
i {
|
||||
color: $c-escheat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: $surface;
|
||||
|
||||
i {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__tl-name {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: $muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
&__tl-info {
|
||||
font-size: 8px;
|
||||
color: $subtle;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
&__tl-sub {
|
||||
font-size: 9px;
|
||||
color: $subtle;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__tl-days {
|
||||
font-size: 10px;
|
||||
color: $muted;
|
||||
background: #e9eef5;
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__tl-reached-tag {
|
||||
font-size: 9px;
|
||||
color: $c-high;
|
||||
background: $bg-high;
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
font-weight: 700;
|
||||
border: 1px solid $bd-high;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__tl-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: $border;
|
||||
margin: 14px 2px 0;
|
||||
min-width: 20px;
|
||||
border-radius: 2px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&--done {
|
||||
background: $c-safe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pulse animation for risk dot ───────────────────────────────────────────
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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, Input, OnInit } from '@angular/core';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export type DormancyRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'escheat';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-dormancy-alert-panel',
|
||||
templateUrl: './dormancy-alert-panel.component.html',
|
||||
styleUrls: ['./dormancy-alert-panel.component.scss'],
|
||||
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||
})
|
||||
export class DormancyAlertPanelComponent implements OnInit {
|
||||
/** Pass full account data — for ACCOUNT VIEW (general tab) */
|
||||
@Input() accountData: any;
|
||||
/** Pass product template — for ACCOUNT CREATION (currency step) */
|
||||
@Input() productTemplate: any;
|
||||
|
||||
risk: DormancyRisk = 'safe';
|
||||
riskLabel = '';
|
||||
riskSeverity = ''; // e.g. "Low", "Medium", "High"
|
||||
|
||||
statusLabel = '';
|
||||
statusId = 0;
|
||||
|
||||
lastTxnDate: string | null = null;
|
||||
accountOpenedDate: string | null = null;
|
||||
|
||||
daysToInactive: number | null = null;
|
||||
daysToDormancy: number | null = null;
|
||||
daysToEscheat: number | null = null;
|
||||
|
||||
isMonitoringActive = false; // isDormancyTrackingActive
|
||||
isAlertEnabled = false; // dormancyAlert
|
||||
|
||||
shouldShow = false;
|
||||
|
||||
/** true = product template mode (creation stepper), false = account view mode */
|
||||
get isProductMode(): boolean {
|
||||
return !!this.productTemplate && !this.accountData;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.productTemplate && !this.accountData) {
|
||||
this.initFromProductTemplate();
|
||||
} else if (this.accountData) {
|
||||
this.initFromAccountData();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Product Template Mode (Currency Step) ─────────────────────
|
||||
private initFromProductTemplate(): void {
|
||||
const pt = this.productTemplate;
|
||||
|
||||
// Savings: fields come with "FromProduct" suffix
|
||||
// RD/Current: fields come directly (isDormancyTrackingActive, daysToInactive, etc.)
|
||||
this.isMonitoringActive = !!(pt.isDormancyTrackingActiveFromProduct ?? pt.isDormancyTrackingActive ?? false);
|
||||
this.isAlertEnabled = !!pt.dormancyAlert;
|
||||
|
||||
this.daysToInactive = pt.daysToInactiveFromProduct ?? pt.daysToInactive ?? null;
|
||||
this.daysToDormancy = pt.daysToDormancyFromProduct ?? pt.daysToDormancy ?? null;
|
||||
this.daysToEscheat = pt.daysToEscheatFromProduct ?? pt.daysToEscheat ?? null;
|
||||
|
||||
this.statusId = 0;
|
||||
this.statusLabel = 'New Account';
|
||||
|
||||
this.computeRiskFromProduct();
|
||||
this.shouldShow = this.isMonitoringActive || this.isAlertEnabled;
|
||||
}
|
||||
|
||||
private computeRiskFromProduct(): void {
|
||||
// Product mode — always show safe/neutral, no risk colouring
|
||||
this.risk = 'safe';
|
||||
this.riskLabel = 'Configured';
|
||||
this.riskSeverity = this.isMonitoringActive ? 'Active' : 'Inactive';
|
||||
}
|
||||
|
||||
// ── Account Data Mode (General Tab) ───────────────────────────
|
||||
private initFromAccountData(): void {
|
||||
const a = this.accountData;
|
||||
|
||||
this.statusId = a.subStatus?.id ?? 0;
|
||||
this.statusLabel = a.subStatus?.value ?? 'Active';
|
||||
this.isAlertEnabled = !!a.dormancyAlert;
|
||||
this.isMonitoringActive = !!a.isDormancyTrackingActive;
|
||||
|
||||
this.daysToInactive = a.daysToInactive ?? null;
|
||||
this.daysToDormancy = a.daysToDormancy ?? null;
|
||||
this.daysToEscheat = a.daysToEscheat ?? null;
|
||||
|
||||
// Last active transaction date
|
||||
const raw = a.lastActiveTransactionDate;
|
||||
if (raw) {
|
||||
const d = Array.isArray(raw) && raw.length >= 3 ? new Date(raw[0], raw[1] - 1, raw[2]) : new Date(raw);
|
||||
this.lastTxnDate = d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// Account opened / activated date
|
||||
const tl = a.timeline;
|
||||
if (tl?.activatedOnDate) {
|
||||
const ad =
|
||||
Array.isArray(tl.activatedOnDate) && tl.activatedOnDate.length >= 3
|
||||
? new Date(tl.activatedOnDate[0], tl.activatedOnDate[1] - 1, tl.activatedOnDate[2])
|
||||
: new Date(tl.activatedOnDate);
|
||||
this.accountOpenedDate = ad.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
this.computeRiskFromAccount();
|
||||
this.shouldShow =
|
||||
this.isAlertEnabled ||
|
||||
this.isMonitoringActive ||
|
||||
this.statusId > 0 ||
|
||||
(this.daysToInactive !== null && this.daysToInactive <= 90) ||
|
||||
!!this.lastTxnDate;
|
||||
}
|
||||
|
||||
private computeRiskFromAccount(): void {
|
||||
if (this.statusId === 300) {
|
||||
this.risk = 'escheat';
|
||||
this.riskLabel = 'Escheat';
|
||||
this.riskSeverity = 'Critical';
|
||||
} else if (this.statusId === 200) {
|
||||
this.risk = 'critical';
|
||||
this.riskLabel = 'Dormant';
|
||||
this.riskSeverity = 'Critical';
|
||||
} else if (this.statusId === 100) {
|
||||
this.risk = 'high';
|
||||
this.riskLabel = 'Inactive';
|
||||
this.riskSeverity = 'High';
|
||||
} else if (this.daysToInactive !== null && this.daysToInactive <= 30) {
|
||||
this.risk = 'high';
|
||||
this.riskLabel = 'High';
|
||||
this.riskSeverity = 'High';
|
||||
} else if (this.daysToInactive !== null && this.daysToInactive <= 60) {
|
||||
this.risk = 'medium';
|
||||
this.riskLabel = 'Medium';
|
||||
this.riskSeverity = 'Medium';
|
||||
} else if (this.daysToInactive !== null && this.daysToInactive <= 90) {
|
||||
this.risk = 'low';
|
||||
this.riskLabel = 'Low';
|
||||
this.riskSeverity = 'Low';
|
||||
} else {
|
||||
this.risk = 'safe';
|
||||
this.riskLabel = 'Active';
|
||||
this.riskSeverity = 'Safe';
|
||||
}
|
||||
}
|
||||
|
||||
get timelineSteps() {
|
||||
return [
|
||||
{
|
||||
label: 'Inactive',
|
||||
tooltip: 'Account flagged as inactive after no customer-initiated transaction',
|
||||
days: this.daysToInactive,
|
||||
reached: this.statusId >= 100,
|
||||
current: this.statusId === 100
|
||||
},
|
||||
{
|
||||
label: 'Dormant',
|
||||
tooltip: 'Account classified as dormant per RBI/regulatory guidelines',
|
||||
days: this.daysToDormancy,
|
||||
reached: this.statusId >= 200,
|
||||
current: this.statusId === 200
|
||||
},
|
||||
{
|
||||
label: 'Escheat',
|
||||
tooltip: 'Unclaimed funds transferred to government/regulator as per law',
|
||||
days: this.daysToEscheat,
|
||||
reached: this.statusId >= 300,
|
||||
current: this.statusId === 300
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/** e.g. "Inactive after 30 days of no transaction" */
|
||||
inactiveSubtext(): string {
|
||||
return this.daysToInactive !== null ? `No transaction for ${this.daysToInactive} days` : 'Threshold not configured';
|
||||
}
|
||||
|
||||
dormancySubtext(): string {
|
||||
const total = (this.daysToInactive ?? 0) + (this.daysToDormancy ?? 0);
|
||||
return this.daysToDormancy !== null ? `${this.daysToDormancy} days after inactive` : 'Threshold not configured';
|
||||
}
|
||||
|
||||
escheatSubtext(): string {
|
||||
return this.daysToEscheat !== null ? `${this.daysToEscheat} days after dormant` : 'Threshold not configured';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<!--
|
||||
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="intro-section">
|
||||
<div class="intro-section__header">
|
||||
<i class="fas fa-user-check"></i>
|
||||
<span>Introducer</span>
|
||||
<span class="intro-badge intro-badge--required">Required</span>
|
||||
</div>
|
||||
|
||||
<div class="intro-body">
|
||||
@if (!selectedClient) {
|
||||
<!-- Search Box -->
|
||||
<div class="intro-search-wrap">
|
||||
<mat-form-field appearance="outline" class="intro-search-field">
|
||||
<mat-label>Search by Name or Client ID</mat-label>
|
||||
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" autocomplete="off" />
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
@if (searching) {
|
||||
<div class="intro-results">
|
||||
<div class="intro-results__loading">
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!searching && searchResults.length > 0) {
|
||||
<div class="intro-results-inline">
|
||||
@for (r of searchResults; track r.id) {
|
||||
<div class="intro-result-item" (click)="selectClient(r)">
|
||||
<mat-icon class="intro-result-item__icon">person</mat-icon>
|
||||
<div class="intro-result-item__info">
|
||||
<span class="intro-result-item__name">{{ r.displayName }}</span>
|
||||
<span class="intro-result-item__meta">
|
||||
ID: {{ r.id }}
|
||||
@if (r.officeName) {
|
||||
· {{ r.officeName }}
|
||||
}
|
||||
@if (r.mobileNo) {
|
||||
· {{ r.mobileNo }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!searching && searchText.length >= 2 && searchResults.length === 0) {
|
||||
<div class="intro-no-results">No clients found</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedClient) {
|
||||
<!-- Selected Client Card -->
|
||||
<div class="intro-card">
|
||||
<div class="intro-card__avatar">
|
||||
@if (linkedProfileImage) {
|
||||
<img [src]="linkedProfileImage" alt="Profile" class="intro-card__photo" />
|
||||
} @else {
|
||||
<mat-icon class="intro-card__avatar-icon">person</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="intro-card__info">
|
||||
<span class="intro-card__name">{{ selectedClient.displayName }}</span>
|
||||
<span class="intro-card__meta">
|
||||
Client ID: {{ selectedClient.id }}
|
||||
@if (selectedClient.accountNumber) {
|
||||
· A/C: {{ selectedClient.accountNumber }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<button mat-icon-button type="button" (click)="removeClient()" class="intro-card__remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (fetchingLinkedData) {
|
||||
<div class="intro-fetching">
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Fetching client details...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!fetchingLinkedData) {
|
||||
<div class="intro-details-grid">
|
||||
<div class="intro-detail">
|
||||
<span class="intro-detail__label">FULL NAME</span>
|
||||
<span class="intro-detail__value">{{ selectedClient.displayName }}</span>
|
||||
</div>
|
||||
<div class="intro-detail">
|
||||
<span class="intro-detail__label">DATE OF BIRTH</span>
|
||||
<span class="intro-detail__value">{{ clientDob || '—' }}</span>
|
||||
</div>
|
||||
@if (clientAadhaar) {
|
||||
<div class="intro-detail">
|
||||
<span class="intro-detail__label">AADHAAR</span>
|
||||
<span class="intro-detail__value">
|
||||
{{ clientAadhaar }}
|
||||
@if (linkedKycStatus?.aadhaarVerified) {
|
||||
<span class="intro-verified"><mat-icon>verified</mat-icon> Verified</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (clientPan) {
|
||||
<div class="intro-detail">
|
||||
<span class="intro-detail__label">PAN</span>
|
||||
<span class="intro-detail__value">
|
||||
{{ clientPan }}
|
||||
@if (linkedKycStatus?.panVerified) {
|
||||
<span class="intro-verified"><mat-icon>verified</mat-icon> Verified</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$blue: #2563eb;
|
||||
$blue-light: #eff6ff;
|
||||
$green: #16a34a;
|
||||
$green-light: #dcfce7;
|
||||
$border: #e2e8f0;
|
||||
$text: #1e293b;
|
||||
$muted: #64748b;
|
||||
$gray-bg: #f8fafc;
|
||||
|
||||
:host {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.intro-section {
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
overflow: visible;
|
||||
background: #fff;
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
background: $gray-bg;
|
||||
border-bottom: 1px solid $border;
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
i {
|
||||
color: $blue;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.intro-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&--required {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-body {
|
||||
padding: 16px 20px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
// Search
|
||||
.intro-search-wrap {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.intro-search-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.intro-results-inline {
|
||||
margin-top: 4px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
|
||||
overflow: hidden;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.intro-results {
|
||||
position: absolute !important;
|
||||
z-index: 99999 !important;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 10%);
|
||||
max-height: 260px;
|
||||
overflow: hidden auto;
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid $border;
|
||||
min-width: 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $blue-light;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: $muted;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 11px;
|
||||
color: $muted;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-no-results {
|
||||
padding: 10px 0;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Selected client card
|
||||
.intro-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
background: $gray-bg;
|
||||
margin-bottom: 14px;
|
||||
min-width: 0;
|
||||
|
||||
&__avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: $blue-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__avatar-icon {
|
||||
color: $blue;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
flex: 0 0 auto;
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-fetching {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
// Details grid
|
||||
.intro-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;
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
|
||||
&__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;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-verified {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $green;
|
||||
background: $green-light;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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, Output, EventEmitter, inject } from '@angular/core';
|
||||
import { DomSanitizer } 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 { FormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-introducer-search',
|
||||
templateUrl: './introducer-search.component.html',
|
||||
styleUrls: ['./introducer-search.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatProgressSpinner,
|
||||
MatIconButton,
|
||||
MatIcon
|
||||
]
|
||||
})
|
||||
export class IntroducerSearchComponent {
|
||||
private clientsService = inject(ClientsService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
@Output() introducerChange = new EventEmitter<{ clientId: number; displayName: string } | null>();
|
||||
|
||||
entryMode: 'linked' = 'linked';
|
||||
searchText = '';
|
||||
searching = false;
|
||||
searchResults: any[] = [];
|
||||
selectedClient: any = null;
|
||||
fetchingLinkedData = false;
|
||||
linkedProfileImage: any = null;
|
||||
linkedKycStatus: any = null;
|
||||
|
||||
// Fetched client details
|
||||
clientDob = '';
|
||||
clientAadhaar = '';
|
||||
clientPan = '';
|
||||
|
||||
private searchTimer: any;
|
||||
|
||||
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;
|
||||
},
|
||||
error: () => {
|
||||
this.searchResults = [];
|
||||
this.searching = false;
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
selectClient(client: any): void {
|
||||
this.selectedClient = client;
|
||||
this.searchResults = [];
|
||||
this.searchText = '';
|
||||
this.clientDob = '';
|
||||
this.clientAadhaar = '';
|
||||
this.clientPan = '';
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
this.fetchLinkedClientData(client.id);
|
||||
this.introducerChange.emit({ clientId: client.id, displayName: client.displayName });
|
||||
}
|
||||
|
||||
removeClient(): void {
|
||||
this.selectedClient = null;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
this.clientDob = '';
|
||||
this.clientAadhaar = '';
|
||||
this.clientPan = '';
|
||||
this.introducerChange.emit(null);
|
||||
}
|
||||
|
||||
private fetchLinkedClientData(clientId: string): void {
|
||||
this.fetchingLinkedData = true;
|
||||
|
||||
this.clientsService.getClientData(clientId).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' });
|
||||
}
|
||||
this.fetchingLinkedData = false;
|
||||
},
|
||||
error: () => {
|
||||
this.fetchingLinkedData = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.clientsService.getClientKyc(clientId).subscribe({
|
||||
next: (kyc: any) => {
|
||||
if (kyc) {
|
||||
this.clientAadhaar = kyc.aadhaarNumberMasked || '';
|
||||
this.clientPan = kyc.panNumber || '';
|
||||
this.linkedKycStatus = {
|
||||
panVerified: kyc.panVerified || false,
|
||||
aadhaarVerified: kyc.aadhaarVerified || false
|
||||
};
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
this.clientsService.getClientProfileImage(clientId).subscribe({
|
||||
next: (imageData: any) => {
|
||||
if (imageData) {
|
||||
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(imageData);
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<!--
|
||||
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-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>
|
||||
</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>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!searching && searchText.length >= 2 && searchResults.length === 0) {
|
||||
<div class="nom-no-results">No clients found</div>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
@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>
|
||||
</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>
|
||||
|
||||
<mat-form-field appearance="outline" class="nom-field">
|
||||
<mat-label>Nominee Share (%)</mat-label>
|
||||
<input matInput type="number" [value]="100" readonly />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
@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;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// ─── Wrapper ───────────────────────────────────────────────────
|
||||
.nom-section {
|
||||
border: 1px solid $border;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
margin-top: 20px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
// ─── Header ────────────────────────────────────────────────────
|
||||
.nom-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
background: $gray-bg;
|
||||
border-bottom: 1px solid $border;
|
||||
border-radius: 14px 14px 0 0;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
i {
|
||||
color: $purple;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
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 ──────────────────────────────────────────────────────
|
||||
.nom-body {
|
||||
padding: 20px;
|
||||
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 {
|
||||
width: 100%;
|
||||
|
||||
&--full {
|
||||
width: 100%;
|
||||
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;
|
||||
color: $muted;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.nom-flags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
// ─── Search (link mode) ────────────────────────────────────────
|
||||
.nom-search-wrap {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.nom-results {
|
||||
margin-top: 4px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgb(0 0 0 / 10%);
|
||||
overflow: hidden;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.nom-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid $border;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $blue-light;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: $muted;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 11px;
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.nom-no-results {
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// ─── Selected client card ──────────────────────────────────────
|
||||
.nom-client-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
background: $gray-bg;
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__avatar {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
background: $blue-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__avatar-icon {
|
||||
color: $blue;
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
flex: 0 0 auto;
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.nom-fetching {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.nom-verified {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $green;
|
||||
background: $green-light;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extras for linked (relationship + share) ──────────────────
|
||||
.nom-linked-extras {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 4px;
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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, Output, EventEmitter, inject, OnInit } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { DomSanitizer } 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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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',
|
||||
styleUrls: ['./nominee-form.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatProgressSpinner,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatCheckbox,
|
||||
MatDatepickerModule
|
||||
]
|
||||
})
|
||||
export class NomineeFormComponent implements OnInit {
|
||||
private fb = inject(UntypedFormBuilder);
|
||||
private clientsService = inject(ClientsService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
@Output() nomineeChange = new EventEmitter<NomineeData | null>();
|
||||
|
||||
mode: 'manual' | 'linked' = 'manual';
|
||||
relationships = RELATIONSHIPS;
|
||||
|
||||
// Manual form
|
||||
manualForm!: UntypedFormGroup;
|
||||
minDate = new Date(1900, 0, 1);
|
||||
maxDate = new Date();
|
||||
|
||||
// Link mode
|
||||
searchText = '';
|
||||
searching = false;
|
||||
searchResults: any[] = [];
|
||||
selectedClient: any = null;
|
||||
fetchingLinkedData = false;
|
||||
linkedProfileImage: any = null;
|
||||
linkedKycStatus: any = null;
|
||||
clientDob = '';
|
||||
clientAadhaar = '';
|
||||
clientPan = '';
|
||||
linkedRelationship = '';
|
||||
linkedShare = 100;
|
||||
|
||||
private searchTimer: any;
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
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;
|
||||
},
|
||||
error: () => {
|
||||
this.searchResults = [];
|
||||
this.searching = false;
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
selectClient(client: any): void {
|
||||
this.selectedClient = client;
|
||||
this.searchResults = [];
|
||||
this.searchText = '';
|
||||
this.fetchLinkedClientData(client.id);
|
||||
this.nomineeChange.emit({ mode: 'linked', clientId: client.id, displayName: client.displayName });
|
||||
}
|
||||
|
||||
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,
|
||||
relationship: rel,
|
||||
nomineeShare: this.linkedShare
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearLinked(): void {
|
||||
this.selectedClient = null;
|
||||
this.linkedProfileImage = null;
|
||||
this.linkedKycStatus = null;
|
||||
this.clientDob = '';
|
||||
this.clientAadhaar = '';
|
||||
this.clientPan = '';
|
||||
this.searchText = '';
|
||||
this.searchResults = [];
|
||||
this.linkedRelationship = '';
|
||||
this.linkedShare = 100;
|
||||
}
|
||||
|
||||
private fetchLinkedClientData(clientId: string): void {
|
||||
this.fetchingLinkedData = true;
|
||||
|
||||
this.clientsService.getClientData(clientId).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' });
|
||||
}
|
||||
this.fetchingLinkedData = false;
|
||||
},
|
||||
error: () => {
|
||||
this.fetchingLinkedData = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.clientsService.getClientKyc(clientId).subscribe({
|
||||
next: (kyc: any) => {
|
||||
if (kyc) {
|
||||
this.clientAadhaar = kyc.aadhaarNumberMasked || '';
|
||||
this.clientPan = kyc.panNumber || '';
|
||||
this.linkedKycStatus = {
|
||||
panVerified: kyc.panVerified || false,
|
||||
aadhaarVerified: kyc.aadhaarVerified || false
|
||||
};
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
this.clientsService.getClientProfileImage(clientId).subscribe({
|
||||
next: (imageData: any) => {
|
||||
if (imageData) {
|
||||
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(imageData);
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user