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
+1 -1
View File
@@ -105,7 +105,7 @@
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "mifosx-web-app:build",
"proxyConfig": "proxy.conf.js"
"proxyConfig": "proxy.localhost.conf.js"
},
"configurations": {
"development": {
+3 -2
View File
@@ -15,8 +15,9 @@
"ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
"start": "npm run env -s && ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && ng serve --source-map",
"start": "npm run env -s && ng serve --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && ng serve --source-map --proxy-config proxy.localhost.conf.js",
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
"lint": "eslint . && stylelint \"src/**/*.scss\" && prettier . --check && htmlhint \"src\" --config .htmlhintrc",
"test": "jest --config jest.config.ts",
+2 -3
View File
@@ -20,13 +20,12 @@ const proxyConfig = [
{
context: ['/fineract-provider'],
target: 'https://demo.mifos.community',
pathRewrite: { '^/fineract-provider': '' },
changeOrigin: true,
secure: true,
logLevel: 'debug',
onProxyReq: function (proxyReq, req, res) {
const rewrittenPath = (req.url || '').replace(/^\/fineract-provider/, '');
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
const forwardedPath = req.url || '';
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + forwardedPath);
},
onError: function (err, req, res) {
console.error(
+3 -4
View File
@@ -16,14 +16,13 @@
module.exports = [
{
context: ['/fineract-provider'],
target: 'http://localhost:8443',
pathRewrite: { '^/fineract-provider': '' },
target: 'https://localhost:8443',
changeOrigin: true,
secure: false,
logLevel: 'debug',
onProxyReq: function (proxyReq, req, res) {
const rewrittenPath = (req.url || '').replace(/^\/fineract-provider/, '');
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
const forwardedPath = req.url || '';
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + forwardedPath);
},
onError: function (err, req, res) {
console.error(
+6
View File
@@ -49,6 +49,9 @@ import { ProfileModule } from './profile/profile.module';
import { TasksModule } from './tasks/tasks.module';
import { ConfigurationWizardModule } from './configuration-wizard/configuration-wizard.module';
import { RemittancesModule } from './remittances/remittances.module';
import { SupersetAnalyticsModule } from './superset-analytics/superset-analytics.module';
import { CkycModule } from './ckyc/ckyc.module';
import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module';
import { PortalModule } from '@angular/cdk/portal';
/** Main Routing Module */
@@ -129,6 +132,9 @@ export function HttpLoaderFactory(http: HttpClient) {
TasksModule,
ConfigurationWizardModule,
RemittancesModule,
SupersetAnalyticsModule,
CkycModule,
RbiCircularsModule,
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
@@ -0,0 +1,19 @@
<!--
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="container">
<mat-card>
<mat-card-content>
<div class="placeholder-content">
<fa-icon icon="chart-pie" size="3x" class="placeholder-icon"></fa-icon>
<h3>{{ 'labels.heading.CKYC Reports' | translate }}</h3>
<p>{{ 'labels.text.This section is under development.' | translate }}</p>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,21 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@Component({
selector: 'mifosx-ckyc-reports',
templateUrl: './ckyc-reports.component.html',
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class CkycReportsComponent {}
+58
View File
@@ -0,0 +1,58 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { CkycComponent } from './ckyc.component';
import { RecordMasterComponent } from './record-master/record-master.component';
import { CkycVerificationComponent } from './ckyc-verification/ckyc-verification.component';
import { CkycReportsComponent } from './ckyc-reports/ckyc-reports.component';
import { EkycComponent } from './ekyc/ekyc.component';
const routes: Routes = [
Route.withShell([
{
path: 'ckyc',
data: { title: 'CKYC', breadcrumb: 'CKYC' },
children: [
{
path: '',
component: CkycComponent
},
{
path: 'record-master',
component: RecordMasterComponent,
data: { title: 'Record Master', breadcrumb: 'Record Master' }
},
{
path: 'ckyc-verification',
component: CkycVerificationComponent,
data: { title: 'CKYC', breadcrumb: 'CKYC Verification' }
},
{
path: 'ckyc-reports',
component: CkycReportsComponent,
data: { title: 'CKYC Reports', breadcrumb: 'CKYC Reports' }
},
{
path: 'ekyc',
component: EkycComponent,
data: { title: 'E-KYC', breadcrumb: 'E-KYC' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class CkycRoutingModule {}
@@ -0,0 +1,19 @@
<!--
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="container">
<mat-card>
<mat-card-content>
<div class="placeholder-content">
<fa-icon icon="shield-alt" size="3x" class="placeholder-icon"></fa-icon>
<h3>{{ 'labels.heading.CKYC' | translate }}</h3>
<p>{{ 'labels.text.This section is under development.' | translate }}</p>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,21 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@Component({
selector: 'mifosx-ckyc-verification',
templateUrl: './ckyc-verification.component.html',
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class CkycVerificationComponent {}
+125
View File
@@ -0,0 +1,125 @@
<!--
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="container">
<mat-card>
<div class="layout-row responsive-column gap-20px">
<div class="flex-50">
<mat-nav-list>
<mat-list-item>
<div class="menu-list-item-content">
<div class="menu-left-section" [routerLink]="['record-master']">
<mat-icon matListIcon>
<fa-icon icon="database" size="sm"></fa-icon>
</mat-icon>
<div matLine>
{{ 'labels.heading.Record Master' | translate }}
@if (arrowBooleans[0]) {
<p matLine [routerLink]="['record-master']" class="menu-explanation">
{{ 'labels.text.Manage master records for CKYC processing' | translate }}
</p>
}
</div>
</div>
<div class="menu-right-section">
@if (!arrowBooleans[0]) {
<fa-icon (click)="arrowBooleansToggle(0)" icon="arrow-down" size="md"></fa-icon>
}
@if (arrowBooleans[0]) {
<fa-icon (click)="arrowBooleansToggle(0)" icon="arrow-up" size="md"></fa-icon>
}
</div>
</div>
</mat-list-item>
<mat-list-item>
<div class="menu-list-item-content">
<div class="menu-left-section" [routerLink]="['ckyc-verification']">
<mat-icon matListIcon>
<fa-icon icon="shield-alt" size="sm"></fa-icon>
</mat-icon>
<div matLine>
{{ 'labels.heading.CKYC' | translate }}
@if (arrowBooleans[1]) {
<p matLine [routerLink]="['ckyc-verification']" class="menu-explanation">
{{ 'labels.text.Central KYC verification and document management' | translate }}
</p>
}
</div>
</div>
<div class="menu-right-section">
@if (!arrowBooleans[1]) {
<fa-icon (click)="arrowBooleansToggle(1)" icon="arrow-down" size="md"></fa-icon>
}
@if (arrowBooleans[1]) {
<fa-icon (click)="arrowBooleansToggle(1)" icon="arrow-up" size="md"></fa-icon>
}
</div>
</div>
</mat-list-item>
</mat-nav-list>
</div>
<div class="flex-50">
<mat-nav-list>
<mat-list-item>
<div class="menu-list-item-content">
<div class="menu-left-section" [routerLink]="['ckyc-reports']">
<mat-icon matListIcon>
<fa-icon icon="chart-pie" size="sm"></fa-icon>
</mat-icon>
<div matLine>
{{ 'labels.heading.CKYC Reports' | translate }}
@if (arrowBooleans[2]) {
<p matLine [routerLink]="['ckyc-reports']" class="menu-explanation">
{{ 'labels.text.View and generate CKYC compliance reports' | translate }}
</p>
}
</div>
</div>
<div class="menu-right-section">
@if (!arrowBooleans[2]) {
<fa-icon (click)="arrowBooleansToggle(2)" icon="arrow-down" size="md"></fa-icon>
}
@if (arrowBooleans[2]) {
<fa-icon (click)="arrowBooleansToggle(2)" icon="arrow-up" size="md"></fa-icon>
}
</div>
</div>
</mat-list-item>
<mat-list-item>
<div class="menu-list-item-content">
<div class="menu-left-section" [routerLink]="['ekyc']">
<mat-icon matListIcon>
<fa-icon icon="fingerprint" size="sm"></fa-icon>
</mat-icon>
<div matLine>
{{ 'labels.heading.E-KYC' | translate }}
@if (arrowBooleans[3]) {
<p matLine [routerLink]="['ekyc']" class="menu-explanation">
{{ 'labels.text.Electronic KYC verification using Aadhaar and biometrics' | translate }}
</p>
}
</div>
</div>
<div class="menu-right-section">
@if (!arrowBooleans[3]) {
<fa-icon (click)="arrowBooleansToggle(3)" icon="arrow-down" size="md"></fa-icon>
}
@if (arrowBooleans[3]) {
<fa-icon (click)="arrowBooleansToggle(3)" icon="arrow-up" size="md"></fa-icon>
}
</div>
</div>
</mat-list-item>
</mat-nav-list>
</div>
</div>
</mat-card>
</div>
+7
View File
@@ -0,0 +1,7 @@
/**
* 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/.
*/
+36
View File
@@ -0,0 +1,36 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatNavList, MatListItem } from '@angular/material/list';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-ckyc',
templateUrl: './ckyc.component.html',
styleUrls: ['./ckyc.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatNavList,
MatListItem,
MatIcon,
FaIconComponent,
MatLine
]
})
export class CkycComponent {
arrowBooleans: boolean[] = new Array(4).fill(false);
arrowBooleansToggle(arrowNumber: number) {
this.arrowBooleans[arrowNumber] = !this.arrowBooleans[arrowNumber];
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { CkycRoutingModule } from './ckyc-routing.module';
import { CkycComponent } from './ckyc.component';
import { RecordMasterComponent } from './record-master/record-master.component';
import { CkycVerificationComponent } from './ckyc-verification/ckyc-verification.component';
import { CkycReportsComponent } from './ckyc-reports/ckyc-reports.component';
import { EkycComponent } from './ekyc/ekyc.component';
@NgModule({
imports: [
SharedModule,
CkycRoutingModule,
CkycComponent,
RecordMasterComponent,
CkycVerificationComponent,
CkycReportsComponent,
EkycComponent
]
})
export class CkycModule {}
+19
View File
@@ -0,0 +1,19 @@
<!--
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="container">
<mat-card>
<mat-card-content>
<div class="placeholder-content">
<fa-icon icon="fingerprint" size="3x" class="placeholder-icon"></fa-icon>
<h3>{{ 'labels.heading.E-KYC' | translate }}</h3>
<p>{{ 'labels.text.This section is under development.' | translate }}</p>
</div>
</mat-card-content>
</mat-card>
</div>
+21
View File
@@ -0,0 +1,21 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@Component({
selector: 'mifosx-ekyc',
templateUrl: './ekyc.component.html',
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class EkycComponent {}
@@ -0,0 +1,19 @@
<!--
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="container">
<mat-card>
<mat-card-content>
<div class="placeholder-content">
<fa-icon icon="database" size="3x" class="placeholder-icon"></fa-icon>
<h3>{{ 'labels.heading.Record Master' | translate }}</h3>
<p>{{ 'labels.text.This section is under development.' | translate }}</p>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,21 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@Component({
selector: 'mifosx-record-master',
templateUrl: './record-master.component.html',
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class RecordMasterComponent {}
@@ -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 }
);
}
});
}
}
@@ -93,6 +93,19 @@
</mat-icon>
<a matLine #navigation>{{ 'labels.menus.Navigation' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/ckyc']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.CKYC' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'READ_CLIENT'"
>
<mat-icon matListIcon>
<fa-icon icon="id-card" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.CKYC' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/checker-inbox-and-tasks/checker-inbox']"
[matTooltipPosition]="tooltipPosition"
@@ -145,6 +158,31 @@
</mat-icon>
<a matLine>{{ 'labels.menus.Remittances' | translate }}</a>
</mat-list-item>
<mat-list-item
*ngIf="supersetEnabled"
[routerLink]="['/analytics']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.Analytics' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>
<fa-icon icon="chart-bar" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.Analytics' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/rbi-circulars']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.RBI Circulars' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>
<fa-icon icon="newspaper" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.RBI Circulars' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/notifications']"
[matTooltipPosition]="tooltipPosition"
@@ -33,6 +33,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { remittanceConfig } from '../../../remittances/remittance.config';
import { environment } from '../../../../environments/environment';
import { catchError, finalize, of, take } from 'rxjs';
@@ -80,6 +81,8 @@ export class SidenavComponent implements OnInit, AfterViewInit {
frequentActivities: any[] = frequentActivities;
/** Whether remittance feature is enabled */
mifosRemittanceEnabled = remittanceConfig.isRemittanceEnabled;
/** Whether Superset Analytics is enabled */
supersetEnabled = environment.supersetEnabled;
/* Refernce of logo */
@ViewChild('logo') logo: ElementRef<any>;
+126 -13
View File
@@ -6,11 +6,14 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card class="card layout-column gap-40px">
<div class="layout-row search">
<div class="cbs-dashboard-container">
<div class="cbs-header">Core Banking System Dashboard</div>
<div class="cbs-content">
<!-- Search Activity -->
<div class="search-row">
<mat-card-content>
<mat-form-field>
<mat-form-field class="search-field">
<mat-label>{{ 'labels.placeholders.Search Activity' | translate }}</mat-label>
<input matInput [matAutocomplete]="activitySearch" [formControl]="searchText" />
</mat-form-field>
@@ -25,20 +28,130 @@
</mat-card-content>
</div>
<div class="layout-row top-card">
<div class="trends-bar">
<mifosx-client-trends-bar></mifosx-client-trends-bar>
<!-- Regulatory & Compliance -->
<div class="section-title">Regulatory & Compliance</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">CRR / SLR</div>
<div class="stat-card-value">&#8377; 125 Cr</div>
<div class="stat-trend up">&#9650; Within Limits</div>
</div>
<div class="stat-card">
<div class="stat-card-title">CRAR</div>
<div class="stat-card-value">13.42%</div>
<div class="stat-trend up">&#9650; Healthy</div>
</div>
<div class="stat-card">
<div class="stat-card-title">DICGC (Samyak)</div>
<div class="stat-card-value">Updated</div>
<div class="stat-trend">Last Sync: Today</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Interbank Exposure</div>
<div class="stat-card-value">72%</div>
<div class="stat-trend down">&#9660; Near Limit</div>
</div>
</div>
<div class="layout-row bottom-card">
<div class="amount-disbursed">
<mifosx-amount-disbursed-pie></mifosx-amount-disbursed-pie>
<!-- Financial Health -->
<div class="section-title">Financial Health</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">Net Worth</div>
<div class="stat-card-value">&#8377; 342 Cr</div>
</div>
<div class="amount-collected">
<mifosx-amount-collected-pie></mifosx-amount-collected-pie>
<div class="stat-card">
<div class="stat-card-title">CD Ratio</div>
<div class="stat-card-value">68.2%</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Capital Working</div>
<div class="stat-card-value">&#8377; 210 Cr</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Per Employee Income</div>
<div class="stat-card-value">&#8377; 8.4 L</div>
</div>
</div>
</mat-card>
<!-- Deposits & Advances -->
<div class="section-title">Deposits & Advances</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">Total Deposits</div>
<div class="stat-card-value">&#8377; 1,240 Cr</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Total Advances</div>
<div class="stat-card-value">&#8377; 845 Cr</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Decay Analysis</div>
<div class="stat-card-value">Moderate</div>
</div>
<div class="stat-card">
<div class="stat-card-title">TSWM</div>
<div class="stat-card-value">Active</div>
</div>
</div>
<!-- Risk & NPA Monitoring -->
<div class="section-title">Risk & NPA Monitoring</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">Gross NPA</div>
<div class="stat-card-value">4.82%</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Net NPA</div>
<div class="stat-card-value">2.11%</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Risk-wise Analysis</div>
<div class="stat-card-value">Stable</div>
</div>
</div>
<!-- Alerts & Notifications -->
<div class="section-title">Alerts & Notifications</div>
<div class="alert-panel">
<span>&#9888; CRAR approaching minimum threshold.</span><br />
<span>&#9888; Interbank exposure nearing limit.</span><br />
<span>&#9888; RBI circular pending review.</span>
</div>
<!-- Existing Charts -->
<div class="section-title">Analytics</div>
<div class="charts-section">
<div class="top-card">
<div class="trends-bar">
<mifosx-client-trends-bar></mifosx-client-trends-bar>
</div>
</div>
<div class="bottom-card">
<div class="amount-disbursed">
<mifosx-amount-disbursed-pie></mifosx-amount-disbursed-pie>
</div>
<div class="amount-collected">
<mifosx-amount-collected-pie></mifosx-amount-collected-pie>
</div>
</div>
</div>
<!-- Footer -->
<div class="cbs-footer">CBS Monitoring Dashboard &bull; Internal Use Only</div>
</div>
</div>
+120 -32
View File
@@ -6,60 +6,148 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.card {
box-sizing: border-box;
padding: 2%;
display: flex;
flex-direction: column;
overflow-x: hidden;
.cbs-dashboard-container {
background: #f4f6f9;
min-height: 100vh;
}
.search {
.cbs-header {
background: #1f3b73;
color: white;
padding: 15px 25px;
font-size: 20px;
font-weight: bold;
}
.cbs-content {
padding: 20px;
}
.search-row {
width: 100%;
margin-bottom: 10px;
mat-card-content {
width: 100%;
padding: 12px;
mat-card-content {
width: 100%;
}
mat-form-field {
width: 100%;
}
}
.top-card {
flex: 1 0 100%;
.search-field {
width: 100%;
}
}
.section-title {
margin: 20px 0 10px;
font-size: 16px;
font-weight: bold;
color: #333;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 15px;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 6px rgb(0 0 0 / 8%);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 12%);
}
}
.stat-card-title {
font-size: 13px;
color: #777;
margin-bottom: 8px;
}
.stat-card-value {
font-size: 22px;
font-weight: bold;
color: #1f3b73;
}
.stat-trend {
font-size: 12px;
margin-top: 5px;
color: #888;
&.up {
color: green;
}
&.down {
color: red;
}
}
.alert-panel {
background: #fff3f3;
border-left: 4px solid red;
padding: 10px 15px;
border-radius: 6px;
font-size: 13px;
line-height: 1.8;
color: #333;
}
.charts-section {
display: flex;
flex-direction: column;
gap: 20px;
.top-card {
.trends-bar {
width: 100%;
flex: 1 0 100%;
}
}
.bottom-card {
display: flex;
flex-flow: row wrap;
gap: 2px;
gap: 15px;
.amount-collected,
.amount-disbursed {
flex: 1 0 calc(40% - 10px);
padding: 0;
margin: 10px;
flex: 1 0 calc(50% - 15px);
min-width: 300px;
}
}
}
.cbs-footer {
margin-top: 30px;
text-align: center;
font-size: 12px;
color: #888;
padding: 15px 0;
}
@media screen and (width <= 768px) {
.top-card,
.bottom-card {
flex-direction: column;
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.trends-bar,
.amount-collected,
.amount-disbursed {
flex: 1 0 100%;
width: 100%;
margin: 10px 0;
.stat-card-value {
font-size: 18px;
}
.charts-section {
.bottom-card {
flex-direction: column;
.amount-collected,
.amount-disbursed {
flex: 1 0 100%;
width: 100%;
}
}
}
}
@@ -24,6 +24,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
*/
@Component({
selector: 'mifosx-dashboard',
standalone: true,
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
imports: [
@@ -56,7 +57,7 @@ export class DashboardComponent implements OnInit {
* Gets user activities from local storage.
*/
constructor() {
this.userActivity = JSON.parse(localStorage.getItem('mifosXLocation'));
this.userActivity = JSON.parse(localStorage.getItem('mifosXLocation')) || [];
}
ngOnInit() {
+4 -1
View File
@@ -96,7 +96,10 @@ export class HomeComponent implements OnInit, AfterViewInit {
this.tenant = this.tenantIdentifier();
this.setFilteredActivities();
if (!this.authenticationService.hasDialogBeenShown()) {
this.dialog.open(WarningDialogComponent);
this.dialog.open(WarningDialogComponent, {
disableClose: false,
width: '500px'
});
this.authenticationService.showDialog();
}
}
@@ -8,6 +8,8 @@
<h1 mat-dialog-title>{{ 'labels.text.' + title | translate }}</h1>
<mat-dialog-content>{{ 'labels.text.Warning message' | translate }}</mat-dialog-content>
<mat-dialog-actions>
<button mat-raised-button color="primary" mat-dialog-close>{{ 'labels.buttons.' + buttonText | translate }}</button>
<mat-dialog-actions align="end">
<button mat-raised-button color="primary" mat-dialog-close (click)="dialogRef.close()">
{{ 'labels.buttons.' + buttonText | translate }}
</button>
</mat-dialog-actions>
+2 -57
View File
@@ -34,21 +34,6 @@
<span class="feature-text">{{ 'labels.heading.Global Impact' | translate }}</span>
</div>
</div>
<div class="hero-links">
<a [href]="'mifosHome' | documentationLink" target="_blank" rel="noopener noreferrer" class="hero-link">
{{ 'labels.text.Mifos Initiative' | translate }}
<fa-icon icon="external-link-alt" size="sm"></fa-icon>
</a>
<a
[href]="'mifosVolunteer' | documentationLink"
target="_blank"
rel="noopener noreferrer"
class="hero-link"
>
{{ 'labels.text.Get involved' | translate }}
<fa-icon icon="external-link-alt" size="sm"></fa-icon>
</a>
</div>
</div>
}
<!-- Production Mode: Minimal branding at bottom -->
@@ -114,52 +99,12 @@
</div>
<!-- Version Info Section -->
<div class="login-version-info" *ngIf="displayBackendInfo">
<div class="version-info-container">
<div class="version-item">
<span class="version-label">{{ 'labels.version.Tenant' | translate }}</span>
<span class="version-value">{{ tenantDisplayName }}</span>
</div>
<div class="version-item">
<span class="version-label">{{ 'labels.version.Mifos WebApp' | translate }}</span>
<span class="version-value">{{ versions?.mifos }}</span>
</div>
<div class="version-item">
<span class="version-label">{{ 'labels.version.Apache Fineract' | translate }}</span>
<span class="version-value">{{ versions?.fineract?.version }}</span>
</div>
<div class="version-item">
<span class="version-label">{{ 'labels.version.Server' | translate }}</span>
<span class="version-value">{{ server }}</span>
</div>
</div>
</div>
<div class="login-version-info" *ngIf="displayBackendInfo"></div>
<!-- Resources Links (hidden in production mode) -->
@if (!productionMode) {
<div class="resources-section">
<div class="resources-branding">
<span class="branding-text"
>{{ 'labels.text.Powered by' | translate }}
<a href="https://mifos.org" target="_blank" rel="noopener noreferrer">{{
'labels.text.Mifos Initiative' | translate
}}</a></span
>
</div>
<div class="resources-links">
<button mat-button [matMenuTriggerFor]="resourcesMenu" class="resource-button">
<fa-icon icon="book" size="sm"></fa-icon>
{{ 'labels.links.Resources' | translate }}
</button>
<button mat-button [matMenuTriggerFor]="communityMenu" class="resource-button">
<fa-icon icon="users" size="sm"></fa-icon>
{{ 'labels.links.Community' | translate }}
</button>
<button mat-button [matMenuTriggerFor]="contributeMenu" class="resource-button">
<fa-icon icon="heart" size="sm"></fa-icon>
{{ 'labels.links.Contribute' | translate }}
</button>
</div>
<div class="resources-branding"></div>
</div>
}
</div>
+23 -2
View File
@@ -164,7 +164,7 @@
.hero-panel {
flex: 0 0 70%;
position: relative;
background: url('../../assets/images/cover_image_resized.webp') no-repeat center center;
background: url('../../assets/images/cover_image_resized.png') no-repeat center center;
background-size: cover;
overflow: hidden;
@@ -545,10 +545,31 @@
width: auto;
margin: 8px 24px;
opacity: 0.95;
transition: opacity 0.3s ease;
transition:
opacity 0.3s ease,
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.4s ease;
filter: drop-shadow(0 4px 12px rgb(16 116 185 / 20%));
animation: logo-entrance 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.2s both;
&:hover {
opacity: 1;
transform: scale(1.05);
filter: drop-shadow(0 8px 24px rgb(16 116 185 / 35%));
}
}
@keyframes logo-entrance {
0% {
opacity: 0;
transform: scale(0.8) translateY(10px);
filter: drop-shadow(0 0 0 transparent);
}
100% {
opacity: 0.95;
transform: scale(1) translateY(0);
filter: drop-shadow(0 4px 12px rgb(16 116 185 / 20%));
}
}
@@ -0,0 +1,29 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { RbiCircularsComponent } from './rbi-circulars.component';
const routes: Routes = [
Route.withShell([
{
path: 'rbi-circulars',
component: RbiCircularsComponent,
data: { title: 'RBI Circulars', breadcrumb: 'RBI Circulars' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class RbiCircularsRoutingModule {}
@@ -0,0 +1,144 @@
<!--
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="rbi-container">
<!-- Header -->
<div class="rbi-header">
<div class="rbi-header-left">
<div class="rbi-title">
<fa-icon icon="newspaper" class="title-icon"></fa-icon>
<h2>RBI Circulars & Notifications</h2>
</div>
<span class="rbi-subtitle">Stay updated with the latest Reserve Bank of India communications</span>
</div>
<div class="rbi-header-actions">
<button
mat-raised-button
color="primary"
(click)="refreshFeeds()"
[disabled]="isRefreshing"
matTooltip="Fetch latest feeds from RBI"
>
<fa-icon icon="sync" class="mr-05"></fa-icon>
{{ isRefreshing ? 'Fetching...' : 'Refresh Feeds' }}
</button>
</div>
</div>
<!-- Search Bar -->
<div class="search-bar">
<mat-form-field class="search-field" appearance="outline">
<mat-label>Search circulars...</mat-label>
<input
matInput
[value]="searchText"
(input)="onSearchChange($any($event.target).value)"
placeholder="Search by title or description"
/>
<fa-icon matSuffix icon="search" class="search-icon"></fa-icon>
</mat-form-field>
</div>
<!-- Tabs always visible -->
<mat-tab-group animationDuration="200ms" class="rbi-tabs">
<mat-tab *ngFor="let tab of tabs">
<!-- Tab Label -->
<ng-template mat-tab-label>
<div class="tab-label">
<fa-icon [icon]="tab.icon" class="tab-icon" [style.color]="tab.color"></fa-icon>
<span>{{ tab.label }}</span>
<span class="tab-badge" [style.background]="tab.color">{{ tab.dataSource.filteredData.length }}</span>
</div>
</ng-template>
<!-- Tab Content -->
<div class="tab-content">
<!-- Loading State -->
<div *ngIf="isLoading" class="loading-state">
<mat-spinner diameter="40"></mat-spinner>
<p>Loading RBI circulars...</p>
</div>
<!-- Error State -->
<div *ngIf="errorMessage && !isLoading" class="error-state">
<fa-icon icon="exclamation-triangle" class="error-icon"></fa-icon>
<p>{{ errorMessage }}</p>
<button mat-raised-button color="primary" (click)="loadFeeds()">
<fa-icon icon="sync" class="mr-05"></fa-icon> Retry
</button>
</div>
<!-- Empty state for this tab -->
<div *ngIf="!isLoading && !errorMessage && tab.dataSource.filteredData.length === 0" class="empty-state">
<fa-icon icon="archive" class="empty-icon"></fa-icon>
<h3>No {{ tab.label.toLowerCase() }} found</h3>
<p *ngIf="searchText">No results matching "{{ searchText }}"</p>
<p *ngIf="!searchText">Click "Refresh Feeds" to fetch the latest RBI communications</p>
</div>
<!-- Data Table -->
<div *ngIf="!isLoading && !errorMessage && tab.dataSource.filteredData.length > 0" class="table-wrapper">
<table mat-table [dataSource]="tab.dataSource" class="feed-table">
<!-- S.No Column -->
<ng-container matColumnDef="sno">
<th mat-header-cell *matHeaderCellDef class="col-sno">S.No</th>
<td mat-cell *matCellDef="let row" class="col-sno">
{{ getRowIndex(tab, row) }}
</td>
</ng-container>
<!-- Title Column -->
<ng-container matColumnDef="title">
<th mat-header-cell *matHeaderCellDef>Title</th>
<td mat-cell *matCellDef="let row" class="col-title">
<div class="title-cell">
<span class="new-badge" *ngIf="isNew(row.pubDate)">NEW</span>
<span class="title-text">{{ row.title }}</span>
</div>
<div *ngIf="row.description" class="description-text">{{ stripHtml(row.description) }}</div>
</td>
</ng-container>
<!-- Date Column -->
<ng-container matColumnDef="pubDate">
<th mat-header-cell *matHeaderCellDef class="col-date">Published Date</th>
<td mat-cell *matCellDef="let row" class="col-date">
<div class="date-cell">
<fa-icon icon="calendar-alt" class="date-icon"></fa-icon>
{{ formatDate(row.pubDate) }}
</div>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="col-actions">Link</th>
<td mat-cell *matCellDef="let row" class="col-actions">
<button
mat-raised-button
color="primary"
class="view-btn"
(click)="openLink(row.link)"
matTooltip="Open on RBI website"
>
<fa-icon icon="external-link-alt" class="mr-05"></fa-icon>
View
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="feed-row"></tr>
</table>
<div class="results-info">Showing {{ tab.dataSource.filteredData.length }} items</div>
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
@@ -0,0 +1,325 @@
/**
* 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/.
*/
.rbi-container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
// Header
.rbi-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.rbi-header-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.rbi-title {
display: flex;
align-items: center;
gap: 12px;
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1a237e;
}
.title-icon {
font-size: 1.4rem;
color: #1a237e;
}
}
.rbi-subtitle {
font-size: 0.85rem;
color: #666;
margin-left: 36px;
}
.mr-05 {
margin-right: 5px;
}
// Search
.search-bar {
margin-bottom: 8px;
.search-field {
width: 100%;
max-width: 500px;
}
.search-icon {
color: #999;
margin-right: 8px;
}
}
// Tabs
.rbi-tabs {
::ng-deep .mat-mdc-tab-header {
border-bottom: 2px solid #e8e8e8;
}
::ng-deep .mat-mdc-tab {
min-width: 120px;
}
}
.tab-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
.tab-icon {
font-size: 0.85rem;
}
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
color: white;
font-size: 0.7rem;
font-weight: 600;
}
.tab-content {
padding: 16px 0;
}
// Table
.table-wrapper {
overflow-x: auto;
background: white;
border-radius: 8px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
}
.feed-table {
width: 100%;
.col-sno {
width: 60px;
text-align: center;
}
.col-date {
width: 160px;
white-space: nowrap;
}
.col-actions {
width: 100px;
text-align: center;
}
}
.title-cell {
display: flex;
align-items: center;
gap: 8px;
}
.title-text {
font-weight: 500;
color: #222;
line-height: 1.4;
}
.description-text {
font-size: 0.8rem;
color: #888;
margin-top: 4px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.date-cell {
display: flex;
align-items: center;
gap: 6px;
color: #555;
font-size: 0.85rem;
.date-icon {
font-size: 0.75rem;
color: #aaa;
}
}
.view-btn {
font-size: 0.8rem;
padding: 0 12px;
line-height: 30px;
height: 30px;
}
.new-badge {
display: inline-block;
background: linear-gradient(135deg, #ff5252, #d32f2f);
color: white;
font-size: 0.6rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
letter-spacing: 0.5px;
animation: pulse 2s infinite;
white-space: nowrap;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.feed-row {
cursor: default;
&:hover {
background: #f5f7ff;
}
}
// mat-header styling
::ng-deep .mat-mdc-header-cell {
font-weight: 600;
color: #444;
font-size: 0.85rem;
background: #fafafa;
}
::ng-deep .mat-mdc-cell {
font-size: 0.85rem;
padding: 12px 16px !important;
}
// Results info
.results-info {
font-size: 0.8rem;
color: #888;
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
}
// Loading
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 0;
gap: 16px;
p {
color: #888;
font-size: 0.9rem;
}
}
// Error
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 0;
gap: 12px;
text-align: center;
.error-icon {
font-size: 2rem;
color: #f44336;
}
p {
color: #666;
font-size: 0.9rem;
}
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 0;
text-align: center;
.empty-icon {
font-size: 2.5rem;
color: #ccc;
margin-bottom: 12px;
}
h3 {
margin: 0 0 8px;
color: #555;
font-size: 1rem;
}
p {
color: #999;
font-size: 0.85rem;
}
}
// Responsive
@media screen and (width <= 768px) {
.rbi-container {
padding: 16px;
}
.rbi-header {
flex-direction: column;
}
.rbi-title h2 {
font-size: 1.2rem;
}
.rbi-subtitle {
margin-left: 0;
}
.search-bar .search-field {
max-width: 100%;
}
.description-text {
display: none;
}
.col-sno {
width: 40px;
}
}
@@ -0,0 +1,217 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { RbiCircularsService, RbiFeedItem } from './rbi-circulars.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
interface CategoryTab {
key: string;
label: string;
icon: string;
color: string;
dataSource: MatTableDataSource<RbiFeedItem>;
}
@Component({
selector: 'mifosx-rbi-circulars',
standalone: true,
templateUrl: './rbi-circulars.component.html',
styleUrls: ['./rbi-circulars.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatProgressSpinner,
MatTooltip,
MatTabsModule,
MatTableModule,
MatPaginatorModule,
MatSortModule
]
})
export class RbiCircularsComponent implements OnInit {
private rbiService = inject(RbiCircularsService);
feedItems: RbiFeedItem[] = [];
isLoading = true;
isRefreshing = false;
errorMessage: string | null = null;
searchText = '';
displayedColumns: string[] = [
'sno',
'title',
'pubDate',
'actions'
];
tabs: CategoryTab[] = [];
/** Fixed category config */
private categoryConfig: Record<string, { label: string; icon: string; color: string }> = {
ALL: { label: 'All Feeds', icon: 'list', color: '#1a237e' },
PRESS_RELEASE: { label: 'Press Releases', icon: 'newspaper', color: '#1976d2' },
NOTIFICATION: { label: 'Notifications', icon: 'bell', color: '#f57c00' },
SPEECH: { label: 'Speeches', icon: 'comment-alt', color: '#4527a0' },
TENDER: { label: 'Tenders', icon: 'file', color: '#546e7a' },
PUBLICATION: { label: 'Publications', icon: 'book-open', color: '#2e7d32' }
};
ngOnInit() {
this.buildTabs(); // Build empty tabs immediately so they always show
this.loadFeeds();
}
loadFeeds() {
this.isLoading = true;
this.errorMessage = null;
this.rbiService.getAll().subscribe({
next: (items) => {
this.feedItems = this.sortByDate(items);
this.updateTabData();
this.isLoading = false;
this.isRefreshing = false;
},
error: () => {
this.isLoading = false;
this.isRefreshing = false;
this.errorMessage = 'Failed to load RBI circulars. Click "Refresh Feeds" to try again.';
}
});
}
refreshFeeds() {
this.isRefreshing = true;
this.rbiService.triggerFetch().subscribe({
next: () => this.loadFeeds(),
error: () => this.loadFeeds()
});
}
onSearchChange(value: string) {
this.searchText = value;
const query = value.toLowerCase().trim();
this.tabs.forEach((tab) => {
tab.dataSource.filter = query;
});
}
openLink(url: string) {
if (url) {
window.open(url, '_blank');
}
}
/** Parse Fineract date (array [y,m,d,h,min,s] or ISO string) into a Date */
parseDate(value: any): Date | null {
if (!value) return null;
if (Array.isArray(value)) {
const [
y,
m,
d,
h = 0,
min = 0,
s = 0
] = value;
return new Date(y, m - 1, d, h, min, s);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
formatDate(pubDate: any): string {
const date = this.parseDate(pubDate);
if (!date) return '-';
return date.toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
}
isNew(pubDate: any): boolean {
const date = this.parseDate(pubDate);
if (!date) return false;
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
return date > threeDaysAgo;
}
getCategoryLabel(category: string): string {
return this.categoryConfig[category]?.label || category;
}
getCategoryColor(category: string): string {
return this.categoryConfig[category]?.color || '#607d8b';
}
stripHtml(html: string): string {
if (!html) return '';
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
}
getRowIndex(tab: CategoryTab, row: RbiFeedItem): number {
return tab.dataSource.filteredData.indexOf(row) + 1;
}
private buildTabs() {
const categoryKeys = [
'ALL',
'PRESS_RELEASE',
'NOTIFICATION',
'SPEECH',
'TENDER',
'PUBLICATION'
];
this.tabs = categoryKeys.map((key) => {
const config = this.categoryConfig[key];
const ds = new MatTableDataSource<RbiFeedItem>([]);
ds.filterPredicate = (data: RbiFeedItem, filter: string) => {
return (
(data.title?.toLowerCase().includes(filter) || data.description?.toLowerCase().includes(filter)) ?? false
);
};
return {
key,
label: config.label,
icon: config.icon,
color: config.color,
dataSource: ds
};
});
}
private updateTabData() {
this.tabs.forEach((tab) => {
const items = tab.key === 'ALL' ? this.feedItems : this.feedItems.filter((i) => i.category === tab.key);
tab.dataSource.data = items;
if (this.searchText.trim()) {
tab.dataSource.filter = this.searchText.toLowerCase().trim();
}
});
}
private sortByDate(items: RbiFeedItem[]): RbiFeedItem[] {
return [...items].sort((a, b) => {
const dateA = this.parseDate(a.pubDate)?.getTime() || 0;
const dateB = this.parseDate(b.pubDate)?.getTime() || 0;
return dateB - dateA;
});
}
}
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { RbiCircularsRoutingModule } from './rbi-circulars-routing.module';
import { RbiCircularsComponent } from './rbi-circulars.component';
@NgModule({
imports: [
SharedModule,
RbiCircularsRoutingModule,
RbiCircularsComponent
]
})
export class RbiCircularsModule {}
@@ -0,0 +1,44 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface RbiFeedItem {
id: number;
title: string;
link: string;
description: string;
pubDate: any; // Fineract returns [year,month,day,hour,min,sec] array
category: string;
fetchedOn?: any;
}
@Injectable({
providedIn: 'root'
})
export class RbiCircularsService {
private http = inject(HttpClient);
getAll(category?: string): Observable<RbiFeedItem[]> {
let params = new HttpParams();
if (category) {
params = params.set('category', category);
}
return this.http.get<RbiFeedItem[]>('/rbi-feeds', { params });
}
getById(id: number): Observable<RbiFeedItem> {
return this.http.get<RbiFeedItem>(`/rbi-feeds/${id}`);
}
triggerFetch(): Observable<any> {
return this.http.post('/rbi-feeds/fetch', {});
}
}
@@ -0,0 +1,151 @@
<!--
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="passbook-container">
<!-- Date Range Filter -->
<div class="passbook-filter-bar">
<form [formGroup]="dateRangeForm" class="passbook-filter-form">
<mat-form-field appearance="outline" class="passbook-date-field" (click)="fromDatePicker.open()">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="fromDatePicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="fromDatePicker"></mat-datepicker-toggle>
<mat-datepicker #fromDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="passbook-date-field" (click)="toDatePicker.open()">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="toDatePicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="toDatePicker"></mat-datepicker-toggle>
<mat-datepicker #toDatePicker></mat-datepicker>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!dateRangeForm.valid"
(click)="filterTransactions()"
>
<mat-icon>filter_list</mat-icon>
{{ 'labels.buttons.Filter' | translate }}
</button>
@if (isFiltered) {
<button mat-stroked-button type="button" (click)="clearFilter()">
<mat-icon>clear</mat-icon>
{{ 'labels.buttons.Clear' | translate }}
</button>
}
<span class="passbook-filter-spacer"></span>
<button mat-raised-button color="accent" type="button" (click)="printPassbook()">
<mat-icon>print</mat-icon>
{{ 'labels.buttons.Print Passbook' | translate }}
</button>
</form>
</div>
<!-- Passbook Preview (Printable Content) -->
<div class="passbook-preview" #passbookContent>
<div class="passbook-header">
<h2>{{ 'labels.heading.Savings Account Passbook' | translate }}</h2>
<h3>{{ productName }}</h3>
</div>
<!-- Account Info -->
<div class="passbook-info-grid">
<div class="passbook-info-item">
<div class="label">{{ 'labels.inputs.Account Number' | translate }}</div>
<div class="value">{{ accountNo }}</div>
</div>
<div class="passbook-info-item">
<div class="label">{{ 'labels.inputs.Account Holder' | translate }}</div>
<div class="value">{{ clientName }}</div>
</div>
<div class="passbook-info-item">
<div class="label">{{ 'labels.inputs.Savings Product' | translate }}</div>
<div class="value">{{ productName }}</div>
</div>
<div class="passbook-info-item">
<div class="label">{{ 'labels.inputs.Opening Date' | translate }}</div>
<div class="value">{{ activatedOnDate | dateFormat }}</div>
</div>
<div class="passbook-info-item">
<div class="label">{{ 'labels.inputs.Status' | translate }}</div>
<div class="value">{{ status }}</div>
</div>
<div class="passbook-info-item">
<div class="label">{{ 'labels.inputs.Interest Rate' | translate }}</div>
<div class="value">{{ interestRate }}%</div>
</div>
</div>
<!-- Balance Summary -->
<div class="passbook-balance-row">
<div class="balance-item">
<span>{{ 'labels.inputs.Current Balance' | translate }}: </span>
<b>{{ accountBalance | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}</b>
</div>
<div class="balance-item">
<span>{{ 'labels.inputs.Available Balance' | translate }}: </span>
<b>{{ availableBalance | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}</b>
</div>
</div>
<!-- Transactions Table -->
@if (filteredTransactions.length > 0) {
<table>
<thead>
<tr>
<th>#</th>
<th>{{ 'labels.inputs.Date' | translate }}</th>
<th>{{ 'labels.inputs.Description' | translate }}</th>
<th class="text-right">{{ 'labels.inputs.Debit' | translate }}</th>
<th class="text-right">{{ 'labels.inputs.Credit' | translate }}</th>
<th class="text-right">{{ 'labels.inputs.Balance' | translate }}</th>
</tr>
</thead>
<tbody>
@for (txn of filteredTransactions; track txn.id; let i = $index) {
<tr [class.reversed]="txn.reversed">
<td>{{ i + 1 }}</td>
<td>{{ txn.date | dateFormat }}</td>
<td>{{ txn.transactionType.value }}</td>
<td class="text-right">
@if (isDebit(txn.transactionType)) {
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
}
</td>
<td class="text-right">
@if (!isDebit(txn.transactionType)) {
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
}
</td>
<td class="text-right">
{{ txn.runningBalance | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="passbook-no-data">
<mat-icon>info_outline</mat-icon>
<span>{{ 'labels.text.No transactions found for the selected date range.' | translate }}</span>
</div>
}
<!-- Footer -->
<div class="passbook-footer">
{{ 'labels.text.Generated on' | translate }}: {{ maxDate | dateFormat }}
&nbsp;|&nbsp;
{{ 'labels.text.Total Transactions' | translate }}: {{ filteredTransactions.length }}
</div>
</div>
</div>
@@ -0,0 +1,206 @@
/**
* 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/.
*/
.passbook-container {
padding: 16px;
}
// Filter Bar
.passbook-filter-bar {
margin-bottom: 16px;
}
.passbook-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.passbook-date-field {
width: 180px;
}
.passbook-filter-spacer {
flex: 1;
}
// Passbook Preview
.passbook-preview {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 24px;
background: #fff;
}
.passbook-header {
text-align: center;
margin-bottom: 20px;
border-bottom: 2px solid #1976d2;
padding-bottom: 12px;
h2 {
color: #1976d2;
font-size: 20px;
margin: 0 0 4px;
font-weight: 600;
}
h3 {
font-size: 14px;
font-weight: 400;
color: #666;
margin: 0;
}
}
.passbook-info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 20px;
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 6px;
}
.passbook-info-item {
.label {
font-size: 10px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
font-size: 13px;
color: #333;
margin-top: 2px;
}
}
.passbook-balance-row {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
padding: 10px 14px;
background: #e3f2fd;
border-radius: 6px;
font-size: 14px;
.balance-item {
b {
color: #1976d2;
}
}
}
// Transactions Table
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
background: #1976d2;
color: #fff;
padding: 8px 10px;
text-align: left;
font-weight: 600;
font-size: 12px;
}
td {
padding: 6px 10px;
border-bottom: 1px solid #e0e0e0;
}
tr:nth-child(even) {
background: #fafafa;
}
tr:hover {
background: #f0f0f0;
}
.reversed {
text-decoration: line-through;
color: #999;
}
}
.text-right {
text-align: right;
}
.passbook-no-data {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
color: #888;
font-size: 14px;
mat-icon {
color: #bbb;
}
}
.passbook-footer {
margin-top: 16px;
font-size: 11px;
color: #999;
text-align: center;
border-top: 1px solid #e0e0e0;
padding-top: 10px;
}
// Responsive
@media (width <= 768px) {
.passbook-info-grid {
grid-template-columns: 1fr 1fr;
}
.passbook-balance-row {
flex-direction: column;
gap: 8px;
}
.passbook-filter-form {
flex-direction: column;
align-items: stretch;
}
.passbook-date-field {
width: 100%;
}
.passbook-filter-spacer {
display: none;
}
}
@media (width <= 480px) {
.passbook-info-grid {
grid-template-columns: 1fr;
}
}
// Print-specific styles
@media print {
.passbook-filter-bar {
display: none !important;
}
.passbook-preview {
border: none;
padding: 0;
}
}
@@ -0,0 +1,186 @@
/**
* 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, ElementRef, ViewChild } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, Validators } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { Currency } from 'app/shared/models/general.model';
import {
SavingsAccountTransaction,
SavingsAccountTransactionType
} from 'app/savings/models/savings-account-transaction.model';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-passbook-tab',
templateUrl: './passbook-tab.component.html',
styleUrls: ['./passbook-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon,
FaIconComponent,
DateFormatPipe,
CurrencyPipe
]
})
export class PassbookTabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private formBuilder = inject(UntypedFormBuilder);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private destroy$ = new Subject<void>();
@ViewChild('passbookContent') passbookContent: ElementRef;
/** Account data */
accountNo: string = '';
clientName: string = '';
productName: string = '';
currency: Currency | null = null;
status: string = '';
interestRate: number = 0;
accountBalance: number = 0;
availableBalance: number = 0;
activatedOnDate: any = null;
/** Transactions */
allTransactions: SavingsAccountTransaction[] = [];
filteredTransactions: SavingsAccountTransaction[] = [];
/** Date range form */
minDate = new Date(2000, 0, 1);
maxDate = new Date();
dateRangeForm: any;
isFiltered = false;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.route.parent.data.pipe(takeUntil(this.destroy$)).subscribe((data: { savingsAccountData: any }) => {
const account = data.savingsAccountData;
this.accountNo = account.accountNo || '';
this.clientName = account.clientName || account.groupName || '';
this.productName = account.savingsProductName || '';
this.currency = account.currency || null;
this.status = account.status?.value || '';
this.interestRate = account.nominalAnnualInterestRate || 0;
this.accountBalance = account.summary?.accountBalance || 0;
this.availableBalance = account.summary?.availableBalance || 0;
this.activatedOnDate = account.timeline?.activatedOnDate || null;
this.allTransactions = account.transactions || [];
this.filteredTransactions = [...this.allTransactions];
});
this.dateRangeForm = this.formBuilder.group({
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
]
});
}
isDebit(transactionType: SavingsAccountTransactionType): boolean {
return (
transactionType.withdrawal === true ||
transactionType.feeDeduction === true ||
transactionType.overdraftInterest === true ||
transactionType.withholdTax === true
);
}
filterTransactions(): void {
const fromDate = this.dateRangeForm.value.fromDate;
const toDate = this.dateRangeForm.value.toDate;
if (!fromDate || !toDate) return;
const from = new Date(fromDate).getTime();
const to = new Date(toDate).getTime();
this.filteredTransactions = this.allTransactions.filter((txn: SavingsAccountTransaction) => {
const txnDate = this.getTransactionDateAsTime(txn.date);
return txnDate >= from && txnDate <= to;
});
this.isFiltered = true;
}
clearFilter(): void {
this.dateRangeForm.reset();
this.filteredTransactions = [...this.allTransactions];
this.isFiltered = false;
}
private getTransactionDateAsTime(date: any): number {
if (Array.isArray(date) && date.length >= 3) {
return new Date(date[0], date[1] - 1, date[2]).getTime();
}
return new Date(date).getTime();
}
printPassbook(): void {
const content = this.passbookContent?.nativeElement?.innerHTML;
if (!content) return;
const printWindow = window.open('', 'Passbook', 'height=700,width=900');
printWindow.document.write(`
<html>
<head>
<title>Savings Account Passbook - ${this.accountNo}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 20px; color: #333; }
.passbook-header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #1976d2; padding-bottom: 12px; }
.passbook-header h2 { color: #1976d2; font-size: 18px; margin-bottom: 4px; }
.passbook-header h3 { font-size: 14px; font-weight: 400; color: #666; }
.passbook-info-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px 16px; margin-bottom: 16px; padding: 10px; background: #f5f5f5; border-radius: 4px; }
.passbook-info-item { font-size: 12px; }
.passbook-info-item .label { font-weight: 600; color: #666; text-transform: uppercase; font-size: 10px; }
.passbook-info-item .value { color: #333; }
.passbook-balance-row { display: flex; justify-content: space-between; margin-bottom: 16px; padding: 8px 10px; background: #e3f2fd; border-radius: 4px; font-size: 13px; }
.passbook-balance-row .balance-item b { color: #1976d2; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th { background: #1976d2; color: #fff; padding: 6px 8px; text-align: left; font-weight: 600; }
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) { background: #fafafa; }
.text-right { text-align: right; }
.passbook-footer { margin-top: 16px; font-size: 10px; color: #999; text-align: center; border-top: 1px solid #e0e0e0; padding-top: 8px; }
.reversed { text-decoration: line-through; color: #999; }
</style>
</head>
<body>
${content}
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
printWindow.close();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
@@ -163,6 +163,15 @@
>
{{ 'labels.inputs.Transactions' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./passbook']"
routerLinkActive
#passbook="routerLinkActive"
[active]="passbook.isActive"
>
{{ 'labels.inputs.Passbook' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./charges']"
@@ -51,6 +51,7 @@ import { SavingNotesResolver } from './common-resolvers/saving-notes.resolver';
import { SavingDocumentsResolver } from './common-resolvers/saving-documents.resolver';
import { SavingsTransactionGeneralTabComponent } from './savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component';
import { GeneralTabComponent } from './savings-account-view/general-tab/general-tab.component';
import { PassbookTabComponent } from './savings-account-view/passbook-tab/passbook-tab.component';
/** Savings Routes */
const routes: Routes = [
@@ -104,6 +105,11 @@ const routes: Routes = [
}
]
},
{
path: 'passbook',
component: PassbookTabComponent,
data: { title: 'Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false }
},
{
path: 'charges',
component: ChargesTabComponent,
+3 -1
View File
@@ -62,6 +62,7 @@ import { SavingsTransactionGeneralTabComponent } from './savings-account-view/tr
import { SavingsTransactionDatatableTabComponent } from './savings-account-view/transactions/view-transaction/savings-transaction-datatable-tab/savings-transaction-datatable-tab.component';
import { GeneralTabComponent } from './savings-account-view/general-tab/general-tab.component';
import { SavingsActiveClientMembersComponent } from './savings-account-stepper/savings-active-client-members/savings-active-client-members.component';
import { PassbookTabComponent } from './savings-account-view/passbook-tab/passbook-tab.component';
/**
* Savings Module
@@ -119,7 +120,8 @@ import { SavingsActiveClientMembersComponent } from './savings-account-stepper/s
SavingsTransactionGeneralTabComponent,
SavingsTransactionDatatableTabComponent,
GeneralTabComponent,
SavingsActiveClientMembersComponent
SavingsActiveClientMembersComponent,
PassbookTabComponent
],
providers: []
})
+16 -2
View File
@@ -135,7 +135,14 @@ import {
faLightbulb,
faCodeBranch,
faHandHoldingHand,
faHandHoldingHeart
faHandHoldingHeart,
faIdCard,
faDatabase,
faChartPie,
faFingerprint,
faNewspaper,
faRss,
faFilter
} from '@fortawesome/free-solid-svg-icons';
/**
@@ -277,7 +284,14 @@ export class IconsModule {
faReceipt,
faTableCells,
faSave,
faUnlink
faUnlink,
faIdCard,
faDatabase,
faChartPie,
faFingerprint,
faNewspaper,
faRss,
faFilter
);
}
}
@@ -0,0 +1,29 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { SupersetDashboardComponent } from './superset-dashboard.component';
const routes: Routes = [
Route.withShell([
{
path: 'analytics',
component: SupersetDashboardComponent,
data: { title: 'Analytics', breadcrumb: 'Analytics' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class SupersetAnalyticsRoutingModule {}
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { SupersetAnalyticsRoutingModule } from './superset-analytics-routing.module';
import { SupersetDashboardComponent } from './superset-dashboard.component';
@NgModule({
imports: [
SharedModule,
SupersetAnalyticsRoutingModule,
SupersetDashboardComponent
]
})
export class SupersetAnalyticsModule {}
@@ -0,0 +1,74 @@
<!--
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="container">
<div class="header">
<h2>
<fa-icon icon="chart-bar" class="mr-05"></fa-icon>
{{ 'labels.heading.Analytics Dashboard' | translate }}
</h2>
<button mat-raised-button color="primary" (click)="openSupersetFullScreen()">
<fa-icon icon="external-link-alt" class="mr-05"></fa-icon>
{{ 'labels.buttons.Open Full Dashboard' | translate }}
</button>
</div>
<mat-card *ngIf="isLoading" class="loading-card">
<mat-card-content class="loading-content">
<mat-spinner diameter="40"></mat-spinner>
<p>{{ 'labels.text.Loading analytics dashboard...' | translate }}</p>
</mat-card-content>
</mat-card>
<mat-card *ngIf="errorMessage" class="error-card">
<mat-card-content>
<p class="error-message">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ errorMessage }}
</p>
<button mat-raised-button color="primary" (click)="loadDashboard()">
{{ 'labels.buttons.Retry' | translate }}
</button>
</mat-card-content>
</mat-card>
<ng-container *ngIf="!isLoading && !errorMessage">
<!-- Dashboard ID configured: show embedded iframe -->
<div *ngIf="dashboardUrl" class="dashboard-container">
<iframe [src]="dashboardUrl" class="superset-iframe" frameBorder="0" allowfullscreen> </iframe>
</div>
<!-- No Dashboard ID: show setup instructions -->
<mat-card *ngIf="!dashboardUrl" class="setup-card">
<mat-card-content>
<div class="setup-content">
<fa-icon icon="chart-bar" size="3x" class="setup-icon"></fa-icon>
<h3>{{ 'labels.heading.Apache Superset Analytics' | translate }}</h3>
<p>{{ 'labels.text.Superset is connected but no dashboard ID is configured.' | translate }}</p>
<div class="setup-steps">
<h4>{{ 'labels.heading.Quick Setup' | translate }}</h4>
<ol>
<li>
{{ 'labels.text.Open Superset and create a dashboard' | translate }}:
<a [href]="supersetBaseUrl" target="_blank">{{ supersetBaseUrl }}</a>
</li>
<li>{{ 'labels.text.Copy the dashboard ID from the URL' | translate }}</li>
<li>{{ 'labels.text.Set supersetDashboardId in your environment config' | translate }}</li>
</ol>
</div>
<button mat-raised-button color="primary" (click)="openSupersetFullScreen()">
<fa-icon icon="external-link-alt" class="mr-05"></fa-icon>
{{ 'labels.buttons.Open Superset' | translate }}
</button>
</div>
</mat-card-content>
</mat-card>
</ng-container>
</div>
@@ -0,0 +1,129 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
font-size: 1.4rem;
font-weight: 500;
}
}
.mr-05 {
margin-right: 5px;
}
.dashboard-container {
width: 100%;
height: calc(100vh - 180px);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.superset-iframe {
width: 100%;
height: 100%;
border: none;
}
.loading-card {
margin-top: 24px;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px;
gap: 16px;
p {
color: rgb(0 0 0 / 54%);
}
}
.error-card {
margin-top: 24px;
.error-message {
color: #f44336;
margin-bottom: 16px;
fa-icon {
margin-right: 8px;
}
}
}
.setup-card {
margin-top: 24px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.setup-content {
text-align: center;
padding: 32px 16px;
.setup-icon {
color: rgb(0 0 0 / 26%);
margin-bottom: 16px;
}
h3 {
margin: 16px 0 8px;
font-size: 1.2rem;
}
p {
color: rgb(0 0 0 / 54%);
margin-bottom: 24px;
}
}
.setup-steps {
text-align: left;
background: #f5f5f5;
border-radius: 8px;
padding: 16px 24px;
margin-bottom: 24px;
h4 {
margin: 0 0 12px;
}
ol {
margin: 0;
padding-left: 20px;
li {
margin-bottom: 8px;
line-height: 1.5;
a {
color: #1976d2;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
@@ -0,0 +1,57 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { SafeResourceUrl } from '@angular/platform-browser';
import { SupersetService } from './superset.service';
import { environment } from '../../environments/environment';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
@Component({
selector: 'mifosx-superset-dashboard',
standalone: true,
templateUrl: './superset-dashboard.component.html',
styleUrls: ['./superset-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatProgressSpinner
]
})
export class SupersetDashboardComponent implements OnInit {
private supersetService = inject(SupersetService);
dashboardUrl: SafeResourceUrl | null = null;
isLoading = true;
errorMessage: string | null = null;
supersetBaseUrl: string;
ngOnInit() {
this.supersetBaseUrl = environment.supersetUrl;
this.loadDashboard();
}
loadDashboard() {
const dashboardId = environment.supersetDashboardId;
if (!dashboardId) {
this.isLoading = false;
this.errorMessage = null;
return;
}
this.isLoading = false;
this.dashboardUrl = this.supersetService.getEmbeddedDashboardUrl(dashboardId);
}
openSupersetFullScreen() {
window.open(this.supersetBaseUrl, '_blank');
}
}
@@ -0,0 +1,88 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpBackend } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
@Injectable({
providedIn: 'root'
})
export class SupersetService {
private sanitizer = inject(DomSanitizer);
private supersetHttp: HttpClient;
constructor(httpBackend: HttpBackend) {
// Bypass Fineract interceptors for Superset API calls
this.supersetHttp = new HttpClient(httpBackend);
}
get supersetUrl(): string {
return environment.supersetUrl;
}
get isEnabled(): boolean {
return environment.supersetEnabled;
}
/**
* Login to Superset and get access token
*/
login(username: string, password: string): Observable<string> {
return this.supersetHttp
.post<any>(`${this.supersetUrl}/api/v1/security/login`, {
username,
password,
provider: 'db',
refresh: true
})
.pipe(map((response) => response.access_token));
}
/**
* Fetch guest token for embedded dashboard
*/
fetchGuestToken(accessToken: string, dashboardId: string): Observable<string> {
return this.supersetHttp
.post<any>(
`${this.supersetUrl}/api/v1/security/guest_token/`,
{
resources: [{ type: 'dashboard', id: dashboardId }],
user: { username: 'guest', first_name: 'Guest', last_name: 'User' },
rls: []
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
)
.pipe(map((response) => response.token));
}
/**
* Get the embedded dashboard URL (standalone mode via iframe)
*/
getEmbeddedDashboardUrl(dashboardId: string): SafeResourceUrl {
const url = `${this.supersetUrl}/superset/dashboard/${dashboardId}/?standalone=true`;
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
}
/**
* Get full login + guest token flow for embedding
*/
getGuestTokenForDashboard(dashboardId: string, username: string, password: string): Observable<string> {
return this.login(username, password).pipe(
switchMap((accessToken) => this.fetchGuestToken(accessToken, dashboardId))
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

+132 -39
View File
@@ -1,5 +1,5 @@
{
"APP_NAME": "Mifos® X WebApp",
"APP_NAME": "CBSNexora",
"Logged in as": "Logged in as",
"Remember me": "Remember me",
"error.resource.notImplemented.type": "Not Implemented Error",
@@ -287,7 +287,7 @@
"labels": {
"version": {
"Tenant": "Tenant",
"Mifos WebApp": "Mifos® WebApp",
"Mifos WebApp": "CBSNexora® WebApp",
"Apache Fineract": "Apache Fineract®",
"Server": "Server",
"Username": "Username",
@@ -295,18 +295,18 @@
"Render Time": "Render Time"
},
"licensing": {
"Apache Fineract Description": "The <a href=\"https://fineract.apache.org/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Fineract</a> platform is licensed under the <a href=\"https://www.apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noopener noreferrer\">Apache License, Version 2.0</a>. The Mifos Initiative was the guiding force behind Apache Fineract, launching it as Mifos X in 2011 and contributing to Apache in 2016 with graduation to a top-level project of the Apache Software Foundation in 2017.",
"Mifos X Distribution": "The open source Mifos X distribution powered by Apache Fineract (including web apps, mobile apps, and business intelligence engine) is licensed under the <a href=\"https://www.mozilla.org/en-US/MPL/2.0/\" target=\"_blank\" rel=\"noopener noreferrer\">Mozilla Public License, v2.0</a>.",
"Legacy Codebase": "Our legacy codebase (Mifos 2) is governed by the <a href=\"https://www.apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noopener noreferrer\">Apache 2.0 License</a>. Please read the following to learn how to correctly adhere to the terms of the license.",
"How to License Code": "<a href=\"https://mifosforge.jira.com/wiki/spaces/RES/pages/174456833/How+to+properly+license+Mifos+code+that+you+are+contributing\" target=\"_blank\" rel=\"noopener noreferrer\">How to properly license Mifos code that you are contributing.</a>",
"Contributor Agreement": "<a href=\"https://mifos.org/resources/community/contributor-agreement/\" target=\"_blank\" rel=\"noopener noreferrer\">Mifos Contributor Agreement</a>",
"Apache Fineract Description": "The <a href=\"https://fineract.apache.org/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Fineract</a> platform is licensed under the <a href=\"https://www.apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noopener noreferrer\">Apache License, Version 2.0</a>. The CBSNexora Initiative was the guiding force behind Apache Fineract, launching it as CBSNexora X in 2011 and contributing to Apache in 2016 with graduation to a top-level project of the Apache Software Foundation in 2017.",
"Mifos X Distribution": "The open source CBSNexora distribution powered by Apache Fineract (including web apps, mobile apps, and business intelligence engine) is licensed under the <a href=\"https://www.mozilla.org/en-US/MPL/2.0/\" target=\"_blank\" rel=\"noopener noreferrer\">Mozilla Public License, v2.0</a>.",
"Legacy Codebase": "Our legacy codebase (CBSNexora 2) is governed by the <a href=\"https://www.apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noopener noreferrer\">Apache 2.0 License</a>. Please read the following to learn how to correctly adhere to the terms of the license.",
"How to License Code": "<a href=\"https://mifosforge.jira.com/wiki/spaces/RES/pages/174456833/How+to+properly+license+Mifos+code+that+you+are+contributing\" target=\"_blank\" rel=\"noopener noreferrer\">How to properly license CBSNexora code that you are contributing.</a>",
"Contributor Agreement": "<a href=\"https://mifos.org/resources/community/contributor-agreement/\" target=\"_blank\" rel=\"noopener noreferrer\">CBSNexora Contributor Agreement</a>",
"License FAQs": "Implications of what this means when modifying the source code can be found in the <a href=\"https://www.apache.org/foundation/license-faq.html\" target=\"_blank\" rel=\"noopener noreferrer\">Apache License FAQs</a>",
"Adding External Software": "Considerations to factor in when <a href=\"https://mifosforge.jira.com/wiki/spaces/RES/pages/174456843/Adding+External+Software+to+Mifos\" target=\"_blank\" rel=\"noopener noreferrer\">adding external software to Mifos</a>.",
"Code of Conduct": "Mifos <a href=\"https://mifos.org/resources/community/code-of-conduct/\" target=\"_blank\" rel=\"noopener noreferrer\">Code of Conduct</a> to abide by when collaborating in the community."
"Adding External Software": "Considerations to factor in when <a href=\"https://mifosforge.jira.com/wiki/spaces/RES/pages/174456843/Adding+External+Software+to+Mifos\" target=\"_blank\" rel=\"noopener noreferrer\">adding external software to CBSNexora</a>.",
"Code of Conduct": "CBSNexora <a href=\"https://mifos.org/resources/community/code-of-conduct/\" target=\"_blank\" rel=\"noopener noreferrer\">Code of Conduct</a> to abide by when collaborating in the community."
},
"aboutUs": {
"Introduction": "The Mifos Initiative is a 501(c)(3) non-profit organization and a global community of developers, financial institutions, and social entrepreneurs. We believe that access to financial services is a fundamental human right, yet billions of people remain excluded from the formal financial system.",
"Platform Description": "We bridge this gap by providing the <a href=\"https://mifos.org\" target=\"_blank\" rel=\"noopener noreferrer\">Mifos X Platform</a>\u2014the world's leading open-source core banking system. By making enterprise-grade banking technology free and accessible, we lower the barrier to entry for Microfinance Institutions (MFIs), Fintechs, and NGOs, allowing them to scale their impact and serve the \"last mile\" efficiently.",
"Introduction": "The CBSNexora Initiative is a 501(c)(3) non-profit organization and a global community of developers, financial institutions, and social entrepreneurs. We believe that access to financial services is a fundamental human right, yet billions of people remain excluded from the formal financial system.",
"Platform Description": "We bridge this gap by providing the <a href=\"https://mifos.org\" target=\"_blank\" rel=\"noopener noreferrer\">CBSNexora Platform</a>\u2014the world's leading open-source core banking system. By making enterprise-grade banking technology free and accessible, we lower the barrier to entry for Microfinance Institutions (MFIs), Fintechs, and NGOs, allowing them to scale their impact and serve the \"last mile\" efficiently.",
"Mission": "To accelerate financial inclusion by catalyzing an ecosystem of open-source technology, community, and knowledge sharing.",
"Vision": "A world where every individual and small business has access to the financial tools they need to thrive, regardless of their location or income level.",
"Why Open Source": "We chose the Open Source model because we believe financial infrastructure should be:",
@@ -315,16 +315,15 @@
"Affordable": "Affordable",
"Affordable Description": "Eliminating licensing costs allows partners to invest more in their clients.",
"Innovative": "Innovative",
"Innovative Description": "A global community of developers ensures the platform evolves faster than any single vendor could manage.",
"Secure": "Secure",
"Innovative Description": "Transforming banking infrastructure with a unified, API-driven core designed for speed, resilience, and limitless innovation",
"Secure Description": "With eyes on the code from around the world, security and stability are paramount.",
"Ecosystem Introduction": "The Mifos Initiative is the steward of the platform, but the real magic happens in the ecosystem:",
"The Mifos X Platform": "The Mifos X Platform",
"Ecosystem Introduction": "The CBSNexora Initiative is the steward of the platform, but the real magic happens in the ecosystem:",
"The Mifos X Platform": "The CBSNexora Platform",
"Platform Role": "The robust core banking engine.",
"The Community": "The Community",
"Community Role": "Hundreds of developers and implementers contributing code, translations, and support.",
"Certified Partners": "Certified Partners",
"Partners Role": "A global network of implementation partners who customize and deploy Mifos for local institutions.",
"Partners Role": "A global network of implementation partners who customize and deploy CBSNexora for local institutions.",
"The Foundation": "The Foundation",
"Foundation Role": "The non-profit body ensuring the long-term sustainability and governance of the project."
},
@@ -515,6 +514,8 @@
"Pattern": "Pattern",
"Pause Delinquency Classification": "Pause Delinquency Classification",
"Pay": "Pay",
"Open Full Dashboard": "Open Full Dashboard",
"Open Superset": "Open Superset",
"Payments": "Payments",
"Permissions": "Permissions",
"Pie Chart": "Pie Chart",
@@ -522,6 +523,7 @@
"Previous": "Previous",
"Preview": "Preview",
"Print": "Print",
"Print Passbook": "Print Passbook",
"Proceed": "Proceed",
"Productive Collection Sheet": "Productive Collection Sheet",
"Reactivate": "Reactivate",
@@ -941,6 +943,13 @@
"NotEqual": "Not equal"
},
"heading": {
"Analytics Dashboard": "Analytics Dashboard",
"Apache Superset Analytics": "Apache Superset Analytics",
"Quick Setup": "Quick Setup",
"CKYC": "CKYC",
"CKYC Reports": "CKYC Reports",
"E-KYC": "E-KYC",
"Record Master": "Record Master",
"Account Linked Financial": "List of accounts linked to different financial activities. To know more click:",
"Account Name": "Account Name",
"Account Number Preferences": "Account Number Preferences",
@@ -1019,7 +1028,7 @@
"Configure Maker Checker Tasks": "Configure Maker Checker Tasks",
"Configure Maker-Checker Tasks": "Configure Maker-Checker Tasks",
"Customer Attributes": "Customer Attributes",
"Configure Mifos Organization Needs": "Following guide tour will help you configure Mifos® according to your organization needs.",
"Configure Mifos Organization Needs": "Following guide tour will help you configure CBSNexora® according to your organization needs.",
"Create Charge": "Create Charge",
"Create Closure": "Create Closure",
"Create Code": "Create Code",
@@ -1039,6 +1048,8 @@
"Create share product": "Create share product",
"Currency": "Currency",
"Currency Configuration": "Currency Configuration",
"CKYC Verification": "CKYC Verification",
"CKYC Documents": "CKYC Documents",
"Dashboard": "Dashboard",
"Default Shares per Client": "Default Shares per Client",
"Define New Mapping": "Define New Mapping",
@@ -1072,8 +1083,10 @@
"Expenses": "Expenses",
"External Asset Owner": "External Asset Owner",
"External Services": "External Services",
"Family Member": "Family Member",
"Family Members": "Family Members",
"Family Member": "Nominee",
"Family Members": "Nominee",
"Nominee Details (DA1 / DA5)": "Nominee Details (DA1 / DA5)",
"Guardian Details (If Minor)": "Guardian Details (If Minor)",
"Personal Information": "Personal Information",
"Entity Details": "Entity Details",
"Contact Information": "Contact Information",
@@ -1133,7 +1146,7 @@
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Last Active": "Last Active",
"Liabilities": "Liabilities",
"List of all codes in the Mifos system": "List of all codes in the Mifos® system for your organization.",
"List of all codes in the Mifos system": "List of all codes in the CBSNexora® system for your organization.",
"List of all currently available data tables": "List of all currently available data tables.",
"List of all currently available reports": "List of all currently available reports. For more details click",
"List of all maker checker tasks": "List of all maker checker tasks. For more details click",
@@ -1241,6 +1254,7 @@
"Saved Successfully": "Saved Successfully",
"Saving Accounts": "Saving Accounts",
"Savings Account Overview": "Savings Account Overview",
"Savings Account Passbook": "Savings Account Passbook",
"Savings Account": "Savings Account",
"Savings Accounts": "Savings Accounts",
"Savings Details": "Savings Details",
@@ -1324,7 +1338,7 @@
"View Client Signature": "View Client Signature",
"View Journal Entry": "View Journal Entry",
"Waive Charge": "Waive Charge",
"Welcome to Mifos": "Welcome to Mifos®",
"Welcome to Mifos": "Welcome to Nexora®",
"WriteOff reasons to Expense accounts": "WriteOff reasons to Expense accounts",
"Withdraw Money From Saving Account": "Withdraw Money From Saving Account",
"Why Open Source": "Why Open Source",
@@ -1355,7 +1369,7 @@
"Community Driven": "Community Driven",
"Global Impact": "Global Impact",
"Assign Payout": "Assign Payout",
"Assign to Mifos Client": "Assign to Mifos Client",
"Assign to Mifos Client": "Assign to CBSNexora Client",
"Beneficiary Details": "Beneficiary Details",
"Confirm Payment": "Confirm Payment",
"Confirm Payout": "Confirm Payout",
@@ -1408,6 +1422,7 @@
"Account Name": "Account Name",
"Account No": "Account No.",
"Account Number": "Account Number",
"Account Holder": "Account Holder",
"Account Number Preferences": "Account Number Preferences",
"Total Repaid": "Total Repaid",
"Outstanding Balance": "Outstanding Balance",
@@ -1859,9 +1874,10 @@
"External ID lookup timed out": "External ID lookup timed out",
"event with entity name of": "event with entity name of",
"FAMILY MEMBERS": "FAMILY MEMBERS",
"Nominee": "Nominee",
"FCM End Point": "FCM End Point",
"Failure Count": "Failure Count",
"Family Members": "Family Members",
"Family Members": "Nominee",
"Favicon": "Favicon",
"Fee": "Fee",
"Fee Amount": "Fee Amount",
@@ -1961,6 +1977,53 @@
"Id": "Id",
"Identities": "Identities",
"Identity Documents": "Identity Documents",
"KYC": "KYC",
"KYC Verification": "KYC Verification",
"CKYC": "CKYC",
"CKYC Verification": "CKYC Verification",
"CKYC Documents": "CKYC Documents",
"Address Proof Document": "Address Proof Document",
"Photo Proof Document": "Photo Proof Document",
"Image of Client": "Image of Client",
"Signature of Client": "Signature of Client",
"Select Account": "Select Account",
"Accepted formats": "Accepted formats",
"Request ID": "Request ID",
"Acknowledgement Number": "Acknowledgement Number",
"Processing Status": "Processing Status",
"KYC Status": "KYC Status",
"KYC Type": "KYC Type",
"PAN Number": "PAN Number",
"Aadhaar Number": "Aadhaar Number",
"GSTIN Number": "GSTIN Number",
"CKYC Number": "CKYC Number",
"Risk Category": "Risk Category",
"Verified": "Verified",
"Not Verified": "Not Verified",
"Verify": "Verify",
"Re-Verify": "Re-Verify",
"Verified On": "Verified On",
"Last Verified On": "Last Verified On",
"Initiated On": "Initiated On",
"Enter 10-character PAN": "Enter 10-character PAN (e.g. ABCDE1234F)",
"Enter 12-digit Aadhaar": "Enter 12-digit Aadhaar number",
"Enter 15-character GSTIN": "Enter 15-character GSTIN",
"PAN Verified": "PAN Verified",
"Aadhaar Verified": "Aadhaar Verified",
"GSTIN Verified": "GSTIN Verified",
"KYC details saved successfully": "KYC details saved successfully",
"KYC verification initiated": "KYC verification initiated",
"Form 60": "Form 60",
"Form 60 Declaration": "Form 60 Declaration",
"Father's Name": "Father's Name",
"Estimated Total Income": "Estimated Total Income",
"Reason for Not Having PAN": "Reason for Not Having PAN",
"Account Opening": "Account Opening",
"Cash Deposit": "Cash Deposit",
"Loan Processing": "Loan Processing",
"Applied for PAN": "Applied for PAN",
"Income below taxable limit": "Income below taxable limit",
"Other": "Other",
"Images": "Images",
"Import Time": "Import Time",
"In Multiples Of": "In Multiples Of",
@@ -2040,6 +2103,14 @@
"Is Cash Payment": "Is Cash Payment",
"Is Dependent": "Is Dependent",
"Is Dependent?": "Is Dependent",
"Nominee Name": "Nominee Name",
"Nominee is Minor": "Nominee is Minor",
"Nomination Type": "Nomination Type",
"DA1 - Add Nominee": "DA1 - Add Nominee",
"DA5 - Modify / Cancel Nominee": "DA5 - Modify / Cancel Nominee",
"Percentage Share": "Percentage Share",
"Guardian Name": "Guardian Name",
"Relationship with Minor": "Relationship with Minor",
"Is Differential": "Is Differential",
"Is Equal Amortization": "Is Equal Amortization?",
"Is Floating Rate": "Is Floating Rate",
@@ -2313,6 +2384,7 @@
"Parent Office": "Parent Office",
"Parent Type": "Parent Type",
"Parent officeData": "Parent officeData",
"Passbook": "Passbook",
"Password": "Password",
"Password never expires": "Password never expires",
"Passwords": "Passwords",
@@ -2865,6 +2937,7 @@
"Charge-Off": "Charge-Off",
"Chart of Accounts": "Chart of Accounts",
"Checker Inbox and Tasks": "Checker Inbox and Tasks",
"CKYC": "CKYC",
"Clients": "Clients",
"Close": "Close",
"Close (as Rescheduled)": "Close (as Rescheduled)",
@@ -2901,6 +2974,8 @@
"Merchant Issued Refund": "Merchant Issued Refund",
"Modify Application": "Modify Application",
"More": "More",
"Analytics": "Analytics",
"RBI Circulars": "RBI Circulars & Notifications",
"Navigation": "Navigation",
"Notifications": "Notifications",
"Organization": "Organization",
@@ -3018,6 +3093,11 @@
"text": {
"About Us": "About Us",
"A": "A",
"Loading analytics dashboard...": "Loading analytics dashboard...",
"Superset is connected but no dashboard ID is configured.": "Superset is connected but no dashboard ID is configured.",
"Open Superset and create a dashboard": "Open Superset and create a dashboard",
"Copy the dashboard ID from the URL": "Copy the dashboard ID from the URL",
"Set supersetDashboardId in your environment config": "Set supersetDashboardId in your environment config",
"Account Transfers": "Account Transfers",
"Ability to manage holidays for individual offices": "The ability to manage holidays for individual offices is a very useful tool for an organization spanning multiple locations.Use this option to customize holidays for each office of your organization.",
"Account Detail": "Account Detail",
@@ -3026,7 +3106,12 @@
"Accounting": "Accounting",
"Accounting Closures": "Accounting Closures",
"Accounting Rules": "Accounting Rules",
"Accounting to journal entries": "In Mifos X, you can close accounting to journal entries. Closures can be applied to all branches or to each branch separately. It is important for organizations to close accounting periodically to prevent transactions that affect the accounting books.",
"Central KYC verification and document management": "Central KYC verification and document management",
"Electronic KYC verification using Aadhaar and biometrics": "Electronic KYC verification using Aadhaar and biometrics",
"Manage master records for CKYC processing": "Manage master records for CKYC processing",
"View and generate CKYC compliance reports": "View and generate CKYC compliance reports",
"This section is under development.": "This section is under development.",
"Accounting to journal entries": "In CBSNexora, you can close accounting to journal entries. Closures can be applied to all branches or to each branch separately. It is important for organizations to close accounting periodically to prevent transactions that affect the accounting books.",
"Accounts linked to Financial Activities": "Accounts linked to Financial Activities are mapped accounts which are needed to do system generating accounting transaction.",
"Accrues income, expenses and liabilities": "Accrues income, expenses and liabilities as on the provided date",
"access to": "access to",
@@ -3034,6 +3119,11 @@
"Add Role": "Add Role",
"Add customized reports and edit core reports": "You may add customized reports and edit core reports for your organization.",
"Loading data": "Loading data",
"Generated on": "Generated on",
"Total Transactions": "Total Transactions",
"No transactions found for the selected date range.": "No transactions found for the selected date range.",
"PAN not provided. Please fill Form 60 as per Income Tax Rule 114B.": "PAN not provided. Please fill Form 60 as per Income Tax Rule 114B.",
"I declare that I do not have PAN and information provided is correct.": "I declare that I do not have PAN and information provided is correct.",
"No repayment schedule available": "No repayment schedule available",
"Add Job Step to Workflow": "Add Job Step to Workflow",
"Add new extra fields to any entity": "Add new extra fields to any entity in the form of data table",
@@ -3118,7 +3208,7 @@
"Collection Sheet": "Collection Sheet",
"Committee": "Committee",
"Configurations": "Configurations",
"Configurations page allows you to further customize": "The Configurations page allows you to further customize the way your organization uses the Mifos® system.",
"Configurations page allows you to further customize": "The Configurations page allows you to further customize the way your organization uses the CBSNexora® system.",
"Configure Maker Checker Tasks": "Configure Maker Checker Tasks",
"Copy": "Copy",
"Copy Account Number": "Copy Account Number",
@@ -3227,7 +3317,7 @@
"Define or modify Maker Checker tasks": "Define or modify Maker Checker tasks",
"Define or modify entity to entity mappings": "Define or modify entity to entity mappings",
"Define or modify roles and associated permissions": "Define or modify roles and associated permissions",
"Define what currencies interactions within the Mifos X system": "This section allows an organization to define what currencies will be used throughout their interactions within the Mifos X system.",
"Define what currencies interactions within the Mifos X system": "This section allows an organization to define what currencies will be used throughout their interactions within the CBSNexora system.",
"Defines rules for taking multiple rules": "Defines rules for taking multiple rules",
"Delete clients signature": "Are you sure you want to delete this clients signature",
"Delinquency Buckets": "Delinquency Buckets",
@@ -3235,7 +3325,7 @@
"deselect": "deselect",
"Documents": "Documents",
"Dropdown self service user administrative portal": "Dropdown provides access to self service user administrative portal, app configuration and task management.",
"Dropdown system reports": "Dropdown provides access to all types of system reports including the default reports provided by Mifos® and system reports that will be developed by the organization.",
"Dropdown system reports": "Dropdown provides access to all types of system reports including the default reports provided by CBSNexora® and system reports that will be developed by the organization.",
"Easy way to reassign all the loan": "Easy way to reassign all the loan from one LO to another LO",
"Edit": "Edit",
"Edit Account Number Preference": "Edit Account Number Preference",
@@ -3303,8 +3393,8 @@
"External Events configuration, to enable or disable": "External Events configuration, to enable or disable the event publication",
"External Services": "External Services",
"External Services Configuration": "External Services Configuration",
"Family Member View": "Family Member View",
"Family Members": "Family Members",
"Family Member View": "Nominee View",
"Family Members": "Nominee",
"fcm_end_point": "fcm_end_point",
"Filling Details": "Click to start filling the details. * mark fields are necessary. For more details click:",
"Financial Activity Mappings": "Financial Activity Mappings",
@@ -3334,7 +3424,7 @@
"FromAccountId": "From Account Id",
"FromClientId": "From Client Id",
"gcm_end_point": "gcm_end_point",
"GLAccount Balances Application": "This makes it easier for an organization wanting to move to Mifos® to transfer GL account balances (from their existing accounting application or manual system) to Mifos X Accounting.",
"GLAccount Balances Application": "This makes it easier for an organization wanting to move to CBSNexora® to transfer GL account balances (from their existing accounting application or manual system) to CBSNexora Accounting.",
"GSIM Account View": "GSIM Account View",
"General": "General",
"Get involved": "Get involved",
@@ -3485,7 +3575,7 @@
"Loans Deferred Income": "Loans Deferred Income",
"Login into Tenant": "Login into Tenant",
"Login": "Login",
"Learn about the Mifos Initiative and our mission": "Learn about the Mifos Initiative and our mission",
"Learn about the Mifos Initiative and our mission": "Learn about the CBSNexora Initiative and our mission",
"Manage Currencies": "Manage Currencies",
"Manage Data Tables": "Manage Data Tables",
"Manage Delinquency Bucket": "Manage Delinquency Bucket",
@@ -3511,9 +3601,9 @@
"Manage your Services": "Manage your Services",
"Manual journal entry transactions recorded in a journal": "Manual journal entry transactions recorded in a journal",
"Message Gateway": "Message Gateway",
"Mifos Initiative": "Mifos® Initiative",
"Mifos Initiative": "CBSNexora® Initiative",
"Powered by": "Powered by",
"Mifos X data-tables allow the user to add custom fields": "Mifos X data-tables allow the user to add custom fields in addition to the built-in fields to a client profile, loan/saving account, group, center or an office.",
"Mifos X data-tables allow the user to add custom fields": "CBSNexora data-tables allow the user to add custom fields in addition to the built-in fields to a client profile, loan/saving account, group, center or an office.",
"Migrate Opening Balances": "Migrate Opening Balances",
"Modify Loans Account": "Modify Loans Account",
"more_vert": "more_vert",
@@ -3593,7 +3683,7 @@
"Revert this transaction?": "Are you sure you want to revert this transaction?",
"row": "row",
"Roles and Permissions": "Roles and Permissions",
"Roles and Permissions allow the administrator to control access": "Roles and Permissions allow the administrator to control access to Mifos® based on what roles are assigned to Users and what Permissions are assigned to roles.",
"Roles and Permissions allow the administrator to control access": "Roles and Permissions allow the administrator to control access to CBSNexora® based on what roles are assigned to Users and what Permissions are assigned to roles.",
"S3 Amazon Service Configuration": "S3 Amazon Service Configuration",
"SMS": "SMS",
"SMS Campaigns": "SMS Campaigns",
@@ -3619,7 +3709,7 @@
"Scheduler Job History": "Scheduler Job History",
"Scheduler Jobs can set batch jobs": "By using Scheduler Jobs, you can set batch jobs to be completed at regular intervals. From this page you can schedule, modify or delete jobs.",
"Search": "Search",
"Search and assign this beneficiary to an existing Mifos client account": "Search and assign this beneficiary to an existing Mifos client account",
"Search and assign this beneficiary to an existing Mifos client account": "Search and assign this beneficiary to an existing CBSNexora client account",
"Search by Text": "Search by Text",
"SearchByName": "Search by Name",
"SearchByClient": "Search by client name, external Id, mobile",
@@ -3679,7 +3769,7 @@
"Tellers": "Tellers",
"Template I am trying to use": "This is the template I am trying to use. Is that fine.",
"Templates": "Templates",
"The Chart of Accounts - General Ledger Setup": "The Chart of Accounts - General Ledger Setup allows you to organize your different accounts within Mifos X.",
"The Chart of Accounts - General Ledger Setup": "The Chart of Accounts - General Ledger Setup allows you to organize your different accounts within CBSNexora.",
"The report request": "The report request has been sent and the report will be exported to file repository",
"The usage of stronger passwords": "Define standards for enforcing the usage of stronger passwords",
"These are predefined postings": "These are predefined postings",
@@ -3701,7 +3791,7 @@
"Transaction": "Transaction",
"Transactions": "Transactions",
"Two Factor Authentication": "Two Factor Authentication",
"Two types of charges—fees and penalties": "Mifos supports two types of charges—fees and penalties.Fees are charged for services—for example, membership fees, loan disbursement fees, withdrawal fees. Penalties are charged to discourage clients from deviating from the terms of a product",
"Two types of charges—fees and penalties": "CBSNexora supports two types of charges—fees and penalties.Fees are charged for services—for example, membership fees, loan disbursement fees, withdrawal fees. Penalties are charged to discourage clients from deviating from the terms of a product",
"Two-factor authentication configuration": "Two-factor authentication configuration settings",
"Unassign Staff client": "Are you sure you want to unassign staff for this client",
"Unassign role for client member": "Are you sure you want to unassign role for client member with id",
@@ -3786,8 +3876,8 @@
"by": "by",
"do not match": "do not match",
"edit": "edit",
"elimination of poverty": "that aims to speed the elimination of poverty by enabling Organizations to more effectively and efficiently deliver responsible financial services to the world’s poor and unbanked.Sounds interesting?",
"global community": "global community",
"elimination of poverty": "Transforming banking infrastructure with a unified, API-driven core designed for speed, resilience, and limitless innovation",
"global community": "next-generation core banking platform engineered to",
"is designed by the": "is designed by the",
"per annum": "per annum",
"undefined": "undefined",
@@ -3925,6 +4015,9 @@
"Activate": "Activate",
"Add": "Add",
"Add Filters": "Add Filters",
"Analytics": "Analytics Dashboard (Apache Superset)",
"RBI Circulars": "RBI Circulars & Notifications",
"CKYC": "Central KYC Management",
"Print Loan Application": "Print Loan Application",
"Adjust Charge": "Adjust Charge",
"After the minimum deposit term has passed": "After the minimum deposit term has passed, additional deposit durations may be specified.",
+10 -2
View File
@@ -1,9 +1,17 @@
/**
* 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/.
*/
// IMPORTANT: THIS FILE IS AUTO GENERATED! DO NOT MANUALLY EDIT OR CHECKIN!
/* tslint:disable */
export default {
'mifos_x': {
'version': '260108',
'hash': 'g5af11fe75'
'version': '260310',
'hash': 'g07f701f65'
},
'allow_switching_backend_instance': true
};
+5
View File
@@ -122,6 +122,11 @@ export const environment = {
productionModeEnableRBAC:
loadedEnv['productionModeEnableRBAC'] === 'true' || loadedEnv['productionModeEnableRBAC'] === true || false,
/** Apache Superset Analytics Integration */
supersetEnabled: loadedEnv['supersetEnabled'] === 'true' || loadedEnv['supersetEnabled'] === true || false,
supersetUrl: loadedEnv['supersetUrl'] || '',
supersetDashboardId: loadedEnv['supersetDashboardId'] || '',
OIDC: {
// Support legacy FINERACT_PLUGIN_OIDC_* variable names for backward compatibility
oidcServerEnabled:
+5
View File
@@ -126,6 +126,11 @@ export const environment = {
productionModeEnableRBAC:
loadedEnv.productionModeEnableRBAC === 'true' || loadedEnv.productionModeEnableRBAC === true || false,
/** Apache Superset Analytics Integration */
supersetEnabled: loadedEnv.supersetEnabled === 'true' || loadedEnv.supersetEnabled === true || true,
supersetUrl: loadedEnv.supersetUrl || 'https://dashboard.solwio.in',
supersetDashboardId: loadedEnv.supersetDashboardId || '2',
OIDC: {
// Support legacy FINERACT_PLUGIN_OIDC_* variable names for backward compatibility
oidcServerEnabled: