From 73aa69ee025b57c6581fcb0a647cb34da00bb5c8 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 10 Mar 2026 15:13:35 +0530 Subject: [PATCH] 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 --- angular.json | 2 +- package.json | 5 +- proxy.conf.js | 5 +- proxy.localhost.conf.js | 7 +- src/app/app.module.ts | 6 + .../ckyc-reports/ckyc-reports.component.html | 19 + .../ckyc-reports/ckyc-reports.component.ts | 21 + src/app/ckyc/ckyc-routing.module.ts | 58 ++ .../ckyc-verification.component.html | 19 + .../ckyc-verification.component.ts | 21 + src/app/ckyc/ckyc.component.html | 125 ++++ src/app/ckyc/ckyc.component.scss | 7 + src/app/ckyc/ckyc.component.ts | 36 + src/app/ckyc/ckyc.module.ts | 30 + src/app/ckyc/ekyc/ekyc.component.html | 19 + src/app/ckyc/ekyc/ekyc.component.ts | 21 + .../record-master.component.html | 19 + .../record-master/record-master.component.ts | 21 + ...client-family-member-dialog.component.html | 130 ++-- ...client-family-member-dialog.component.scss | 12 + .../client-family-member-dialog.component.ts | 84 +-- .../client-family-members-step.component.html | 28 +- .../client-general-step.component.html | 34 +- .../client-general-step.component.scss | 49 ++ .../client-general-step.component.ts | 15 +- .../client-kyc-step.component.html | 117 ++++ .../client-kyc-step.component.scss | 252 +++++++ .../client-kyc-step.component.ts | 84 +++ src/app/clients/clients-routing.module.ts | 22 +- .../ckyc-tab/ckyc-tab.component.html | 351 ++++++++++ .../ckyc-tab/ckyc-tab.component.scss | 182 +++++ .../ckyc-tab/ckyc-tab.component.ts | 219 ++++++ .../clients-view/clients-view.component.html | 15 + .../add-family-member.component.html | 163 ++--- .../add-family-member.component.ts | 82 +-- .../edit-family-member.component.html | 124 ++-- .../edit-family-member.component.ts | 85 +-- .../family-members-tab.component.html | 50 +- .../family-members-tab.component.ts | 11 +- .../form60-tab/form60-tab.component.html | 185 ++++++ .../form60-tab/form60-tab.component.scss | 84 +++ .../form60-tab/form60-tab.component.ts | 194 ++++++ .../kyc-tab/kyc-tab.component.html | 621 ++++++++++++++++++ .../kyc-tab/kyc-tab.component.scss | 371 +++++++++++ .../clients-view/kyc-tab/kyc-tab.component.ts | 426 ++++++++++++ src/app/clients/clients.module.ts | 10 +- src/app/clients/clients.service.ts | 85 +++ .../create-client.component.html | 7 +- .../create-client/create-client.component.ts | 40 +- .../core/shell/sidenav/sidenav.component.html | 38 ++ .../core/shell/sidenav/sidenav.component.ts | 3 + .../home/dashboard/dashboard.component.html | 139 +++- .../home/dashboard/dashboard.component.scss | 152 ++++- src/app/home/dashboard/dashboard.component.ts | 3 +- src/app/home/home.component.ts | 5 +- .../warning-dialog.component.html | 6 +- src/app/login/login.component.html | 59 +- src/app/login/login.component.scss | 25 +- .../rbi-circulars-routing.module.ts | 29 + .../rbi-circulars.component.html | 144 ++++ .../rbi-circulars.component.scss | 325 +++++++++ .../rbi-circulars/rbi-circulars.component.ts | 217 ++++++ src/app/rbi-circulars/rbi-circulars.module.ts | 22 + .../rbi-circulars/rbi-circulars.service.ts | 44 ++ .../passbook-tab/passbook-tab.component.html | 151 +++++ .../passbook-tab/passbook-tab.component.scss | 206 ++++++ .../passbook-tab/passbook-tab.component.ts | 186 ++++++ .../savings-account-view.component.html | 9 + src/app/savings/savings-routing.module.ts | 6 + src/app/savings/savings.module.ts | 4 +- src/app/shared/icons.module.ts | 18 +- .../superset-analytics-routing.module.ts | 29 + .../superset-analytics.module.ts | 22 + .../superset-dashboard.component.html | 74 +++ .../superset-dashboard.component.scss | 129 ++++ .../superset-dashboard.component.ts | 57 ++ .../superset-analytics/superset.service.ts | 88 +++ src/assets/images/cover_image_resized.jpg | Bin 0 -> 9623 bytes src/assets/images/cover_image_resized.png | Bin 0 -> 666783 bytes src/assets/images/default_home.png | Bin 36590 -> 7459 bytes src/assets/translations/en-US.json | 171 +++-- src/environments/.env.ts | 12 +- src/environments/environment.prod.ts | 5 + src/environments/environment.ts | 5 + 84 files changed, 6256 insertions(+), 700 deletions(-) create mode 100644 src/app/ckyc/ckyc-reports/ckyc-reports.component.html create mode 100644 src/app/ckyc/ckyc-reports/ckyc-reports.component.ts create mode 100644 src/app/ckyc/ckyc-routing.module.ts create mode 100644 src/app/ckyc/ckyc-verification/ckyc-verification.component.html create mode 100644 src/app/ckyc/ckyc-verification/ckyc-verification.component.ts create mode 100644 src/app/ckyc/ckyc.component.html create mode 100644 src/app/ckyc/ckyc.component.scss create mode 100644 src/app/ckyc/ckyc.component.ts create mode 100644 src/app/ckyc/ckyc.module.ts create mode 100644 src/app/ckyc/ekyc/ekyc.component.html create mode 100644 src/app/ckyc/ekyc/ekyc.component.ts create mode 100644 src/app/ckyc/record-master/record-master.component.html create mode 100644 src/app/ckyc/record-master/record-master.component.ts create mode 100644 src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html create mode 100644 src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss create mode 100644 src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts create mode 100644 src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html create mode 100644 src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss create mode 100644 src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts create mode 100644 src/app/clients/clients-view/form60-tab/form60-tab.component.html create mode 100644 src/app/clients/clients-view/form60-tab/form60-tab.component.scss create mode 100644 src/app/clients/clients-view/form60-tab/form60-tab.component.ts create mode 100644 src/app/clients/clients-view/kyc-tab/kyc-tab.component.html create mode 100644 src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss create mode 100644 src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts create mode 100644 src/app/rbi-circulars/rbi-circulars-routing.module.ts create mode 100644 src/app/rbi-circulars/rbi-circulars.component.html create mode 100644 src/app/rbi-circulars/rbi-circulars.component.scss create mode 100644 src/app/rbi-circulars/rbi-circulars.component.ts create mode 100644 src/app/rbi-circulars/rbi-circulars.module.ts create mode 100644 src/app/rbi-circulars/rbi-circulars.service.ts create mode 100644 src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html create mode 100644 src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.scss create mode 100644 src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts create mode 100644 src/app/superset-analytics/superset-analytics-routing.module.ts create mode 100644 src/app/superset-analytics/superset-analytics.module.ts create mode 100644 src/app/superset-analytics/superset-dashboard.component.html create mode 100644 src/app/superset-analytics/superset-dashboard.component.scss create mode 100644 src/app/superset-analytics/superset-dashboard.component.ts create mode 100644 src/app/superset-analytics/superset.service.ts create mode 100644 src/assets/images/cover_image_resized.jpg create mode 100644 src/assets/images/cover_image_resized.png diff --git a/angular.json b/angular.json index 323a10ada..1920fbe55 100644 --- a/angular.json +++ b/angular.json @@ -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": { diff --git a/package.json b/package.json index 9372d57bf..11771d707 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/proxy.conf.js b/proxy.conf.js index dda6cde79..e5f9b19a6 100644 --- a/proxy.conf.js +++ b/proxy.conf.js @@ -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( diff --git a/proxy.localhost.conf.js b/proxy.localhost.conf.js index 88d5d190f..a3cb77478 100644 --- a/proxy.localhost.conf.js +++ b/proxy.localhost.conf.js @@ -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( diff --git a/src/app/app.module.ts b/src/app/app.module.ts index cd95d8e33..75d6543a7 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/ckyc/ckyc-reports/ckyc-reports.component.html b/src/app/ckyc/ckyc-reports/ckyc-reports.component.html new file mode 100644 index 000000000..f90b22d87 --- /dev/null +++ b/src/app/ckyc/ckyc-reports/ckyc-reports.component.html @@ -0,0 +1,19 @@ + + +
+ + +
+ +

