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:
co-authored by
Claude Opus 4.7
parent
bf3e26a0d0
commit
b149fef72e
+33
-17
@@ -16,29 +16,45 @@
|
||||
<div class="step-header__left">
|
||||
<fa-icon icon="users" class="step-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h3 class="step-header__title">Family Members</h3>
|
||||
<p class="step-header__sub">Add family members and dependents of the client.</p>
|
||||
@if (nomineeOnly) {
|
||||
<h3 class="step-header__title">Nominees</h3>
|
||||
<p class="step-header__sub">
|
||||
Nominees for this client. Family members are not applicable for this entity type.
|
||||
</p>
|
||||
} @else {
|
||||
<h3 class="step-header__title">Family Members</h3>
|
||||
<p class="step-header__sub">Add family members and dependents of the client.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (nomineeOnly) {
|
||||
<div class="fm-step__empty">
|
||||
<mat-icon class="fm-step__empty-icon">shield</mat-icon>
|
||||
<span>Nominees are managed per account from the <strong>Nominees</strong> tab once accounts are opened.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ═══ FAMILY MEMBERS ═══ -->
|
||||
<div class="fm-step__header">
|
||||
<div class="fm-step__title">
|
||||
<fa-icon icon="users" class="fm-step__icon"></fa-icon>
|
||||
<span>Family Members</span>
|
||||
@if (clientFamilyMembers.length > 0) {
|
||||
<span class="fm-step__count">{{ clientFamilyMembers.length }}</span>
|
||||
@if (!nomineeOnly) {
|
||||
<div class="fm-step__header">
|
||||
<div class="fm-step__title">
|
||||
<fa-icon icon="users" class="fm-step__icon"></fa-icon>
|
||||
<span>Family Members</span>
|
||||
@if (clientFamilyMembers.length > 0) {
|
||||
<span class="fm-step__count">{{ clientFamilyMembers.length }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (!readOnly) {
|
||||
<button mat-flat-button color="primary" type="button" class="fm-step__add-btn" (click)="addFamilyMember()">
|
||||
<mat-icon>add</mat-icon> Add Member
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (!readOnly) {
|
||||
<button mat-flat-button color="primary" type="button" class="fm-step__add-btn" (click)="addFamilyMember()">
|
||||
<mat-icon>add</mat-icon> Add Member
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (clientFamilyMembers.length === 0) {
|
||||
@if (!nomineeOnly && clientFamilyMembers.length === 0) {
|
||||
<div class="fm-step__empty fm-step__empty--required">
|
||||
<mat-icon class="fm-step__empty-icon">people_outline</mat-icon>
|
||||
<span>No family members added yet.</span>
|
||||
@@ -47,14 +63,14 @@
|
||||
}
|
||||
|
||||
<!-- Spouse hint for married clients -->
|
||||
@if (isMarried && !hasSpouse) {
|
||||
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
||||
<div class="fm-spouse-hint">
|
||||
<mat-icon class="fm-spouse-hint__icon">favorite</mat-icon>
|
||||
<span>Marital status is <strong>Married</strong>. Consider adding spouse as a family member.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (clientFamilyMembers.length > 0) {
|
||||
@if (!nomineeOnly && clientFamilyMembers.length > 0) {
|
||||
<div class="fm-table">
|
||||
<div class="fm-table__header">
|
||||
<span class="fm-table__col fm-table__col--name">Name</span>
|
||||
|
||||
+7
@@ -52,6 +52,12 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
@Input() maritalStatusOptions: any[] = [];
|
||||
/** Read-only mode — hides add/edit/delete buttons */
|
||||
@Input() readOnly = false;
|
||||
/**
|
||||
* When true, hide the family-member section entirely. Used for clients that
|
||||
* are eligible for nominees but not for family members (e.g. proprietorship
|
||||
* entities — see {@link nomineeAllowed} / {@link familyAllowed}).
|
||||
*/
|
||||
@Input() nomineeOnly = false;
|
||||
|
||||
/** Family Members */
|
||||
clientFamilyMembers: any[] = [];
|
||||
@@ -131,6 +137,7 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
addFamilyMember() {
|
||||
if (this.nomineeOnly || this.readOnly) return;
|
||||
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: {
|
||||
context: 'Add',
|
||||
|
||||
+2
-2
@@ -1024,8 +1024,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Family Members (Individual only) -->
|
||||
@if (client?.legalFormId !== 2) {
|
||||
<!-- Family Members — hidden when client isn't eligible (entities other than proprietorship). -->
|
||||
@if (showFamilyPreview) {
|
||||
<div class="info-card">
|
||||
<div class="card-header">
|
||||
<fa-icon icon="users" class="card-icon"></fa-icon>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { YesnoPipe } from '../../../pipes/yesno.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||
import { familyAllowed, nomineeAllowed } from 'app/clients/utils/client-rules';
|
||||
|
||||
/**
|
||||
* Client Preview Step Component
|
||||
@@ -69,6 +70,11 @@ export class ClientPreviewStepComponent {
|
||||
@Input() nominees: any[] = [];
|
||||
/** Family members from family step */
|
||||
@Input() familyMembers: any[] = [];
|
||||
/**
|
||||
* Constitution display name for entity clients — drives the proprietorship-only
|
||||
* nominee rule so the preview matches what's eligible elsewhere.
|
||||
*/
|
||||
@Input() constitutionLabel: string = '';
|
||||
/** Marital Status ID from general step */
|
||||
@Input() maritalStatusId: number | null = null;
|
||||
/** Marital Status Options */
|
||||
@@ -99,6 +105,14 @@ export class ClientPreviewStepComponent {
|
||||
/** Form submission event */
|
||||
@Output() submitEvent = new EventEmitter();
|
||||
|
||||
get showFamilyPreview(): boolean {
|
||||
return familyAllowed(this.client?.legalFormId, this.constitutionLabel);
|
||||
}
|
||||
|
||||
get showNomineePreview(): boolean {
|
||||
return nomineeAllowed(this.client?.legalFormId, this.constitutionLabel);
|
||||
}
|
||||
|
||||
/** Expose enum to template */
|
||||
readonly LegalFormId = LegalFormId;
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -123,10 +124,12 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'nominees',
|
||||
component: NomineeTabComponent,
|
||||
canActivate: [nomineeAllowedGuard],
|
||||
data: { title: 'Nominees', breadcrumb: 'Nominees', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'family-members',
|
||||
canActivate: [familyAllowedGuard],
|
||||
data: { title: 'Family Members', breadcrumb: 'Family Members', routeParamBreadcrumb: false },
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -481,7 +481,7 @@
|
||||
>
|
||||
<i class="fa fa-map-marker cv-tab-icon"></i> {{ 'labels.inputs.Address' | translate }}
|
||||
</a>
|
||||
@if (clientViewData?.legalForm?.id !== 2) {
|
||||
@if (showFamilyTab) {
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./family-members']"
|
||||
@@ -492,15 +492,17 @@
|
||||
<i class="fa fa-users cv-tab-icon"></i> Family Members
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./nominees']"
|
||||
routerLinkActive
|
||||
#nomineesTab="routerLinkActive"
|
||||
[active]="nomineesTab.isActive"
|
||||
>
|
||||
<i class="fa fa-shield cv-tab-icon"></i> Nominee
|
||||
</a>
|
||||
@if (showNomineeTab) {
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./nominees']"
|
||||
routerLinkActive
|
||||
#nomineesTab="routerLinkActive"
|
||||
[active]="nomineesTab.isActive"
|
||||
>
|
||||
<i class="fa fa-shield cv-tab-icon"></i> Nominee
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./identities']"
|
||||
|
||||
@@ -26,6 +26,7 @@ import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dial
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients.service';
|
||||
import { LegalFormId } from '../models/legal-form.enum';
|
||||
import { familyAllowed, nomineeAllowed } from '../utils/client-rules';
|
||||
import {
|
||||
MatCard,
|
||||
MatCardHeader,
|
||||
@@ -143,6 +144,21 @@ export class ClientsViewComponent implements OnInit {
|
||||
clientImage: any;
|
||||
clientTemplateData: any;
|
||||
|
||||
/** Constitution display name (entity clients only). Empty for individuals. */
|
||||
get constitutionLabel(): string {
|
||||
return this.clientViewData?.clientNonPersonDetails?.constitution?.name || '';
|
||||
}
|
||||
|
||||
/** Whether the "Family Members" tab should appear. */
|
||||
get showFamilyTab(): boolean {
|
||||
return familyAllowed(this.clientViewData?.legalForm?.id, this.constitutionLabel);
|
||||
}
|
||||
|
||||
/** Whether the "Nominees" tab should appear. */
|
||||
get showNomineeTab(): boolean {
|
||||
return nomineeAllowed(this.clientViewData?.legalForm?.id, this.constitutionLabel);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { clientViewData: any; clientTemplateData: any; clientDatatables: any }) => {
|
||||
this.clientViewData = data.clientViewData;
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
<h3 class="nom-header__title">Family Members</h3>
|
||||
<span class="nom-header__badge">{{ familyMembers.length }}</span>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" class="nom-header__btn" (click)="addFamilyMember()">
|
||||
<mat-icon>add</mat-icon> Add Member
|
||||
</button>
|
||||
@if (canAdd) {
|
||||
<button mat-flat-button color="primary" class="nom-header__btn" (click)="addFamilyMember()">
|
||||
<mat-icon>add</mat-icon> Add Member
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (familyMembers.length === 0) {
|
||||
@@ -44,21 +46,28 @@
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="nom-actions">
|
||||
<div class="nom-actions__buttons">
|
||||
<button mat-stroked-button color="primary" class="nom-actions__edit" (click)="editFamilyMember(member, i)">
|
||||
<fa-icon icon="edit" class="m-r-10"></fa-icon> Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
class="nom-actions__delete"
|
||||
(click)="deleteMember(member, 'Family Member')"
|
||||
>
|
||||
<fa-icon icon="trash" class="m-r-10"></fa-icon> Delete
|
||||
</button>
|
||||
@if (canAdd) {
|
||||
<div class="nom-actions">
|
||||
<div class="nom-actions__buttons">
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="nom-actions__edit"
|
||||
(click)="editFamilyMember(member, i)"
|
||||
>
|
||||
<fa-icon icon="edit" class="m-r-10"></fa-icon> Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
class="nom-actions__delete"
|
||||
(click)="deleteMember(member, 'Family Member')"
|
||||
>
|
||||
<fa-icon icon="trash" class="m-r-10"></fa-icon> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="nom-grid">
|
||||
@if (member.firstName) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ClientsService } from '../../clients.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { familyAllowed } from '../../utils/client-rules';
|
||||
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -71,6 +72,12 @@ export class FamilyMembersTabComponent {
|
||||
clientId: string;
|
||||
/** Family member template options */
|
||||
familyMemberOptions: any;
|
||||
/**
|
||||
* Defensive flag — the route guard already blocks unauthorised clients, but
|
||||
* we re-check here so that if the component is somehow reachable via direct
|
||||
* link or stale state, the Add / Edit buttons stay hidden.
|
||||
*/
|
||||
canAdd = false;
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { clientFamilyMembers: any }) => {
|
||||
@@ -80,6 +87,17 @@ export class FamilyMembersTabComponent {
|
||||
// so the clientId param lives two levels up, not one.
|
||||
this.clientId =
|
||||
this.route.parent?.parent?.snapshot.params['clientId'] || this.route.parent?.snapshot.params['clientId'];
|
||||
if (this.clientId) {
|
||||
this.clientsService.getClientData(this.clientId).subscribe({
|
||||
next: (client: any) => {
|
||||
const constitutionLabel = client?.clientNonPersonDetails?.constitution?.name || '';
|
||||
this.canAdd = familyAllowed(client?.legalForm?.id, constitutionLabel);
|
||||
},
|
||||
error: () => {
|
||||
this.canAdd = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Family members (non-nominee entries) */
|
||||
@@ -116,6 +134,7 @@ export class FamilyMembersTabComponent {
|
||||
}
|
||||
|
||||
addFamilyMember() {
|
||||
if (!this.canAdd) return;
|
||||
this.ensureTemplateLoaded().then(() => {
|
||||
const dialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
width: '640px',
|
||||
@@ -124,7 +143,10 @@ export class FamilyMembersTabComponent {
|
||||
data: {
|
||||
context: 'Add',
|
||||
options: this.familyMemberOptions,
|
||||
existingMembers: this.familyMembers
|
||||
existingMembers: this.familyMembers,
|
||||
// Pass the host client's id so the dialog's self-add guard can
|
||||
// filter the search results and refuse self-link attempts.
|
||||
hostClientId: this.clientId
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
@@ -139,6 +161,7 @@ export class FamilyMembersTabComponent {
|
||||
}
|
||||
|
||||
editFamilyMember(member: any, _index: number) {
|
||||
if (!this.canAdd) return;
|
||||
this.ensureTemplateLoaded().then(() => {
|
||||
const dialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
width: '640px',
|
||||
@@ -148,7 +171,8 @@ export class FamilyMembersTabComponent {
|
||||
context: 'Edit',
|
||||
options: this.familyMemberOptions,
|
||||
member,
|
||||
existingMembers: this.familyMembers
|
||||
existingMembers: this.familyMembers,
|
||||
hostClientId: this.clientId
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
@@ -226,6 +250,7 @@ export class FamilyMembersTabComponent {
|
||||
// ═══ Common ═══
|
||||
|
||||
deleteMember(member: any, label: string) {
|
||||
if (!this.canAdd) return;
|
||||
const realIndex = this.clientFamilyMembers.indexOf(member);
|
||||
const deleteFamilyMemberDialogRef = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `${label} ${member.firstName || ''}` }
|
||||
|
||||
@@ -560,9 +560,14 @@
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="Enter 12-digit Aadhaar"
|
||||
[readonly]="aadhaarOtpSent"
|
||||
/>
|
||||
@if (kycData?.aadhaarNumberMasked) {
|
||||
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
|
||||
}
|
||||
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
|
||||
}
|
||||
|
||||
@@ -147,12 +147,15 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.loading = false;
|
||||
this.kycData = data;
|
||||
if (data) {
|
||||
this.kycId = data.id?.toString() || null;
|
||||
const rawKycId = data.id ?? data.kycId ?? data.resourceId;
|
||||
this.kycId = rawKycId != null ? rawKycId.toString() : null;
|
||||
this.kycForm.patchValue({
|
||||
kycType: data.kycType || 1,
|
||||
riskCategory: data.riskCategory || 1,
|
||||
panNumber: data.panNumber || '',
|
||||
aadhaarNumber: data.aadhaarNumberMasked || '',
|
||||
// Aadhaar is stored masked on the server; user must re-enter
|
||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||
aadhaarNumber: '',
|
||||
gstinNumber: data.gstinNumber || '',
|
||||
remarks: data.remarks || ''
|
||||
});
|
||||
@@ -181,6 +184,15 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
toggleEditMode(): void {
|
||||
this.isEditMode = !this.isEditMode;
|
||||
this.resetOtpState();
|
||||
if (this.isEditMode) {
|
||||
// Clear Aadhaar so the user types a fresh 12-digit number for re-verify;
|
||||
// server only returns the masked form which would fail validation.
|
||||
this.kycForm.get('aadhaarNumber')?.reset('');
|
||||
this.verificationStatus.aadhaar = null;
|
||||
this.verificationStatus.pan = null;
|
||||
this.verificationError.aadhaar = null;
|
||||
this.verificationError.pan = null;
|
||||
}
|
||||
}
|
||||
|
||||
saveKyc(): void {
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (selectedAccountId && !showAddForm) {
|
||||
@if (canAdd && selectedAccountId && !showAddForm) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
@@ -153,18 +153,20 @@
|
||||
<button mat-icon-button type="button" matTooltip="View" (click)="viewNominee(nominee)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Delete"
|
||||
class="nom-card__delete"
|
||||
(click)="deleteNominee(nominee, i)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
@if (canAdd) {
|
||||
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Delete"
|
||||
class="nom-card__delete"
|
||||
(click)="deleteNominee(nominee, i)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (nominee.nomineeIsMinor && (nominee.guardianDisplayName || nominee.guardianName)) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { nomineeAllowed } from '../../utils/client-rules';
|
||||
import { DeleteDialogComponent } from '../../../shared/delete-dialog/delete-dialog.component';
|
||||
import { ImageZoomDialogComponent } from '../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
@@ -55,6 +56,12 @@ export class NomineeTabComponent implements OnInit {
|
||||
clientId: string;
|
||||
loading = true;
|
||||
accountGroups: AccountGroup[] = [];
|
||||
/**
|
||||
* Defensive flag — route guard already blocks unauthorised clients, but
|
||||
* we re-check here so Add / Edit / Delete stay hidden if the component is
|
||||
* reached via direct link or stale state.
|
||||
*/
|
||||
canAdd = false;
|
||||
selectedType: string = '';
|
||||
selectedAccountId: number | null = null;
|
||||
|
||||
@@ -132,6 +139,23 @@ export class NomineeTabComponent implements OnInit {
|
||||
this.clientId = this.route.parent.snapshot.params['clientId'];
|
||||
this.loadAccounts();
|
||||
this.loadTemplate();
|
||||
this.loadClientEligibility();
|
||||
}
|
||||
|
||||
private loadClientEligibility(): void {
|
||||
if (!this.clientId) {
|
||||
this.canAdd = false;
|
||||
return;
|
||||
}
|
||||
this.clientsService.getClientData(this.clientId).subscribe({
|
||||
next: (client: any) => {
|
||||
const constitutionLabel = client?.clientNonPersonDetails?.constitution?.name || '';
|
||||
this.canAdd = nomineeAllowed(client?.legalForm?.id, constitutionLabel);
|
||||
},
|
||||
error: () => {
|
||||
this.canAdd = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadTemplate(): void {
|
||||
@@ -462,6 +486,7 @@ export class NomineeTabComponent implements OnInit {
|
||||
// ═══ Save Nominee ═══
|
||||
|
||||
saveNominee(): void {
|
||||
if (!this.canAdd) return;
|
||||
if (!this.selectedClient || !this.nomineeRelationshipId || !this.nomineeShare) return;
|
||||
if (this.totalShare + this.nomineeShare > 100) return;
|
||||
|
||||
@@ -531,6 +556,7 @@ export class NomineeTabComponent implements OnInit {
|
||||
}
|
||||
|
||||
editNominee(nominee: any, index: number): void {
|
||||
if (!this.canAdd) return;
|
||||
this.editingIndex = index;
|
||||
this.editRelId = nominee.relationshipId || null;
|
||||
this.editShare = nominee.percentageShare;
|
||||
@@ -566,6 +592,7 @@ export class NomineeTabComponent implements OnInit {
|
||||
}
|
||||
|
||||
deleteNominee(nominee: any, index: number): void {
|
||||
if (!this.canAdd) return;
|
||||
const ref = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `nominee ${nominee.nomineeDisplayName || nominee.firstName || ''}` }
|
||||
});
|
||||
|
||||
@@ -150,12 +150,12 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab: Family Members / Nominees (Individual only) -->
|
||||
@if (legalFormType !== 2) {
|
||||
<!-- Tab: Family / Nominees — hidden for entity clients that aren't proprietorships -->
|
||||
@if (showFamilyTab) {
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="users" class="tab-icon"></fa-icon>
|
||||
FAMILY
|
||||
{{ familyTabNomineeOnly ? 'NOMINEES' : 'FAMILY' }}
|
||||
</ng-template>
|
||||
@let familyTabIdx = legalFormType === 2 ? 4 : 3;
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(familyTabIdx)">
|
||||
@@ -183,6 +183,7 @@
|
||||
[maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value"
|
||||
[maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []"
|
||||
[readOnly]="isTabReadOnly(familyTabIdx)"
|
||||
[nomineeOnly]="familyTabNomineeOnly"
|
||||
></mifosx-client-family-members-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
@@ -205,6 +206,7 @@
|
||||
[guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''"
|
||||
[entityClassificationData]="clientEntityKycStep?.classificationData"
|
||||
[familyMembers]="clientFamilyMembersStep?.clientFamilyMembers || []"
|
||||
[constitutionLabel]="constitutionLabel"
|
||||
[maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value"
|
||||
[maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []"
|
||||
[educationLevelOptions]="clientGeneralStep?.educationLevelOptions || []"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { map } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients.service';
|
||||
import { familyAllowed, nomineeAllowed, isProprietorship } from '../utils/client-rules';
|
||||
|
||||
/** Custom Components */
|
||||
import { ClientGeneralStepComponent } from '../client-stepper/client-general-step/client-general-step.component';
|
||||
@@ -130,9 +131,37 @@ export class CreateClientComponent {
|
||||
return this.clientGeneralStep?.createClientForm;
|
||||
}
|
||||
|
||||
/** Total tab count: Individual has Family tab, Non-Individual has Scheme & Sector tab — both 5 */
|
||||
/**
|
||||
* Constitution display name for the selected entity constitution. Drives the
|
||||
* proprietorship-only nominee rule below. Empty / null for individual clients.
|
||||
*/
|
||||
get constitutionLabel(): string {
|
||||
return this.clientGeneralStep?.selectedConstitution?.name || '';
|
||||
}
|
||||
|
||||
/** Whether the Family/Nominee tab should appear at all. */
|
||||
get showFamilyTab(): boolean {
|
||||
return nomineeAllowed(this.legalFormType, this.constitutionLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Family/Nominee tab should render in nominee-only mode (family
|
||||
* member rows hidden). True for entity proprietorship, false for individuals.
|
||||
*/
|
||||
get familyTabNomineeOnly(): boolean {
|
||||
return !familyAllowed(this.legalFormType, this.constitutionLabel) && this.showFamilyTab;
|
||||
}
|
||||
|
||||
/** Total tab count varies by legal form + constitution.
|
||||
* Individual: General + KYC + Address + Family + Preview = 5
|
||||
* Entity (proprietorship): General + KYC + S&S + Address + Nominees + Preview = 6
|
||||
* Entity (non-proprietor): General + KYC + S&S + Address + Preview = 5
|
||||
*/
|
||||
get totalTabs(): number {
|
||||
return 5;
|
||||
let count = 4; // General + KYC + Address + Preview
|
||||
if (this.legalFormType === 2) count++; // S&S only for entities
|
||||
if (this.showFamilyTab) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,9 +387,11 @@ export class CreateClientComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Family tab (index 3 for Individual, 4 for Non-Individual)
|
||||
// Family tab (index 3 for Individual, 4 for Non-Individual proprietorship).
|
||||
// Skip entirely when the tab is hidden (entity non-proprietor) so we don't
|
||||
// confuse Preview at the same index slot with the Family step.
|
||||
const familyTabIndex = this.legalFormType === 2 ? 4 : 3;
|
||||
if (tabIndex === familyTabIndex) {
|
||||
if (this.showFamilyTab && tabIndex === familyTabIndex) {
|
||||
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
|
||||
this.saveClientFamilyMembers(clientId);
|
||||
}
|
||||
|
||||
@@ -138,33 +138,40 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab: Family Members / Nominees -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="users" class="tab-icon"></fa-icon>
|
||||
FAMILY
|
||||
</ng-template>
|
||||
@let familyTabIdx = legalFormType === 2 ? 4 : 3;
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(familyTabIdx)">
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(familyTabIdx)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(familyTabIdx)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button class="tab-mode-btn tab-mode-btn--cancel" (click)="cancelTabEdit(familyTabIdx)">
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
<!-- Tab: Family / Nominees — hidden for entity clients that aren't proprietorships -->
|
||||
@if (showFamilyTab) {
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="users" class="tab-icon"></fa-icon>
|
||||
{{ familyTabNomineeOnly ? 'NOMINEES' : 'FAMILY' }}
|
||||
</ng-template>
|
||||
@let familyTabIdx = legalFormType === 2 ? 4 : 3;
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(familyTabIdx)">
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(familyTabIdx)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(familyTabIdx)">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="tab-mode-btn tab-mode-btn--cancel"
|
||||
(click)="cancelTabEdit(familyTabIdx)"
|
||||
>
|
||||
<mat-icon>close</mat-icon> Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<mifosx-client-family-members-step
|
||||
#clientFamilyMembers
|
||||
[clientTemplate]="clientDataAndTemplate"
|
||||
[clientId]="clientId"
|
||||
[readOnly]="isTabReadOnly(familyTabIdx)"
|
||||
[nomineeOnly]="familyTabNomineeOnly"
|
||||
></mifosx-client-family-members-step>
|
||||
</div>
|
||||
<mifosx-client-family-members-step
|
||||
#clientFamilyMembers
|
||||
[clientTemplate]="clientDataAndTemplate"
|
||||
[clientId]="clientId"
|
||||
[readOnly]="isTabReadOnly(familyTabIdx)"
|
||||
></mifosx-client-family-members-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab>
|
||||
}
|
||||
|
||||
<!-- Tab: Application Summary & Submit -->
|
||||
<mat-tab>
|
||||
@@ -182,6 +189,7 @@
|
||||
[guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''"
|
||||
[entityClassificationData]="clientEntityKycStep?.classificationData"
|
||||
[familyMembers]="clientFamilyMembersStep?.clientFamilyMembers || []"
|
||||
[constitutionLabel]="constitutionLabel"
|
||||
[maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value"
|
||||
[maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []"
|
||||
[educationLevelOptions]="clientGeneralStep?.educationLevelOptions || []"
|
||||
|
||||
@@ -17,6 +17,7 @@ import { map } from 'rxjs/operators';
|
||||
import { ClientsService } from '../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { familyAllowed, nomineeAllowed } from '../utils/client-rules';
|
||||
|
||||
/** Custom Components */
|
||||
import { ClientGeneralStepComponent } from '../client-stepper/client-general-step/client-general-step.component';
|
||||
@@ -121,9 +122,40 @@ export class EditClientComponent {
|
||||
return this.clientGeneralStep?.createClientForm;
|
||||
}
|
||||
|
||||
/** Total tab count (+1 for Nominees, +1 for Non-Individual Entity KYC) */
|
||||
/**
|
||||
* Constitution display name pulled from the live general step (preferred —
|
||||
* stays in sync if the user changes constitution mid-edit) with a fallback
|
||||
* to the resolver-provided snapshot. Drives the proprietorship-only nominee
|
||||
* rule below.
|
||||
*/
|
||||
get constitutionLabel(): string {
|
||||
return (
|
||||
this.clientGeneralStep?.selectedConstitution?.name ||
|
||||
this.clientDataAndTemplate?.clientNonPersonDetails?.constitution?.name ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the Family/Nominee tab should appear at all. */
|
||||
get showFamilyTab(): boolean {
|
||||
return nomineeAllowed(this.legalFormType, this.constitutionLabel);
|
||||
}
|
||||
|
||||
/** True when the tab should render in nominee-only mode (family rows hidden). */
|
||||
get familyTabNomineeOnly(): boolean {
|
||||
return !familyAllowed(this.legalFormType, this.constitutionLabel) && this.showFamilyTab;
|
||||
}
|
||||
|
||||
/** Total tab count varies by legal form + constitution.
|
||||
* Individual: General + KYC + Address + Family + Preview = 5
|
||||
* Entity (proprietorship): General + KYC + S&S + Address + Nominees + Preview = 6
|
||||
* Entity (non-proprietor): General + KYC + S&S + Address + Preview = 5
|
||||
*/
|
||||
get totalTabs(): number {
|
||||
return this.legalFormType === 2 ? 6 : 5;
|
||||
let count = 4; // General + KYC + Address + Preview
|
||||
if (this.legalFormType === 2) count++; // S&S only for entities
|
||||
if (this.showFamilyTab) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Get constitution type from general step for entity KYC */
|
||||
@@ -411,9 +443,10 @@ export class EditClientComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Family tab
|
||||
// Family tab — only present for individuals and proprietorship entities.
|
||||
// Skip when hidden so Preview at the same index isn't mistaken for Family.
|
||||
const familyTabIdx = this.legalFormType === 2 ? 4 : 3;
|
||||
if (tabIndex === familyTabIdx) {
|
||||
if (this.showFamilyTab && tabIndex === familyTabIdx) {
|
||||
this.savingClient = true;
|
||||
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
|
||||
this.saveClientFamilyMembers(this.clientId);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Predicates that decide which optional family/nominee UI a client is
|
||||
* eligible for. Mirrors the backend authorization checks so the UI stays
|
||||
* in lockstep with what the server will accept.
|
||||
*
|
||||
* Individual (legalForm = 1) → family + nominees
|
||||
* Non-individual + Proprietorship → nominees only
|
||||
* Non-individual + anything else → neither
|
||||
*/
|
||||
|
||||
const normalize = (s: string | null | undefined): string => (s || '').toUpperCase().replace(/[\s_-]+/g, '');
|
||||
|
||||
export function isProprietorship(constitutionLabel: string | null | undefined): boolean {
|
||||
const n = normalize(constitutionLabel);
|
||||
return n === 'PROPRIETORSHIP' || n === 'PROPRIETOR' || n === 'SOLEPROPRIETORSHIP' || n === 'SOLEPROPRIETOR';
|
||||
}
|
||||
|
||||
export function isIndividualClient(legalFormId: number | null | undefined): boolean {
|
||||
return legalFormId === 1; // 1 = PERSON, 2 = ENTITY
|
||||
}
|
||||
|
||||
export function familyAllowed(legalFormId: number | null | undefined, _constitutionLabel?: string | null): boolean {
|
||||
return isIndividualClient(legalFormId);
|
||||
}
|
||||
|
||||
export function nomineeAllowed(legalFormId: number | null | undefined, constitutionLabel?: string | null): boolean {
|
||||
if (isIndividualClient(legalFormId)) return true;
|
||||
return isProprietorship(constitutionLabel);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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 { inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
import { ClientsService } from '../clients.service';
|
||||
import { familyAllowed, nomineeAllowed } from './client-rules';
|
||||
|
||||
/**
|
||||
* Walk up the route snapshot tree until we find a `clientId` path param. The
|
||||
* family-members / nominees routes are nested two levels under the client
|
||||
* shell, so simple `route.params['clientId']` returns undefined.
|
||||
*/
|
||||
function findClientId(route: ActivatedRouteSnapshot): string | null {
|
||||
let cur: ActivatedRouteSnapshot | null = route;
|
||||
while (cur) {
|
||||
const id = cur.params?.['clientId'];
|
||||
if (id) return id;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a guard that redirects to the client's General tab when the client
|
||||
* isn't eligible for the section. Resolves the client lazily so we don't
|
||||
* couple the guard to any specific parent resolver.
|
||||
*/
|
||||
function makeEligibilityGuard(
|
||||
predicate: (legalFormId: number | null | undefined, constitutionLabel?: string | null) => boolean
|
||||
): CanActivateFn {
|
||||
return (route) => {
|
||||
const router = inject(Router);
|
||||
const clientsService = inject(ClientsService);
|
||||
const clientId = findClientId(route);
|
||||
// No clientId in the URL — let the framework's own missing-param handling kick in.
|
||||
if (!clientId) return true;
|
||||
|
||||
return clientsService.getClientData(clientId).pipe(
|
||||
map((client: any) => {
|
||||
const legalFormId = client?.legalForm?.id;
|
||||
const constitutionLabel = client?.clientNonPersonDetails?.constitution?.name || '';
|
||||
if (predicate(legalFormId, constitutionLabel)) return true;
|
||||
return router.createUrlTree([
|
||||
'/clients',
|
||||
clientId,
|
||||
'general'
|
||||
]);
|
||||
}),
|
||||
// If the client lookup fails, fail open and let downstream handle it
|
||||
// rather than redirecting users into an infinite loop on a stale URL.
|
||||
catchError(() => of(true))
|
||||
) as Observable<boolean | ReturnType<Router['createUrlTree']>>;
|
||||
};
|
||||
}
|
||||
|
||||
export const familyAllowedGuard: CanActivateFn = makeEligibilityGuard(familyAllowed);
|
||||
export const nomineeAllowedGuard: CanActivateFn = makeEligibilityGuard(nomineeAllowed);
|
||||
Reference in New Issue
Block a user