feat: KYC re-verify UX, family/nominee tab gating, savings dialogs, cfcfrms shell

- KYC tab: fix Verify (PAN) and Send OTP (Aadhaar) being disabled in edit mode;
  unmask Aadhaar input and show on-file masked value as hint; resolve kycId
  from id/kycId/resourceId fallbacks.
- Client stepper/view: add familyAllowed / nomineeAllowed predicates +
  CanActivate route guards. Family tab hidden for non-individuals, Nominee
  tab hidden for non-proprietor entities. Family-members step accepts a
  nomineeOnly input for proprietorship clients. Add/Edit/Delete buttons
  inside family-members and nominee tabs gated by canAdd.
- cfcfrms: add cfcfrms-shell wrapper, restyle complaint/fraud/mule/escalate
  pages and dashboard.
- Savings: identity-verify and transaction-confirm dialogs, manage / activate
  / add-charge flows, account preview styling.
- Tasks, core shell, account-nominees, icons: minor style and behaviour
  updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-21 18:49:43 +05:30
co-authored by Claude Opus 4.7
parent bf3e26a0d0
commit b149fef72e
99 changed files with 8575 additions and 2574 deletions
@@ -7,133 +7,137 @@
-->
<div class="cfcfrms-dashboard">
<div class="page-header">
<h2>{{ 'labels.menus.CFCFRMS Dashboard' | translate }}</h2>
<p class="subtitle">{{ 'labels.text.Cyber Fraud Complaint Management Overview' | translate }}</p>
<!-- KPI strip — gradient icon block + value/label -->
<div class="kpi-strip">
<div class="kpi-card kpi-card--blue">
<div class="kpi-card__ico">
<fa-icon icon="file-alt"></fa-icon>
</div>
<div class="kpi-card__body">
<div class="kpi-card__value">{{ stats?.totalComplaints || 0 }}</div>
<div class="kpi-card__label">{{ 'labels.text.Total Complaints' | translate }}</div>
</div>
</div>
<div class="kpi-card kpi-card--amber">
<div class="kpi-card__ico">
<fa-icon icon="clock"></fa-icon>
</div>
<div class="kpi-card__body">
<div class="kpi-card__value">{{ stats?.pendingReview || 0 }}</div>
<div class="kpi-card__label">{{ 'labels.text.Pending Review' | translate }}</div>
</div>
</div>
<div class="kpi-card kpi-card--purple">
<div class="kpi-card__ico">
<fa-icon icon="lock"></fa-icon>
</div>
<div class="kpi-card__body">
<div class="kpi-card__value">{{ stats?.lienMarked || 0 }}</div>
<div class="kpi-card__label">{{ 'labels.text.Lien Marked' | translate }}</div>
</div>
</div>
<div class="kpi-card kpi-card--green">
<div class="kpi-card__ico">
<fa-icon icon="check-circle"></fa-icon>
</div>
<div class="kpi-card__body">
<div class="kpi-card__value">{{ formatCurrency(stats?.fundsRecovered || 0) }}</div>
<div class="kpi-card__label">{{ 'labels.text.Funds Recovered' | translate }}</div>
</div>
</div>
</div>
<!-- Stat Cards -->
<div class="stat-cards">
<mat-card class="stat-card stat-total">
<mat-card-content>
<div class="stat-icon"><fa-icon icon="file-alt" size="2x"></fa-icon></div>
<div class="stat-info">
<span class="stat-value">{{ stats?.totalComplaints || 0 }}</span>
<span class="stat-label">{{ 'labels.text.Total Complaints' | translate }}</span>
</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card stat-pending">
<mat-card-content>
<div class="stat-icon"><fa-icon icon="clock" size="2x"></fa-icon></div>
<div class="stat-info">
<span class="stat-value">{{ stats?.pendingReview || 0 }}</span>
<span class="stat-label">{{ 'labels.text.Pending Review' | translate }}</span>
</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card stat-lien">
<mat-card-content>
<div class="stat-icon"><fa-icon icon="lock" size="2x"></fa-icon></div>
<div class="stat-info">
<span class="stat-value">{{ stats?.lienMarked || 0 }}</span>
<span class="stat-label">{{ 'labels.text.Lien Marked' | translate }}</span>
</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card stat-recovered">
<mat-card-content>
<div class="stat-icon"><fa-icon icon="check-circle" size="2x"></fa-icon></div>
<div class="stat-info">
<span class="stat-value">{{ formatCurrency(stats?.fundsRecovered || 0) }}</span>
<span class="stat-label">{{ 'labels.text.Funds Recovered' | translate }}</span>
</div>
</mat-card-content>
</mat-card>
</div>
<!-- Charts Row -->
<!-- Charts row — two side-by-side analytics cards -->
<div class="charts-row">
<mat-card class="chart-card">
<mat-card-header>
<mat-card-title>{{ 'labels.text.Status Distribution' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="chart-pie" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.text.Status Distribution' | translate }}</h3>
</header>
<div class="nx-card__body">
<div class="chart-container">
<canvas #donutCanvas></canvas>
</div>
</mat-card-content>
</mat-card>
<mat-card class="chart-card">
<mat-card-header>
<mat-card-title>{{ 'labels.text.Fraud Amount by Status' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
</div>
</section>
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="chart-bar" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.text.Fraud Amount by Status' | translate }}</h3>
</header>
<div class="nx-card__body">
<div class="chart-container">
<canvas #barCanvas></canvas>
</div>
</mat-card-content>
</mat-card>
</div>
</section>
</div>
<!-- Recent Complaints Table -->
<mat-card class="recent-complaints-card">
<mat-card-header>
<mat-card-title>{{ 'labels.text.Recent Complaints' | translate }}</mat-card-title>
<span class="spacer"></span>
<a mat-stroked-button color="primary" routerLink="/cfcfrms/complaints">{{
'labels.text.View All' | translate
}}</a>
</mat-card-header>
<mat-card-content>
<!-- Recent complaints -->
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="inbox" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.text.Recent Complaints' | translate }}</h3>
<span class="nx-card__spacer"></span>
<a mat-stroked-button color="primary" routerLink="/cfcfrms/complaints" class="nx-card__action">
{{ 'labels.text.View All' | translate }}
<fa-icon icon="arrow-right" class="ml-04"></fa-icon>
</a>
</header>
<div class="nx-card__body nx-card__body--flush">
@if (isLoading) {
<div class="loading-container">
<div class="nx-state">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else if (recentComplaints.length === 0) {
<div class="empty-state">
<fa-icon icon="inbox" size="3x"></fa-icon>
<p>{{ 'labels.text.No complaints found' | translate }}</p>
<div class="nx-state nx-state--empty">
<fa-icon icon="inbox" class="nx-state__ico"></fa-icon>
<div class="nx-state__title">{{ 'labels.text.No complaints found' | translate }}</div>
<div class="nx-state__sub">Recent activity will appear here.</div>
</div>
} @else {
<table mat-table [dataSource]="recentComplaints" class="full-width">
<ng-container matColumnDef="ticketId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Ticket ID' | translate }}</th>
<td mat-cell *matCellDef="let row">
<a [routerLink]="['/cfcfrms/complaints', row.ticketId]" class="ticket-link">{{ row.ticketId }}</a>
</td>
</ng-container>
<ng-container matColumnDef="complainantName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Complainant' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ row.complainantName }}</td>
</ng-container>
<ng-container matColumnDef="fraudAmount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Fraud Amount' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ formatCurrency(row.fraudAmount) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Status' | translate }}</th>
<td mat-cell *matCellDef="let row">
<mifosx-cfcfrms-status-badge [status]="row.status"></mifosx-cfcfrms-status-badge>
</td>
</ng-container>
<ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Created' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ formatDate(row.createdAt) }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Actions' | translate }}</th>
<td mat-cell *matCellDef="let row">
<a mat-icon-button color="primary" [routerLink]="['/cfcfrms/complaints', row.ticketId]">
<fa-icon icon="eye"></fa-icon>
</a>
</td>
</ng-container>
<div class="nx-table-wrap">
<table mat-table [dataSource]="recentComplaints" class="nx-table">
<ng-container matColumnDef="ticketId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Ticket ID' | translate }}</th>
<td mat-cell *matCellDef="let row">
<a [routerLink]="['/cfcfrms/complaints', row.ticketId]" class="ticket-link">{{ row.ticketId }}</a>
</td>
</ng-container>
<ng-container matColumnDef="complainantName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Complainant' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ row.complainantName }}</td>
</ng-container>
<ng-container matColumnDef="fraudAmount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Fraud Amount' | translate }}</th>
<td mat-cell *matCellDef="let row" class="amount-cell">{{ formatCurrency(row.fraudAmount) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Status' | translate }}</th>
<td mat-cell *matCellDef="let row">
<mifosx-cfcfrms-status-badge [status]="row.status"></mifosx-cfcfrms-status-badge>
</td>
</ng-container>
<ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Created' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ formatDate(row.createdAt) }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="text-right">
{{ 'labels.text.Actions' | translate }}
</th>
<td mat-cell *matCellDef="let row" class="text-right">
<a mat-icon-button color="primary" [routerLink]="['/cfcfrms/complaints', row.ticketId]">
<fa-icon icon="eye"></fa-icon>
</a>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>
}
</mat-card-content>
</mat-card>
</div>
</section>
</div>
@@ -6,147 +6,193 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
.cfcfrms-dashboard {
padding: 24px;
/* Wrapper hero handles outer padding/centering. */
padding: 0;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #0d1b2a;
}
.subtitle {
margin: 4px 0 0;
color: #64748b;
font-size: 14px;
}
}
.stat-cards {
/* ─── KPI strip ─────────────────────────────────────────────────────── */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
gap: 16px;
margin-bottom: 20px;
@media (width <= 1280px) {
@media (width <= 1100px) {
grid-template-columns: repeat(2, 1fr);
}
}
.stat-card {
border-left: 4px solid;
border-radius: 8px !important;
mat-card-content {
display: flex;
align-items: center;
gap: 16px;
padding: 20px !important;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: #64748b;
margin-top: 2px;
}
&.stat-total {
border-color: #1e88e5;
.stat-icon {
background: #1e88e5;
}
.stat-value {
color: #1e88e5;
}
}
&.stat-pending {
border-color: #f59e0b;
.stat-icon {
background: #f59e0b;
}
.stat-value {
color: #f59e0b;
}
}
&.stat-lien {
border-color: #eab308;
.stat-icon {
background: #eab308;
}
.stat-value {
color: #92400e;
}
}
&.stat-recovered {
border-color: #10b981;
.stat-icon {
background: #10b981;
}
.stat-value {
color: #10b981;
}
}
}
.charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
@media (width <= 1280px) {
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.chart-card {
border-radius: 8px !important;
.kpi-card {
display: flex;
align-items: center;
gap: 14px;
background: #fff;
border: 1px solid $border;
border-radius: 14px;
padding: 16px 18px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
border-color 0.15s ease;
mat-card-header {
padding: 16px 16px 0;
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px -6px rgb(15 23 42 / 12%);
}
mat-card-content {
padding: 16px !important;
&__ico {
width: 44px;
height: 44px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 17px;
flex-shrink: 0;
}
&__body {
display: flex;
flex-direction: column;
min-width: 0;
}
&__value {
font-size: 22px;
font-weight: 800;
color: $navy;
line-height: 1.15;
letter-spacing: -0.01em;
}
&__label {
margin-top: 2px;
font-size: 12px;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
/* Variant tint — left accent stripe + gradient icon. */
&--blue {
border-left: 3px solid #1e88e5;
.kpi-card__ico {
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
box-shadow: 0 4px 10px -4px rgb(30 64 175 / 50%);
}
}
&--amber {
border-left: 3px solid #f59e0b;
.kpi-card__ico {
background: linear-gradient(135deg, #fbbf24 0%, #d97706 100%);
box-shadow: 0 4px 10px -4px rgb(217 119 6 / 50%);
}
}
&--purple {
border-left: 3px solid #8b5cf6;
.kpi-card__ico {
background: linear-gradient(135deg, #a78bfa 0%, #6d28d9 100%);
box-shadow: 0 4px 10px -4px rgb(109 40 217 / 50%);
}
}
&--green {
border-left: 3px solid #10b981;
.kpi-card__ico {
background: linear-gradient(135deg, #34d399 0%, #047857 100%);
box-shadow: 0 4px 10px -4px rgb(4 120 87 / 50%);
}
}
}
/* ─── Charts row ────────────────────────────────────────────────────── */
.charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
@media (width <= 1100px) {
grid-template-columns: 1fr;
}
}
/* ─── Generic Nexora card ───────────────────────────────────────────── */
.nx-card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid $border;
background: $soft;
}
&__ico {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: #fff;
font-size: 13px;
}
&__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
&__spacer {
flex: 1;
}
&__action {
height: 32px !important;
border-radius: 8px !important;
font-size: 12.5px !important;
font-weight: 600 !important;
}
&__body {
padding: 18px;
&--flush {
padding: 0;
}
}
}
@@ -155,53 +201,87 @@
position: relative;
}
.recent-complaints-card {
border-radius: 8px !important;
mat-card-header {
display: flex;
align-items: center;
padding: 16px;
.spacer {
flex: 1;
}
}
mat-card-content {
padding: 0 !important;
}
/* ─── Table ─────────────────────────────────────────────────────────── */
.nx-table-wrap {
overflow-x: auto;
}
.full-width {
.nx-table {
width: 100%;
::ng-deep .mat-mdc-header-cell {
background: $soft;
font-size: 12px;
font-weight: 700;
color: $slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 16px;
}
::ng-deep .mat-mdc-cell {
font-size: 13.5px;
color: $navy;
padding: 12px 16px;
}
::ng-deep .mat-mdc-row:hover {
background: #f1f5f9;
}
}
.ticket-link {
color: #1e88e5;
color: #1e40af;
text-decoration: none;
font-weight: 600;
font-weight: 700;
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 12.5px;
&:hover {
text-decoration: underline;
}
}
.loading-container {
display: flex;
justify-content: center;
padding: 40px;
.amount-cell {
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
}
.empty-state {
.text-right {
text-align: right;
}
.ml-04 {
margin-left: 4px;
}
/* ─── States (loading / empty) ──────────────────────────────────────── */
.nx-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px;
color: #94a3b8;
justify-content: center;
gap: 8px;
padding: 56px 24px;
color: $muted;
p {
margin-top: 12px;
font-size: 14px;
&--empty {
.nx-state__ico {
font-size: 38px;
color: #cbd5e1;
margin-bottom: 6px;
}
.nx-state__title {
font-size: 14.5px;
font-weight: 700;
color: $slate;
}
.nx-state__sub {
font-size: 12.5px;
color: $muted;
}
}
}
@@ -9,17 +9,15 @@
import { Component, OnInit, inject, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatIconModule } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Chart, registerables } from 'chart.js';
import { TranslateModule } from '@ngx-translate/core';
import { CfcfrmsService } from '../cfcfrms.service';
import { StatusBadgeComponent } from '../components/status-badge/status-badge.component';
import { Complaint, DashboardStats, ComplaintStatus } from '../models/cfcfrms.models';
import { Complaint, DashboardStats } from '../models/cfcfrms.models';
Chart.register(...registerables);
@@ -29,11 +27,9 @@ Chart.register(...registerables);
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatButtonModule,
MatProgressSpinnerModule,
MatIconModule,
FaIconComponent,
TranslateModule,
StatusBadgeComponent