{{ 'labels.heading.CKYC Reports' | translate }}

+

{{ 'labels.text.This section is under development.' | translate }}

+
+
+
+
diff --git a/src/app/ckyc/ckyc-reports/ckyc-reports.component.ts b/src/app/ckyc/ckyc-reports/ckyc-reports.component.ts new file mode 100644 index 000000000..ad073202c --- /dev/null +++ b/src/app/ckyc/ckyc-reports/ckyc-reports.component.ts @@ -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 {} diff --git a/src/app/ckyc/ckyc-routing.module.ts b/src/app/ckyc/ckyc-routing.module.ts new file mode 100644 index 000000000..f8aff8a02 --- /dev/null +++ b/src/app/ckyc/ckyc-routing.module.ts @@ -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 {} diff --git a/src/app/ckyc/ckyc-verification/ckyc-verification.component.html b/src/app/ckyc/ckyc-verification/ckyc-verification.component.html new file mode 100644 index 000000000..604611c58 --- /dev/null +++ b/src/app/ckyc/ckyc-verification/ckyc-verification.component.html @@ -0,0 +1,19 @@ + + +
+ + +
+ +

{{ 'labels.heading.CKYC' | translate }}

+

{{ 'labels.text.This section is under development.' | translate }}

+
+
+
+
diff --git a/src/app/ckyc/ckyc-verification/ckyc-verification.component.ts b/src/app/ckyc/ckyc-verification/ckyc-verification.component.ts new file mode 100644 index 000000000..8d62b9d5c --- /dev/null +++ b/src/app/ckyc/ckyc-verification/ckyc-verification.component.ts @@ -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 {} diff --git a/src/app/ckyc/ckyc.component.html b/src/app/ckyc/ckyc.component.html new file mode 100644 index 000000000..597ecb322 --- /dev/null +++ b/src/app/ckyc/ckyc.component.html @@ -0,0 +1,125 @@ + + +
+ +
+
+ + + + + + + + + +
+ +
+ + + + + + + + + +
+
+
+
diff --git a/src/app/ckyc/ckyc.component.scss b/src/app/ckyc/ckyc.component.scss new file mode 100644 index 000000000..af9f4c075 --- /dev/null +++ b/src/app/ckyc/ckyc.component.scss @@ -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/. + */ diff --git a/src/app/ckyc/ckyc.component.ts b/src/app/ckyc/ckyc.component.ts new file mode 100644 index 000000000..315569c26 --- /dev/null +++ b/src/app/ckyc/ckyc.component.ts @@ -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]; + } +} diff --git a/src/app/ckyc/ckyc.module.ts b/src/app/ckyc/ckyc.module.ts new file mode 100644 index 000000000..4d6a0b6ad --- /dev/null +++ b/src/app/ckyc/ckyc.module.ts @@ -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 {} diff --git a/src/app/ckyc/ekyc/ekyc.component.html b/src/app/ckyc/ekyc/ekyc.component.html new file mode 100644 index 000000000..1345e177b --- /dev/null +++ b/src/app/ckyc/ekyc/ekyc.component.html @@ -0,0 +1,19 @@ + + +
+ + +
+ +

{{ 'labels.heading.E-KYC' | translate }}

