feat: add RBI circulars, CKYC, KYC, Superset analytics, passbook modules and UI updates

Add new feature modules (RBI feeds, CKYC, KYC tab, Form60, Superset analytics, passbook),
update client family members, dashboard, login page, sidenav, images, translations,
environment config, and fix RBI feeds API auth by using relative URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-03-10 15:13:35 +05:30
co-authored by Claude Opus 4.6
parent 07f701f654
commit 73aa69ee02
84 changed files with 6256 additions and 700 deletions
@@ -9,49 +9,32 @@
<h1 mat-dialog-title>{{ data.context }} {{ 'labels.heading.Family Member' | translate }}</h1>
<form [formGroup]="familyMemberForm">
<div class="layout-row-wrap responsive-column align-start-center gap-2px">
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<h3>{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}</h3>
<div class="layout-row-wrap responsive-column align-start-center">
<!-- Nomination Type -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Nomination Type' | translate }}</mat-label>
<mat-select formControlName="nominationType" required>
<mat-option value="DA1">{{ 'labels.inputs.DA1 - Add Nominee' | translate }}</mat-option>
<mat-option value="DA5">{{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<!-- Nominee Name -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Nominee Name' | translate }}</mat-label>
<input formControlName="firstName" required matInput />
@if (familyMemberForm.controls.firstName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.First Name' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<input formControlName="middleName" matInput />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input formControlName="lastName" required matInput />
@if (familyMemberForm.controls.lastName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Last Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Qualification' | translate }}</mat-label>
<input formControlName="qualification" matInput />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Age' | translate }}</mat-label>
<input type="number" formControlName="age" matInput [min]="0" disabled />
</mat-form-field>
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-48"
>{{ 'labels.inputs.Is Dependent' | translate }}?</mat-checkbox
>
<mat-form-field class="flex-48">
<!-- Relationship -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship' | translate }}</mat-label>
<mat-select formControlName="relationshipId" required>
@for (relation of data.options.relationshipIdOptions; track relation) {
@@ -68,46 +51,8 @@
}
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Gender' | translate }}</mat-label>
<mat-select formControlName="genderId" required>
@for (gender of data.options.genderIdOptions; track gender) {
<mat-option [value]="gender.id">
{{ gender.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
@if (familyMemberForm.controls.genderId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Gender' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Profession' | translate }}</mat-label>
<mat-select formControlName="professionId">
@for (profession of data.options.professionIdOptions; track profession) {
<mat-option [value]="profession.id">
{{ profession.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Marital Status' | translate }}</mat-label>
<mat-select formControlName="maritalStatusId">
@for (maritalStatus of data.options.maritalStatusIdOptions; track maritalStatus) {
<mat-option [value]="maritalStatus.id">
{{ maritalStatus.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-98" (click)="dueDatePicker.open()">
<!-- Date of Birth -->
<mat-form-field class="flex-50-minus-25px" (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Date Of Birth' | translate }}</mat-label>
<input
formControlName="dateOfBirth"
@@ -119,6 +64,41 @@
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
</mat-form-field>
<!-- Percentage Share -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Percentage Share' | translate }} (%)</mat-label>
<input type="number" formControlName="percentageShare" matInput min="0" max="100" />
</mat-form-field>
<!-- Nominee is Minor -->
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-50-minus-25px">
{{ 'labels.inputs.Nominee is Minor' | translate }}?
</mat-checkbox>
</div>
<!-- Guardian Details (shown when minor) -->
@if (familyMemberForm.get('isDependent').value) {
<h3>{{ 'labels.heading.Guardian Details (If Minor)' | translate }}</h3>
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Guardian Name' | translate }}</mat-label>
<input formControlName="guardianName" matInput />
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship with Minor' | translate }}</mat-label>
<input formControlName="guardianRelationship" matInput />
</mat-form-field>
</div>
}
<!-- Remarks -->
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<input formControlName="remarks" matInput />
</mat-form-field>
</div>
<mat-dialog-actions align="center">
@@ -19,4 +19,16 @@ form {
.layout-row-wrap {
width: 100%;
}
@media screen and (width >= 960px) {
.layout-row-wrap > .flex-fill:nth-child(odd),
.layout-row-wrap > .flex-50-minus-25px:nth-child(odd) {
margin-right: 25px;
}
.layout-row-wrap > .flex-fill:last-child,
.layout-row-wrap > .flex-50-minus-25px:last-child {
margin-right: 0;
}
}
}
@@ -56,54 +56,17 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
this.createFamilyMemberForm();
if (this.data.context === 'Edit') {
this.familyMemberForm.patchValue({
nominationType: this.data.member.nominationType || 'DA1',
firstName: this.data.member.firstName,
middleName: this.data.member.middleName,
lastName: this.data.member.lastName,
qualification: this.data.member.qualification,
age: this.data.member.age,
isDependent: this.data.member.isDependent,
relationshipId: this.data.member.relationshipId,
genderId: this.data.member.genderId,
professionId: this.data.member.professionId,
maritalStatusId: this.data.member.maritalStatusId,
dateOfBirth: this.data.member.dateOfBirth && new Date(this.data.member.dateOfBirth)
dateOfBirth: this.data.member.dateOfBirth && new Date(this.data.member.dateOfBirth),
percentageShare: this.data.member.percentageShare ?? 100,
isDependent: this.data.member.isDependent,
guardianName: this.data.member.guardianName || '',
guardianRelationship: this.data.member.guardianRelationship || '',
remarks: this.data.member.remarks || ''
});
}
// Add subscription to date of birth changes to update age
this.familyMemberForm.get('dateOfBirth').valueChanges.subscribe((dateOfBirth: any) => {
if (dateOfBirth) {
const age = this.calculateAge(dateOfBirth);
this.familyMemberForm.get('age').setValue(age);
} else {
this.familyMemberForm.get('age').setValue('');
}
});
// If a date of birth is already set, calculate the age
const currentDob = this.familyMemberForm.get('dateOfBirth').value;
if (currentDob) {
const age = this.calculateAge(currentDob);
this.familyMemberForm.get('age').setValue(age);
}
}
/**
* Calculates age from date of birth
* @param {Date} dateOfBirth Date of Birth
* @returns {number} Age
*/
calculateAge(dateOfBirth: Date): number {
const today = new Date(this.settingsService.businessDate);
const birthDate = new Date(dateOfBirth);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
/**
@@ -111,30 +74,21 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
*/
createFamilyMemberForm() {
this.familyMemberForm = this.formBuilder.group({
nominationType: ['DA1'],
firstName: [
'',
Validators.required
],
middleName: [''],
lastName: [
'',
Validators.required
],
qualification: [''],
age: [
{ value: '', disabled: true }],
isDependent: [''],
relationshipId: [
'',
Validators.required
],
genderId: [
'',
Validators.required
],
professionId: [''],
maritalStatusId: [''],
dateOfBirth: ['']
dateOfBirth: [''],
percentageShare: [100],
isDependent: [false],
guardianName: [''],
guardianRelationship: [''],
remarks: ['']
});
}
@@ -142,27 +96,19 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
* Returns Formatted Family Member
*/
get familyMember() {
// Get form values including disabled controls like age
const formValue = {
...this.familyMemberForm.getRawValue()
...this.familyMemberForm.value
};
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevDateOfBirth: Date = formValue.dateOfBirth;
// Calculate age from dateOfBirth if present
if (prevDateOfBirth) {
if (formValue.dateOfBirth instanceof Date) {
formValue.dateOfBirth = this.dateUtils.formatDate(prevDateOfBirth, dateFormat);
}
// Ensure age is calculated even if it wasn't already
if (!formValue.age && prevDateOfBirth) {
formValue.age = this.calculateAge(prevDateOfBirth);
}
} else {
// If no date of birth, remove age and dateOfBirth from submission
delete formValue.age;
delete formValue.dateOfBirth;
}
@@ -20,10 +20,10 @@
<mat-expansion-panel class="family-member">
<mat-expansion-panel-header>
<mat-panel-title>
{{ member.firstName }} {{ member.middleName ? member.middleName + ' ' + member.lastName : member.lastName }}
{{ member.firstName }}
</mat-panel-title>
<mat-panel-description>
{{ member.relationship }}
{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}
</mat-panel-description>
</mat-expansion-panel-header>
<mat-divider></mat-divider>
@@ -36,23 +36,21 @@
</button>
</div>
<p>
{{ 'labels.inputs.First Name' | translate }} : {{ member.firstName }}<br />
{{ 'labels.inputs.Middle Name' | translate }} : {{ member.middleName }}<br />
{{ 'labels.inputs.Last Name' | translate }} : {{ member.lastName }}<br />
{{ 'labels.inputs.Qualification' | translate }} : {{ member.qualification }}<br />
{{ 'labels.inputs.Nomination Type' | translate }} : {{ member.nominationType }}<br />
{{ 'labels.inputs.Nominee Name' | translate }} : {{ member.firstName }}<br />
{{ 'labels.inputs.Relationship' | translate }} :
{{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name'
}}<br />
{{ 'labels.inputs.Age' | translate }} : {{ member.age }}<br />
{{ 'labels.inputs.Is Dependent' | translate }} : {{ member.isDependent ? 'Yes' : 'No' }}<br />
{{ 'labels.inputs.Marital Status' | translate }} :
{{ member.maritalStatusId | find: clientTemplate.familyMemberOptions.maritalStatusIdOptions : 'id' : 'name'
}}<br />
{{ 'labels.inputs.Gender' | translate }} :
{{ member.genderId | find: clientTemplate.familyMemberOptions.genderIdOptions : 'id' : 'name' }}<br />
{{ 'labels.inputs.Profession' | translate }} :
{{ member.professionId | find: clientTemplate.familyMemberOptions.professionIdOptions : 'id' : 'name' }}<br />
{{ 'labels.inputs.Date Of Birth' | translate }} : {{ member.dateOfBirth | dateFormat }}<br />
{{ 'labels.inputs.Percentage Share' | translate }} : {{ member.percentageShare }}%<br />
{{ 'labels.inputs.Nominee is Minor' | translate }} : {{ member.isDependent ? 'Yes' : 'No' }}<br />
@if (member.isDependent) {
{{ 'labels.inputs.Guardian Name' | translate }} : {{ member.guardianName }}<br />
{{ 'labels.inputs.Relationship with Minor' | translate }} : {{ member.guardianRelationship }}<br />
}
@if (member.remarks) {
{{ 'labels.inputs.Remarks' | translate }} : {{ member.remarks }}<br />
}
</p>
</mat-expansion-panel>
}
@@ -222,10 +222,36 @@
<mat-divider class="flex-98"></mat-divider>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Mobile No' | translate }}</mat-label>
<input matInput type="text" formControlName="mobileNo" />
</mat-form-field>
<div class="dip-info flex-98">
<mat-icon
class="dip-info-icon"
matTooltip="Digital Intelligence Platform (DIP) refers to the fraud-monitoring and intelligence ecosystem created by the Department of Telecommunications (DoT)"
matTooltipPosition="above"
>info</mat-icon
>
<span class="dip-info-text">DIP Verification</span>
</div>
<div class="mobile-verify-row flex-48">
<mat-form-field class="mobile-field">
<mat-label>{{ 'labels.inputs.Mobile No' | translate }}</mat-label>
<input matInput type="text" formControlName="mobileNo" />
</mat-form-field>
@if (!mobileVerified) {
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn"
[disabled]="!createClientForm.get('mobileNo').value"
(click)="verifyWithDIP()"
>
Verify with DIP
</button>
} @else {
<span class="text-success verified-label">&#10004; Verified</span>
}
</div>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Email Address' | translate }}</mat-label>
@@ -123,6 +123,55 @@ mat-checkbox {
}
}
.dip-info {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
.dip-info-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #1976d2;
cursor: pointer;
}
.dip-info-text {
font-size: 13px;
font-weight: 500;
color: #555;
}
}
.mobile-verify-row {
display: flex;
align-items: center;
gap: 10px;
.mobile-field {
flex: 1;
}
.verify-btn {
height: 40px;
white-space: nowrap;
font-size: 13px;
margin-bottom: 18px;
}
.verified-label {
white-space: nowrap;
margin-bottom: 18px;
}
.text-success {
color: #388e3c;
font-size: 14px;
font-weight: 600;
}
}
.external-id-status {
font-size: 12px;
font-weight: 500;
@@ -29,6 +29,8 @@ import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -46,7 +48,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox,
MatStepperPrevious,
FaIconComponent,
MatStepperNext
MatStepperNext,
MatIcon,
MatTooltip
]
})
export class ClientGeneralStepComponent implements OnInit, OnDestroy {
@@ -92,6 +96,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy {
genderOptions: any;
/** Saving Product Options */
savingProductOptions: any;
/** Mobile number DIP verification status */
mobileVerified = false;
/**
* @param {FormBuilder} formBuilder Form Builder
@@ -251,6 +257,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
/**
* Verify mobile number with DIP.
*/
verifyWithDIP() {
this.mobileVerified = true;
}
getDateLabel(legalFormId: number, values: string[]): string {
return legalFormId === LegalFormId.PERSON ? values[0] : values[1];
}
@@ -0,0 +1,117 @@
<!--
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="mat-typography" [formGroup]="kycForm">
<div class="kyc-header">
<h3>{{ 'labels.inputs.KYC Verification' | translate }}</h3>
<span class="kyc-badge">KYC</span>
</div>
<p class="kyc-subtitle">
{{ 'labels.inputs.KYC' | translate }} — RBI Master Direction compliant. Fields are optional but recommended for
compliance.
</p>
<div class="kyc-info-hint">
<mat-icon class="kyc-info-hint-icon">info</mat-icon>
<span>Verification (PAN, Aadhaar, GSTIN) will be available from the KYC tab after the client is created.</span>
</div>
<mat-divider class="m-b-20 m-t-10"></mat-divider>
<!-- PAN Section -->
<div class="kyc-field-row">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.PAN Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
<input
matInput
formControlName="panNumber"
placeholder="{{ 'labels.inputs.Enter 10-character PAN' | translate }}"
maxlength="10"
style="text-transform: uppercase"
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })"
/>
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 10-character PAN' | translate }}</mat-error>
}
</mat-form-field>
</div>
<mat-divider class="m-b-20 m-t-10"></mat-divider>
<!-- Aadhaar Section -->
<div class="kyc-field-row">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
<input
matInput
formControlName="aadhaarNumber"
placeholder="{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}"
maxlength="12"
type="password"
/>
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
}
</mat-form-field>
</div>
<mat-divider class="m-b-20 m-t-10"></mat-divider>
<!-- GSTIN Section -->
<div class="kyc-field-row">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.GSTIN Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'labels.inputs.GSTIN Number' | translate }}</mat-label>
<input
matInput
formControlName="gstinNumber"
placeholder="{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}"
maxlength="15"
style="text-transform: uppercase"
(input)="kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}</mat-error>
}
</mat-form-field>
</div>
<mat-divider class="m-b-20 m-t-10"></mat-divider>
<!-- Additional Fields -->
<div class="kyc-additional">
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<textarea matInput formControlName="remarks" rows="3"></textarea>
</mat-form-field>
</div>
<!-- Navigation -->
<div class="layout-row align-center gap-2percent margin-t responsive-column">
<button mat-raised-button matStepperPrevious type="button">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<button mat-raised-button matStepperNext type="button">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
@@ -0,0 +1,252 @@
/**
* 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/.
*/
.kyc-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
h3 {
margin: 0;
}
}
.kyc-badge {
background-color: #1976d2;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 0.5px;
line-height: 18px;
}
.kyc-tag {
background-color: #e3f2fd;
color: #1565c0;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
border: 1px solid #90caf9;
letter-spacing: 0.5px;
line-height: 16px;
}
.kyc-subtitle {
color: #666;
font-size: 13px;
margin: 0 0 8px;
}
.kyc-info-hint {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #1565c0;
padding: 8px 12px;
background-color: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 6px;
margin-bottom: 4px;
}
.kyc-info-hint-icon {
font-size: 18px;
height: 18px;
width: 18px;
color: #1565c0;
}
.kyc-field-row {
margin-bottom: 8px;
}
.kyc-field-label-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.kyc-field-title {
font-weight: 600;
font-size: 14px;
}
.kyc-input-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.kyc-input {
flex: 1;
}
.verify-btn {
margin-top: 4px;
min-width: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.kyc-status-row {
margin-top: 4px;
margin-bottom: 8px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
padding: 4px 10px;
border-radius: 16px;
&.verified {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
&.pending {
background-color: #fff8e1;
color: #f57f17;
border: 1px solid #ffe082;
}
&.failed {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ef9a9a;
}
}
.status-chip-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
.status-icon {
font-size: 20px;
&.verified {
color: #2e7d32;
}
&.failed {
color: #c62828;
}
}
.kyc-additional {
margin-top: 8px;
}
.full-width {
width: 100%;
}
.m-b-20 {
margin-bottom: 20px;
}
.m-t-10 {
margin-top: 10px;
}
/* OTP Section */
.otp-section {
margin-top: 8px;
padding: 12px 16px;
background-color: #f3f8ff;
border: 1px solid #bbdefb;
border-radius: 8px;
}
.otp-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #1565c0;
margin: 0 0 10px;
font-weight: 500;
}
.otp-info-icon {
font-size: 18px;
height: 18px;
width: 18px;
color: #1565c0;
}
.otp-input {
max-width: 200px;
}
.resend-btn {
margin-top: 4px;
font-size: 12px;
}
/* Warning & Error messages */
.kyc-warning {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #f57f17;
margin-top: 4px;
padding: 6px 10px;
background-color: #fffde7;
border: 1px solid #fff59d;
border-radius: 6px;
}
.kyc-warning-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #f57f17;
}
.kyc-error-msg {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #c62828;
margin-top: 4px;
padding: 6px 10px;
background-color: #ffebee;
border: 1px solid #ef9a9a;
border-radius: 6px;
}
.kyc-error-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #c62828;
}
.verification-detail {
display: inline-block;
font-size: 12px;
color: #333;
margin-left: 8px;
font-weight: 500;
}
@@ -0,0 +1,84 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
/** Custom Imports */
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Client KYC Step Component
* Collects PAN, Aadhaar, and GSTIN details during client creation.
* KYC data is submitted via POST /clients/{clientId}/kyc after client creation.
* Verification happens later from the KYC tab on the client view page.
*/
@Component({
selector: 'mifosx-client-kyc-step',
templateUrl: './client-kyc-step.component.html',
styleUrls: ['./client-kyc-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatStepperPrevious,
MatStepperNext,
MatDivider,
MatIcon,
FaIconComponent
]
})
export class ClientKycStepComponent {
private formBuilder = inject(UntypedFormBuilder);
/** KYC Reactive Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
panNumber: [
'',
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
],
aadhaarNumber: [
'',
[Validators.pattern(/^\d{12}$/)]
],
gstinNumber: [
'',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
],
remarks: ['']
});
/**
* Returns the KYC payload matching the backend API contract.
* POST /clients/{clientId}/kyc expects: kycType, riskCategory, panNumber,
* aadhaarNumberMasked (NOT full aadhaarNumber), gstinNumber, remarks.
*/
get kycData(): any {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
return {
locale: 'en',
kycType: 1,
riskCategory: 1,
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null
};
}
/** Returns true if any KYC data has been entered */
get hasKycData(): boolean {
const v = this.kycForm.value;
return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber);
}
}
+20 -2
View File
@@ -55,6 +55,9 @@ import { ClientChargeViewResolver } from './common-resolvers/client-charge-view.
import { ClientTransactionPayResolver } from './common-resolvers/client-transaction-pay.resolver';
import { ClientDataAndTemplateResolver } from './common-resolvers/client-and-template.resolver';
import { ClientCollateralResolver } from './common-resolvers/client-collateral.resolver';
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';
const routes: Routes = [
Route.withShell([
@@ -117,7 +120,7 @@ const routes: Routes = [
},
{
path: 'family-members',
data: { title: 'Family Members', breadcrumb: 'Family Members', routeParamBreadcrumb: false },
data: { title: 'Nominee', breadcrumb: 'Nominee', routeParamBreadcrumb: false },
children: [
{
path: '',
@@ -140,7 +143,7 @@ const routes: Routes = [
{
path: 'edit',
component: EditFamilyMemberComponent,
data: { title: 'Family Member View', routeParamBreadcrumb: 'familyMemberId' },
data: { title: 'Nominee View', routeParamBreadcrumb: 'familyMemberId' },
resolve: {
clientTemplate: ClientTemplateResolver,
editFamilyMember: ClientFamilyMemberResolver
@@ -159,6 +162,21 @@ const routes: Routes = [
clientIdentifierTemplate: ClientIdentifierTemplateResolver
}
},
{
path: 'kyc',
component: KycTabComponent,
data: { title: 'KYC', breadcrumb: 'KYC', routeParamBreadcrumb: false }
},
{
path: 'ckyc',
component: CkycTabComponent,
data: { title: 'CKYC', breadcrumb: 'CKYC', routeParamBreadcrumb: false }
},
{
path: 'form60',
component: Form60TabComponent,
data: { title: 'Form 60', breadcrumb: 'Form 60', routeParamBreadcrumb: false }
},
{
path: 'documents',
component: DocumentsTabComponent,
@@ -0,0 +1,351 @@
<!--
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="ckyc-tab-container">
<!-- Loading State -->
@if (loading) {
<div class="ckyc-loading">
<mat-progress-spinner mode="indeterminate" diameter="48"></mat-progress-spinner>
<p>{{ 'labels.inputs.CKYC' | translate }}...</p>
</div>
}
@if (!loading) {
<!-- Header -->
<div class="ckyc-tab-header">
<div class="ckyc-tab-title">
<h3>{{ 'labels.heading.CKYC Verification' | translate }}</h3>
<span class="ckyc-badge">CKYC</span>
</div>
</div>
<!-- Top Section: Read-Only Client Info -->
<mat-card class="ckyc-info-card">
<mat-card-content>
<div class="ckyc-info-grid">
<!-- Account Number Dropdown -->
<div class="ckyc-info-item">
<span class="ckyc-info-label">{{ 'labels.inputs.Account Number' | translate }}</span>
<mat-form-field appearance="outline">
<mat-select
[value]="selectedAccount?.accountNo || ''"
(selectionChange)="onAccountSelect($event.value)"
[placeholder]="'labels.inputs.Select Account' | translate"
>
@for (account of allAccounts; track account.accountNo) {
<mat-option [value]="account.accountNo">
{{ account.accountNo }} ({{ account.productName }})
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Client ID -->
<div class="ckyc-info-item">
<span class="ckyc-info-label">{{ 'labels.inputs.Client ID' | translate }}</span>
<span class="ckyc-info-value">{{ clientId }}</span>
</div>
<!-- Client Name -->
<div class="ckyc-info-item">
<span class="ckyc-info-label">{{ 'labels.inputs.Client Name' | translate }}</span>
<span class="ckyc-info-value">{{ clientName }}</span>
</div>
<!-- Account Type -->
<div class="ckyc-info-item">
<span class="ckyc-info-label">{{ 'labels.inputs.Account Type' | translate }}</span>
<span class="ckyc-info-value">{{ selectedAccountType || '—' }}</span>
</div>
</div>
</mat-card-content>
</mat-card>
<!-- Document Upload Section -->
<h4 class="ckyc-section-title">{{ 'labels.heading.CKYC Documents' | translate }}</h4>
<div class="ckyc-upload-grid">
<!-- Address Proof Document -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
<div class="ckyc-upload-header">
<mat-icon class="ckyc-upload-icon">home</mat-icon>
<span>{{ 'labels.inputs.Address Proof Document' | translate }}</span>
</div>
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#addressProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ADDRESS_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="addressProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="addressProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!addressProofFile || uploading['ADDRESS_PROOF']"
(click)="uploadDocument('ADDRESS_PROOF')"
>
@if (uploading['ADDRESS_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['ADDRESS_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
</div>
}
@if (uploadResponses['ADDRESS_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].processingStatus }}
</p>
</div>
}
</mat-card-content>
</mat-card>
<!-- Photo Proof Document -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
<div class="ckyc-upload-header">
<mat-icon class="ckyc-upload-icon">photo_camera</mat-icon>
<span>{{ 'labels.inputs.Photo Proof Document' | translate }}</span>
</div>
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#photoProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'PHOTO_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="photoProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="photoProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!photoProofFile || uploading['PHOTO_PROOF']"
(click)="uploadDocument('PHOTO_PROOF')"
>
@if (uploading['PHOTO_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['PHOTO_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
</div>
}
@if (uploadResponses['PHOTO_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].processingStatus }}
</p>
</div>
}
</mat-card-content>
</mat-card>
<!-- Client Image -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
<div class="ckyc-upload-header">
<mat-icon class="ckyc-upload-icon">person</mat-icon>
<span>{{ 'labels.inputs.Image of Client' | translate }}</span>
</div>
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
type="file"
#clientImageInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_IMAGE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientImageFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientImageInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientImageFile || uploading['CLIENT_IMAGE']"
(click)="uploadDocument('CLIENT_IMAGE')"
>
@if (uploading['CLIENT_IMAGE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['CLIENT_IMAGE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_IMAGE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].processingStatus }}
</p>
</div>
}
</mat-card-content>
</mat-card>
<!-- Client Signature -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
<div class="ckyc-upload-header">
<mat-icon class="ckyc-upload-icon">draw</mat-icon>
<span>{{ 'labels.inputs.Signature of Client' | translate }}</span>
</div>
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
type="file"
#clientSignatureInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_SIGNATURE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientSignatureFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientSignatureInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientSignatureFile || uploading['CLIENT_SIGNATURE']"
(click)="uploadDocument('CLIENT_SIGNATURE')"
>
@if (uploading['CLIENT_SIGNATURE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }}
</p>
</div>
}
</mat-card-content>
</mat-card>
</div>
}
</div>
@@ -0,0 +1,182 @@
/**
* 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/.
*/
.ckyc-tab-container {
padding: 16px;
}
.ckyc-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
gap: 16px;
color: #666;
}
.ckyc-tab-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.ckyc-tab-title {
display: flex;
align-items: center;
gap: 10px;
h3 {
margin: 0;
font-weight: 600;
}
}
.ckyc-badge {
background-color: #1976d2;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 0.5px;
line-height: 18px;
}
.ckyc-info-card {
margin-bottom: 24px;
}
.ckyc-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
align-items: start;
}
.ckyc-info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.ckyc-info-label {
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ckyc-info-value {
font-size: 14px;
color: #333;
min-height: 20px;
}
.ckyc-section-title {
margin: 24px 0 12px;
font-weight: 600;
}
.ckyc-upload-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.ckyc-upload-card {
.mat-mdc-card-content {
padding: 16px;
}
}
.ckyc-upload-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
}
.ckyc-upload-icon {
font-size: 20px;
height: 20px;
width: 20px;
color: #1976d2;
}
.ckyc-upload-hint {
font-size: 12px;
color: #888;
margin: 0 0 12px;
}
.ckyc-file-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 12px;
}
.ckyc-file-name-field {
flex: 1;
}
.ckyc-upload-error {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 8px 12px;
background-color: #fdecea;
border: 1px solid #f5c6cb;
border-radius: 6px;
font-size: 12px;
color: #b71c1c;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
.ckyc-upload-response {
margin-top: 12px;
padding: 10px 14px;
background-color: #e8f5e9;
border: 1px solid #a5d6a7;
border-radius: 8px;
font-size: 12px;
p {
margin: 4px 0;
}
}
@media (width <= 600px) {
.ckyc-info-grid {
grid-template-columns: 1fr;
}
.ckyc-upload-grid {
grid-template-columns: 1fr;
}
.ckyc-file-row {
flex-direction: column;
}
.ckyc-file-name-field {
width: 100%;
}
}
@@ -0,0 +1,219 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subject, takeUntil, catchError, of } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { AlertService } from 'app/core/alert/alert.service';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
styleUrls: ['./ckyc-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon,
MatProgressSpinner,
FaIconComponent
]
})
export class CkycTabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private alertService = inject(AlertService);
private destroy$ = new Subject<void>();
/** Top section - read-only client info */
clientId: string;
clientName: string = '';
allAccounts: any[] = [];
selectedAccount: any = null;
selectedAccountType: string = '';
/** State */
loading = true;
ckycData: any = null;
/** File references */
addressProofFile: File | null = null;
photoProofFile: File | null = null;
clientImageFile: File | null = null;
clientSignatureFile: File | null = null;
/** Per-document upload state */
uploading: { [key: string]: boolean } = {
ADDRESS_PROOF: false,
PHOTO_PROOF: false,
CLIENT_IMAGE: false,
CLIENT_SIGNATURE: false
};
/** Per-document upload response */
uploadResponses: { [key: string]: any } = {};
/** Per-document error messages */
uploadErrors: { [key: string]: string | null } = {
ADDRESS_PROOF: null,
PHOTO_PROOF: null,
CLIENT_IMAGE: null,
CLIENT_SIGNATURE: null
};
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
this.loadClientInfo();
this.loadAccounts();
this.loadCkycData();
});
}
private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => {
if (data?.clientViewData) {
this.clientName = data.clientViewData.displayName || '';
}
});
}
private loadAccounts(): void {
this.clientsService
.getClientAccountData(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => of(null))
)
.subscribe((data: any) => {
if (data) {
const savings = (data.savingsAccounts || []).map((a: any) => ({
accountNo: a.accountNo,
accountType: 'Savings',
productName: a.productName || '',
id: a.id
}));
const loans = (data.loanAccounts || []).map((a: any) => ({
accountNo: a.accountNo,
accountType: 'Loan',
productName: a.productName || '',
id: a.id
}));
this.allAccounts = [
...savings,
...loans
];
}
});
}
private loadCkycData(): void {
this.loading = true;
this.clientsService
.getClientCkyc(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => {
this.loading = false;
return of(null);
})
)
.subscribe((data: any) => {
this.loading = false;
this.ckycData = data;
});
}
onAccountSelect(accountNo: string): void {
this.selectedAccount = this.allAccounts.find((a) => a.accountNo === accountNo) || null;
this.selectedAccountType = this.selectedAccount?.accountType || '';
}
onFileSelect(event: any, documentType: string): void {
const file = event.target?.files?.[0];
if (!file) return;
switch (documentType) {
case 'ADDRESS_PROOF':
this.addressProofFile = file;
break;
case 'PHOTO_PROOF':
this.photoProofFile = file;
break;
case 'CLIENT_IMAGE':
this.clientImageFile = file;
break;
case 'CLIENT_SIGNATURE':
this.clientSignatureFile = file;
break;
}
this.uploadErrors[documentType] = null;
}
getFile(documentType: string): File | null {
switch (documentType) {
case 'ADDRESS_PROOF':
return this.addressProofFile;
case 'PHOTO_PROOF':
return this.photoProofFile;
case 'CLIENT_IMAGE':
return this.clientImageFile;
case 'CLIENT_SIGNATURE':
return this.clientSignatureFile;
default:
return null;
}
}
uploadDocument(documentType: string): void {
const file = this.getFile(documentType);
if (!file) return;
this.uploading[documentType] = true;
this.uploadErrors[documentType] = null;
const formData = new FormData();
formData.append('file', file);
formData.append('documentType', documentType);
this.clientsService
.uploadCkycDocument(this.clientId, formData)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.uploading[documentType] = false;
this.uploadResponses[documentType] = response;
this.alertService.alert({
type: 'success',
message: response?.message || 'Document uploaded successfully.'
});
},
error: (err: any) => {
this.uploading[documentType] = false;
this.uploadErrors[documentType] =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.';
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
@@ -440,6 +440,21 @@
>
{{ 'labels.inputs.Identities' | translate }}
</a>
<a mat-tab-link [routerLink]="['./kyc']" routerLinkActive #kyc="routerLinkActive" [active]="kyc.isActive">
{{ 'labels.inputs.KYC' | translate }}
</a>
<a mat-tab-link [routerLink]="['./ckyc']" routerLinkActive #ckyc="routerLinkActive" [active]="ckyc.isActive">
{{ 'labels.inputs.CKYC' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./form60']"
routerLinkActive
#form60="routerLinkActive"
[active]="form60.isActive"
>
{{ 'labels.inputs.Form 60' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./documents']"
@@ -8,83 +8,31 @@
<div class="add-family-member-container">
<form [formGroup]="addFamilyMemberForm" (ngSubmit)="submit()">
<h3>{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}</h3>
<div class="layout-row-wrap responsive-column align-start-center">
<!-- Nomination Type -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<input
formControlName="firstName"
required
matInput
[attr.aria-label]="'labels.inputs.First Name' | translate"
placeholder="{{ 'labels.inputs.First Name' | translate }}"
[attr.title]="'labels.inputs.First Name' | translate"
/>
<mat-label>{{ 'labels.inputs.Nomination Type' | translate }}</mat-label>
<mat-select formControlName="nominationType" required>
<mat-option value="DA1">{{ 'labels.inputs.DA1 - Add Nominee' | translate }}</mat-option>
<mat-option value="DA5">{{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<!-- Nominee Name -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Nominee Name' | translate }}</mat-label>
<input formControlName="firstName" required matInput />
@if (addFamilyMemberForm.controls.firstName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.First Name' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<input
formControlName="middleName"
matInput
[attr.aria-label]="'labels.inputs.Middle Name' | translate"
placeholder="{{ 'labels.inputs.Middle Name' | translate }}"
[attr.title]="'labels.inputs.Middle Name' | translate"
/>
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input
formControlName="lastName"
required
matInput
[attr.aria-label]="'labels.inputs.Last Name' | translate"
placeholder="{{ 'labels.inputs.Last Name' | translate }}"
[attr.title]="'labels.inputs.Last Name' | translate"
/>
@if (addFamilyMemberForm.controls.lastName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Last Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Qualification' | translate }}</mat-label>
<input
formControlName="qualification"
matInput
[attr.aria-label]="'labels.inputs.Qualification' | translate"
placeholder="{{ 'labels.inputs.Qualification' | translate }}"
[attr.title]="'labels.inputs.Qualification' | translate"
/>
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Age' | translate }}</mat-label>
<input
type="number"
formControlName="age"
matInput
[min]="minAge"
disabled
[attr.aria-label]="'labels.inputs.Age' | translate"
placeholder="{{ 'labels.inputs.Age' | translate }}"
[attr.title]="'labels.inputs.Age' | translate"
/>
</mat-form-field>
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-50-minus-25px">
{{ 'labels.inputs.Is Dependent' | translate }}?
</mat-checkbox>
<!-- Relationship -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship' | translate }}</mat-label>
<mat-select formControlName="relationshipId" required>
@@ -102,46 +50,8 @@
}
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Gender' | translate }}</mat-label>
<mat-select formControlName="genderId" required>
@for (gender of addFamilyMemberTemplate.genderIdOptions; track gender) {
<mat-option [value]="gender.id">
{{ gender.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
@if (addFamilyMemberForm.controls.genderId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Gender' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Profession' | translate }}</mat-label>
<mat-select formControlName="professionId">
@for (profession of addFamilyMemberTemplate.professionIdOptions; track profession) {
<mat-option [value]="profession.id">
{{ profession.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Marital Status' | translate }}</mat-label>
<mat-select formControlName="maritalStatusId">
@for (maritalStatus of addFamilyMemberTemplate.maritalStatusIdOptions; track maritalStatus) {
<mat-option [value]="maritalStatus.id">
{{ maritalStatus.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-fill" (click)="dueDatePicker.open()">
<!-- Date of Birth -->
<mat-form-field class="flex-50-minus-25px" (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Date Of Birth' | translate }}</mat-label>
<input
formControlName="dateOfBirth"
@@ -149,12 +59,45 @@
[max]="maxDate"
[matDatepicker]="dueDatePicker"
placeholder="{{ 'labels.text.Optional' | translate }}"
[attr.aria-label]="'labels.inputs.Date Of Birth' | translate"
[attr.title]="'labels.inputs.Date Of Birth' | translate"
/>
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
</mat-form-field>
<!-- Percentage Share -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Percentage Share' | translate }} (%)</mat-label>
<input type="number" formControlName="percentageShare" matInput min="0" max="100" />
</mat-form-field>
<!-- Nominee is Minor -->
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-50-minus-25px">
{{ 'labels.inputs.Nominee is Minor' | translate }}?
</mat-checkbox>
</div>
<!-- Guardian Details (shown when minor) -->
@if (addFamilyMemberForm.get('isDependent').value) {
<h3>{{ 'labels.heading.Guardian Details (If Minor)' | translate }}</h3>
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Guardian Name' | translate }}</mat-label>
<input formControlName="guardianName" matInput />
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship with Minor' | translate }}</mat-label>
<input formControlName="guardianRelationship" matInput />
</mat-form-field>
</div>
}
<!-- Remarks -->
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<input formControlName="remarks" matInput />
</mat-form-field>
</div>
<div class="layout-row gap-32px layout-align-center">
@@ -40,8 +40,6 @@ export class AddFamilyMemberComponent implements OnInit {
/** Maximum Due Date allowed. */
maxDate = new Date();
/** Minimum age allowed is 0. */
minAge = 0;
/** Add family member form. */
addFamilyMemberForm: UntypedFormGroup;
/** Add family member template. */
@@ -49,14 +47,6 @@ export class AddFamilyMemberComponent implements OnInit {
/** Client ID */
clientId: any;
/**
* @param {FormBuilder} formBuilder FormBuilder
* @param {Dates} dateUtils Date Utils
* @param {Router} router Router
* @param {Route} route Route
* @param {ClientsService} clientsService Clients Service
* @param {SettingsService} settingsService Setting service
*/
constructor() {
this.route.data.subscribe((data: { clientTemplate: any }) => {
this.addFamilyMemberTemplate = data.clientTemplate.familyMemberOptions;
@@ -67,91 +57,39 @@ export class AddFamilyMemberComponent implements OnInit {
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createAddFamilyMemberForm();
this.addFamilyMemberForm.get('dateOfBirth').valueChanges.subscribe((dateOfBirth: any) => {
if (dateOfBirth) {
const age = this.calculateAge(dateOfBirth);
this.addFamilyMemberForm.get('age').setValue(age);
} else {
this.addFamilyMemberForm.get('age').setValue('');
}
});
}
/**
* Calculates age from date of birth
* @param {Date} dateOfBirth Date of Birth
* @returns {number} Age
*/
calculateAge(dateOfBirth: Date): number {
const today = new Date(this.settingsService.businessDate);
const birthDate = new Date(dateOfBirth);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
/**
* Creates the add family member form
*/
createAddFamilyMemberForm() {
this.addFamilyMemberForm = this.formBuilder.group({
nominationType: ['DA1'],
firstName: [
'',
Validators.required
],
middleName: [''],
lastName: [
'',
Validators.required
],
qualification: [''],
age: [
{ value: '', disabled: true }],
isDependent: [''],
relationshipId: [
'',
Validators.required
],
genderId: [
'',
Validators.required
],
professionId: [''],
maritalStatusId: [''],
dateOfBirth: ['']
dateOfBirth: [''],
percentageShare: [100],
isDependent: [false],
guardianName: [''],
guardianRelationship: [''],
remarks: ['']
});
}
/**
* Submits the form and adds the family member
*/
submit() {
// Get form values including disabled controls like age
const formValue = {
...this.addFamilyMemberForm.getRawValue()
};
const formValue = { ...this.addFamilyMemberForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevDateOfBirth: Date = formValue.dateOfBirth;
// Calculate age from dateOfBirth if present
if (prevDateOfBirth) {
if (formValue.dateOfBirth) {
if (formValue.dateOfBirth instanceof Date) {
formValue.dateOfBirth = this.dateUtils.formatDate(prevDateOfBirth, dateFormat);
}
// Ensure age is calculated even if it wasn't already
if (!formValue.age && prevDateOfBirth) {
formValue.age = this.calculateAge(prevDateOfBirth);
formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat);
}
} else {
// If no date of birth, remove age and dateOfBirth from submission
delete formValue.age;
delete formValue.dateOfBirth;
}
@@ -8,48 +8,31 @@
<div class="add-family-member-container">
<form [formGroup]="editFamilyMemberForm" (ngSubmit)="submit()">
<h3>{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}</h3>
<div class="layout-row-wrap responsive-column align-start-center">
<!-- Nomination Type -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<mat-label>{{ 'labels.inputs.Nomination Type' | translate }}</mat-label>
<mat-select formControlName="nominationType" required>
<mat-option value="DA1">{{ 'labels.inputs.DA1 - Add Nominee' | translate }}</mat-option>
<mat-option value="DA5">{{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<!-- Nominee Name -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Nominee Name' | translate }}</mat-label>
<input formControlName="firstName" required matInput />
@if (editFamilyMemberForm.controls.firstName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.First Name' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<input formControlName="middleName" matInput />
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input formControlName="lastName" required matInput />
@if (editFamilyMemberForm.controls.lastName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Last Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Qualification' | translate }}</mat-label>
<input formControlName="qualification" matInput />
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Age' | translate }}</mat-label>
<input type="number" formControlName="age" matInput disabled />
</mat-form-field>
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-50-minus-25px">
{{ 'labels.inputs.Is Dependent' | translate }}
</mat-checkbox>
<!-- Relationship -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship' | translate }}</mat-label>
<mat-select formControlName="relationshipId" required>
@@ -67,46 +50,8 @@
}
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Gender' | translate }}</mat-label>
<mat-select formControlName="genderId" required>
@for (gender of addFamilyMemberTemplate.genderIdOptions; track gender) {
<mat-option [value]="gender.id">
{{ gender.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
@if (editFamilyMemberForm.controls.genderId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Gender' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Profession' | translate }}</mat-label>
<mat-select formControlName="professionId">
@for (profession of addFamilyMemberTemplate.professionIdOptions; track profession) {
<mat-option [value]="profession.id">
{{ profession.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Marital Status' | translate }}</mat-label>
<mat-select formControlName="maritalStatusId">
@for (maritalStatus of addFamilyMemberTemplate.maritalStatusIdOptions; track maritalStatus) {
<mat-option [value]="maritalStatus.id">
{{ maritalStatus.name | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-fill" (click)="dueDatePicker.open()">
<!-- Date of Birth -->
<mat-form-field class="flex-50-minus-25px" (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Date Of Birth' | translate }}</mat-label>
<input
formControlName="dateOfBirth"
@@ -118,6 +63,41 @@
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
</mat-form-field>
<!-- Percentage Share -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Percentage Share' | translate }} (%)</mat-label>
<input type="number" formControlName="percentageShare" matInput min="0" max="100" />
</mat-form-field>
<!-- Nominee is Minor -->
<mat-checkbox formControlName="isDependent" labelPosition="before" class="flex-50-minus-25px">
{{ 'labels.inputs.Nominee is Minor' | translate }}?
</mat-checkbox>
</div>
<!-- Guardian Details (shown when minor) -->
@if (editFamilyMemberForm.get('isDependent').value) {
<h3>{{ 'labels.heading.Guardian Details (If Minor)' | translate }}</h3>
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Guardian Name' | translate }}</mat-label>
<input formControlName="guardianName" matInput />
</mat-form-field>
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Relationship with Minor' | translate }}</mat-label>
<input formControlName="guardianRelationship" matInput />
</mat-form-field>
</div>
}
<!-- Remarks -->
<div class="layout-row-wrap responsive-column align-start-center">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<input formControlName="remarks" matInput />
</mat-form-field>
</div>
<div class="layout-row gap-32px layout-align-center">
@@ -40,21 +40,13 @@ export class EditFamilyMemberComponent implements OnInit {
/** Maximum Due Date allowed. */
maxDate = new Date();
/** Add family member form. */
/** Edit family member form. */
editFamilyMemberForm: UntypedFormGroup;
/** Add family member template. */
/** Family member template options. */
addFamilyMemberTemplate: any;
/** Family Members Details */
familyMemberDetails: any;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {Dates} dateUtils Date Utils
* @param {Router} router Router
* @param {ActivatedRoute} route Route
* @param {ClientsService} clientsService Clients Service
* @param {SettingsService} settingsService Setting service
*/
constructor() {
this.route.data.subscribe((data: { clientTemplate: any; editFamilyMember: any }) => {
this.addFamilyMemberTemplate = data.clientTemplate.familyMemberOptions;
@@ -65,94 +57,41 @@ export class EditFamilyMemberComponent implements OnInit {
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createEditFamilyMemberForm(this.familyMemberDetails);
this.editFamilyMemberForm.get('dateOfBirth').valueChanges.subscribe((dateOfBirth: any) => {
if (dateOfBirth) {
const age = this.calculateAge(dateOfBirth);
this.editFamilyMemberForm.get('age').setValue(age);
} else {
this.editFamilyMemberForm.get('age').setValue('');
}
});
}
/**
* Calculates age from date of birth
* @param {Date} dateOfBirth Date of Birth
* @returns {number} Age
*/
calculateAge(dateOfBirth: Date): number {
const today = new Date(this.settingsService.businessDate);
const birthDate = new Date(dateOfBirth);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
/**
* Creates Edit Family Member Form
* @param {any} familyMember Family Member
*/
createEditFamilyMemberForm(familyMember: any) {
this.editFamilyMemberForm = this.formBuilder.group({
nominationType: [familyMember.nominationType || 'DA1'],
firstName: [
familyMember.firstName,
Validators.required
],
middleName: [familyMember.middleName],
lastName: [
familyMember.lastName,
Validators.required
],
qualification: [familyMember.qualification],
age: [
{ value: familyMember.age, disabled: true }],
isDependent: [familyMember.isDependent],
relationshipId: [
familyMember.relationshipId,
Validators.required
],
genderId: [
familyMember.genderId,
Validators.required
],
professionId: [familyMember.professionId],
maritalStatusId: [familyMember.maritalStatusId],
dateOfBirth: [
familyMember.dateOfBirth ? this.dateUtils.formatDate(familyMember.dateOfBirth, 'yyyy-MM-dd') : null
]
],
percentageShare: [familyMember.percentageShare ?? 100],
isDependent: [familyMember.isDependent || false],
guardianName: [familyMember.guardianName || ''],
guardianRelationship: [familyMember.guardianRelationship || ''],
remarks: [familyMember.remarks || '']
});
}
/**
* Submits the form and updates the client family member.
*/
submit() {
// Get form values including disabled controls like age
const formValue = {
...this.editFamilyMemberForm.getRawValue()
};
const formValue = { ...this.editFamilyMemberForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevDateOfBirth: Date = formValue.dateOfBirth;
// Calculate age from dateOfBirth if present
if (prevDateOfBirth) {
if (formValue.dateOfBirth) {
if (formValue.dateOfBirth instanceof Date) {
formValue.dateOfBirth = this.dateUtils.formatDate(prevDateOfBirth, dateFormat);
}
// Ensure age is calculated even if it wasn't already
if (!formValue.age && prevDateOfBirth) {
formValue.age = this.calculateAge(prevDateOfBirth);
formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat);
}
} else {
// If no date of birth, remove age and dateOfBirth from submission
delete formValue.age;
delete formValue.dateOfBirth;
}
@@ -51,39 +51,35 @@
</button>
</div>
<p>
@if (member.nominationType) {
<span>{{ 'labels.inputs.Nomination Type' | translate }} : {{ member.nominationType }}<br /></span>
}
@if (member.firstName) {
<span>{{ 'labels.inputs.First Name' | translate }} : {{ member.firstName }}<br /></span>
<span>{{ 'labels.inputs.Nominee Name' | translate }} : {{ member.firstName }}<br /></span>
}
@if (member.middleName) {
<span>{{ 'labels.inputs.Middle Name' | translate }} : {{ member.middleName }}<br /></span>
}
@if (member.lastName) {
<span>{{ 'labels.inputs.Last Name' | translate }} : {{ member.lastName }}<br /></span>
}
@if (member.qualification) {
<span>{{ 'labels.inputs.Qualification' | translate }} : {{ member.qualification }}<br /></span>
}
@if (member.mobileNumber) {
<span>{{ 'labels.inputs.Mobile Number' | translate }} : {{ member.mobileNumber }}<br /></span>
}
@if (member.age) {
<span>{{ 'labels.inputs.Age' | translate }} : {{ member.age }}<br /></span>
}
@if (member.isDependent) {
<span>{{ 'labels.inputs.Is Dependent' | translate }} : {{ member.isDependent | yesNo }}<br /></span>
}
@if (member.maritalStatus) {
<span>{{ 'labels.inputs.Marital Status' | translate }} : {{ member.maritalStatus }}<br /></span>
}
@if (member.gender) {
<span>{{ 'labels.inputs.Gender' | translate }} : {{ member.gender }}<br /></span>
}
@if (member.profession) {
<span>{{ 'labels.inputs.Profession' | translate }} : {{ member.profession }}<br /></span>
@if (member.relationship) {
<span>{{ 'labels.inputs.Relationship' | translate }} : {{ member.relationship }}<br /></span>
}
@if (member.dateOfBirth) {
<span>{{ 'labels.inputs.Date Of Birth' | translate }} : {{ member.dateOfBirth | dateFormat }}<br /></span>
}
@if (member.percentageShare != null) {
<span>{{ 'labels.inputs.Percentage Share' | translate }} : {{ member.percentageShare }}%<br /></span>
}
@if (member.isDependent) {
<span>{{ 'labels.inputs.Nominee is Minor' | translate }} : {{ member.isDependent | yesNo }}<br /></span>
}
@if (member.guardianName) {
<span>{{ 'labels.inputs.Guardian Name' | translate }} : {{ member.guardianName }}<br /></span>
}
@if (member.guardianRelationship) {
<span
>{{ 'labels.inputs.Relationship with Minor' | translate }} : {{ member.guardianRelationship }}<br
/></span>
}
@if (member.remarks) {
<span>{{ 'labels.inputs.Remarks' | translate }} : {{ member.remarks }}<br /></span>
}
</p>
</mat-expansion-panel>
}
@@ -74,7 +74,7 @@ export class FamilyMembersTabComponent {
*/
deleteFamilyMember(clientId: string, id: string, name: string, index: number) {
const deleteFamilyMemberDialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `Family member id:${id} name : ${name} ${index}` }
data: { deleteContext: `Nominee id:${id} name : ${name}` }
});
deleteFamilyMemberDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
@@ -86,13 +86,6 @@ export class FamilyMembersTabComponent {
}
displayName(member: any): string {
let fullName: string = member.firstName;
if (member.middleName) {
fullName = fullName + ' ' + member.middleName;
}
if (member.lastName) {
fullName = fullName + ' ' + member.lastName;
}
return fullName;
return member.firstName || '';
}
}
@@ -0,0 +1,185 @@
<!--
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="form60-container">
<!-- Warning Notice -->
<div class="form60-notice">
<mat-icon>warning</mat-icon>
<span>{{ 'labels.text.PAN not provided. Please fill Form 60 as per Income Tax Rule 114B.' | translate }}</span>
</div>
@if (loading) {
<div class="form60-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else {
<!-- Read-only view when data exists and not editing -->
@if (form60Data && !isEditMode) {
<mat-card>
<mat-card-header>
<mat-card-title>{{ 'labels.inputs.Form 60 Declaration' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="form60-summary">
@if (form60Data.fullName) {
<div class="form60-field">
<span class="label">{{ 'labels.inputs.Full Name' | translate }}</span>
<span class="value">{{ form60Data.fullName }}</span>
</div>
}
@if (form60Data.dateOfBirth) {
<div class="form60-field">
<span class="label">{{ 'labels.inputs.Date Of Birth' | translate }}</span>
<span class="value">{{ form60Data.dateOfBirth | dateFormat }}</span>
</div>
}
@if (form60Data.fatherName) {
<div class="form60-field">
<span class="label">{{ "labels.inputs.Father's Name" | translate }}</span>
<span class="value">{{ form60Data.fatherName }}</span>
</div>
}
@if (form60Data.address) {
<div class="form60-field">
<span class="label">{{ 'labels.inputs.Address' | translate }}</span>
<span class="value">{{ form60Data.address }}</span>
</div>
}
@if (form60Data.transactionType) {
<div class="form60-field">
<span class="label">{{ 'labels.inputs.Transaction Type' | translate }}</span>
<span class="value">{{ form60Data.transactionType }}</span>
</div>
}
@if (form60Data.transactionAmount) {
<div class="form60-field">
<span class="label">{{ 'labels.inputs.Transaction Amount' | translate }}</span>
<span class="value">{{ form60Data.transactionAmount }}</span>
</div>
}
@if (form60Data.estimatedTotalIncome) {
<div class="form60-field">
<span class="label">{{ 'labels.inputs.Estimated Total Income' | translate }}</span>
<span class="value">{{ form60Data.estimatedTotalIncome }}</span>
</div>
}
@if (form60Data.reasonForNoPan) {
<div class="form60-field">
<span class="label">{{ 'labels.inputs.Reason for Not Having PAN' | translate }}</span>
<span class="value">{{ form60Data.reasonForNoPan }}</span>
</div>
}
</div>
</mat-card-content>
<mat-card-actions>
<button mat-raised-button color="primary" (click)="toggleEditMode()">
<mat-icon>edit</mat-icon>
{{ 'labels.buttons.Edit' | translate }}
</button>
</mat-card-actions>
</mat-card>
}
<!-- Edit / Create Form -->
@if (isEditMode) {
<mat-card>
<mat-card-header>
<mat-card-title>{{ 'labels.inputs.Form 60 Declaration' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form60Form" (ngSubmit)="submit()">
<div class="layout-row-wrap responsive-column align-start-center">
<!-- Full Name -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Full Name' | translate }}</mat-label>
<input formControlName="fullName" required matInput />
@if (form60Form.controls.fullName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Full Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Date of Birth -->
<mat-form-field class="flex-50-minus-25px" (click)="dobPicker.open()">
<mat-label>{{ 'labels.inputs.Date Of Birth' | translate }}</mat-label>
<input formControlName="dateOfBirth" matInput [max]="maxDate" [matDatepicker]="dobPicker" />
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
</mat-form-field>
<!-- Father's Name -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ "labels.inputs.Father's Name" | translate }}</mat-label>
<input formControlName="fatherName" matInput />
</mat-form-field>
<!-- Address -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Address' | translate }}</mat-label>
<input formControlName="address" matInput />
</mat-form-field>
<!-- Transaction Type -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Transaction Type' | translate }}</mat-label>
<mat-select formControlName="transactionType">
@for (txnType of transactionTypes; track txnType.value) {
<mat-option [value]="txnType.value">{{ txnType.key | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Transaction Amount -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Transaction Amount' | translate }}</mat-label>
<input type="number" formControlName="transactionAmount" matInput />
</mat-form-field>
<!-- Estimated Total Income -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Estimated Total Income' | translate }}</mat-label>
<input type="number" formControlName="estimatedTotalIncome" matInput />
</mat-form-field>
<!-- Reason for Not Having PAN -->
<mat-form-field class="flex-50-minus-25px">
<mat-label>{{ 'labels.inputs.Reason for Not Having PAN' | translate }}</mat-label>
<mat-select formControlName="reasonForNoPan">
@for (reason of reasonOptions; track reason.value) {
<mat-option [value]="reason.value">{{ reason.key | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Declaration Checkbox -->
<div class="form60-declaration">
<mat-checkbox formControlName="declaration">
{{ 'labels.text.I declare that I do not have PAN and information provided is correct.' | translate }}
</mat-checkbox>
</div>
<!-- Action Buttons -->
<div class="layout-row gap-32px layout-align-center">
@if (form60Data) {
<button type="button" mat-raised-button (click)="toggleEditMode()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
}
<button type="submit" mat-raised-button color="primary" [disabled]="!form60Form.valid">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
}
</div>
@@ -0,0 +1,84 @@
/**
* 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/.
*/
.form60-container {
padding: 16px;
}
.form60-notice {
display: flex;
align-items: center;
gap: 10px;
background: #fff3cd;
padding: 12px 16px;
border-left: 5px solid #fc0;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
color: #856404;
mat-icon {
color: #856404;
}
}
.form60-loading {
display: flex;
justify-content: center;
padding: 32px;
}
.form60-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
padding: 16px 0;
}
.form60-field {
.label {
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
display: block;
}
.value {
font-size: 14px;
color: #333;
margin-top: 4px;
display: block;
}
}
.form60-declaration {
margin: 16px 0 24px;
padding: 12px 16px;
background: #f5f5f5;
border-radius: 6px;
}
@media screen and (width >= 960px) {
.layout-row-wrap > .flex-fill:nth-child(odd),
.layout-row-wrap > .flex-50-minus-25px:nth-child(odd) {
margin-right: 25px;
}
.layout-row-wrap > .flex-fill:last-child,
.layout-row-wrap > .flex-50-minus-25px:last-child {
margin-right: 0;
}
}
@media (width <= 768px) {
.form60-summary {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,194 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subject, takeUntil, catchError, of } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
@Component({
selector: 'mifosx-form60-tab',
templateUrl: './form60-tab.component.html',
styleUrls: ['./form60-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon,
MatProgressSpinner,
DateFormatPipe
]
})
export class Form60TabComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private alertService = inject(AlertService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private destroy$ = new Subject<void>();
/** Client info */
clientId: string;
/** State */
loading = true;
form60Data: any = null;
isEditMode = false;
/** Maximum date allowed */
maxDate = new Date();
/** Form */
form60Form: UntypedFormGroup;
/** Dropdown options */
transactionTypes = [
{ value: 'Account Opening', key: 'labels.inputs.Account Opening' },
{ value: 'Cash Deposit', key: 'labels.inputs.Cash Deposit' },
{ value: 'Fixed Deposit', key: 'labels.inputs.Fixed Deposit' },
{ value: 'Loan Processing', key: 'labels.inputs.Loan Processing' }
];
reasonOptions = [
{ value: 'Applied for PAN', key: 'labels.inputs.Applied for PAN' },
{ value: 'Income below taxable limit', key: 'labels.inputs.Income below taxable limit' },
{ value: 'Other', key: 'labels.inputs.Other' }
];
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.createForm();
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
this.loadForm60Data();
});
}
private createForm(): void {
this.form60Form = this.formBuilder.group({
fullName: [
'',
Validators.required
],
dateOfBirth: [''],
fatherName: [''],
address: [''],
transactionType: ['Account Opening'],
transactionAmount: [''],
estimatedTotalIncome: [''],
reasonForNoPan: ['Applied for PAN'],
declaration: [
false,
Validators.requiredTrue
]
});
}
private loadForm60Data(): void {
this.loading = true;
this.clientsService
.getClientForm60(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => {
this.loading = false;
return of(null);
})
)
.subscribe((data: any) => {
this.loading = false;
this.form60Data = data;
if (data) {
this.populateForm(data);
} else {
this.isEditMode = true;
}
});
}
private populateForm(data: any): void {
this.form60Form.patchValue({
fullName: data.fullName || '',
dateOfBirth: data.dateOfBirth ? this.dateUtils.formatDate(data.dateOfBirth, 'yyyy-MM-dd') : '',
fatherName: data.fatherName || '',
address: data.address || '',
transactionType: data.transactionType || 'Account Opening',
transactionAmount: data.transactionAmount || '',
estimatedTotalIncome: data.estimatedTotalIncome || '',
reasonForNoPan: data.reasonForNoPan || 'Applied for PAN',
declaration: data.declaration || false
});
}
toggleEditMode(): void {
this.isEditMode = !this.isEditMode;
if (!this.isEditMode && this.form60Data) {
this.populateForm(this.form60Data);
}
}
submit(): void {
const formValue = { ...this.form60Form.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
if (formValue.dateOfBirth) {
if (formValue.dateOfBirth instanceof Date) {
formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat);
}
} else {
delete formValue.dateOfBirth;
}
const data = {
...formValue,
dateFormat,
locale
};
this.clientsService
.submitClientForm60(this.clientId, data)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.form60Data = response || data;
this.isEditMode = false;
this.alertService.alert({
type: 'success',
message: 'Form 60 submitted successfully.'
});
},
error: (err: any) => {
this.alertService.alert({
type: 'error',
message: err?.error?.developerMessage || err?.error?.defaultUserMessage || 'Failed to submit Form 60.'
});
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
@@ -0,0 +1,621 @@
<!--
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="kyc-tab-container">
<!-- Loading State -->
@if (loading) {
<div class="kyc-loading">
<mat-progress-spinner mode="indeterminate" diameter="48"></mat-progress-spinner>
<p>{{ 'labels.inputs.KYC Verification' | translate }}...</p>
</div>
}
@if (!loading) {
<!-- Header -->
<div class="kyc-tab-header">
<div class="kyc-tab-title">
<h3>{{ 'labels.inputs.KYC Verification' | translate }}</h3>
<span class="kyc-badge">KYC</span>
@if (kycData?.kycStatusCode) {
<span class="kyc-overall-status" [class]="'status-' + kycData.kycStatusCode.toLowerCase()">
{{ kycData.kycStatusCode }}
</span>
}
</div>
<button mat-raised-button color="primary" (click)="toggleEditMode()" type="button">
<fa-icon [icon]="isEditMode ? 'times' : 'edit'" class="m-r-10"></fa-icon>
{{ isEditMode ? ('labels.buttons.Cancel' | translate) : ('labels.buttons.Edit' | translate) }}
</button>
</div>
<!-- KYC Summary (read-only when not in edit mode) -->
@if (!isEditMode && kycData) {
<div class="kyc-summary-grid">
<!-- PAN Card -->
<mat-card class="kyc-card">
<mat-card-content>
<div class="kyc-card-header">
<div class="kyc-card-label">
<mat-icon class="kyc-card-icon">credit_card</mat-icon>
<span>{{ 'labels.inputs.PAN Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
@if (kycData.panVerified) {
<span class="status-chip verified">
<mat-icon class="status-chip-icon">check_circle</mat-icon>
{{ 'labels.inputs.Verified' | translate }}
</span>
} @else {
<span class="status-chip not-verified">
<mat-icon class="status-chip-icon">radio_button_unchecked</mat-icon>
{{ 'labels.inputs.Not Verified' | translate }}
</span>
}
</div>
<p class="kyc-value">{{ kycData.panNumber || '—' }}</p>
@if (kycData.panVerifiedOn) {
<p class="kyc-meta">
{{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.panVerifiedOn) }}
</p>
}
@if (verificationError.pan) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.pan }}</span>
</div>
}
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
<p class="kyc-meta verification-detail-text">{{ verificationDetails.pan.name }}</p>
}
<button
mat-stroked-button
color="primary"
class="re-verify-btn"
type="button"
[disabled]="verifying.pan || !kycData.panNumber"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.inputs.Re-Verify' | translate }}
}
</button>
</mat-card-content>
</mat-card>
<!-- Aadhaar Card -->
<mat-card class="kyc-card">
<mat-card-content>
<div class="kyc-card-header">
<div class="kyc-card-label">
<mat-icon class="kyc-card-icon">fingerprint</mat-icon>
<span>{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
@if (kycData.aadhaarVerified) {
<span class="status-chip verified">
<mat-icon class="status-chip-icon">check_circle</mat-icon>
{{ 'labels.inputs.Verified' | translate }}
</span>
} @else {
<span class="status-chip not-verified">
<mat-icon class="status-chip-icon">radio_button_unchecked</mat-icon>
{{ 'labels.inputs.Not Verified' | translate }}
</span>
}
</div>
<p class="kyc-value">{{ kycData.aadhaarNumberMasked || '—' }}</p>
@if (kycData.aadhaarVerifiedOn) {
<p class="kyc-meta">
{{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.aadhaarVerifiedOn) }}
</p>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.aadhaar }}</span>
</div>
}
@if (verificationStatus.aadhaar === 'VERIFIED' && verificationDetails.aadhaar?.name) {
<p class="kyc-meta verification-detail-text">{{ verificationDetails.aadhaar.name }}</p>
}
<!-- Step 1: Re-Verify button -->
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
<button
mat-stroked-button
color="primary"
class="re-verify-btn"
type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
(click)="startAadhaarReVerify()"
>
{{ 'labels.inputs.Re-Verify' | translate }}
</button>
}
<!-- Step 2: Enter full Aadhaar + Send OTP -->
@if (showAadhaarReVerify && !aadhaarOtpSent) {
<div class="otp-section">
<div class="otp-inline">
<mat-form-field appearance="outline" class="otp-field">
<mat-label>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-label>
<input
matInput
[(ngModel)]="aadhaarFullNumber"
[ngModelOptions]="{ standalone: true }"
maxlength="12"
type="password"
/>
</mat-form-field>
<button
mat-raised-button
color="accent"
class="otp-verify-btn"
type="button"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
}
</button>
</div>
</div>
}
<!-- Step 3: Enter OTP + Verify -->
@if (aadhaarOtpSent) {
<div class="otp-section">
<p class="otp-info">
<mat-icon class="otp-info-icon">sms</mat-icon>
OTP sent to Aadhaar-linked mobile
</p>
<div class="otp-inline">
<mat-form-field appearance="outline" class="otp-field">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button
mat-raised-button
color="accent"
class="otp-verify-btn"
type="button"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify OTP
}
</button>
<button
mat-stroked-button
type="button"
class="otp-resend-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
</div>
</div>
}
</mat-card-content>
</mat-card>
<!-- GSTIN Card -->
<mat-card class="kyc-card">
<mat-card-content>
<div class="kyc-card-header">
<div class="kyc-card-label">
<mat-icon class="kyc-card-icon">business</mat-icon>
<span>{{ 'labels.inputs.GSTIN Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
@if (kycData.gstinVerified) {
<span class="status-chip verified">
<mat-icon class="status-chip-icon">check_circle</mat-icon>
{{ 'labels.inputs.Verified' | translate }}
</span>
} @else {
<span class="status-chip not-verified">
<mat-icon class="status-chip-icon">radio_button_unchecked</mat-icon>
{{ 'labels.inputs.Not Verified' | translate }}
</span>
}
</div>
<p class="kyc-value">{{ kycData.gstinNumber || '—' }}</p>
@if (kycData.gstinVerifiedOn) {
<p class="kyc-meta">
{{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.gstinVerifiedOn) }}
</p>
}
@if (verificationError.gstin) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.gstin }}</span>
</div>
}
@if (verificationStatus.gstin === 'VERIFIED' && verificationDetails.gstin?.businessName) {
<p class="kyc-meta verification-detail-text">{{ verificationDetails.gstin.businessName }}</p>
}
<button
mat-stroked-button
color="primary"
class="re-verify-btn"
type="button"
[disabled]="verifying.gstin || !kycData.gstinNumber"
(click)="verifyGSTIN()"
>
@if (verifying.gstin) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.inputs.Re-Verify' | translate }}
}
</button>
</mat-card-content>
</mat-card>
</div>
<!-- Additional Info -->
<mat-card class="kyc-info-card">
<mat-card-content>
<div class="kyc-info-grid">
<div class="kyc-info-item">
<span class="kyc-info-label">{{ 'labels.inputs.KYC Type' | translate }}</span>
<span class="kyc-info-value">{{
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
}}</span>
</div>
<div class="kyc-info-item">
<span class="kyc-info-label">{{ 'labels.inputs.Risk Category' | translate }}</span>
<span class="kyc-info-value">{{
kycData.riskCategory === 1
? 'Low'
: kycData.riskCategory === 2
? 'Medium'
: kycData.riskCategory === 3
? 'High'
: '—'
}}</span>
</div>
<div class="kyc-info-item">
<span class="kyc-info-label">{{ 'labels.inputs.Initiated On' | translate }}</span>
<span class="kyc-info-value">{{ kycData.initiatedOn || '—' }}</span>
</div>
<div class="kyc-info-item">
<span class="kyc-info-label">{{ 'labels.inputs.Last Verified On' | translate }}</span>
<span class="kyc-info-value">{{ formatDate(kycData.verifiedOn) }}</span>
</div>
<div class="kyc-info-item">
<span class="kyc-info-label">{{ 'labels.inputs.Expiry Date' | translate }}</span>
<span class="kyc-info-value">{{ kycData.expiryDate || '—' }}</span>
</div>
@if (kycData.remarks) {
<div class="kyc-info-item full-span">
<span class="kyc-info-label">{{ 'labels.inputs.Remarks' | translate }}</span>
<span class="kyc-info-value">{{ kycData.remarks }}</span>
</div>
}
</div>
</mat-card-content>
</mat-card>
}
<!-- No KYC Data State -->
@if (!isEditMode && !kycData) {
<div class="kyc-empty">
<mat-icon class="kyc-empty-icon">verified_user</mat-icon>
<p>{{ 'labels.inputs.KYC Verification' | translate }} not initiated yet.</p>
<button mat-raised-button color="primary" (click)="toggleEditMode()" type="button">
{{ 'labels.inputs.KYC verification initiated' | translate }}
</button>
</div>
}
<!-- Edit Form -->
@if (isEditMode) {
<mat-card class="kyc-edit-card">
<mat-card-content>
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
<div class="kyc-edit-grid">
<!-- PAN -->
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.PAN Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
<input
matInput
formControlName="panNumber"
maxlength="10"
style="text-transform: uppercase"
(input)="
kycForm
.get('panNumber')
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 10-character PAN' | translate }}</mat-error>
}
@if (verificationStatus.pan === 'VERIFIED') {
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
}
@if (verificationStatus.pan === 'FAILED') {
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
}
</mat-form-field>
<button
mat-raised-button
color="accent"
type="button"
class="verify-btn"
[disabled]="
!kycId || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || verifying.pan
"
(click)="verifyPAN()"
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.inputs.Verify' | translate }}
}
</button>
</div>
@if (verificationError.pan) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.pan }}</span>
</div>
}
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
}
</div>
<!-- Aadhaar -->
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
type="password"
[readonly]="aadhaarOtpSent"
/>
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
}
@if (verificationStatus.aadhaar === 'VERIFIED') {
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
}
@if (verificationStatus.aadhaar === 'FAILED') {
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
}
</mat-form-field>
@if (!aadhaarOtpSent) {
<button
mat-raised-button
color="accent"
type="button"
class="verify-btn"
[disabled]="
!kycId ||
!kycForm.get('aadhaarNumber')?.value ||
kycForm.get('aadhaarNumber')?.invalid ||
verifying.aadhaar
"
(click)="sendAadhaarOtp()"
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
}
</button>
}
</div>
<!-- OTP Section in Edit Mode -->
@if (aadhaarOtpSent) {
<div class="otp-section">
<p class="otp-info">
<mat-icon class="otp-info-icon">sms</mat-icon>
OTP sent to Aadhaar-linked mobile
</p>
<div class="otp-inline">
<mat-form-field appearance="outline" class="otp-field">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button
mat-raised-button
color="accent"
class="otp-verify-btn"
type="button"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify OTP
}
</button>
<button
mat-stroked-button
type="button"
class="otp-resend-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
</div>
</div>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.aadhaar }}</span>
</div>
}
</div>
<!-- GSTIN -->
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.GSTIN Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.GSTIN Number' | translate }}</mat-label>
<input
matInput
formControlName="gstinNumber"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm
.get('gstinNumber')
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 15-character GSTIN' | translate }}</mat-error>
}
@if (verificationStatus.gstin === 'VERIFIED') {
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
}
@if (verificationStatus.gstin === 'FAILED') {
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
}
</mat-form-field>
<button
mat-raised-button
color="accent"
type="button"
class="verify-btn"
[disabled]="
!kycId ||
!kycForm.get('gstinNumber')?.value ||
kycForm.get('gstinNumber')?.invalid ||
verifying.gstin
"
(click)="verifyGSTIN()"
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
>
@if (verifying.gstin) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.inputs.Verify' | translate }}
}
</button>
</div>
@if (verificationError.gstin) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.gstin }}</span>
</div>
}
@if (verificationStatus.gstin === 'VERIFIED' && verificationDetails.gstin?.businessName) {
<span class="verification-detail-text">{{ verificationDetails.gstin.businessName }}</span>
}
</div>
<!-- KYC Type -->
@if (!kycId) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.KYC Type' | translate }}</mat-label>
<mat-select formControlName="kycType">
<mat-option [value]="1">Individual</mat-option>
<mat-option [value]="2">Non-Individual</mat-option>
</mat-select>
</mat-form-field>
}
<!-- Risk Category -->
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Risk Category' | translate }}</mat-label>
<mat-select formControlName="riskCategory">
<mat-option [value]="1">Low</mat-option>
<mat-option [value]="2">Medium</mat-option>
<mat-option [value]="3">High</mat-option>
</mat-select>
</mat-form-field>
<!-- Remarks -->
<mat-form-field appearance="outline" class="full-span">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<textarea matInput formControlName="remarks" rows="3"></textarea>
</mat-form-field>
</div>
@if (errorMessage) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ errorMessage }}</span>
</div>
}
@if (!kycId) {
<div class="kyc-info-hint">
<mat-icon class="kyc-info-hint-icon">info</mat-icon>
<span>Save KYC details first to enable verification buttons.</span>
</div>
}
<div class="kyc-form-actions">
<button mat-raised-button type="submit" color="primary" [disabled]="kycForm.invalid || saving">
@if (saving) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Submit' | translate }}
}
</button>
<button mat-stroked-button type="button" (click)="toggleEditMode()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
}
</div>
@@ -0,0 +1,371 @@
/**
* 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/.
*/
.kyc-tab-container {
padding: 16px;
}
.kyc-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
gap: 16px;
color: #666;
}
.kyc-tab-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.kyc-tab-title {
display: flex;
align-items: center;
gap: 10px;
h3 {
margin: 0;
}
}
.kyc-badge {
background-color: #1976d2;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 0.5px;
line-height: 18px;
}
.kyc-tag {
background-color: #e3f2fd;
color: #1565c0;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
border: 1px solid #90caf9;
letter-spacing: 0.5px;
line-height: 16px;
}
.kyc-overall-status {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
&.status-verified,
&.status-complete {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
&.status-pending,
&.status-initiated {
background-color: #fff8e1;
color: #f57f17;
border: 1px solid #ffe082;
}
&.status-rejected,
&.status-failed {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ef9a9a;
}
}
.kyc-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.kyc-card {
.mat-mdc-card-content {
padding: 16px;
}
}
.kyc-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.kyc-card-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
}
.kyc-card-icon {
font-size: 18px;
height: 18px;
width: 18px;
color: #1976d2;
}
.kyc-value {
font-size: 16px;
font-weight: 500;
letter-spacing: 1px;
margin: 8px 0 4px;
font-family: monospace;
}
.kyc-meta {
font-size: 12px;
color: #888;
margin: 0 0 8px;
}
.re-verify-btn {
font-size: 12px;
padding: 0 12px;
height: 28px;
line-height: 28px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 3px 8px;
border-radius: 14px;
&.verified {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
&.not-verified {
background-color: #f5f5f5;
color: #757575;
border: 1px solid #e0e0e0;
}
}
.status-chip-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
.kyc-info-card {
margin-bottom: 16px;
}
.kyc-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.kyc-info-item {
display: flex;
flex-direction: column;
gap: 2px;
&.full-span {
grid-column: 1 / -1;
}
}
.kyc-info-label {
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kyc-info-value {
font-size: 14px;
color: #333;
}
.kyc-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 16px;
gap: 16px;
color: #888;
text-align: center;
}
.kyc-empty-icon {
font-size: 64px;
height: 64px;
width: 64px;
color: #bdbdbd;
}
.kyc-edit-card {
max-width: 900px;
}
.kyc-edit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.kyc-edit-field {
display: flex;
flex-direction: column;
}
.kyc-field-label-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.kyc-field-title {
font-weight: 600;
font-size: 14px;
}
.kyc-input-row {
display: flex;
align-items: flex-start;
gap: 10px;
}
.kyc-input {
flex: 1;
}
.verify-btn {
margin-top: 4px;
min-width: 90px;
}
.full-span {
grid-column: 1 / -1;
}
.kyc-form-actions {
display: flex;
gap: 12px;
margin-top: 16px;
align-items: center;
}
/* OTP Section */
.otp-section {
margin-top: 8px;
padding: 10px 14px;
background-color: #f3f8ff;
border: 1px solid #bbdefb;
border-radius: 8px;
}
.otp-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #1565c0;
margin: 0 0 8px;
font-weight: 500;
}
.otp-info-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #1565c0;
}
.otp-inline {
display: flex;
align-items: flex-start;
gap: 8px;
}
.otp-field {
max-width: 160px;
}
.otp-verify-btn {
margin-top: 4px;
font-size: 12px;
height: 36px;
}
.otp-resend-btn {
margin-top: 4px;
font-size: 11px;
height: 36px;
}
/* Error message */
.kyc-error-msg {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #c62828;
margin-top: 6px;
padding: 5px 8px;
background-color: #ffebee;
border: 1px solid #ef9a9a;
border-radius: 6px;
}
.kyc-error-icon {
font-size: 14px;
height: 14px;
width: 14px;
color: #c62828;
}
.verification-detail-text {
display: block;
font-size: 12px;
color: #2e7d32;
font-weight: 500;
margin-top: 4px;
}
/* Status icons in edit mode */
.status-icon {
font-size: 20px;
&.verified {
color: #2e7d32;
}
&.failed {
color: #c62828;
}
}
@@ -0,0 +1,426 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { Dates } from 'app/core/utils/dates';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* KYC Tab Component.
* Displays and manages KYC (Know Your Customer) details for an existing client.
* API: GET/POST /clients/{clientId}/kyc
*/
@Component({
selector: 'mifosx-kyc-tab',
templateUrl: './kyc-tab.component.html',
styleUrls: ['./kyc-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatIcon,
MatProgressSpinner,
MatTooltip,
FaIconComponent
]
})
export class KycTabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private destroy$ = new Subject<void>();
clientId: string;
kycId: string | null = null;
kycData: any = null;
clientDateOfBirth: string | null = null;
loading = true;
saving = false;
isEditMode = false;
errorMessage: string | null = null;
verifying: { pan: boolean; aadhaar: boolean; gstin: boolean } = {
pan: false,
aadhaar: false,
gstin: false
};
/** Verification status tracking for re-verify */
verificationStatus: { pan: string | null; aadhaar: string | null; gstin: string | null } = {
pan: null,
aadhaar: null,
gstin: null
};
verificationError: { pan: string | null; aadhaar: string | null; gstin: string | null } = {
pan: null,
aadhaar: null,
gstin: null
};
verificationDetails: { pan: any; aadhaar: any; gstin: any } = {
pan: null,
aadhaar: null,
gstin: null
};
/** Aadhaar OTP flow state */
aadhaarOtpSent = false;
aadhaarOtpReferenceId: string | null = null;
aadhaarOtp = '';
aadhaarOtpVerifying = false;
showAadhaarReVerify = false;
aadhaarFullNumber = '';
kycForm: UntypedFormGroup = this.formBuilder.group({
kycType: [
1,
[Validators.required]
],
riskCategory: [
1,
[Validators.required]
],
panNumber: [
'',
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
],
aadhaarNumber: [
'',
[Validators.pattern(/^\d{12}$/)]
],
gstinNumber: [
'',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
],
remarks: ['']
});
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
this.loadKycData();
this.loadClientDateOfBirth();
});
}
loadKycData(): void {
this.loading = true;
this.errorMessage = null;
this.clientsService
.getClientKyc(this.clientId)
.pipe(
catchError(() => {
this.loading = false;
return of(null);
})
)
.subscribe((data: any) => {
this.loading = false;
this.kycData = data;
if (data) {
this.kycId = data.id?.toString() || null;
this.kycForm.patchValue({
kycType: data.kycType || 1,
riskCategory: data.riskCategory || 1,
panNumber: data.panNumber || '',
aadhaarNumber: data.aadhaarNumberMasked || '',
gstinNumber: data.gstinNumber || '',
remarks: data.remarks || ''
});
}
});
}
/** Load client's date of birth from parent route resolved data */
loadClientDateOfBirth(): void {
this.route.parent.data.subscribe((data: any) => {
if (data?.clientViewData?.dateOfBirth) {
const dob = data.clientViewData.dateOfBirth;
if (Array.isArray(dob) && dob.length >= 3) {
this.clientDateOfBirth = `${dob[0]}-${String(dob[1]).padStart(2, '0')}-${String(dob[2]).padStart(2, '0')}`;
} else if (typeof dob === 'string') {
this.clientDateOfBirth = dob;
}
}
});
}
toggleEditMode(): void {
this.isEditMode = !this.isEditMode;
this.resetOtpState();
}
saveKyc(): void {
if (this.kycForm.invalid) {
return;
}
this.saving = true;
this.errorMessage = null;
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
const payload: any = {
locale: 'en',
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null
};
if (!this.kycId) {
payload.kycType = this.kycForm.value.kycType || 1;
payload.riskCategory = this.kycForm.value.riskCategory || 1;
} else {
payload.riskCategory = this.kycForm.value.riskCategory || 1;
}
const save$ = this.kycId
? this.clientsService.updateClientKyc(this.clientId, this.kycId, payload)
: this.clientsService.saveClientKyc(this.clientId, payload);
save$.subscribe({
next: (response: any) => {
this.saving = false;
this.isEditMode = false;
if (!this.kycId && response?.resourceId) {
this.kycId = response.resourceId.toString();
}
this.loadKycData();
},
error: (err: any) => {
this.saving = false;
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to save KYC details.';
}
});
}
verifyPAN(): void {
if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
return;
}
this.verifying.pan = true;
this.verificationStatus.pan = 'PENDING';
this.verificationError.pan = null;
this.verificationDetails.pan = null;
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
});
}
/** Show Aadhaar input in summary view for re-verification */
startAadhaarReVerify(): void {
this.showAadhaarReVerify = true;
this.aadhaarFullNumber = '';
this.verificationError.aadhaar = null;
}
/** Step 1: Send OTP to Aadhaar-linked mobile */
sendAadhaarOtp(): void {
const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber;
if (!aadhaar || aadhaar.length !== 12) {
this.verificationError.aadhaar = 'Please enter the full 12-digit Aadhaar number to generate OTP.';
return;
}
if (!this.kycId) {
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
return;
}
this.verifying.aadhaar = true;
this.verificationStatus.aadhaar = 'PENDING';
this.verificationError.aadhaar = null;
this.aadhaarOtpSent = false;
this.aadhaarOtp = '';
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
}
/** Step 2: Verify OTP entered by user */
verifyAadhaarOtp(): void {
if (!this.kycId || !this.aadhaarOtp || this.aadhaarOtp.length < 4) {
return;
}
this.aadhaarOtpVerifying = true;
this.verificationError.aadhaar = null;
this.clientsService
.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId || '')
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.aadhaarOtpVerifying = false;
if (response?.changes?.aadhaarVerified) {
this.verificationStatus.aadhaar = 'VERIFIED';
this.verificationDetails.aadhaar = response.changes;
this.resetOtpState();
this.loadKycData();
} else {
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar = 'OTP verification failed.';
this.resetOtpState();
}
},
error: (err: any) => {
this.aadhaarOtpVerifying = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'OTP verification failed. Please try again.';
this.resetOtpState();
}
});
}
verifyGSTIN(): void {
if (!this.kycId) {
this.verificationError.gstin = 'KYC record not found. Save KYC details first.';
return;
}
this.verifying.gstin = true;
this.verificationStatus.gstin = 'PENDING';
this.verificationError.gstin = null;
this.verificationDetails.gstin = null;
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
});
}
formatDate(date: any): string {
if (!date) return '—';
let year: number, month: number, day: number;
if (Array.isArray(date) && date.length >= 3) {
[
year,
month,
day
] = date;
} else {
return date;
}
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
return `${day} ${months[month - 1]} ${year}`;
}
private resetOtpState(): void {
this.aadhaarOtpSent = false;
this.aadhaarOtp = '';
this.aadhaarOtpReferenceId = null;
this.aadhaarOtpVerifying = false;
this.showAadhaarReVerify = false;
this.aadhaarFullNumber = '';
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
+9 -1
View File
@@ -65,6 +65,10 @@ import { ClientFamilyMemberDialogComponent } from './client-stepper/client-famil
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
import { ClientKycStepComponent } from './client-stepper/client-kyc-step/client-kyc-step.component';
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';
/**
* Clients Module
@@ -125,7 +129,11 @@ import { ClientDatatableStepComponent } from './client-stepper/client-datatable-
ClientFamilyMemberDialogComponent,
CaptureImageDialogComponent,
AddClientCollateralComponent,
ClientDatatableStepComponent
ClientDatatableStepComponent,
ClientKycStepComponent,
KycTabComponent,
CkycTabComponent,
Form60TabComponent
],
providers: []
})
+85
View File
@@ -472,4 +472,89 @@ export class ClientsService {
return this.externalHttp.post(apiUrl, { externalId }, { headers });
}
/**
* Gets KYC details for a client.
* @param clientId Client ID
*/
getClientKyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/kyc`);
}
/**
* Saves KYC details for a client.
* @param clientId Client ID
* @param kycData KYC payload
*/
saveClientKyc(clientId: string, kycData: any): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc`, kycData);
}
/**
* Updates existing KYC details for a client.
* @param clientId Client ID
* @param kycId KYC record ID
* @param kycData KYC payload
*/
updateClientKyc(clientId: string, kycId: string, kycData: any): Observable<any> {
return this.http.put(`/clients/${clientId}/kyc/${kycId}`, kycData);
}
/**
* Verifies PAN number via LincPay (reads PAN from the stored KYC record).
* @param clientId Client ID
* @param kycId KYC record ID
*/
verifyPAN(clientId: string, kycId: string): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-pan`, {});
}
/**
* Step 1 of Aadhaar e-KYC: sends OTP to the Aadhaar-linked mobile via LincPay.
* @param clientId Client ID
* @param kycId KYC record ID
* @param aadhaarNumber Full 12-digit Aadhaar number (never stored)
*/
sendAadhaarOtp(clientId: string, kycId: string, aadhaarNumber: string): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=aadhaar-generate-otp`, { aadhaarNumber });
}
/**
* Step 2 of Aadhaar e-KYC: verifies the OTP entered by the client via LincPay.
* @param clientId Client ID
* @param kycId KYC record ID
* @param otp OTP received on Aadhaar-linked mobile
* @param aadhaarRefNumber Reference number returned by sendAadhaarOtp (changes.aadhaarRefNumber)
*/
verifyAadhaarOtp(clientId: string, kycId: string, otp: string, aadhaarRefNumber: string): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-aadhaar`, {
aadhaarRefNumber,
aadhaarOtp: otp
});
}
/**
* Verifies GSTIN via LincPay (reads GSTIN from the stored KYC record).
* @param clientId Client ID
* @param kycId KYC record ID
*/
verifyGSTIN(clientId: string, kycId: string): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
}
getClientCkyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/ckyc`);
}
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
}
getClientForm60(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/form60`);
}
submitClientForm60(clientId: string, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/form60`, data);
}
}
@@ -38,7 +38,7 @@
</mat-step>
<mat-step>
<ng-template matStepLabel>{{ 'labels.inputs.FAMILY MEMBERS' | translate }}</ng-template>
<ng-template matStepLabel>{{ 'labels.inputs.Nominee' | translate }}</ng-template>
<mifosx-client-family-members-step
#clientFamily
@@ -46,6 +46,11 @@
></mifosx-client-family-members-step>
</mat-step>
<mat-step>
<ng-template matStepLabel>{{ 'labels.inputs.KYC' | translate }}</ng-template>
<mifosx-client-kyc-step #clientKyc></mifosx-client-kyc-step>
</mat-step>
@if (clientTemplate.isAddressEnabled) {
<mat-step>
<ng-template matStepLabel>{{ 'labels.inputs.ADDRESS' | translate }}</ng-template>
@@ -18,6 +18,7 @@ import { ClientGeneralStepComponent } from '../client-stepper/client-general-ste
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
import { ClientAddressStepComponent } from '../client-stepper/client-address-step/client-address-step.component';
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
@@ -44,6 +45,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
ClientFamilyMembersStepComponent,
ClientAddressStepComponent,
ClientDatatableStepComponent,
ClientKycStepComponent,
ClientPreviewStepComponent
]
})
@@ -59,6 +61,8 @@ export class CreateClientComponent {
@ViewChild('clientFamily') clientFamilyMembersStep: ClientFamilyMembersStepComponent;
/** Client Address Step */
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
/** Client KYC Step */
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
/** Get handle on dtclient tags in the template */
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
@@ -167,13 +171,35 @@ export class CreateClientComponent {
}
this.clientsService.createClient(clientData).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
const clientId = response.resourceId;
if (this.clientKycStep?.hasKycData) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
complete: () =>
this.router.navigate(
[
'../',
clientId
],
{ relativeTo: this.route }
),
error: () =>
this.router.navigate(
[
'../',
clientId
],
{ relativeTo: this.route }
)
});
} else {
this.router.navigate(
[
'../',
clientId
],
{ relativeTo: this.route }
);
}
});
}
}