+

{{ 'labels.text.This section is under development.' | translate }}

+
+
+
+
diff --git a/src/app/ckyc/ekyc/ekyc.component.ts b/src/app/ckyc/ekyc/ekyc.component.ts new file mode 100644 index 000000000..59732d290 --- /dev/null +++ b/src/app/ckyc/ekyc/ekyc.component.ts @@ -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 {} diff --git a/src/app/ckyc/record-master/record-master.component.html b/src/app/ckyc/record-master/record-master.component.html new file mode 100644 index 000000000..c7ad688fc --- /dev/null +++ b/src/app/ckyc/record-master/record-master.component.html @@ -0,0 +1,19 @@ + + +
+ + +
+ +

{{ 'labels.heading.Record Master' | translate }}

+

{{ 'labels.text.This section is under development.' | translate }}

+
+
+
+
diff --git a/src/app/ckyc/record-master/record-master.component.ts b/src/app/ckyc/record-master/record-master.component.ts new file mode 100644 index 000000000..cb0e10aa8 --- /dev/null +++ b/src/app/ckyc/record-master/record-master.component.ts @@ -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 {} diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html index 65f7f6a53..416c1b7d1 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html @@ -9,49 +9,32 @@

{{ data.context }} {{ 'labels.heading.Family Member' | translate }}

-
- - {{ 'labels.inputs.First Name' | translate }} +

{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}

+ +
+ + + {{ 'labels.inputs.Nomination Type' | translate }} + + {{ 'labels.inputs.DA1 - Add Nominee' | translate }} + {{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }} + + + + + + {{ 'labels.inputs.Nominee Name' | translate }} @if (familyMemberForm.controls.firstName.hasError('required')) { - {{ 'labels.inputs.First Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} } - - {{ 'labels.inputs.Middle Name' | translate }} - - - - - {{ 'labels.inputs.Last Name' | translate }} - - @if (familyMemberForm.controls.lastName.hasError('required')) { - - {{ 'labels.inputs.Last Name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Qualification' | translate }} - - - - - {{ 'labels.inputs.Age' | translate }} - - - - {{ 'labels.inputs.Is Dependent' | translate }}? - - + + {{ 'labels.inputs.Relationship' | translate }} @for (relation of data.options.relationshipIdOptions; track relation) { @@ -68,46 +51,8 @@ } - - {{ 'labels.inputs.Gender' | translate }} - - @for (gender of data.options.genderIdOptions; track gender) { - - {{ gender.name | translateKey: 'catalogs' }} - - } - - @if (familyMemberForm.controls.genderId.hasError('required')) { - - {{ 'labels.inputs.Gender' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Profession' | translate }} - - @for (profession of data.options.professionIdOptions; track profession) { - - {{ profession.name | translateKey: 'catalogs' }} - - } - - - - - {{ 'labels.inputs.Marital Status' | translate }} - - @for (maritalStatus of data.options.maritalStatusIdOptions; track maritalStatus) { - - {{ maritalStatus.name | translateKey: 'catalogs' }} - - } - - - - + + {{ 'labels.inputs.Date Of Birth' | translate }} + + + + {{ 'labels.inputs.Percentage Share' | translate }} (%) + + + + + + {{ 'labels.inputs.Nominee is Minor' | translate }}? + +
+ + + @if (familyMemberForm.get('isDependent').value) { +

{{ 'labels.heading.Guardian Details (If Minor)' | translate }}

+
+ + {{ 'labels.inputs.Guardian Name' | translate }} + + + + + {{ 'labels.inputs.Relationship with Minor' | translate }} + + +
+ } + + +
+ + {{ 'labels.inputs.Remarks' | translate }} + +
diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss index 91b72dab6..ff3254c01 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss @@ -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; + } + } } diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts index fedf3c57a..df589a286 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts @@ -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; } diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.html index af10c6748..0cfb46e48 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.html @@ -20,10 +20,10 @@ - {{ member.firstName }} {{ member.middleName ? member.middleName + ' ' + member.lastName : member.lastName }} + {{ member.firstName }} - {{ member.relationship }} + {{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }} @@ -36,23 +36,21 @@

- {{ 'labels.inputs.First Name' | translate }} : {{ member.firstName }}
- {{ 'labels.inputs.Middle Name' | translate }} : {{ member.middleName }}
- {{ 'labels.inputs.Last Name' | translate }} : {{ member.lastName }}
- {{ 'labels.inputs.Qualification' | translate }} : {{ member.qualification }}
+ {{ 'labels.inputs.Nomination Type' | translate }} : {{ member.nominationType }}
+ {{ 'labels.inputs.Nominee Name' | translate }} : {{ member.firstName }}
{{ 'labels.inputs.Relationship' | translate }} : {{ member.relationshipId | find: clientTemplate.familyMemberOptions.relationshipIdOptions : 'id' : 'name' }}
- {{ 'labels.inputs.Age' | translate }} : {{ member.age }}
- {{ 'labels.inputs.Is Dependent' | translate }} : {{ member.isDependent ? 'Yes' : 'No' }}
- {{ 'labels.inputs.Marital Status' | translate }} : - {{ member.maritalStatusId | find: clientTemplate.familyMemberOptions.maritalStatusIdOptions : 'id' : 'name' - }}
- {{ 'labels.inputs.Gender' | translate }} : - {{ member.genderId | find: clientTemplate.familyMemberOptions.genderIdOptions : 'id' : 'name' }}
- {{ 'labels.inputs.Profession' | translate }} : - {{ member.professionId | find: clientTemplate.familyMemberOptions.professionIdOptions : 'id' : 'name' }}
{{ 'labels.inputs.Date Of Birth' | translate }} : {{ member.dateOfBirth | dateFormat }}
+ {{ 'labels.inputs.Percentage Share' | translate }} : {{ member.percentageShare }}%
+ {{ 'labels.inputs.Nominee is Minor' | translate }} : {{ member.isDependent ? 'Yes' : 'No' }}
+ @if (member.isDependent) { + {{ 'labels.inputs.Guardian Name' | translate }} : {{ member.guardianName }}
+ {{ 'labels.inputs.Relationship with Minor' | translate }} : {{ member.guardianRelationship }}
+ } + @if (member.remarks) { + {{ 'labels.inputs.Remarks' | translate }} : {{ member.remarks }}
+ }

} diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index ba69c7b13..7f60c80af 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -222,10 +222,36 @@ - - {{ 'labels.inputs.Mobile No' | translate }} - - +
+ info + DIP Verification +
+ +
+ + {{ 'labels.inputs.Mobile No' | translate }} + + + @if (!mobileVerified) { + + } @else { + ✔ Verified + } +
{{ 'labels.inputs.Email Address' | translate }} diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss index c70f269b7..42a67d85c 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss @@ -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; diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index d607e414c..4295b9203 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -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]; } diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html new file mode 100644 index 000000000..ceed0902e --- /dev/null +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -0,0 +1,117 @@ + + +
+
+

{{ 'labels.inputs.KYC Verification' | translate }}

+ KYC +
+ +

+ {{ 'labels.inputs.KYC' | translate }} — RBI Master Direction compliant. Fields are optional but recommended for + compliance. +

+ +
+ info + Verification (PAN, Aadhaar, GSTIN) will be available from the KYC tab after the client is created. +
+ + + + +
+
+ {{ 'labels.inputs.PAN Number' | translate }} + KYC +
+ + {{ 'labels.inputs.PAN Number' | translate }} + + @if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) { + {{ 'labels.inputs.Enter 10-character PAN' | translate }} + } + +
+ + + + +
+
+ {{ 'labels.inputs.Aadhaar Number' | translate }} + KYC +
+ + {{ 'labels.inputs.Aadhaar Number' | translate }} + + @if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) { + {{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }} + } + +
+ + + + +
+
+ {{ 'labels.inputs.GSTIN Number' | translate }} + KYC +
+ + {{ 'labels.inputs.GSTIN Number' | translate }} + + @if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) { + {{ 'labels.inputs.Enter 15-character GSTIN' | translate }} + } + +
+ + + + +
+ + {{ 'labels.inputs.Remarks' | translate }} + + +
+ + +
+ + +
+
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss new file mode 100644 index 000000000..400792bc6 --- /dev/null +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss @@ -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; +} diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts new file mode 100644 index 000000000..cfe37e4b6 --- /dev/null +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -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); + } +} diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index 27c754589..d41b97718 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -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, diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html new file mode 100644 index 000000000..813399750 --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html @@ -0,0 +1,351 @@ + + +
+ + @if (loading) { +
+ +

{{ 'labels.inputs.CKYC' | translate }}...

+
+ } + + @if (!loading) { + +
+
+

{{ 'labels.heading.CKYC Verification' | translate }}

+ CKYC +
+
+ + + + +
+ +
+ {{ 'labels.inputs.Account Number' | translate }} + + + @for (account of allAccounts; track account.accountNo) { + + {{ account.accountNo }} ({{ account.productName }}) + + } + + +
+ + +
+ {{ 'labels.inputs.Client ID' | translate }} + {{ clientId }} +
+ + +
+ {{ 'labels.inputs.Client Name' | translate }} + {{ clientName }} +
+ + +
+ {{ 'labels.inputs.Account Type' | translate }} + {{ selectedAccountType || '—' }} +
+
+
+
+ + +

{{ 'labels.heading.CKYC Documents' | translate }}

+ +
+ + + +
+ home + {{ 'labels.inputs.Address Proof Document' | translate }} +
+

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF

+
+ + + + + +
+ + @if (uploadErrors['ADDRESS_PROOF']) { +
+ error_outline + {{ uploadErrors['ADDRESS_PROOF'] }} +
+ } + @if (uploadResponses['ADDRESS_PROOF']) { +
+

+ {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['ADDRESS_PROOF'].status }} +

+

+ {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['ADDRESS_PROOF'].requestId }} +

+

+ {{ 'labels.inputs.Acknowledgement Number' | translate }}: + {{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }} +

+

+ {{ 'labels.inputs.Processing Status' | translate }}: + {{ uploadResponses['ADDRESS_PROOF'].processingStatus }} +

+
+ } +
+
+ + + + +
+ photo_camera + {{ 'labels.inputs.Photo Proof Document' | translate }} +
+

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF

+
+ + + + + +
+ + @if (uploadErrors['PHOTO_PROOF']) { +
+ error_outline + {{ uploadErrors['PHOTO_PROOF'] }} +
+ } + @if (uploadResponses['PHOTO_PROOF']) { +
+

+ {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['PHOTO_PROOF'].status }} +

+

+ {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['PHOTO_PROOF'].requestId }} +

+

+ {{ 'labels.inputs.Acknowledgement Number' | translate }}: + {{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }} +

+

+ {{ 'labels.inputs.Processing Status' | translate }}: + {{ uploadResponses['PHOTO_PROOF'].processingStatus }} +

+
+ } +
+
+ + + + +
+ person + {{ 'labels.inputs.Image of Client' | translate }} +
+

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG

+
+ + + + + +
+ + @if (uploadErrors['CLIENT_IMAGE']) { +
+ error_outline + {{ uploadErrors['CLIENT_IMAGE'] }} +
+ } + @if (uploadResponses['CLIENT_IMAGE']) { +
+

+ {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['CLIENT_IMAGE'].status }} +

+

+ {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['CLIENT_IMAGE'].requestId }} +

+

+ {{ 'labels.inputs.Acknowledgement Number' | translate }}: + {{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }} +

+

+ {{ 'labels.inputs.Processing Status' | translate }}: + {{ uploadResponses['CLIENT_IMAGE'].processingStatus }} +

+
+ } +
+
+ + + + +
+ draw + {{ 'labels.inputs.Signature of Client' | translate }} +
+

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG

+
+ + + + + +
+ + @if (uploadErrors['CLIENT_SIGNATURE']) { +
+ error_outline + {{ uploadErrors['CLIENT_SIGNATURE'] }} +
+ } + @if (uploadResponses['CLIENT_SIGNATURE']) { +
+

+ {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['CLIENT_SIGNATURE'].status }} +

+

+ {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['CLIENT_SIGNATURE'].requestId }} +

+

+ {{ 'labels.inputs.Acknowledgement Number' | translate }}: + {{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }} +

+

+ {{ 'labels.inputs.Processing Status' | translate }}: + {{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }} +

+
+ } +
+
+
+ } +
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss new file mode 100644 index 000000000..1b5d6650d --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss @@ -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%; + } +} diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts new file mode 100644 index 000000000..5f66d01bc --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts @@ -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(); + + /** 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(); + } +} diff --git a/src/app/clients/clients-view/clients-view.component.html b/src/app/clients/clients-view/clients-view.component.html index 3c8f9aa75..fb86db405 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -440,6 +440,21 @@ > {{ 'labels.inputs.Identities' | translate }} + + {{ 'labels.inputs.KYC' | translate }} + + + {{ 'labels.inputs.CKYC' | translate }} + + + {{ 'labels.inputs.Form 60' | translate }} + +

{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}

+
+ - {{ 'labels.inputs.First Name' | translate }} - + {{ 'labels.inputs.Nomination Type' | translate }} + + {{ 'labels.inputs.DA1 - Add Nominee' | translate }} + {{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }} + + + + + + {{ 'labels.inputs.Nominee Name' | translate }} + @if (addFamilyMemberForm.controls.firstName.hasError('required')) { - {{ 'labels.inputs.First Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} } - - {{ 'labels.inputs.Middle Name' | translate }} - - - - - {{ 'labels.inputs.Last Name' | translate }} - - @if (addFamilyMemberForm.controls.lastName.hasError('required')) { - - {{ 'labels.inputs.Last Name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Qualification' | translate }} - - - - - {{ 'labels.inputs.Age' | translate }} - - - - - {{ 'labels.inputs.Is Dependent' | translate }}? - - + {{ 'labels.inputs.Relationship' | translate }} @@ -102,46 +50,8 @@ } - - {{ 'labels.inputs.Gender' | translate }} - - @for (gender of addFamilyMemberTemplate.genderIdOptions; track gender) { - - {{ gender.name | translateKey: 'catalogs' }} - - } - - @if (addFamilyMemberForm.controls.genderId.hasError('required')) { - - {{ 'labels.inputs.Gender' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Profession' | translate }} - - @for (profession of addFamilyMemberTemplate.professionIdOptions; track profession) { - - {{ profession.name | translateKey: 'catalogs' }} - - } - - - - - {{ 'labels.inputs.Marital Status' | translate }} - - @for (maritalStatus of addFamilyMemberTemplate.maritalStatusIdOptions; track maritalStatus) { - - {{ maritalStatus.name | translateKey: 'catalogs' }} - - } - - - - + + {{ 'labels.inputs.Date Of Birth' | translate }} + + + + {{ 'labels.inputs.Percentage Share' | translate }} (%) + + + + + + {{ 'labels.inputs.Nominee is Minor' | translate }}? + +
+ + + @if (addFamilyMemberForm.get('isDependent').value) { +

{{ 'labels.heading.Guardian Details (If Minor)' | translate }}

+
+ + {{ 'labels.inputs.Guardian Name' | translate }} + + + + + {{ 'labels.inputs.Relationship with Minor' | translate }} + + +
+ } + + +
+ + {{ 'labels.inputs.Remarks' | translate }} + +
diff --git a/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts b/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts index 3dd3c0f8e..a1a175d25 100644 --- a/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts +++ b/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts @@ -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; } diff --git a/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.html b/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.html index ce300efa1..cdd131a5f 100644 --- a/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.html +++ b/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.html @@ -8,48 +8,31 @@
+

{{ 'labels.heading.Nominee Details (DA1 / DA5)' | translate }}

+
+ - {{ 'labels.inputs.First Name' | translate }} + {{ 'labels.inputs.Nomination Type' | translate }} + + {{ 'labels.inputs.DA1 - Add Nominee' | translate }} + {{ 'labels.inputs.DA5 - Modify / Cancel Nominee' | translate }} + + + + + + {{ 'labels.inputs.Nominee Name' | translate }} @if (editFamilyMemberForm.controls.firstName.hasError('required')) { - {{ 'labels.inputs.First Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Nominee Name' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} } - - {{ 'labels.inputs.Middle Name' | translate }} - - - - - {{ 'labels.inputs.Last Name' | translate }} - - @if (editFamilyMemberForm.controls.lastName.hasError('required')) { - - {{ 'labels.inputs.Last Name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Qualification' | translate }} - - - - - {{ 'labels.inputs.Age' | translate }} - - - - - {{ 'labels.inputs.Is Dependent' | translate }} - - + {{ 'labels.inputs.Relationship' | translate }} @@ -67,46 +50,8 @@ } - - {{ 'labels.inputs.Gender' | translate }} - - @for (gender of addFamilyMemberTemplate.genderIdOptions; track gender) { - - {{ gender.name | translateKey: 'catalogs' }} - - } - - @if (editFamilyMemberForm.controls.genderId.hasError('required')) { - - {{ 'labels.inputs.Gender' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Profession' | translate }} - - @for (profession of addFamilyMemberTemplate.professionIdOptions; track profession) { - - {{ profession.name | translateKey: 'catalogs' }} - - } - - - - - {{ 'labels.inputs.Marital Status' | translate }} - - @for (maritalStatus of addFamilyMemberTemplate.maritalStatusIdOptions; track maritalStatus) { - - {{ maritalStatus.name | translateKey: 'catalogs' }} - - } - - - - + + {{ 'labels.inputs.Date Of Birth' | translate }} + + + + {{ 'labels.inputs.Percentage Share' | translate }} (%) + + + + + + {{ 'labels.inputs.Nominee is Minor' | translate }}? + +
+ + + @if (editFamilyMemberForm.get('isDependent').value) { +

{{ 'labels.heading.Guardian Details (If Minor)' | translate }}

+
+ + {{ 'labels.inputs.Guardian Name' | translate }} + + + + + {{ 'labels.inputs.Relationship with Minor' | translate }} + + +
+ } + + +
+ + {{ 'labels.inputs.Remarks' | translate }} + +
diff --git a/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.ts b/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.ts index 9cb5ecfae..d55700c2e 100644 --- a/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.ts +++ b/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.ts @@ -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; } diff --git a/src/app/clients/clients-view/family-members-tab/family-members-tab.component.html b/src/app/clients/clients-view/family-members-tab/family-members-tab.component.html index 18b2dc35a..e588f5de7 100644 --- a/src/app/clients/clients-view/family-members-tab/family-members-tab.component.html +++ b/src/app/clients/clients-view/family-members-tab/family-members-tab.component.html @@ -51,39 +51,35 @@

+ @if (member.nominationType) { + {{ 'labels.inputs.Nomination Type' | translate }} : {{ member.nominationType }}
+ } @if (member.firstName) { - {{ 'labels.inputs.First Name' | translate }} : {{ member.firstName }}
+ {{ 'labels.inputs.Nominee Name' | translate }} : {{ member.firstName }}
} - @if (member.middleName) { - {{ 'labels.inputs.Middle Name' | translate }} : {{ member.middleName }}
- } - @if (member.lastName) { - {{ 'labels.inputs.Last Name' | translate }} : {{ member.lastName }}
- } - @if (member.qualification) { - {{ 'labels.inputs.Qualification' | translate }} : {{ member.qualification }}
- } - @if (member.mobileNumber) { - {{ 'labels.inputs.Mobile Number' | translate }} : {{ member.mobileNumber }}
- } - @if (member.age) { - {{ 'labels.inputs.Age' | translate }} : {{ member.age }}
- } - @if (member.isDependent) { - {{ 'labels.inputs.Is Dependent' | translate }} : {{ member.isDependent | yesNo }}
- } - @if (member.maritalStatus) { - {{ 'labels.inputs.Marital Status' | translate }} : {{ member.maritalStatus }}
- } - @if (member.gender) { - {{ 'labels.inputs.Gender' | translate }} : {{ member.gender }}
- } - @if (member.profession) { - {{ 'labels.inputs.Profession' | translate }} : {{ member.profession }}
+ @if (member.relationship) { + {{ 'labels.inputs.Relationship' | translate }} : {{ member.relationship }}
} @if (member.dateOfBirth) { {{ 'labels.inputs.Date Of Birth' | translate }} : {{ member.dateOfBirth | dateFormat }}
} + @if (member.percentageShare != null) { + {{ 'labels.inputs.Percentage Share' | translate }} : {{ member.percentageShare }}%
+ } + @if (member.isDependent) { + {{ 'labels.inputs.Nominee is Minor' | translate }} : {{ member.isDependent | yesNo }}
+ } + @if (member.guardianName) { + {{ 'labels.inputs.Guardian Name' | translate }} : {{ member.guardianName }}
+ } + @if (member.guardianRelationship) { + {{ 'labels.inputs.Relationship with Minor' | translate }} : {{ member.guardianRelationship }}
+ } + @if (member.remarks) { + {{ 'labels.inputs.Remarks' | translate }} : {{ member.remarks }}
+ }

} diff --git a/src/app/clients/clients-view/family-members-tab/family-members-tab.component.ts b/src/app/clients/clients-view/family-members-tab/family-members-tab.component.ts index 09b97895c..ba18e24b5 100644 --- a/src/app/clients/clients-view/family-members-tab/family-members-tab.component.ts +++ b/src/app/clients/clients-view/family-members-tab/family-members-tab.component.ts @@ -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 || ''; } } diff --git a/src/app/clients/clients-view/form60-tab/form60-tab.component.html b/src/app/clients/clients-view/form60-tab/form60-tab.component.html new file mode 100644 index 000000000..368088722 --- /dev/null +++ b/src/app/clients/clients-view/form60-tab/form60-tab.component.html @@ -0,0 +1,185 @@ + + +
+ +
+ warning + {{ 'labels.text.PAN not provided. Please fill Form 60 as per Income Tax Rule 114B.' | translate }} +
+ + @if (loading) { +
+ +
+ } @else { + + @if (form60Data && !isEditMode) { + + + {{ 'labels.inputs.Form 60 Declaration' | translate }} + + +
+ @if (form60Data.fullName) { +
+ {{ 'labels.inputs.Full Name' | translate }} + {{ form60Data.fullName }} +
+ } + @if (form60Data.dateOfBirth) { +
+ {{ 'labels.inputs.Date Of Birth' | translate }} + {{ form60Data.dateOfBirth | dateFormat }} +
+ } + @if (form60Data.fatherName) { +
+ {{ "labels.inputs.Father's Name" | translate }} + {{ form60Data.fatherName }} +
+ } + @if (form60Data.address) { +
+ {{ 'labels.inputs.Address' | translate }} + {{ form60Data.address }} +
+ } + @if (form60Data.transactionType) { +
+ {{ 'labels.inputs.Transaction Type' | translate }} + {{ form60Data.transactionType }} +
+ } + @if (form60Data.transactionAmount) { +
+ {{ 'labels.inputs.Transaction Amount' | translate }} + {{ form60Data.transactionAmount }} +
+ } + @if (form60Data.estimatedTotalIncome) { +
+ {{ 'labels.inputs.Estimated Total Income' | translate }} + {{ form60Data.estimatedTotalIncome }} +
+ } + @if (form60Data.reasonForNoPan) { +
+ {{ 'labels.inputs.Reason for Not Having PAN' | translate }} + {{ form60Data.reasonForNoPan }} +
+ } +
+
+ + + +
+ } + + + @if (isEditMode) { + + + {{ 'labels.inputs.Form 60 Declaration' | translate }} + + + +
+ + + {{ 'labels.inputs.Full Name' | translate }} + + @if (form60Form.controls.fullName.hasError('required')) { + + {{ 'labels.inputs.Full Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + + {{ 'labels.inputs.Date Of Birth' | translate }} + + + + + + + + {{ "labels.inputs.Father's Name" | translate }} + + + + + + {{ 'labels.inputs.Address' | translate }} + + + + + + {{ 'labels.inputs.Transaction Type' | translate }} + + @for (txnType of transactionTypes; track txnType.value) { + {{ txnType.key | translate }} + } + + + + + + {{ 'labels.inputs.Transaction Amount' | translate }} + + + + + + {{ 'labels.inputs.Estimated Total Income' | translate }} + + + + + + {{ 'labels.inputs.Reason for Not Having PAN' | translate }} + + @for (reason of reasonOptions; track reason.value) { + {{ reason.key | translate }} + } + + +
+ + +
+ + {{ 'labels.text.I declare that I do not have PAN and information provided is correct.' | translate }} + +
+ + +
+ @if (form60Data) { + + } + +
+ +
+
+ } + } +
diff --git a/src/app/clients/clients-view/form60-tab/form60-tab.component.scss b/src/app/clients/clients-view/form60-tab/form60-tab.component.scss new file mode 100644 index 000000000..9e685ad4e --- /dev/null +++ b/src/app/clients/clients-view/form60-tab/form60-tab.component.scss @@ -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; + } +} diff --git a/src/app/clients/clients-view/form60-tab/form60-tab.component.ts b/src/app/clients/clients-view/form60-tab/form60-tab.component.ts new file mode 100644 index 000000000..9350c62a5 --- /dev/null +++ b/src/app/clients/clients-view/form60-tab/form60-tab.component.ts @@ -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(); + + /** 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(); + } +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html new file mode 100644 index 000000000..7a40792e4 --- /dev/null +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -0,0 +1,621 @@ + + +
+ + @if (loading) { +
+ +

{{ 'labels.inputs.KYC Verification' | translate }}...

+
+ } + + @if (!loading) { + +
+
+

{{ 'labels.inputs.KYC Verification' | translate }}

+ KYC + @if (kycData?.kycStatusCode) { + + {{ kycData.kycStatusCode }} + + } +
+ +
+ + + @if (!isEditMode && kycData) { +
+ + + +
+
+ credit_card + {{ 'labels.inputs.PAN Number' | translate }} + KYC +
+ @if (kycData.panVerified) { + + check_circle + {{ 'labels.inputs.Verified' | translate }} + + } @else { + + radio_button_unchecked + {{ 'labels.inputs.Not Verified' | translate }} + + } +
+

{{ kycData.panNumber || '—' }}

+ @if (kycData.panVerifiedOn) { +

+ {{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.panVerifiedOn) }} +

+ } + @if (verificationError.pan) { +
+ error_outline + {{ verificationError.pan }} +
+ } + @if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) { +

{{ verificationDetails.pan.name }}

+ } + +
+
+ + + + +
+
+ fingerprint + {{ 'labels.inputs.Aadhaar Number' | translate }} + KYC +
+ @if (kycData.aadhaarVerified) { + + check_circle + {{ 'labels.inputs.Verified' | translate }} + + } @else { + + radio_button_unchecked + {{ 'labels.inputs.Not Verified' | translate }} + + } +
+

{{ kycData.aadhaarNumberMasked || '—' }}

+ @if (kycData.aadhaarVerifiedOn) { +

+ {{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.aadhaarVerifiedOn) }} +

+ } + @if (verificationError.aadhaar) { +
+ error_outline + {{ verificationError.aadhaar }} +
+ } + @if (verificationStatus.aadhaar === 'VERIFIED' && verificationDetails.aadhaar?.name) { +

{{ verificationDetails.aadhaar.name }}

+ } + + + @if (!showAadhaarReVerify && !aadhaarOtpSent) { + + } + + + @if (showAadhaarReVerify && !aadhaarOtpSent) { +
+
+ + {{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }} + + + +
+
+ } + + + @if (aadhaarOtpSent) { +
+

+ sms + OTP sent to Aadhaar-linked mobile +

+
+ + Enter OTP + + + + +
+
+ } +
+
+ + + + +
+
+ business + {{ 'labels.inputs.GSTIN Number' | translate }} + KYC +
+ @if (kycData.gstinVerified) { + + check_circle + {{ 'labels.inputs.Verified' | translate }} + + } @else { + + radio_button_unchecked + {{ 'labels.inputs.Not Verified' | translate }} + + } +
+

{{ kycData.gstinNumber || '—' }}

+ @if (kycData.gstinVerifiedOn) { +

+ {{ 'labels.inputs.Last Verified On' | translate }}: {{ formatDate(kycData.gstinVerifiedOn) }} +

+ } + @if (verificationError.gstin) { +
+ error_outline + {{ verificationError.gstin }} +
+ } + @if (verificationStatus.gstin === 'VERIFIED' && verificationDetails.gstin?.businessName) { +

{{ verificationDetails.gstin.businessName }}

+ } + +
+
+
+ + + + +
+
+ {{ 'labels.inputs.KYC Type' | translate }} + {{ + kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' + }} +
+
+ {{ 'labels.inputs.Risk Category' | translate }} + {{ + kycData.riskCategory === 1 + ? 'Low' + : kycData.riskCategory === 2 + ? 'Medium' + : kycData.riskCategory === 3 + ? 'High' + : '—' + }} +
+
+ {{ 'labels.inputs.Initiated On' | translate }} + {{ kycData.initiatedOn || '—' }} +
+
+ {{ 'labels.inputs.Last Verified On' | translate }} + {{ formatDate(kycData.verifiedOn) }} +
+
+ {{ 'labels.inputs.Expiry Date' | translate }} + {{ kycData.expiryDate || '—' }} +
+ @if (kycData.remarks) { +
+ {{ 'labels.inputs.Remarks' | translate }} + {{ kycData.remarks }} +
+ } +
+
+
+ } + + + @if (!isEditMode && !kycData) { +
+ verified_user +

{{ 'labels.inputs.KYC Verification' | translate }} not initiated yet.

+ +
+ } + + + @if (isEditMode) { + + +
+
+ +
+
+ {{ 'labels.inputs.PAN Number' | translate }} + KYC +
+
+ + {{ 'labels.inputs.PAN Number' | translate }} + + @if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) { + {{ 'labels.inputs.Enter 10-character PAN' | translate }} + } + @if (verificationStatus.pan === 'VERIFIED') { + check_circle + } + @if (verificationStatus.pan === 'FAILED') { + cancel + } + + +
+ @if (verificationError.pan) { +
+ error_outline + {{ verificationError.pan }} +
+ } + @if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) { + {{ verificationDetails.pan.name }} + } +
+ + +
+
+ {{ 'labels.inputs.Aadhaar Number' | translate }} + KYC +
+
+ + {{ 'labels.inputs.Aadhaar Number' | translate }} + + @if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) { + {{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }} + } + @if (verificationStatus.aadhaar === 'VERIFIED') { + check_circle + } + @if (verificationStatus.aadhaar === 'FAILED') { + cancel + } + + @if (!aadhaarOtpSent) { + + } +
+ + + @if (aadhaarOtpSent) { +
+

+ sms + OTP sent to Aadhaar-linked mobile +

+
+ + Enter OTP + + + + +
+
+ } + + @if (verificationError.aadhaar) { +
+ error_outline + {{ verificationError.aadhaar }} +
+ } +
+ + +
+
+ {{ 'labels.inputs.GSTIN Number' | translate }} + KYC +
+
+ + {{ 'labels.inputs.GSTIN Number' | translate }} + + @if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) { + {{ 'labels.inputs.Enter 15-character GSTIN' | translate }} + } + @if (verificationStatus.gstin === 'VERIFIED') { + check_circle + } + @if (verificationStatus.gstin === 'FAILED') { + cancel + } + + +
+ @if (verificationError.gstin) { +
+ error_outline + {{ verificationError.gstin }} +
+ } + @if (verificationStatus.gstin === 'VERIFIED' && verificationDetails.gstin?.businessName) { + {{ verificationDetails.gstin.businessName }} + } +
+ + + @if (!kycId) { + + {{ 'labels.inputs.KYC Type' | translate }} + + Individual + Non-Individual + + + } + + + + {{ 'labels.inputs.Risk Category' | translate }} + + Low + Medium + High + + + + + + {{ 'labels.inputs.Remarks' | translate }} + + +
+ + @if (errorMessage) { +
+ error_outline + {{ errorMessage }} +
+ } + + @if (!kycId) { +
+ info + Save KYC details first to enable verification buttons. +
+ } + +
+ + +
+
+
+
+ } + } +
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss new file mode 100644 index 000000000..152cbfc42 --- /dev/null +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss @@ -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; + } +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts new file mode 100644 index 000000000..cd625dbe9 --- /dev/null +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -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(); + + 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(); + } +} diff --git a/src/app/clients/clients.module.ts b/src/app/clients/clients.module.ts index 2115234fd..5171c0131 100644 --- a/src/app/clients/clients.module.ts +++ b/src/app/clients/clients.module.ts @@ -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: [] }) diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 776f1c4f3..e672575cf 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {}); + } + + getClientCkyc(clientId: string): Observable { + return this.http.get(`/clients/${clientId}/ckyc`); + } + + uploadCkycDocument(clientId: string, formData: FormData): Observable { + return this.http.post(`/clients/${clientId}/ckyc/documents`, formData); + } + + getClientForm60(clientId: string): Observable { + return this.http.get(`/clients/${clientId}/form60`); + } + + submitClientForm60(clientId: string, data: any): Observable { + return this.http.post(`/clients/${clientId}/form60`, data); + } } diff --git a/src/app/clients/create-client/create-client.component.html b/src/app/clients/create-client/create-client.component.html index cd61f30ba..02c7f2dcb 100644 --- a/src/app/clients/create-client/create-client.component.html +++ b/src/app/clients/create-client/create-client.component.html @@ -38,7 +38,7 @@ - {{ 'labels.inputs.FAMILY MEMBERS' | translate }} + {{ 'labels.inputs.Nominee' | translate }} + + {{ 'labels.inputs.KYC' | translate }} + + + @if (clientTemplate.isAddressEnabled) { {{ 'labels.inputs.ADDRESS' | translate }} diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 9f1b73ad5..417a4f0f0 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -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; @@ -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 } + ); + } }); } } diff --git a/src/app/core/shell/sidenav/sidenav.component.html b/src/app/core/shell/sidenav/sidenav.component.html index 776d35ffa..f2a75c519 100644 --- a/src/app/core/shell/sidenav/sidenav.component.html +++ b/src/app/core/shell/sidenav/sidenav.component.html @@ -93,6 +93,19 @@
{{ 'labels.menus.Navigation' | translate }} + + + + + {{ 'labels.menus.CKYC' | translate }} + {{ 'labels.menus.Remittances' | translate }} + + + + + {{ 'labels.menus.Analytics' | translate }} + + + + + + {{ 'labels.menus.RBI Circulars' | translate }} + ; diff --git a/src/app/home/dashboard/dashboard.component.html b/src/app/home/dashboard/dashboard.component.html index b19728351..ccc16f8ee 100644 --- a/src/app/home/dashboard/dashboard.component.html +++ b/src/app/home/dashboard/dashboard.component.html @@ -6,11 +6,14 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -