Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f499a6b57e | ||
|
|
f0b4e98e71 | ||
|
|
e58fd3c79a | ||
|
|
1b2ef5126e | ||
|
|
b953afe2da | ||
|
|
ff781e50e3 | ||
|
|
7ef75c902d | ||
|
|
c18219fce5 | ||
|
|
2d93f80d50 | ||
|
|
1d1e76497c | ||
|
|
352a65cb5d | ||
|
|
8d67a22f18 | ||
|
|
997e8920e4 | ||
|
|
5581d4180d | ||
|
|
75df8865dd | ||
|
|
5a6fb1f9b5 |
@@ -69,3 +69,10 @@ src/environments/.env.ts
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# JVM crash / replay logs
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
|
||||
# Microsoft Office temporary lock files
|
||||
~$*.xlsx
|
||||
|
||||
Generated
+289
@@ -44,6 +44,7 @@
|
||||
"moment": "^2.29.4",
|
||||
"ngx-image-cropper": "^9.1.6",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"rxjs": "^7.8.2",
|
||||
"signature_pad": "^5.1.3",
|
||||
"tinymce": "^8.2.2",
|
||||
@@ -77,6 +78,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@vendure/ngx-translate-extract": "^9.4.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"cypress": "^13.17.0",
|
||||
"eslint": "^9.39.1",
|
||||
"git-describe": "^4.1.1",
|
||||
@@ -3559,6 +3561,13 @@
|
||||
"url": "https://github.com/sponsors/JounQin"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||
@@ -6126,6 +6135,256 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/nice": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz",
|
||||
@@ -11451,6 +11710,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -19922,6 +20199,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.7.284",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
|
||||
"integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
|
||||
+6
-4
@@ -15,10 +15,10 @@
|
||||
"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.localhost.conf.js",
|
||||
"start:share": "npm run env -s && ng serve --host 0.0.0.0 --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",
|
||||
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
|
||||
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
|
||||
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
|
||||
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" 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",
|
||||
@@ -73,6 +73,7 @@
|
||||
"moment": "^2.29.4",
|
||||
"ngx-image-cropper": "^9.1.6",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"rxjs": "^7.8.2",
|
||||
"signature_pad": "^5.1.3",
|
||||
"tinymce": "^8.2.2",
|
||||
@@ -106,6 +107,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@vendure/ngx-translate-extract": "^9.4.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"cypress": "^13.17.0",
|
||||
"eslint": "^9.39.1",
|
||||
"git-describe": "^4.1.1",
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
|
||||
/** HTTP service for Clerk Withdrawal Request maker-checker workflow. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -50,8 +51,12 @@ export class ClerkWithdrawalService {
|
||||
return this.http.post(`/clerk/withdrawal-requests/${id}/reject`, { reason });
|
||||
}
|
||||
|
||||
// Silenced: onTokNoBlur() probes this expecting a possible legitimate 404 (token not from this
|
||||
// pool, could be a dividend claim's instead) and shows its own tailored message either way.
|
||||
getByToken(token: string): Observable<any> {
|
||||
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`);
|
||||
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
|
||||
|
||||
@@ -925,42 +925,50 @@
|
||||
<div class="te-acs-meta">
|
||||
<span
|
||||
class="te-acs-tag"
|
||||
[class.te-acs-tag--active]="accountDetails?.status?.value === 'Active'"
|
||||
[class.te-acs-tag--active]="activeDividendClaimRequest || accountDetails?.status?.value === 'Active'"
|
||||
[class.te-acs-tag--blocked]="accountDetails?.status?.value === 'Blocked'"
|
||||
[class.te-acs-tag--dormant]="accountDetails?.subStatus?.value === 'Dormant'"
|
||||
>{{
|
||||
accountDetails?.subStatus?.value === 'Dormant' ? 'Dormant' : (accountDetails?.status?.value ?? '—')
|
||||
activeDividendClaimRequest
|
||||
? 'Awaiting Disbursement'
|
||||
: accountDetails?.subStatus?.value === 'Dormant'
|
||||
? 'Dormant'
|
||||
: (accountDetails?.status?.value ?? '—')
|
||||
}}</span
|
||||
>
|
||||
<span class="te-acs-kv"
|
||||
><b>A/c</b>
|
||||
{{
|
||||
accountDetails?.depositType?.value ||
|
||||
(selectedRow?.acTyp === 'CA'
|
||||
? 'Current'
|
||||
: selectedRow?.acTyp === 'SD'
|
||||
? 'Savings'
|
||||
: selectedRow?.acTyp === 'FD'
|
||||
? 'Fixed Dep.'
|
||||
: selectedRow?.acTyp === 'RD'
|
||||
? 'Recurring'
|
||||
: selectedRow?.acTyp === 'GL'
|
||||
? (accountDetails?.type?.value ?? 'GL Account')
|
||||
: selectedRow?.acTyp === 'SHR'
|
||||
? 'Share'
|
||||
: selectedRow?.acTyp === 'LA'
|
||||
? 'Loan'
|
||||
: '—')
|
||||
(activeDividendClaimRequest
|
||||
? 'Dividend Claim'
|
||||
: selectedRow?.acTyp === 'CA'
|
||||
? 'Current'
|
||||
: selectedRow?.acTyp === 'SD'
|
||||
? 'Savings'
|
||||
: selectedRow?.acTyp === 'FD'
|
||||
? 'Fixed Dep.'
|
||||
: selectedRow?.acTyp === 'RD'
|
||||
? 'Recurring'
|
||||
: selectedRow?.acTyp === 'GL'
|
||||
? (accountDetails?.type?.value ?? 'GL Account')
|
||||
: selectedRow?.acTyp === 'SHR'
|
||||
? 'Share'
|
||||
: selectedRow?.acTyp === 'LA'
|
||||
? 'Loan'
|
||||
: '—')
|
||||
}}</span
|
||||
>
|
||||
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
||||
<span class="te-acs-kv"
|
||||
><b>Product</b>
|
||||
{{
|
||||
accountDetails?.savingsProductName ||
|
||||
accountDetails?.depositProductName ||
|
||||
accountDetails?.productName ||
|
||||
'—'
|
||||
activeDividendClaimRequest
|
||||
? 'Dividend Cash Payout'
|
||||
: accountDetails?.savingsProductName ||
|
||||
accountDetails?.depositProductName ||
|
||||
accountDetails?.productName ||
|
||||
'—'
|
||||
}}</span
|
||||
>
|
||||
<span class="te-acs-kv"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
|
||||
import { forkJoin, of, Subject } from 'rxjs';
|
||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -20,6 +20,7 @@ import { CashierService } from '../cashier.service';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
|
||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||||
import { CashierDepositService } from '../cashier-deposit.service';
|
||||
@@ -749,6 +750,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Active approved withdrawal (cashier disburse flow)
|
||||
activeWithdrawalRequest: any = null;
|
||||
activeDividendClaimRequest: any = null;
|
||||
loadingWithdrawal = false;
|
||||
|
||||
private http = inject(HttpClient);
|
||||
@@ -757,6 +759,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
private dialog = inject(MatDialog);
|
||||
private clerkUiState = inject(ClerkUiStateService);
|
||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||
private dividendClaimRequestService = inject(DividendClaimRequestService);
|
||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||||
private cashierDepositService = inject(CashierDepositService);
|
||||
@@ -1731,10 +1734,28 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const token = row.tokNo.trim();
|
||||
this.loadingWithdrawal = true;
|
||||
this.activeWithdrawalRequest = null;
|
||||
this.activeDividendClaimRequest = null;
|
||||
this.clerkWithdrawalService
|
||||
.getByToken(token)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
|
||||
// which are legitimate matches with their own specific error) — try the dividend
|
||||
// cash-claim token pool before giving up, since both share the same TOK. NO. field.
|
||||
if (err?.status === 404) {
|
||||
return this.dividendClaimRequestService.getByToken(token).pipe(
|
||||
catchError((divErr: any) => {
|
||||
this.loadingWithdrawal = false;
|
||||
const msg: string =
|
||||
divErr?.error?.message ??
|
||||
divErr?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
`Token '${token}' could not be verified. Please try again.`;
|
||||
this.showAlert('error', `Token '${token}'`, [msg]);
|
||||
return of(null);
|
||||
}),
|
||||
map((divReq: any) => (divReq ? { __dividendClaim: true, ...divReq } : null))
|
||||
);
|
||||
}
|
||||
this.loadingWithdrawal = false;
|
||||
const msg: string =
|
||||
err?.error?.message ??
|
||||
@@ -1747,6 +1768,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.subscribe((req: any) => {
|
||||
this.loadingWithdrawal = false;
|
||||
if (!req) return;
|
||||
|
||||
if (req.__dividendClaim) {
|
||||
this.activeDividendClaimRequest = req;
|
||||
// No real savings/current account backs a dividend cash claim (it's a pure GL
|
||||
// liability settled at claim-approval time) — stub just enough of accountDetails
|
||||
// and kycDetails for onSave()'s generic guards to pass without stale/unrelated data.
|
||||
this.accountDetails = { id: null, accountNo: 'DIVIDEND-CLAIM-' + req.id, clientName: req.client_name };
|
||||
this.kycDetails = null;
|
||||
this.clientDetails = null;
|
||||
this.accountError = '';
|
||||
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
|
||||
row.acTyp = 'SD';
|
||||
row.amount = Number(req.amount) || null;
|
||||
row.dbCr = 'Dr';
|
||||
this.selectedRow = row;
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWithdrawalRequest = req;
|
||||
row.acNo = req.account_no ?? '';
|
||||
row.acTyp = 'SD';
|
||||
@@ -3012,7 +3051,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Cashier direct withdrawal BLOCKED — must use approved maker-checker token
|
||||
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
|
||||
if (
|
||||
!this.isClerk &&
|
||||
!this.reviewMode &&
|
||||
!isDeposit &&
|
||||
!this.activeWithdrawalRequest &&
|
||||
!this.activeDividendClaimRequest
|
||||
) {
|
||||
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||||
'Withdrawals cannot be processed directly by the cashier.',
|
||||
'All withdrawals must go through the Clerk → Manager approval process.',
|
||||
@@ -3021,9 +3066,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved withdrawal: amount/denomination must exactly match
|
||||
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
|
||||
const approvedAmt = Number(this.activeWithdrawalRequest.amount);
|
||||
// Approved withdrawal / dividend cash claim: amount/denomination must exactly match
|
||||
if (!this.isClerk && (this.activeWithdrawalRequest || this.activeDividendClaimRequest) && !isDeposit) {
|
||||
const rawApprovedAmt = Number(this.activeWithdrawalRequest?.amount ?? this.activeDividendClaimRequest?.amount);
|
||||
// A dividend cash claim's liability is stored with paise precision (a proportional profit
|
||||
// share), but cash can only be handed over in whole-rupee notes/coins -- compare against
|
||||
// the floored amount so an exact denomination match is actually achievable. Ordinary
|
||||
// withdrawal-request amounts are already whole rupees by convention, so this only changes
|
||||
// behavior for dividend claims.
|
||||
const approvedAmt = this.activeDividendClaimRequest ? Math.floor(rawApprovedAmt) : rawApprovedAmt;
|
||||
if (Math.abs(amount - approvedAmt) > 0.005) {
|
||||
if (isCash) {
|
||||
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
|
||||
@@ -3116,6 +3167,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const isCash = !!selectedPtEarly?.isCashPayment;
|
||||
const isGlType = row.acTyp === 'GL';
|
||||
|
||||
// Dividend cash claim token disbursement: the GL entry (Dr Dividend Payable / Cr Cash at
|
||||
// Main Vault) already posted at claim-approval time — there is no real savings/current
|
||||
// account behind this row, so skip balance/min-balance/KYC/dormant/facility checks entirely
|
||||
// (none apply to a GL-only liability payout) and go straight to the confirm dialog.
|
||||
if (this.activeDividendClaimRequest) {
|
||||
if (!row.paymentTypeId) {
|
||||
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
|
||||
return;
|
||||
}
|
||||
if (isWithdrawal && this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
|
||||
const lines = this.denominationOverdrafts.map(
|
||||
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
|
||||
);
|
||||
this.showAlert('error', 'Insufficient Denomination Stock', [
|
||||
'Your cash drawer does not have enough notes/coins:',
|
||||
...lines,
|
||||
'Adjust denomination or request vault replenishment.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
|
||||
return;
|
||||
}
|
||||
|
||||
// GL transactions post as journal entries — skip account-level facility/balance checks
|
||||
if (isGlType) {
|
||||
if (!this.cashVaultGlId) {
|
||||
@@ -3261,7 +3336,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
||||
// Token-based cashier disbursement: balance was already deducted at manager approval
|
||||
const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest;
|
||||
const isDisbursement =
|
||||
!isDeposit && !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest);
|
||||
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
|
||||
const isDepositRequest =
|
||||
isDeposit &&
|
||||
@@ -3292,7 +3368,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
paymentType: selectedPt?.name ?? 'Cash',
|
||||
chequeNo: row.chequeNo || null,
|
||||
symbol: this.currencySymbol,
|
||||
tokNo: this.activeWithdrawalRequest?.tok_no ?? null,
|
||||
tokNo: this.activeWithdrawalRequest?.tok_no ?? this.activeDividendClaimRequest?.tok_no ?? null,
|
||||
isDdCashGl: this.isDdCashGlRow(row),
|
||||
ddFaceValue: row.amount ?? 0,
|
||||
ddCharges: row.ddCharges ?? 0,
|
||||
@@ -3354,9 +3430,52 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dividend cash claim token disbursement → GL entry already posted at claim-approval time.
|
||||
// Do NOT post any new transaction/journal entry — just record the physical hand-over via
|
||||
// DividendClaimRequestService, mirroring the activeWithdrawalRequest branch just below.
|
||||
if (!this.isClerk && this.activeDividendClaimRequest && !isDeposit) {
|
||||
this.saving = true;
|
||||
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
const denomJson =
|
||||
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
|
||||
const req = this.activeDividendClaimRequest;
|
||||
this.dividendClaimRequestService
|
||||
.disburse(req.id, denomJson, amount)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.message ??
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
'Disbursement recording failed. Please try again.';
|
||||
this.showAlert('error', 'Disbursement Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
this.showAlert('success', 'Cash Disbursed Successfully', [
|
||||
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
|
||||
`Token No: ${req.tok_no} · Client: ${req.client_name}`
|
||||
]);
|
||||
this.activeDividendClaimRequest = null;
|
||||
this.clearDenominations();
|
||||
this.loadOpeningPosition();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
|
||||
// Extra safety net in case confirmSubmit() is ever called directly
|
||||
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
|
||||
if (
|
||||
!this.isClerk &&
|
||||
!this.reviewMode &&
|
||||
!isDeposit &&
|
||||
!this.activeWithdrawalRequest &&
|
||||
!this.activeDividendClaimRequest
|
||||
) {
|
||||
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||||
'Withdrawals must go through the maker-checker approval process.',
|
||||
'Enter the approved token number to disburse.'
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpContext } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
|
||||
/**
|
||||
* HTTP service for the maker-checker "claim my dividend early" workflow — a clerk submits a claim
|
||||
@@ -27,6 +28,7 @@ export class DividendClaimRequestService {
|
||||
savingsAccountId?: number;
|
||||
savingsApplyAmount?: number;
|
||||
cashApplyAmount?: number;
|
||||
tokNo?: string;
|
||||
}
|
||||
): Observable<any> {
|
||||
const body: any = { lockId };
|
||||
@@ -45,9 +47,27 @@ export class DividendClaimRequestService {
|
||||
if (breakdown.cashApplyAmount != null) {
|
||||
body.cashApplyAmount = String(breakdown.cashApplyAmount);
|
||||
}
|
||||
if (breakdown.tokNo != null) {
|
||||
body.tokNo = breakdown.tokNo;
|
||||
}
|
||||
return this.http.post('/clerk/dividend-claim-requests', body);
|
||||
}
|
||||
|
||||
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
|
||||
// token pool 404s, and shows its own tailored message either way.
|
||||
getByToken(token: string): Observable<any> {
|
||||
return this.http.get(`/clerk/dividend-claim-requests/by-token/${encodeURIComponent(token)}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
|
||||
return this.http.post(`/clerk/dividend-claim-requests/${id}/disburse`, {
|
||||
denominationData,
|
||||
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
|
||||
});
|
||||
}
|
||||
|
||||
getOverdueLoans(clientId: number): Observable<any> {
|
||||
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
|
||||
}
|
||||
|
||||
@@ -283,6 +283,12 @@
|
||||
<span>Cash</span>
|
||||
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
|
||||
</label>
|
||||
@if (claimCashAmount[l.lockId] > 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Token No. (for cashier hand-over)</span>
|
||||
<input type="text" [(ngModel)]="claimTokNo[l.lockId]" />
|
||||
</label>
|
||||
}
|
||||
<div class="dd-apply-panel-total">
|
||||
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
|
||||
</div>
|
||||
@@ -353,7 +359,12 @@
|
||||
}}
|
||||
</td>
|
||||
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
|
||||
<td class="num">{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}</td>
|
||||
<td class="num">
|
||||
{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
|
||||
@if (req.cash_apply_amount > 0 && req.tok_no) {
|
||||
<div class="pg-empty-inline">Tok: {{ req.tok_no }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
@if (req.has_overdue_loan) {
|
||||
|
||||
@@ -68,6 +68,7 @@ export class DividendDashboardComponent implements OnInit {
|
||||
claimSavingsAmount: Record<number, number | null> = {};
|
||||
claimSavingsAccountOverride: Record<number, number | null> = {};
|
||||
claimCashAmount: Record<number, number | null> = {};
|
||||
claimTokNo: Record<number, string> = {};
|
||||
claimSubmitting = false;
|
||||
claimError: string | null = null;
|
||||
|
||||
@@ -99,6 +100,7 @@ export class DividendDashboardComponent implements OnInit {
|
||||
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
|
||||
this.claimSavingsAccountOverride[lock.lockId] = null;
|
||||
this.claimCashAmount[lock.lockId] = null;
|
||||
this.claimTokNo[lock.lockId] = '';
|
||||
this.claimRequestService
|
||||
.getOverdueLoans(lock.clientId)
|
||||
.pipe(catchError(() => of([])))
|
||||
@@ -117,14 +119,17 @@ export class DividendDashboardComponent implements OnInit {
|
||||
return (
|
||||
Number(this.claimLoanAmount[lock.lockId] || 0) +
|
||||
Number(this.claimSavingsAmount[lock.lockId] || 0) +
|
||||
Number(this.claimCashAmount[lock.lockId] || 0)
|
||||
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
|
||||
);
|
||||
}
|
||||
|
||||
submitClaim(lock: any): void {
|
||||
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
|
||||
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
|
||||
const cashAmount = Number(this.claimCashAmount[lock.lockId] || 0);
|
||||
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
|
||||
// dividend share often works out to paise — floor to the nearest rupee so the denomination
|
||||
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
|
||||
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
|
||||
const total = loanAmount + savingsAmount + cashAmount;
|
||||
if (total <= 0) {
|
||||
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
|
||||
@@ -142,6 +147,11 @@ export class DividendDashboardComponent implements OnInit {
|
||||
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
|
||||
return;
|
||||
}
|
||||
const tokNo = this.claimTokNo[lock.lockId]?.trim();
|
||||
if (cashAmount > 0 && !tokNo) {
|
||||
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
|
||||
return;
|
||||
}
|
||||
this.claimSubmitting = true;
|
||||
this.claimError = null;
|
||||
this.claimRequestService
|
||||
@@ -151,7 +161,8 @@ export class DividendDashboardComponent implements OnInit {
|
||||
savingsAccountId:
|
||||
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
|
||||
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
|
||||
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined
|
||||
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
|
||||
tokNo: cashAmount > 0 ? tokNo : undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
|
||||
+126
-20
@@ -136,7 +136,14 @@
|
||||
| translate
|
||||
}}
|
||||
</mat-label>
|
||||
<input matInput required formControlName="fullname" autocomplete="off" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="fullname"
|
||||
autocomplete="off"
|
||||
(blur)="capitalizeControl('fullname')"
|
||||
style="text-transform: capitalize"
|
||||
/>
|
||||
@if (createClientForm.controls.fullname.hasError('required')) {
|
||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||
@@ -151,10 +158,12 @@
|
||||
<div class="full-name-row full-width">
|
||||
<div class="full-name-preview">
|
||||
<span class="full-name-label">Entity Name:</span>
|
||||
<span class="full-name-value">{{ createClientForm.get('fullname')?.value }}</span>
|
||||
<span class="full-name-value" style="text-transform: capitalize">{{
|
||||
createClientForm.get('fullname')?.value
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="verify-inline">
|
||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
||||
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
@@ -164,27 +173,27 @@
|
||||
(click)="verifySanctions()"
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
||||
Verify Entity
|
||||
{{ sanctionsNeedsRescreen ? 'Re-verify Entity' : 'Verify Entity' }}
|
||||
</button>
|
||||
}
|
||||
@if (sanctionsVerified) {
|
||||
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 & PMLA 2002</span>
|
||||
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
|
||||
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||
<mat-icon
|
||||
class="sanctions-info-icon"
|
||||
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
|
||||
[matTooltip]="sanctionsTooltip"
|
||||
matTooltipClass="sanctions-tooltip"
|
||||
>info</mat-icon
|
||||
>
|
||||
}
|
||||
@if (sanctionsBlocked) {
|
||||
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 & PMLA 2002</span>
|
||||
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
|
||||
<span class="verify-status blocked">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
|
||||
</span>
|
||||
<mat-icon
|
||||
class="sanctions-info-icon blocked-info"
|
||||
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
|
||||
[matTooltip]="sanctionsTooltip"
|
||||
matTooltipClass="sanctions-tooltip"
|
||||
>info</mat-icon
|
||||
>
|
||||
@@ -214,7 +223,14 @@
|
||||
<mat-form-field appearance="outline" class="name-field first-name">
|
||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
||||
<input matInput required formControlName="firstname" autocomplete="off" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="firstname"
|
||||
autocomplete="off"
|
||||
(blur)="capitalizeControl('firstname')"
|
||||
style="text-transform: capitalize"
|
||||
/>
|
||||
@if (createClientForm.controls.firstname.hasError('required')) {
|
||||
<mat-error>First name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||
@@ -228,7 +244,13 @@
|
||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
||||
<input matInput formControlName="middlename" autocomplete="off" />
|
||||
<input
|
||||
matInput
|
||||
formControlName="middlename"
|
||||
autocomplete="off"
|
||||
(blur)="capitalizeControl('middlename')"
|
||||
style="text-transform: capitalize"
|
||||
/>
|
||||
@if (
|
||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||
createClientForm.controls.middlename?.touched
|
||||
@@ -241,7 +263,14 @@
|
||||
<mat-form-field appearance="outline" class="name-field last-name">
|
||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
||||
<input matInput required formControlName="lastname" autocomplete="off" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="lastname"
|
||||
autocomplete="off"
|
||||
(blur)="capitalizeControl('lastname')"
|
||||
style="text-transform: capitalize"
|
||||
/>
|
||||
@if (createClientForm.controls.lastname.hasError('required')) {
|
||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||
@@ -261,7 +290,7 @@
|
||||
<span class="full-name-value">{{ fullNamePreview }}</span>
|
||||
</div>
|
||||
<div class="verify-inline">
|
||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
||||
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
@@ -271,27 +300,27 @@
|
||||
(click)="verifySanctions()"
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
||||
Verify Client
|
||||
{{ sanctionsNeedsRescreen ? 'Re-verify Client' : 'Verify Client' }}
|
||||
</button>
|
||||
}
|
||||
@if (sanctionsVerified) {
|
||||
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 & PMLA 2002</span>
|
||||
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
|
||||
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||
<mat-icon
|
||||
class="sanctions-info-icon"
|
||||
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
|
||||
[matTooltip]="sanctionsTooltip"
|
||||
matTooltipClass="sanctions-tooltip"
|
||||
>info</mat-icon
|
||||
>
|
||||
}
|
||||
@if (sanctionsBlocked) {
|
||||
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 & PMLA 2002</span>
|
||||
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
|
||||
<span class="verify-status blocked">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
|
||||
</span>
|
||||
<mat-icon
|
||||
class="sanctions-info-icon blocked-info"
|
||||
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
|
||||
[matTooltip]="sanctionsTooltip"
|
||||
matTooltipClass="sanctions-tooltip"
|
||||
>info</mat-icon
|
||||
>
|
||||
@@ -694,6 +723,9 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Date of Incorporation is captured on the KYC step, next to the entity PAN it is
|
||||
verified against — see client-kyc-step's `incorporationDate` control. -->
|
||||
|
||||
<!-- Authorized Signatories (multiple, registered clients) -->
|
||||
<div class="form-field full-width signatory-section">
|
||||
<h4 class="section-title signatory-title">
|
||||
@@ -822,7 +854,8 @@
|
||||
<div class="guardian-result-item__info">
|
||||
<span class="guardian-result-item__name">{{ result.displayName }}</span>
|
||||
<span class="guardian-result-item__meta"
|
||||
>ID: {{ result.id }} · A/C: {{ result.accountNumber }} ·
|
||||
>ID: {{ result.id }} · A/C: {{ result.accountNumber }}
|
||||
·
|
||||
{{ result.officeName }}
|
||||
@if (result.dateOfBirth) {
|
||||
· DOB: {{ result.dateOfBirth | dateFormat }}
|
||||
@@ -1148,6 +1181,79 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
Section: Turnover & Risk (Non-Individual only)
|
||||
|
||||
An entity has no salary to declare, so the individual Financial & Classification section below
|
||||
never applied to it — which left a company with no turnover on file at all, and its risk stuck
|
||||
on the flat entity default. Turnover is the size signal the branch actually has at onboarding,
|
||||
so it is captured here and the risk band it produces is shown right beside it: the officer sees
|
||||
the grade move as they type, instead of first meeting it on the KYC tab.
|
||||
-->
|
||||
@if (createClientForm.get('legalFormId')?.value === LegalFormId.ENTITY) {
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="chart-line" class="section-icon"></fa-icon>
|
||||
Turnover & Risk Profile
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Monthly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
|
||||
<mat-hint>Yearly turnover fills in automatically (× 12)</mat-hint>
|
||||
@if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Annual Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
<mat-hint>Drives the risk grade below</mat-hint>
|
||||
@if (
|
||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div
|
||||
class="full-width entity-risk-card"
|
||||
[class.entity-risk-card--low]="entityTurnoverRisk.category === 1"
|
||||
[class.entity-risk-card--medium]="entityTurnoverRisk.category === 2"
|
||||
[class.entity-risk-card--high]="entityTurnoverRisk.category === 3"
|
||||
>
|
||||
<div class="entity-risk-card__head">
|
||||
<fa-icon icon="shield-alt" class="entity-risk-card__icon"></fa-icon>
|
||||
<span class="entity-risk-card__title">Auto-calculated Risk Category</span>
|
||||
<span class="entity-risk-card__badge">{{ riskCategoryLabel(entityTurnoverRisk.category) }}</span>
|
||||
</div>
|
||||
<p class="entity-risk-card__reason">{{ entityTurnoverRisk.reason }}</p>
|
||||
<p class="entity-risk-card__scale">
|
||||
Up to ₹1 crore — Low · ₹1 crore to ₹10 crore — Medium · Above ₹10 crore — High
|
||||
</p>
|
||||
<p class="entity-risk-card__note">
|
||||
Carried into the KYC tab, where it can still be overridden with a recorded reason.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Section: Financial & Classification (Individual only) -->
|
||||
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||
<div class="section-block">
|
||||
|
||||
@@ -56,6 +56,103 @@ form {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Live risk grade beside the entity's turnover fields. Neutral by default; the accent tracks the
|
||||
band so a jump from Medium to High is visible without reading the sentence. */
|
||||
.entity-risk-card {
|
||||
margin: 4px 0 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #94a3b8;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
margin-left: auto;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__reason {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__scale,
|
||||
&__note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
border-left-color: #16a34a;
|
||||
|
||||
.entity-risk-card__icon {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.entity-risk-card__badge {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
border-left-color: #d97706;
|
||||
|
||||
.entity-risk-card__icon {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.entity-risk-card__badge {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
border-left-color: #dc2626;
|
||||
|
||||
.entity-risk-card__icon {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.entity-risk-card__badge {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.senior-citizen-auto-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+266
-34
@@ -32,6 +32,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||
import { toProperNameCase } from 'app/clients/utils/name-case.util';
|
||||
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
||||
|
||||
/** Custom Services */
|
||||
@@ -333,6 +334,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
alternateDipLookupLogId: number | null = null;
|
||||
/** Alternate DIP lookup timestamp from API */
|
||||
alternateDipTimestamp: string | null = null;
|
||||
/**
|
||||
* DIP/MNRL status the number actually returned (CLEAN / SUSPECTED / SERVICE_UNAVAILABLE).
|
||||
* Kept separately from `mobileVerified` so an unverifiable number is not indistinguishable
|
||||
* from one that genuinely passed.
|
||||
*/
|
||||
dipStatus: string | null = null;
|
||||
alternateDipStatus: string | null = null;
|
||||
/** Officer rejected the number outright — it must be replaced before saving. */
|
||||
dipRejected = false;
|
||||
alternateDipRejected = false;
|
||||
|
||||
/**
|
||||
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
|
||||
@@ -529,8 +540,37 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
sanctionsVerified = false;
|
||||
/** Sanctions match was found — blocks account opening */
|
||||
sanctionsBlocked = false;
|
||||
/**
|
||||
* Outcome recorded against a match: 'FALSE_POSITIVE' (awaiting Principal Officer approval)
|
||||
* or 'CONFIRMED_MATCH'. Null when the hit has not been reviewed yet. Both keep the client
|
||||
* blocked — only the Principal Officer's approval, applied server-side, releases a name.
|
||||
*/
|
||||
sanctionsDisposition: string | null = null;
|
||||
/**
|
||||
* The name was edited after a block, so the applicant may no longer be the flagged one.
|
||||
* Re-exposes the verify button; the block itself is only lifted by a CLEAR from the API.
|
||||
*/
|
||||
sanctionsNeedsRescreen = false;
|
||||
/** Screening log ID from sanctions API */
|
||||
screeningLogId: number | null = null;
|
||||
|
||||
/** Cites the direction actually in force — kept in step with the verification dialog. */
|
||||
readonly sanctionsCitation = 'UAPA 1967 s.51-A & RBI KYC Directions, 2025 — Chapter IX';
|
||||
readonly sanctionsTooltip =
|
||||
'All customer names must be verified against the UN Security Council Consolidated List before account ' +
|
||||
'opening and periodically thereafter. A match is not a rejection: compare the identifiers, record a ' +
|
||||
'false positive with reasons, or confirm the match and escalate to the Principal Officer immediately.';
|
||||
|
||||
/** Badge text reflecting how far the flagged alert has progressed. */
|
||||
get sanctionsStatusLabel(): string {
|
||||
if (this.sanctionsDisposition === 'FALSE_POSITIVE') {
|
||||
return 'Awaiting PO Approval';
|
||||
}
|
||||
if (this.sanctionsDisposition === 'CONFIRMED_MATCH') {
|
||||
return 'Confirmed Match';
|
||||
}
|
||||
return 'Sanctions Match';
|
||||
}
|
||||
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
||||
private verifiedName: string = '';
|
||||
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
|
||||
@@ -654,18 +694,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
if (this.createClientForm.get('guardianDetails')) {
|
||||
this.createClientForm.removeControl('guardianDetails');
|
||||
}
|
||||
// Same reasoning as the guardian above: the reused component instance would otherwise carry the
|
||||
// previous entity's authorized signatories into a brand new client.
|
||||
this.signatories = [];
|
||||
this.signatorySearchText = '';
|
||||
this.signatorySearchResults = [];
|
||||
// Verification state must not carry over — a previous client's cleared sanctions
|
||||
// screening or verified OTP must not silently apply to a different new client.
|
||||
this.mobileVerified = false;
|
||||
this.dipLookupLogId = null;
|
||||
this.dipTimestamp = null;
|
||||
this.dipStatus = null;
|
||||
this.dipRejected = false;
|
||||
this.alternateMobileVerified = false;
|
||||
this.alternateDipLookupLogId = null;
|
||||
this.alternateDipTimestamp = null;
|
||||
this.alternateDipStatus = null;
|
||||
this.alternateDipRejected = false;
|
||||
this.mobileNewOtpId = null;
|
||||
this.emailNewOtpId = null;
|
||||
this.sanctionsVerified = false;
|
||||
this.sanctionsBlocked = false;
|
||||
this.sanctionsDisposition = null;
|
||||
this.sanctionsNeedsRescreen = false;
|
||||
this.screeningLogId = null;
|
||||
this.verifiedName = '';
|
||||
}
|
||||
@@ -777,8 +828,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
|
||||
remarks: data.clientNonPersonDetails.remarks || '',
|
||||
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
|
||||
authorizedSignatoryClientIds: data.clientNonPersonDetails.authorizedSignatoryClientIds || '',
|
||||
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
|
||||
});
|
||||
this.restoreSignatories(
|
||||
data.clientNonPersonDetails.authorizedSignatoryClientIds,
|
||||
data.clientNonPersonDetails.authorizedSignatory
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,7 +1027,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
|
||||
/**
|
||||
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||
* and annual income for annual/yearly turnover).
|
||||
* and annual income for annual/yearly turnover), but only if the turnover fields
|
||||
* have not been manually modified or cleared.
|
||||
*/
|
||||
private linkIncomeToTurnover(): void {
|
||||
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||
@@ -979,23 +1036,33 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
const annualIncome = this.createClientForm.get('annualIncome');
|
||||
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||
|
||||
if (monthlyIncome && monthlyTurnover) {
|
||||
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
|
||||
|
||||
if (monthlyIncome && monthlyTurnover && annualTurnover) {
|
||||
monthlyIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
||||
if (isPristine || isTurnoverEmpty) {
|
||||
const num = parseFloat(value);
|
||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (annualIncome && annualTurnover) {
|
||||
if (annualIncome && annualTurnover && monthlyTurnover) {
|
||||
annualIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
||||
if (isPristine || isTurnoverEmpty) {
|
||||
const num = parseFloat(value);
|
||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1122,6 +1189,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
mainBusinessLineId: [''],
|
||||
incorpNumber: [''],
|
||||
authorizedSignatory: [''],
|
||||
// Client ids behind the names above, in the same order — what lets an edit rebuild the
|
||||
// signatory rows with their client links and PAN/DIN state. No input is bound to it;
|
||||
// syncSignatoryNames() keeps it in step with the chips.
|
||||
authorizedSignatoryClientIds: [''],
|
||||
additionalInfo: [''],
|
||||
remarks: ['']
|
||||
})
|
||||
@@ -1242,6 +1313,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.mobileVerified = false;
|
||||
// The rejection applied to the previous number, not this one. Leaving it set would
|
||||
// keep Save & Next dead with a message about a number no longer in the field.
|
||||
this.dipRejected = false;
|
||||
this.dipStatus = null;
|
||||
this.dipLookupLogId = null;
|
||||
// Also reset create-time contact-OTP verification — an OTP verified against a
|
||||
// previous mobile number no longer applies once the value is edited. Belt-and-braces
|
||||
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
|
||||
@@ -1256,6 +1332,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.alternateMobileVerified = false;
|
||||
this.alternateDipRejected = false;
|
||||
this.alternateDipStatus = null;
|
||||
this.alternateDipLookupLogId = null;
|
||||
});
|
||||
|
||||
// Reset create-time contact-OTP verification when email address changes — see the
|
||||
@@ -1296,9 +1375,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (!result) return;
|
||||
if (result.verified) {
|
||||
this.mobileVerified = true;
|
||||
}
|
||||
// A flagged number the officer chose to proceed with counts as verified — the decision and
|
||||
// its reason are already stored against the lookup, which is what the save gate checks.
|
||||
this.mobileVerified = result.verified === true || result.decision === 'PROCEEDED';
|
||||
this.dipRejected = result.decision === 'REJECTED';
|
||||
this.dipStatus = result.status || null;
|
||||
this.dipLookupLogId = result.lookupLogId || null;
|
||||
this.dipTimestamp = result.timestamp || null;
|
||||
});
|
||||
@@ -1316,9 +1397,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (!result) return;
|
||||
if (result.verified) {
|
||||
this.alternateMobileVerified = true;
|
||||
}
|
||||
this.alternateMobileVerified = result.verified === true || result.decision === 'PROCEEDED';
|
||||
this.alternateDipRejected = result.decision === 'REJECTED';
|
||||
this.alternateDipStatus = result.status || null;
|
||||
this.alternateDipLookupLogId = result.lookupLogId || null;
|
||||
this.alternateDipTimestamp = result.timestamp || null;
|
||||
});
|
||||
@@ -1343,6 +1424,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
this.removeGuardian();
|
||||
}
|
||||
|
||||
capitalizeControl(controlName: string) {
|
||||
const control = this.createClientForm.get(controlName);
|
||||
if (control && control.value) {
|
||||
const originalValue = control.value;
|
||||
const capitalized = toProperNameCase(originalValue);
|
||||
if (originalValue !== capitalized) {
|
||||
control.setValue(capitalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify client name against sanctions lists via dialog.
|
||||
* Passes the client name to the dialog for real API screening.
|
||||
@@ -1350,10 +1442,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
*/
|
||||
verifySanctions() {
|
||||
const name = this.currentNameSnapshot();
|
||||
const dob = this.createClientForm.get('dateOfBirth')?.value;
|
||||
|
||||
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
|
||||
width: '550px',
|
||||
data: { name }
|
||||
width: '620px',
|
||||
data: {
|
||||
name,
|
||||
// The dialog compares these against the listed particulars — a name-only hit cannot be
|
||||
// judged without the applicant's own identifiers next to it.
|
||||
dateOfBirth: dob ? new Date(dob).toLocaleDateString('en-IN') : '',
|
||||
nationality: this.nationalityLabel()
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (!result) return;
|
||||
@@ -1362,16 +1461,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
// CLEAR — no match found
|
||||
this.sanctionsVerified = true;
|
||||
this.sanctionsBlocked = false;
|
||||
this.sanctionsDisposition = null;
|
||||
this.sanctionsNeedsRescreen = false;
|
||||
this.verifiedName = name;
|
||||
} else if (result.verified === false) {
|
||||
// FLAGGED — match found, block account opening
|
||||
// FLAGGED — blocked either way. A FALSE_POSITIVE is a claim awaiting the Principal
|
||||
// Officer, not a clearance; only their approval lets this name through.
|
||||
this.sanctionsVerified = false;
|
||||
this.sanctionsBlocked = true;
|
||||
this.sanctionsDisposition = result.disposition || null;
|
||||
this.sanctionsNeedsRescreen = false;
|
||||
this.verifiedName = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Human-readable nationality for the sanctions comparison panel. */
|
||||
private nationalityLabel(): string {
|
||||
return this.getSelectedNationality()?.label || '';
|
||||
}
|
||||
|
||||
/** Concatenated current name from the form (handles individual + entity) */
|
||||
private currentNameSnapshot(): string {
|
||||
const firstname = this.createClientForm.get('firstname')?.value || '';
|
||||
@@ -1411,9 +1520,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
// Only invalidate if a verification was previously recorded
|
||||
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
|
||||
if (this.currentNameSnapshot() !== this.verifiedName) {
|
||||
// Editing the name forces a fresh screening, but must NOT silently clear an existing
|
||||
// block: otherwise a flagged applicant is waved through by nudging one character. The
|
||||
// block is only lifted by re-running the check and getting a CLEAR back from the API.
|
||||
this.sanctionsVerified = false;
|
||||
this.sanctionsBlocked = false;
|
||||
this.screeningLogId = null;
|
||||
if (this.sanctionsBlocked) {
|
||||
this.sanctionsNeedsRescreen = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1467,6 +1581,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
target.mobileNo = data.mobileNo || null;
|
||||
target.emailAddress = data.emailAddress || null;
|
||||
target.officeName = data.officeName || null;
|
||||
// Filled in only when the caller has not supplied them. A signatory restored from stored
|
||||
// ids starts as a bare { id } and needs both to render its row; one picked from the search
|
||||
// dropdown already carries them, and a guardian must keep whatever it was given.
|
||||
target.displayName = target.displayName || data.displayName;
|
||||
target.status = target.status || data.status;
|
||||
target._loading = false;
|
||||
},
|
||||
error: () => {
|
||||
@@ -1480,6 +1599,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
target.panVerified = kyc?.panVerified || false;
|
||||
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
|
||||
target.aadhaarVerified = kyc?.aadhaarVerified || false;
|
||||
// Same reasoning as above: a DIN verified in an earlier session lives on the signatory's own
|
||||
// KYC record, so re-reading it here is what stops the edit screen asking for it again.
|
||||
target.dinNumber = target.dinNumber || kyc?.dinNumber || '';
|
||||
target.dinVerified = target.dinVerified || kyc?.dinVerified || false;
|
||||
},
|
||||
error: () => {
|
||||
target.panNumber = '-';
|
||||
@@ -1678,9 +1801,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the authorized-signatory rows when an existing entity client is opened for edit.
|
||||
*
|
||||
* Driven by the stored client ids rather than the stored names: the ids are what make each row a
|
||||
* real client again, so its CIF, PAN and DIN verification can be re-read instead of the officer
|
||||
* having to re-add and re-verify signatories that were already recorded. The saved names are used
|
||||
* only as the label until the lookup returns, and as the fallback for a client that can no longer
|
||||
* be fetched, so a row is never silently dropped.
|
||||
*
|
||||
* Clients created before the ids were stored have names but no ids; those still list, as
|
||||
* name-only rows, rather than showing nothing at all.
|
||||
*/
|
||||
private restoreSignatories(storedIds: string | null | undefined, storedNames: string | null | undefined): void {
|
||||
const ids = (storedIds || '')
|
||||
.split(',')
|
||||
.map((id: string) => id.trim())
|
||||
.filter(Boolean);
|
||||
const names = (storedNames || '')
|
||||
.split(',')
|
||||
.map((name: string) => name.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!ids.length) {
|
||||
this.signatories = names.map((displayName: string) => ({ displayName }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.signatories = ids.map((id: string, index: number) => ({
|
||||
id: Number(id),
|
||||
displayName: names[index] || '',
|
||||
_loading: true
|
||||
}));
|
||||
this.signatories.forEach((sig: any) => this.fetchClientFullDetails(sig.id, sig));
|
||||
}
|
||||
|
||||
private syncSignatoryNames(): void {
|
||||
const names = this.signatories.map((s) => s.displayName).join(', ');
|
||||
this.createClientForm.get('clientNonPersonDetails')?.patchValue({ authorizedSignatory: names });
|
||||
// Ids are written in the same order as the names, so the two columns line up on reload.
|
||||
const clientIds = this.signatories.map((s) => s.id).join(',');
|
||||
this.createClientForm.get('clientNonPersonDetails')?.patchValue({
|
||||
authorizedSignatory: names,
|
||||
authorizedSignatoryClientIds: clientIds
|
||||
});
|
||||
}
|
||||
|
||||
/** Watch MSME turnover & investment for auto-classification */
|
||||
@@ -2164,6 +2327,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
delete generalDetails[key];
|
||||
}
|
||||
}
|
||||
// Entity name is normalised here as well as on blur: a pasted/auto-filled value, or one the
|
||||
// user never blurred out of before submitting, would otherwise reach Fineract exactly as typed.
|
||||
if (generalDetails.fullname) {
|
||||
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
||||
}
|
||||
|
||||
if (generalDetails.submittedOnDate instanceof Date) {
|
||||
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
||||
}
|
||||
@@ -2233,15 +2402,86 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
];
|
||||
unsupportedFields.forEach((f) => delete generalDetails[f]);
|
||||
|
||||
// Format dateOfRegistration if present
|
||||
if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) {
|
||||
generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate(
|
||||
generalDetails.clientNonPersonDetails.dateOfRegistration,
|
||||
dateFormat
|
||||
);
|
||||
return generalDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turnover ladder that decides an entity's risk when no configured risk rule matches it.
|
||||
*
|
||||
* A company's size is the one thing about it the branch always knows at onboarding, and the
|
||||
* bigger the book the more exposure it carries — so the band moves up with annual turnover
|
||||
* instead of every entity landing on a flat Medium regardless of whether it turns over ten lakh
|
||||
* or a hundred crore. Thresholds follow the MSME-style split the business already thinks in
|
||||
* (crores), and are only a fallback: a rule configured in m_client_risk_rule with
|
||||
* minAnnualTurnover / maxAnnualTurnover still wins, which is where a bank sets its own numbers.
|
||||
*
|
||||
* Amounts are in rupees. `upTo: null` is the open-ended top band.
|
||||
*/
|
||||
private static readonly ENTITY_TURNOVER_RISK_BANDS: { upTo: number | null; category: number; label: string }[] = [
|
||||
{ upTo: 10000000, category: 1, label: 'up to ₹1 crore' },
|
||||
{ upTo: 100000000, category: 2, label: '₹1 crore to ₹10 crore' },
|
||||
{ upTo: null, category: 3, label: 'above ₹10 crore' }
|
||||
];
|
||||
|
||||
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
|
||||
riskCategoryLabel(category: number): string {
|
||||
if (category === 3) {
|
||||
return 'High Risk';
|
||||
}
|
||||
return category === 2 ? 'Medium Risk' : 'Low Risk';
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity risk from annual turnover, for the fallback path only.
|
||||
*
|
||||
* AML is deliberately left CLEAR even in the top band. A configured High-risk rule defaults to
|
||||
* FLAGGED because someone decided that case warrants it; turnover on its own is a size signal,
|
||||
* not a suspicion, and auto-flagging every large company would put them all in the AML queue.
|
||||
*/
|
||||
private entityRiskFromTurnover(annualTurnover: number): {
|
||||
category: number;
|
||||
reason: string;
|
||||
amlCheckStatus: string;
|
||||
bannedEntityMatch: string;
|
||||
willfulDefaulter: string;
|
||||
ecgcSalFlag: string;
|
||||
dormantFlag: string;
|
||||
} {
|
||||
const base = {
|
||||
amlCheckStatus: 'CLEAR',
|
||||
bannedEntityMatch: 'NO',
|
||||
willfulDefaulter: 'NO',
|
||||
ecgcSalFlag: 'NO',
|
||||
dormantFlag: 'ACTIVE'
|
||||
};
|
||||
|
||||
// Nothing captured yet — the entity default stands until a turnover is entered.
|
||||
if (!annualTurnover || annualTurnover <= 0) {
|
||||
return {
|
||||
category: 2,
|
||||
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||
...base
|
||||
};
|
||||
}
|
||||
|
||||
return generalDetails;
|
||||
const band =
|
||||
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.find((b) => b.upTo == null || annualTurnover <= b.upTo) ??
|
||||
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS[
|
||||
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.length - 1
|
||||
];
|
||||
|
||||
return {
|
||||
category: band.category,
|
||||
reason: `Annual turnover ₹${annualTurnover.toLocaleString('en-IN')} — ${band.label} (${this.riskCategoryLabel(
|
||||
band.category
|
||||
)})`,
|
||||
...base
|
||||
};
|
||||
}
|
||||
|
||||
/** Live risk band for the entity turnover card in the General step. */
|
||||
get entityTurnoverRisk(): { category: number; reason: string } {
|
||||
return this.calculatedRisk;
|
||||
}
|
||||
|
||||
get calculatedRisk(): {
|
||||
@@ -2338,15 +2578,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
|
||||
// Default Fallbacks
|
||||
if (legalFormId !== LegalFormId.PERSON) {
|
||||
return {
|
||||
category: 2,
|
||||
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||
amlCheckStatus: 'CLEAR',
|
||||
bannedEntityMatch: 'NO',
|
||||
willfulDefaulter: 'NO',
|
||||
ecgcSalFlag: 'NO',
|
||||
dormantFlag: 'ACTIVE'
|
||||
};
|
||||
return this.entityRiskFromTurnover(annualTurnover);
|
||||
}
|
||||
return {
|
||||
category: 1,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
@@ -31,13 +40,18 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="warning-text">This may take a few seconds. Do not close or refresh this window.</span>
|
||||
</div>
|
||||
}
|
||||
<!--
|
||||
An unreachable DoT service means the number was NOT checked. Reporting that as a green
|
||||
"verification completed successfully — number is CLEAN" claimed a check that never ran and
|
||||
stored the client as verified, so the outage is now shown for what it is.
|
||||
-->
|
||||
@if (!verifying && serviceUnavailable) {
|
||||
<div class="verify-result">
|
||||
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
|
||||
<p class="result-msg">DIP & MNRL verification completed successfully.</p>
|
||||
<div class="success-detail">
|
||||
<fa-icon icon="shield-alt" class="success-detail-icon"></fa-icon>
|
||||
<span>Mobile number is <strong>CLEAN</strong> — not found in the revocation list.</span>
|
||||
<div class="verify-result unavailable">
|
||||
<fa-icon icon="exclamation-triangle" class="result-icon unavailable-icon"></fa-icon>
|
||||
<p class="result-msg unavailable-msg">Could not verify — DIP/MNRL service unavailable</p>
|
||||
<div class="unavailable-detail">
|
||||
This number has <strong>not</strong> been checked against the DoT Digital Intelligence Platform. Retry once
|
||||
the service is reachable, or proceed with a recorded reason.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -66,11 +80,63 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<p class="result-msg error-msg">{{ error }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
A number turned away before is a standing signal. Without this the next officer screens
|
||||
it as if it had no history and can wave through what a colleague already refused.
|
||||
-->
|
||||
@if (!verifying && priorRejection) {
|
||||
<div class="prior-rejection">
|
||||
<fa-icon icon="history" class="prior-icon"></fa-icon>
|
||||
<div>
|
||||
<strong>This number was rejected before.</strong>
|
||||
<div class="prior-detail">
|
||||
Rejected by user {{ priorRejection.decidedBy }} on {{ priorRejection.decidedOn | date: 'medium' }} —
|
||||
status was {{ priorRejection.lookupStatus }}.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!verifying && needsDecision && showReasonForm) {
|
||||
<div class="reason-form">
|
||||
<label class="reason-label">Why is this number acceptable despite the DIP/MNRL result?</label>
|
||||
<textarea
|
||||
class="reason-input"
|
||||
rows="3"
|
||||
[(ngModel)]="decisionReason"
|
||||
placeholder="e.g. Number re-issued to the applicant on 02/2026; ownership confirmed against the operator's CAF and a fresh OTP."
|
||||
></textarea>
|
||||
<div class="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
|
||||
Minimum 10 characters — this reason is kept on the lookup record.
|
||||
</div>
|
||||
@if (decisionError) {
|
||||
<div class="reason-hint reason-error">{{ decisionError }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
@if (!verifying) {
|
||||
@if (!verifying && !needsDecision) {
|
||||
<button mat-raised-button color="primary" (click)="close()">OK</button>
|
||||
}
|
||||
<!--
|
||||
A flagged number is a decision, not a notification: the officer must either reject it or
|
||||
say in writing why it is acceptable. Previously a single OK dismissed the warning and the
|
||||
number was onboarded with nothing recorded.
|
||||
-->
|
||||
@if (!verifying && needsDecision && !showReasonForm) {
|
||||
<button mat-stroked-button color="warn" (click)="reject()" [disabled]="saving">Reject this Number</button>
|
||||
<button mat-raised-button color="primary" (click)="startProceed()" [disabled]="saving">
|
||||
Proceed with Reason
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && needsDecision && showReasonForm) {
|
||||
<button mat-stroked-button (click)="cancelProceed()" [disabled]="saving">Back</button>
|
||||
<button mat-raised-button color="primary" (click)="submitProceed()" [disabled]="saving">
|
||||
{{ saving ? 'Saving…' : 'Save Decision & Proceed' }}
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
@@ -228,13 +294,100 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.unavailable-icon {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.unavailable-msg {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.unavailable-detail {
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prior-rejection {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #7f1d1d;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prior-icon {
|
||||
color: #dc2626;
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.prior-detail {
|
||||
font-size: 12px;
|
||||
color: #991b1b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.reason-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.reason-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.reason-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.reason-input:focus {
|
||||
outline: none;
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
.reason-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.reason-error {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 8px 24px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
`
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatProgressBarModule,
|
||||
MatButtonModule,
|
||||
@@ -255,6 +408,25 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
suspectedStatus = '';
|
||||
suspectedReason = '';
|
||||
suspectedReportedBy = '';
|
||||
|
||||
/** Set when this number was rejected on an earlier client — history the officer must see. */
|
||||
priorRejection: any = null;
|
||||
|
||||
/** Officer decision capture for a flagged or unverifiable number. */
|
||||
showReasonForm = false;
|
||||
decisionReason = '';
|
||||
reasonTouched = false;
|
||||
saving = false;
|
||||
decisionError = '';
|
||||
|
||||
/** A hit or an unverified number cannot simply be dismissed with OK. */
|
||||
get needsDecision(): boolean {
|
||||
return this.suspected || this.serviceUnavailable;
|
||||
}
|
||||
|
||||
get reasonValid(): boolean {
|
||||
return this.decisionReason.trim().length >= 10;
|
||||
}
|
||||
private subscription: any;
|
||||
private intervalId: any;
|
||||
private apiResponse: any = null;
|
||||
@@ -305,6 +477,7 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const res = this.apiResponse;
|
||||
this.priorRejection = res?.priorRejection || null;
|
||||
if (res?.data?.status === 'SERVICE_UNAVAILABLE') {
|
||||
this.serviceUnavailable = true;
|
||||
this.resultMessage = res.message || 'DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.';
|
||||
@@ -329,11 +502,68 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
startProceed() {
|
||||
this.showReasonForm = true;
|
||||
this.reasonTouched = false;
|
||||
this.decisionError = '';
|
||||
}
|
||||
|
||||
cancelProceed() {
|
||||
this.showReasonForm = false;
|
||||
this.decisionReason = '';
|
||||
this.decisionError = '';
|
||||
}
|
||||
|
||||
submitProceed() {
|
||||
this.reasonTouched = true;
|
||||
if (!this.reasonValid) {
|
||||
return;
|
||||
}
|
||||
this.recordDecision('PROCEEDED', this.decisionReason.trim(), true);
|
||||
}
|
||||
|
||||
reject() {
|
||||
this.recordDecision('REJECTED', 'Number rejected at onboarding following DIP/MNRL result.', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the decision before closing. The number is only usable downstream once the backend
|
||||
* has stored PROCEEDED — the client-create gate re-checks this, so closing without saving
|
||||
* would simply fail at save time with no explanation.
|
||||
*/
|
||||
private recordDecision(decision: string, reason: string, verified: boolean) {
|
||||
const logId = this.apiResponse?.lookupLogId;
|
||||
if (!logId) {
|
||||
this.decisionError = 'Lookup reference missing — re-run the check before recording a decision.';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.decisionError = '';
|
||||
this.clientsService.recordDipDecision(logId, decision, reason).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.dialogRef.close({
|
||||
verified,
|
||||
decision,
|
||||
lookupLogId: logId,
|
||||
status: this.apiResponse?.data?.status || null,
|
||||
timestamp: this.apiResponse?.timestamp || null
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
this.decisionError =
|
||||
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not save the decision.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
const verified = !this.suspected && !this.error;
|
||||
const verified = !this.suspected && !this.error && !this.serviceUnavailable;
|
||||
this.dialogRef.close({
|
||||
verified,
|
||||
lookupLogId: this.apiResponse?.lookupLogId || null,
|
||||
status: this.apiResponse?.data?.status || null,
|
||||
timestamp: this.apiResponse?.timestamp || null
|
||||
});
|
||||
}
|
||||
|
||||
+248
-8
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
@@ -118,10 +119,78 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!--
|
||||
The list matches on name alone, so a hit is a "stop and verify", not a verdict. The
|
||||
officer has to be able to compare the listed particulars against the applicant's own
|
||||
before deciding — showing only a red banner made that impossible.
|
||||
-->
|
||||
<div class="compare-header">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
Compare the applicant against the listed particulars
|
||||
</div>
|
||||
<div class="applicant-card">
|
||||
<div class="applicant-label">Applicant</div>
|
||||
<div class="applicant-values">
|
||||
<span
|
||||
><strong>{{ clientName }}</strong></span
|
||||
>
|
||||
<span>DOB: {{ applicantDob || '—' }}</span>
|
||||
<span>Nationality: {{ applicantNationality || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="match-list">
|
||||
@for (m of matches; track m.id) {
|
||||
<div class="match-item">
|
||||
<div class="match-name">{{ m.fullName }}</div>
|
||||
<div class="match-meta">
|
||||
<span class="match-type">{{ m.entryType }}</span>
|
||||
@if (m.unListType) {
|
||||
<span class="match-list-type">{{ m.unListType }}</span>
|
||||
}
|
||||
@if (m.referenceNumber) {
|
||||
<span class="match-ref">{{ m.referenceNumber }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="match-aliases">
|
||||
DOB: {{ m.dateOfBirth || '—' }} · Nationality: {{ m.nationality || '—' }} · Place of
|
||||
birth: {{ m.placeOfBirth || '—' }}
|
||||
</div>
|
||||
@if (m.aliases?.length) {
|
||||
<div class="match-aliases">Aliases: {{ m.aliases.join('; ') }}</div>
|
||||
}
|
||||
@if (m.listedOn) {
|
||||
<div class="match-aliases">Listed on: {{ m.listedOn }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="escalate-banner">
|
||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||
</div>
|
||||
@if (showReasonForm) {
|
||||
<div class="reason-form">
|
||||
<label class="reason-label">
|
||||
{{
|
||||
pendingDisposition === 'FALSE_POSITIVE'
|
||||
? 'Why is this NOT the same person? State the identifiers you compared.'
|
||||
: 'Record the basis on which this match is confirmed.'
|
||||
}}
|
||||
</label>
|
||||
<textarea
|
||||
class="reason-input"
|
||||
rows="3"
|
||||
[(ngModel)]="dispositionReason"
|
||||
placeholder="e.g. DOB 12/03/1988 vs listed 1965; nationality IN vs listed AF; passport number does not match."
|
||||
></textarea>
|
||||
<div class="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
|
||||
Minimum 10 characters — this text is the compliance record.
|
||||
</div>
|
||||
@if (dispositionError) {
|
||||
<div class="reason-hint reason-error">{{ dispositionError }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -141,9 +210,26 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
<button mat-raised-button color="warn" (click)="close(false)" class="reject-btn">
|
||||
<fa-icon icon="times"></fa-icon> Flag & Escalate
|
||||
</button>
|
||||
@if (!showReasonForm) {
|
||||
<button mat-stroked-button (click)="startDisposition('FALSE_POSITIVE')" class="approve-btn-secondary">
|
||||
Not the Same Person
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="startDisposition('CONFIRMED_MATCH')" class="reject-btn">
|
||||
<fa-icon icon="times"></fa-icon> Confirm Match & Escalate
|
||||
</button>
|
||||
}
|
||||
@if (showReasonForm) {
|
||||
<button mat-stroked-button (click)="cancelDisposition()" [disabled]="savingDisposition">Back</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
[color]="pendingDisposition === 'FALSE_POSITIVE' ? 'primary' : 'warn'"
|
||||
(click)="submitDisposition()"
|
||||
[disabled]="savingDisposition"
|
||||
class="reject-btn"
|
||||
>
|
||||
{{ savingDisposition ? 'Saving…' : 'Submit for Principal Officer Approval' }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
<button mat-raised-button (click)="close(false)">Close</button>
|
||||
@@ -174,8 +260,13 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
mat-dialog-content {
|
||||
padding: 16px 24px;
|
||||
max-height: unset;
|
||||
overflow: visible;
|
||||
/*
|
||||
The match card lists every hit with its aliases, so this content is now tall enough to
|
||||
push the action row off-screen if the dialog is allowed to grow unbounded — which left
|
||||
"Not the Same Person" unreachable. Scroll the body instead and keep the buttons pinned.
|
||||
*/
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
@@ -489,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
.match-list {
|
||||
width: 100%;
|
||||
max-height: unset;
|
||||
overflow: visible;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
@@ -531,6 +620,84 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.compare-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.applicant-card {
|
||||
width: 100%;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 4px solid #1074b9;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.applicant-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: #1074b9;
|
||||
}
|
||||
|
||||
.applicant-values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.reason-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reason-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.reason-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.reason-input:focus {
|
||||
outline: none;
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
.reason-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.reason-error {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block-msg {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -545,11 +712,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 8px 24px 16px;
|
||||
/* Sits below a scrolling body, so mark it off clearly as the fixed action row. */
|
||||
border-top: 1px solid #e5e7eb;
|
||||
gap: 8px;
|
||||
}
|
||||
`
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatProgressBarModule,
|
||||
MatButtonModule,
|
||||
@@ -558,10 +729,13 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
})
|
||||
export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
private dialogRef = inject(MatDialogRef<SanctionsVerifyDialogComponent>);
|
||||
private data: { name: string } = inject(MAT_DIALOG_DATA);
|
||||
private data: { name: string; dateOfBirth?: string; nationality?: string } = inject(MAT_DIALOG_DATA);
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
clientName = '';
|
||||
/** Applicant's own identifiers, shown beside the listed particulars for comparison. */
|
||||
applicantDob = '';
|
||||
applicantNationality = '';
|
||||
verifying = true;
|
||||
matchFound = false;
|
||||
matchCount = 0;
|
||||
@@ -571,6 +745,19 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
screeningLogId: number | null = null;
|
||||
listDataAsOf: string | null = null;
|
||||
|
||||
/** Disposition capture — a hit must be closed out, not merely acknowledged. */
|
||||
showReasonForm = false;
|
||||
pendingDisposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH' | null = null;
|
||||
dispositionReason = '';
|
||||
reasonTouched = false;
|
||||
savingDisposition = false;
|
||||
dispositionError = '';
|
||||
|
||||
/** Mirrors the backend's minimum: an unexplained assessment is not a review. */
|
||||
get reasonValid(): boolean {
|
||||
return this.dispositionReason.trim().length >= 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
|
||||
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
|
||||
@@ -599,6 +786,8 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit() {
|
||||
this.clientName = this.data?.name || '';
|
||||
this.applicantDob = this.data?.dateOfBirth || '';
|
||||
this.applicantNationality = this.data?.nationality || '';
|
||||
this.dialogRef.disableClose = true;
|
||||
|
||||
// Start API call immediately
|
||||
@@ -710,6 +899,57 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
clearInterval(this.progressTimer);
|
||||
}
|
||||
|
||||
startDisposition(disposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH') {
|
||||
this.pendingDisposition = disposition;
|
||||
this.showReasonForm = true;
|
||||
this.reasonTouched = false;
|
||||
this.dispositionError = '';
|
||||
}
|
||||
|
||||
cancelDisposition() {
|
||||
this.showReasonForm = false;
|
||||
this.pendingDisposition = null;
|
||||
this.dispositionReason = '';
|
||||
this.dispositionError = '';
|
||||
}
|
||||
|
||||
submitDisposition() {
|
||||
this.reasonTouched = true;
|
||||
if (!this.reasonValid || !this.pendingDisposition) {
|
||||
return;
|
||||
}
|
||||
// Without a log id there is nothing to attach the assessment to — close as a plain block
|
||||
// rather than silently discarding the officer's reasoning.
|
||||
if (!this.screeningLogId) {
|
||||
this.dispositionError = 'Screening reference missing — re-run the check before recording an outcome.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.savingDisposition = true;
|
||||
this.clientsService
|
||||
.recordSanctionsDisposition(this.screeningLogId, this.pendingDisposition, this.dispositionReason.trim())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.savingDisposition = false;
|
||||
// Both outcomes keep the client blocked. A false positive only takes effect once the
|
||||
// Principal Officer approves it from the Sanctions Logs screen.
|
||||
this.dialogRef.close({
|
||||
verified: false,
|
||||
screeningLogId: this.screeningLogId,
|
||||
disposition: this.pendingDisposition
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.savingDisposition = false;
|
||||
this.dispositionError =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
'Could not save the review outcome.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(verified: boolean) {
|
||||
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
|
||||
}
|
||||
|
||||
+52
-9
@@ -122,8 +122,41 @@
|
||||
}
|
||||
|
||||
<div class="addr-proof-field">
|
||||
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
|
||||
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly">
|
||||
<div
|
||||
class="addr-proof-label-row"
|
||||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px"
|
||||
>
|
||||
<label class="addr-proof-label" style="margin-bottom: 0"
|
||||
><mat-icon>description</mat-icon> Document type</label
|
||||
>
|
||||
@if (isAddressProofLinkedToKyc && !readOnly) {
|
||||
<div
|
||||
class="addr-proof-linked-note"
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #059669;
|
||||
font-size: 0.85rem;
|
||||
background: #ecfdf5;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px">link</mat-icon>
|
||||
Automatically linked from KYC step
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="unlinkAddressProof()"
|
||||
matTooltip="Provide a different address proof"
|
||||
style="width: 24px; height: 24px; padding: 0; line-height: 24px"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; color: #047857">edit</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly || isAddressProofLinkedToKyc">
|
||||
@for (group of addressGroups; track group.label; let last = $last) {
|
||||
<div class="addr-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
|
||||
<div class="addr-chip-grid">
|
||||
@@ -131,11 +164,11 @@
|
||||
<span
|
||||
class="addr-chip"
|
||||
[class.addr-chip--sel]="isAddrProofSelected(p)"
|
||||
[class.addr-chip--locked]="readOnly && !isAddrProofSelected(p)"
|
||||
[attr.aria-disabled]="readOnly ? 'true' : null"
|
||||
(click)="readOnly ? null : selectAddrProof(p)"
|
||||
(keydown.enter)="readOnly ? null : selectAddrProof(p)"
|
||||
[attr.tabindex]="readOnly ? -1 : 0"
|
||||
[class.addr-chip--locked]="(readOnly || isAddressProofLinkedToKyc) && !isAddrProofSelected(p)"
|
||||
[attr.aria-disabled]="readOnly || isAddressProofLinkedToKyc ? 'true' : null"
|
||||
(click)="readOnly || isAddressProofLinkedToKyc ? null : selectAddrProof(p)"
|
||||
(keydown.enter)="readOnly || isAddressProofLinkedToKyc ? null : selectAddrProof(p)"
|
||||
[attr.tabindex]="readOnly || isAddressProofLinkedToKyc ? -1 : 0"
|
||||
role="button"
|
||||
>
|
||||
<mat-icon class="addr-chip__icon">
|
||||
@@ -167,14 +200,24 @@
|
||||
<input
|
||||
matInput
|
||||
formControlName="documentKey"
|
||||
[readonly]="readOnly || isAddressProofLinkedToKyc"
|
||||
[placeholder]="selectedAddrProof.documentKeyPlaceholder || ''"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (selectedAddrProof.requiresDate) {
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Issue / bill date</mat-label>
|
||||
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="addrIssueDatePicker"
|
||||
formControlName="issueDate"
|
||||
[readonly]="readOnly || isAddressProofLinkedToKyc"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="addrIssueDatePicker"
|
||||
[disabled]="readOnly || isAddressProofLinkedToKyc"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
+111
-10
@@ -55,9 +55,22 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
|
||||
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
|
||||
@Input() isNonIndividual = false;
|
||||
/** KYC form data passed down from the parent wizard so we can pre-fill address proof */
|
||||
@Input() kycData: any;
|
||||
/** KYC document code for robust matching */
|
||||
@Input() kycDocCode: string | undefined;
|
||||
/** KYC document files passed down to avoid re-uploading */
|
||||
@Input() kycDocFiles: { front: File | null; back: File | null } | null = null;
|
||||
/** Combined preview string for KYC document */
|
||||
@Input() kycDocMergedPreview: string | null = null;
|
||||
/** Existing server documents from KYC step (used in Edit mode) */
|
||||
@Input() existingKycDocSides: { front: any | null; back: any | null } | null = null;
|
||||
/** Identifier ID for existing server documents */
|
||||
@Input() existingKycIdentifierId: string | null = null;
|
||||
|
||||
activeTab = 0;
|
||||
sameAsPermanent = false;
|
||||
isAddressProofLinkedToKyc = false;
|
||||
|
||||
permanentForm: UntypedFormGroup = this.fb.group({
|
||||
addressLine1: [
|
||||
@@ -240,6 +253,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
/** Address type name → tab mapping for loading existing data */
|
||||
private typeToTab: Record<string, string> = {};
|
||||
|
||||
private existingAddressProofCheckComplete = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadProofTypes();
|
||||
if (this.clientId) {
|
||||
@@ -247,6 +262,73 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
}
|
||||
|
||||
private findMatchingProof(targetName: string | undefined): any {
|
||||
if (!targetName) return undefined;
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const n = cleanStr(targetName);
|
||||
return this.addressProofs.find((p) => {
|
||||
const pname = cleanStr(p.name);
|
||||
return pname === n || n.includes(pname) || pname.includes(n);
|
||||
});
|
||||
}
|
||||
|
||||
private tryLinkKycData(): void {
|
||||
if (!this.kycData || !this.kycData.kycDocumentName) return;
|
||||
|
||||
if (this.clientId && !this.existingAddressProofCheckComplete) return;
|
||||
|
||||
if (this.isAddressProofLinkedToKyc || !this.permanentForm.get('documentKey')?.value) {
|
||||
let matchedProof = undefined;
|
||||
if (this.kycDocCode) {
|
||||
matchedProof = this.addressProofs.find((p) => p.code === this.kycDocCode);
|
||||
}
|
||||
if (!matchedProof) {
|
||||
matchedProof = this.findMatchingProof(this.kycData.kycDocumentName);
|
||||
}
|
||||
if (matchedProof) {
|
||||
this.isAddressProofLinkedToKyc = true;
|
||||
this.selectAddrProof(matchedProof);
|
||||
this.permanentForm.patchValue({
|
||||
documentKey: this.kycData.kycDocumentNumber,
|
||||
issueDate: this.kycData.kycDocIssueDate
|
||||
});
|
||||
this.currentForm.patchValue({
|
||||
documentKey: this.kycData.kycDocumentNumber,
|
||||
issueDate: this.kycData.kycDocIssueDate
|
||||
});
|
||||
|
||||
// Sync uploaded images if they exist in KYC step
|
||||
if (this.kycDocFiles && (this.kycDocFiles.front || this.kycDocFiles.back)) {
|
||||
this.addrFiles = { front: this.kycDocFiles.front || null, back: this.kycDocFiles.back || null };
|
||||
if (this.kycDocMergedPreview) {
|
||||
this.addrMergedPreview = this.kycDocMergedPreview;
|
||||
}
|
||||
} else if (this.existingKycDocSides && (this.existingKycDocSides.front || this.existingKycDocSides.back)) {
|
||||
this.existingAddressProofDocuments = [];
|
||||
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
|
||||
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
|
||||
if (this.existingKycDocSides.front?.previewUrl) {
|
||||
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
|
||||
} else if (this.existingKycDocSides.back?.previewUrl) {
|
||||
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
|
||||
}
|
||||
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
|
||||
}
|
||||
} else if (this.isAddressProofLinkedToKyc) {
|
||||
// If it was linked but the KYC document changed to something not allowed for address (e.g. PAN for non-individuals), unlink.
|
||||
this.unlinkAddressProof();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unlinkAddressProof(): void {
|
||||
this.isAddressProofLinkedToKyc = false;
|
||||
this.permanentForm.patchValue({ documentKey: '', issueDate: null });
|
||||
this.currentForm.patchValue({ documentKey: '', issueDate: null });
|
||||
this.addrFiles = { front: null, back: null };
|
||||
this.addrMergedPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets transient address-tab state for a brand-new client.
|
||||
*
|
||||
@@ -277,6 +359,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
this.existingAddressProofIdentifierId = null;
|
||||
this.existingAddressProofDocuments = [];
|
||||
this.addressLoaded = false;
|
||||
this.existingAddressProofCheckComplete = false;
|
||||
// Re-fetch rather than re-filter `addressProofs` in place — if the previous session's
|
||||
// isMinor filtering already reduced it (e.g. down to Aadhaar/Passport only), filtering
|
||||
// that reduced list again can never recover the items it already removed.
|
||||
@@ -293,6 +376,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
);
|
||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||
this.loadingProofs = false;
|
||||
this.tryLinkKycData();
|
||||
},
|
||||
error: () => {
|
||||
this.loadingProofs = false;
|
||||
@@ -426,6 +510,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['kycData'] || changes['existingKycDocSides']) {
|
||||
this.tryLinkKycData();
|
||||
}
|
||||
if (changes['existingKycIdentifierId']) {
|
||||
if (
|
||||
this.existingAddressProofIdentifierId &&
|
||||
this.existingAddressProofIdentifierId === this.existingKycIdentifierId
|
||||
) {
|
||||
this.isAddressProofLinkedToKyc = true;
|
||||
}
|
||||
}
|
||||
if (changes['readOnly']) {
|
||||
const forms = [
|
||||
this.permanentForm,
|
||||
@@ -544,19 +639,25 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
||||
next: (identifiers: any[]) => {
|
||||
if (!identifiers || identifiers.length === 0) return;
|
||||
const proofNames = new Set(this.addressProofs.map((p) => p.name?.toLowerCase()).filter(Boolean));
|
||||
const identifier = identifiers.find((i: any) => {
|
||||
const n = i?.documentType?.name?.toLowerCase();
|
||||
return n && proofNames.has(n);
|
||||
});
|
||||
if (!identifier?.id) return;
|
||||
if (!identifiers || identifiers.length === 0) {
|
||||
this.existingAddressProofCheckComplete = true;
|
||||
this.tryLinkKycData();
|
||||
return;
|
||||
}
|
||||
const identifier = identifiers.find((i: any) => this.findMatchingProof(i?.documentType?.name));
|
||||
if (!identifier?.id) {
|
||||
this.existingAddressProofCheckComplete = true;
|
||||
this.tryLinkKycData();
|
||||
return;
|
||||
}
|
||||
|
||||
this.existingAddressProofCheckComplete = true;
|
||||
this.existingAddressProofIdentifierId = identifier.id.toString();
|
||||
if (this.existingKycIdentifierId && this.existingKycIdentifierId === this.existingAddressProofIdentifierId) {
|
||||
this.isAddressProofLinkedToKyc = true;
|
||||
}
|
||||
// Restore selected chip + form values
|
||||
const matchedProof = this.addressProofs.find(
|
||||
(p) => p.name?.toLowerCase() === identifier.documentType?.name?.toLowerCase()
|
||||
);
|
||||
const matchedProof = this.findMatchingProof(identifier.documentType?.name);
|
||||
if (matchedProof) {
|
||||
this.selectedAddrProof = matchedProof;
|
||||
this.permanentForm.patchValue({
|
||||
|
||||
@@ -588,95 +588,23 @@
|
||||
|
||||
<!-- ══ NON-INDIVIDUAL KYC ══ -->
|
||||
@if (isNonIndividual) {
|
||||
<!-- Incorporation Date -->
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
|
||||
<mat-label>Incorporation Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #incorpDatePicker></mat-datepicker>
|
||||
<!-- Date of Incorporation — captured before PAN because Enterprise PAN verification
|
||||
matches the entity on name + date of incorporation as held in the PAN records. -->
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="incorporationDatePicker.open()">
|
||||
<mat-label>Date of Incorporation</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>event_available</mat-icon></span>
|
||||
<input
|
||||
matInput
|
||||
[max]="today"
|
||||
[matDatepicker]="incorporationDatePicker"
|
||||
formControlName="incorporationDate"
|
||||
readonly
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="incorporationDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #incorporationDatePicker></mat-datepicker>
|
||||
<mat-hint>As per Certificate of Incorporation / Registration</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- GSTIN (all non-individual) with Verify -->
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>GSTIN Number</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
|
||||
<input
|
||||
matInput
|
||||
formControlName="gstinNumber"
|
||||
placeholder="15-character GSTIN"
|
||||
maxlength="15"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="verify-btn-inline"
|
||||
[disabled]="
|
||||
readOnly ||
|
||||
!kycForm.get('gstinNumber')?.value ||
|
||||
kycForm.get('gstinNumber')?.value.length < 15 ||
|
||||
gstinVerifying ||
|
||||
!clientId
|
||||
"
|
||||
(click)="verifyGstin()"
|
||||
>
|
||||
@if (gstinVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>GSTIN Verified Successfully</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
@if (gstinVerifyName) {
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Trade Name / Legal Name</span>
|
||||
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (gstinVerifyState) {
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">State</span>
|
||||
<span class="pan-result-value">{{ gstinVerifyState }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
|
||||
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
||||
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
|
||||
@@ -826,8 +754,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
<!-- CIN (Companies & LLP only) -->
|
||||
@if (constitutionMeta?.showCin) {
|
||||
<!-- GSTIN (all non-individual) with Verify -->
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>GSTIN Number</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
|
||||
<input
|
||||
matInput
|
||||
formControlName="gstinNumber"
|
||||
placeholder="15-character GSTIN"
|
||||
maxlength="15"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="verify-btn-inline"
|
||||
[disabled]="
|
||||
readOnly ||
|
||||
!kycForm.get('gstinNumber')?.value ||
|
||||
kycForm.get('gstinNumber')?.value.length < 15 ||
|
||||
gstinVerifying ||
|
||||
!clientId
|
||||
"
|
||||
(click)="verifyGstin()"
|
||||
>
|
||||
@if (gstinVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>GSTIN Verified Successfully</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
@if (gstinVerifyName) {
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Trade Name / Legal Name</span>
|
||||
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (gstinVerifyState) {
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">State</span>
|
||||
<span class="pan-result-value">{{ gstinVerifyState }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- CIN -->
|
||||
@if (isNonIndividual) {
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>CIN (Corporate Identity Number)</mat-label>
|
||||
@@ -850,7 +858,7 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
|
||||
@if (cinVerifyStatus !== 'VERIFIED') {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
@@ -862,16 +870,31 @@
|
||||
@if (cinVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
|
||||
<fa-icon icon="shield-alt"></fa-icon>
|
||||
{{ cinVerifyStatus === 'UNCONFIRMED' ? 'Retry' : 'Verify CIN' }}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (cinVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
@if (cinVerifyStatus === 'UNCONFIRMED') {
|
||||
<span class="verify-chip unconfirmed"
|
||||
><fa-icon icon="info-circle"></fa-icon> Saved — not confirmed</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accepted, but MCA returned no match. Stated plainly rather than as a failure: the
|
||||
number is stored and the officer has nothing to correct. -->
|
||||
@if (cinUnconfirmedMessage) {
|
||||
<div class="cin-unconfirmed-note full-width">
|
||||
<fa-icon icon="info-circle" class="cin-unconfirmed-note__icon"></fa-icon>
|
||||
<span>{{ cinUnconfirmedMessage }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- CIN verification progress -->
|
||||
@if (cinVerifying) {
|
||||
<div class="cin-progress full-width">
|
||||
@@ -1429,15 +1452,19 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
Overriding the risk category is the one thing that makes remarks mandatory here. A verified
|
||||
record still gets the box — the officer needs somewhere to record a re-edit reason, and the
|
||||
server asks for one if the save actually replaces data already on the record — but it is
|
||||
offered, not demanded, so a record that auto-verified mid-wizard does not open in error.
|
||||
-->
|
||||
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>
|
||||
{{
|
||||
isKycVerified
|
||||
? 'Reason for Re-edit'
|
||||
: kycForm.get('allowRiskOverride')?.value
|
||||
? 'Remarks (Required for Risk Override)'
|
||||
: 'Remarks'
|
||||
kycForm.get('allowRiskOverride')?.value
|
||||
? 'Remarks (Required for Risk Override)'
|
||||
: 'Reason for Re-edit (optional)'
|
||||
}}
|
||||
</mat-label>
|
||||
<textarea
|
||||
@@ -1445,18 +1472,13 @@
|
||||
formControlName="remarks"
|
||||
rows="2"
|
||||
[placeholder]="
|
||||
isKycVerified
|
||||
? 'Mandatory — explain why KYC is being re-edited'
|
||||
: kycForm.get('allowRiskOverride')?.value
|
||||
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
||||
: ''
|
||||
kycForm.get('allowRiskOverride')?.value
|
||||
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
||||
: 'Optional — needed only if you change details already recorded on this verified KYC'
|
||||
"
|
||||
></textarea>
|
||||
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
|
||||
<mat-error>
|
||||
Remarks are mandatory when
|
||||
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
|
||||
</mat-error>
|
||||
@if (kycForm.get('allowRiskOverride')?.value && !kycForm.get('remarks')?.value?.trim()) {
|
||||
<mat-error> Remarks are mandatory when overriding the risk category </mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
@@ -1672,77 +1694,92 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||
Form 97 / 98
|
||||
@if (!isNonIndividual) {
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||
Form 97 / 98
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||
}
|
||||
</h4>
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||
}
|
||||
</h4>
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<div class="kyc-info-hint warning-hint">
|
||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form60Type">
|
||||
<mat-option value="FORM_60">Form 97</mat-option>
|
||||
<mat-option value="FORM_61">Form 98</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||
<mat-label>Declaration Date</mat-label>
|
||||
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
||||
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #form60DatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Form 121 ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||
Form 121
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<div class="form-field checkbox-field">
|
||||
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
||||
</div>
|
||||
@if (kycForm.get('form15Applicable')?.value) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form15Type">
|
||||
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
||||
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Financial Year</mat-label>
|
||||
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
|
||||
</mat-form-field>
|
||||
<div class="kyc-info-hint warning-hint">
|
||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form60Type">
|
||||
<mat-option value="FORM_60">Form 97</mat-option>
|
||||
<mat-option value="FORM_61">Form 98</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||
<mat-label>Declaration Date</mat-label>
|
||||
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
||||
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #form60DatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
||||
<!-- ═══ SECTION: Form 121 ═══ -->
|
||||
@if (!isNonIndividual) {
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||
Form 121
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<div class="form-field checkbox-field">
|
||||
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
||||
</div>
|
||||
@if (kycForm.get('form15Applicable')?.value) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form15Type">
|
||||
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
||||
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Financial Year</mat-label>
|
||||
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ═══ SECTION: Photograph & Signature ═══
|
||||
|
||||
An entity gets the photograph box too — a shopfront, signboard or logo is what staff
|
||||
recognise the client by in the ledger, and the client view has always had a photo slot in
|
||||
its header with no way to fill it from the stepper. The specimen signature stays individual-
|
||||
only: an entity signs through its authorised signatories, whose signatures are captured with
|
||||
their own KYC rather than as one image for the company. Optional here, so a company can
|
||||
still be onboarded before anyone has a usable photograph of the premises. -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
||||
Photograph & Signature
|
||||
<span class="section-required-tag">Required</span>
|
||||
{{ isNonIndividual ? 'Entity Photograph' : 'Photograph & Signature' }}
|
||||
@if (isNonIndividual) {
|
||||
<span class="section-optional-tag">Optional</span>
|
||||
} @else {
|
||||
<span class="section-required-tag">Required</span>
|
||||
}
|
||||
</h4>
|
||||
<div class="photo-sig-grid">
|
||||
<div class="photo-sig-grid" [class.photo-sig-grid--single]="isNonIndividual">
|
||||
<div
|
||||
class="upload-box"
|
||||
[class.upload-box--has-preview]="photographPreview"
|
||||
[class.upload-box--missing]="!photographPreview && photoSigTouched"
|
||||
[class.upload-box--missing]="!photographPreview && photoSigTouched && !isNonIndividual"
|
||||
>
|
||||
@if (photographPreview) {
|
||||
<div class="upload-preview">
|
||||
@@ -1758,12 +1795,16 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
|
||||
<div class="upload-box__icon"><fa-icon [icon]="isNonIndividual ? 'building' : 'user'"></fa-icon></div>
|
||||
<span class="upload-box__label">
|
||||
Client Photograph
|
||||
<span class="upload-box__req">*</span>
|
||||
{{ isNonIndividual ? 'Entity Photograph / Logo' : 'Client Photograph' }}
|
||||
@if (!isNonIndividual) {
|
||||
<span class="upload-box__req">*</span>
|
||||
}
|
||||
</span>
|
||||
<span class="upload-box__hint">
|
||||
{{ isNonIndividual ? 'Office premises, signboard or logo — JPG / PNG, max 2MB' : 'JPG / PNG, max 2MB' }}
|
||||
</span>
|
||||
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
||||
<div class="upload-box__btn-row">
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||
@@ -1773,50 +1814,52 @@
|
||||
<fa-icon icon="video"></fa-icon> Use Webcam
|
||||
</button>
|
||||
</div>
|
||||
@if (photoSigTouched) {
|
||||
@if (photoSigTouched && !isNonIndividual) {
|
||||
<span class="upload-box__error">Photograph is required</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="upload-box"
|
||||
[class.upload-box--has-preview]="signaturePreview"
|
||||
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
||||
>
|
||||
@if (signaturePreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||
<div class="upload-preview__actions">
|
||||
<label class="upload-preview__btn recrop">
|
||||
@if (!isNonIndividual) {
|
||||
<div
|
||||
class="upload-box"
|
||||
[class.upload-box--has-preview]="signaturePreview"
|
||||
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
||||
>
|
||||
@if (signaturePreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||
<div class="upload-preview__actions">
|
||||
<label class="upload-preview__btn recrop">
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||
</label>
|
||||
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
||||
<fa-icon icon="trash"></fa-icon> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
||||
<span class="upload-box__label">
|
||||
Specimen Signature
|
||||
<span class="upload-box__req">*</span>
|
||||
</span>
|
||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||
<div class="upload-box__btn-row">
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
||||
<fa-icon icon="trash"></fa-icon> Remove
|
||||
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
||||
<span class="upload-box__label">
|
||||
Specimen Signature
|
||||
<span class="upload-box__req">*</span>
|
||||
</span>
|
||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||
<div class="upload-box__btn-row">
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||
</button>
|
||||
</div>
|
||||
@if (photoSigTouched) {
|
||||
<span class="upload-box__error">Signature is required</span>
|
||||
@if (photoSigTouched) {
|
||||
<span class="upload-box__error">Signature is required</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,6 +36,20 @@
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Same chip, muted — says "you may" where the red one says "you must". */
|
||||
.section-optional-tag {
|
||||
margin-left: 6px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-badge {
|
||||
@@ -312,6 +326,34 @@
|
||||
color: #b45309;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
// Recorded but unconfirmed by the registry. Neutral blue rather than green or red: nothing failed
|
||||
// and nothing passed, so it must not read as either.
|
||||
&.unconfirmed {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CIN saved but not confirmed by MCA ─────────────────────────────────────
|
||||
.cin-unconfirmed-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: -4px 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e40af;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cin-unconfirmed-note__icon {
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ── PAN declaration summary ────────────────────────────────────────────────
|
||||
@@ -1225,6 +1267,12 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
|
||||
/* Entity clients get the photograph box only, so it keeps a card's width instead of
|
||||
stretching across the whole step where the signature box used to sit beside it. */
|
||||
&--single {
|
||||
grid-template-columns: minmax(0, 420px);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||
import { ENTITY_KYC_DOCUMENT_TYPES, KycDocumentType, withEntityKycDocuments } from '../../shared/entity-kyc-documents';
|
||||
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||
@@ -110,6 +111,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
} | null = null;
|
||||
|
||||
today = new Date();
|
||||
/**
|
||||
* ClientKycType on the backend: 1 = INDIVIDUAL, 2 = NON_INDIVIDUAL. Every payload must carry the
|
||||
* value matching the client's legal form — the backend rejects the entity-only fields
|
||||
* (enterprisePan / gstinNumber / cinNumber / incorporationDate) on a KYC record typed INDIVIDUAL.
|
||||
*/
|
||||
get kycTypeValue(): number {
|
||||
return this.isNonIndividual ? 2 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk category as it will actually be saved.
|
||||
*
|
||||
* Read via getRawValue() because the riskCategory control is disabled whenever the override
|
||||
* checkbox is off — which is the normal case — and `kycForm.value` omits disabled controls.
|
||||
* Reading `.value` there yields undefined and quietly falls back to 1 (Low), so a verification
|
||||
* would stamp Low onto the record and the next full save would change it to the auto-calculated
|
||||
* category, showing up as an edit of a verified record that the officer never made.
|
||||
*/
|
||||
private get currentRiskCategory(): number {
|
||||
return this.kycForm.getRawValue().riskCategory || 1;
|
||||
}
|
||||
kycId: string = '';
|
||||
/** Whether existing KYC data has been loaded (edit mode) */
|
||||
kycLoaded = false;
|
||||
@@ -121,12 +143,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
/** KYC Document Types — populated from API template, falls back to defaults */
|
||||
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
|
||||
kycDocumentTypes: KycDocumentType[] = [];
|
||||
|
||||
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
|
||||
kycDocProofs: ProofType[] = [];
|
||||
kycDocGroups: { label: string; items: ProofType[] }[] = [];
|
||||
selectedKycDoc: ProofType | null = null;
|
||||
/**
|
||||
* Constitution whose document list is currently loaded. Distinguishes the first arrival of a
|
||||
* constitution from the officer switching to a different one — see updateDocTypesForConstitution.
|
||||
*/
|
||||
private appliedConstitutionValue: string | null = null;
|
||||
/** Front/back files for the chosen KYC document */
|
||||
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
|
||||
/**
|
||||
@@ -185,6 +212,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
/** Photo + signature are mandatory before leaving the KYC tab */
|
||||
get photoSigComplete(): boolean {
|
||||
if (this.isNonIndividual) {
|
||||
return true;
|
||||
}
|
||||
return this.hasPhoto && this.hasSignature;
|
||||
}
|
||||
|
||||
@@ -225,7 +255,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
|
||||
const panOk = this.entPanVerifyStatus === 'VERIFIED';
|
||||
const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED';
|
||||
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED';
|
||||
// A CIN the provider cannot confirm still counts as done. MCA lookups return no match for
|
||||
// CINs that are perfectly valid, and PAN and GSTIN already establish the entity's identity —
|
||||
// holding the whole KYC open on a check that cannot pass would strand the client instead.
|
||||
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED' || this.cinVerifyStatus === 'UNCONFIRMED';
|
||||
return panOk && gstinOk && cinOk;
|
||||
} else {
|
||||
const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value;
|
||||
@@ -250,18 +283,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
||||
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
||||
];
|
||||
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
|
||||
* Annexure I) — shown for non-individual clients before a specific constitution type is
|
||||
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
|
||||
private nonIndividualDefaultDocTypes = [
|
||||
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
||||
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
||||
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
||||
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
||||
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
||||
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
||||
];
|
||||
/** Constitution-based KYC document types for non-individual clients */
|
||||
/** Constitution-based KYC document types for non-individual clients. Shown in addition to
|
||||
* the RBI entity documents (ENTITY_KYC_DOCUMENT_TYPES), never instead of them — see
|
||||
* withEntityKycDocuments. */
|
||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||
HUF: [
|
||||
{ name: 'PAN Card of HUF', mandatory: true },
|
||||
@@ -513,7 +537,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
gstinVerifyState = '';
|
||||
|
||||
/** CIN verification (dummy) */
|
||||
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
/**
|
||||
* UNCONFIRMED means the CIN is saved and correctly formatted, but MCA (via LincPay) would not
|
||||
* confirm it. Kept distinct from VERIFIED, which is only ever set by a positive answer from the
|
||||
* provider, and from FAILED, which is reserved for the provider being unreachable.
|
||||
*/
|
||||
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'UNCONFIRMED' | 'FAILED' = 'NONE';
|
||||
/** Explanation shown beside the CIN when the provider could not confirm it. */
|
||||
cinUnconfirmedMessage = '';
|
||||
cinVerifying = false;
|
||||
cinVerifiedOn = '';
|
||||
cinCompanyName = '';
|
||||
@@ -648,9 +679,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
||||
* ever applies to individuals. */
|
||||
private getDefaultDocTypes(): { name: string; code: string }[] {
|
||||
return this.isNonIndividual
|
||||
? this.nonIndividualDefaultDocTypes
|
||||
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||
return this.isNonIndividual ? ENTITY_KYC_DOCUMENT_TYPES : this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -673,9 +702,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (this.calculatedRisk) {
|
||||
riskControl?.setValue(this.calculatedRisk.category);
|
||||
}
|
||||
if (!this.isKycVerified) {
|
||||
this.kycForm.get('remarks')?.clearValidators();
|
||||
}
|
||||
// Overriding the risk category is the only thing this form demands a justification for.
|
||||
// A verified record does not, by itself, need one — see loadExistingKycIfNeeded.
|
||||
this.kycForm.get('remarks')?.clearValidators();
|
||||
}
|
||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||
});
|
||||
@@ -797,6 +826,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.gstinVerifiedOn = '';
|
||||
this.gstinVerifyState = '';
|
||||
this.cinVerifyStatus = 'NONE';
|
||||
this.cinUnconfirmedMessage = '';
|
||||
this.cinVerifying = false;
|
||||
this.cinVerifiedOn = '';
|
||||
this.cinCompanyName = '';
|
||||
@@ -898,14 +928,26 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private updateDocTypesForConstitution(): void {
|
||||
const constitutionValue = this.constitutionMeta?.value;
|
||||
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
|
||||
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
|
||||
name: doc.name,
|
||||
code: doc.name,
|
||||
mandatory: doc.mandatory
|
||||
}));
|
||||
// Reset selected document type since options changed
|
||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
// The RBI entity documents stay at the head of the list — picking a constitution adds that
|
||||
// constitution's own requirements, it does not swap out the set the officer was just shown.
|
||||
this.kycDocumentTypes = withEntityKycDocuments(
|
||||
this.constitutionDocTypes[constitutionValue],
|
||||
this.constitutionMeta?.label || null
|
||||
);
|
||||
|
||||
// Clearing the chosen document type only makes sense when the officer switches constitution,
|
||||
// which genuinely invalidates it. It must NOT happen the first time a constitution arrives:
|
||||
// on edit that happens after the saved KYC has been restored, so wiping the value here would
|
||||
// discard the restored document type — and with it the upload slots that display the file
|
||||
// already held against the client, since they render only once a type is selected.
|
||||
const constitutionChanged =
|
||||
this.appliedConstitutionValue !== null && this.appliedConstitutionValue !== constitutionValue;
|
||||
this.appliedConstitutionValue = constitutionValue;
|
||||
if (constitutionChanged) {
|
||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
}
|
||||
// Re-resolves the selected chip against the new list from whatever name is on the form.
|
||||
this.rebuildKycDocPicker();
|
||||
}
|
||||
}
|
||||
@@ -913,18 +955,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Rebuild chip-group picker model from kycDocumentTypes */
|
||||
private rebuildKycDocPicker(): void {
|
||||
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
|
||||
normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
|
||||
normalizeProofType({
|
||||
id: d.code || d.name || i,
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
description: d.code || d.name,
|
||||
// Constitution-specific documents carry no code PROOF_META knows, so their group label
|
||||
// has to be handed over explicitly or they all collect under "Other".
|
||||
category: d.category
|
||||
})
|
||||
);
|
||||
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
|
||||
// Sync selected proof with current form value (e.g. on edit-mode prefill)
|
||||
const currentName = this.kycForm?.get('kycDocumentName')?.value;
|
||||
if (currentName) {
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const targetName = cleanStr(currentName);
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
|
||||
} else {
|
||||
this.selectedKycDoc = null;
|
||||
this.selectedKycDoc = currentName ? this.findKycDocProofByName(currentName) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a stored document-type name back to a chip in the picker.
|
||||
*
|
||||
* A tolerant match is required because the two names are not written by the same thing: an
|
||||
* identifier row carries the CodeValue label the backend holds ("Certificate of Incorporation")
|
||||
* while the picker shows the fuller, constitution-specific label ("Certificate of Incorporation /
|
||||
* Registration Certificate"), so one is usually contained in the other rather than equal to it.
|
||||
* An exact comparison leaves the chip unselected on edit, which in turn hides the file-upload
|
||||
* slots and therefore the document the officer already uploaded.
|
||||
*/
|
||||
private findKycDocProofByName(name: string | null | undefined): ProofType | null {
|
||||
const clean = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const target = clean(name || '');
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const proofs = this.kycDocProofs || [];
|
||||
const exact = proofs.find((p) => clean(p.name) === target);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
// Closest containment wins, so a short label cannot claim a chip that a nearer one also matches.
|
||||
const contained = proofs
|
||||
.filter((p) => {
|
||||
const candidate = clean(p.name);
|
||||
return candidate.includes(target) || target.includes(candidate);
|
||||
})
|
||||
.sort((a, b) => Math.abs(clean(a.name).length - target.length) - Math.abs(clean(b.name).length - target.length));
|
||||
return contained[0] || null;
|
||||
}
|
||||
|
||||
selectKycDoc(proof: ProofType): void {
|
||||
@@ -1102,19 +1177,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
isException: data.isException || false,
|
||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||
});
|
||||
// Set remarks as required if KYC is verified
|
||||
if (this.isKycVerified) {
|
||||
this.kycForm.get('remarks')?.setValidators(Validators.required);
|
||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
|
||||
}
|
||||
// Remarks are NOT forced just because the record is verified. The server asks for them
|
||||
// only when a save actually overwrites data the verified record already held
|
||||
// (ClientKycWritePlatformServiceJpaRepositoryImpl.updateKyc), and it says so in the error
|
||||
// it returns. Demanding them up front made every non-individual client open with a red
|
||||
// "Reason for Re-edit" the moment PAN and GSTIN auto-verified — before the officer had
|
||||
// changed anything, and while they still had the KYC document fields left to fill in.
|
||||
this.persistedIsException = data.isException === true;
|
||||
this.persistedExceptionReason = data.exceptionReason || null;
|
||||
// Sync chip-picker selection with the prefilled document name
|
||||
if (data.kycDocumentName) {
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const targetName = cleanStr(data.kycDocumentName);
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
|
||||
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
|
||||
}
|
||||
// Set verification statuses from existing data
|
||||
if (data.panVerified) {
|
||||
@@ -1175,6 +1248,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
if (data.cinVerified) {
|
||||
this.cinVerifyStatus = 'VERIFIED';
|
||||
// The verified card shows this; without it a reopened client renders the panel blank.
|
||||
this.cinCompanyName = data.cinCompanyName || 'Verified via MCA';
|
||||
const rawCinDate = data.cinVerifiedOn;
|
||||
if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) {
|
||||
this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`;
|
||||
@@ -1243,11 +1318,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// Backfill document type + number on the form in case the KYC record didn't have
|
||||
// them (older clients were created via the identifier flow only).
|
||||
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
|
||||
const proof = this.findKycDocProofByName(identifier.documentType.name);
|
||||
this.selectedKycDoc = proof;
|
||||
// Store the picker's own label rather than the identifier's when one matched, so the
|
||||
// selected chip and the form value agree and a later save round-trips the same name.
|
||||
this.kycForm.patchValue({
|
||||
kycDocumentName: identifier.documentType.name,
|
||||
kycDocumentName: proof?.name || identifier.documentType.name,
|
||||
kycDocumentNumber: identifier.documentKey || ''
|
||||
});
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
|
||||
}
|
||||
|
||||
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||
@@ -1453,13 +1531,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycTemplate = template;
|
||||
this.kycTemplateLoaded = true;
|
||||
if (template?.requiredDocuments?.length > 0) {
|
||||
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
||||
template.requiredDocuments.map((doc: any) => ({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
mandatory: doc.mandatory ?? false
|
||||
}))
|
||||
);
|
||||
const templateDocs = template.requiredDocuments.map((doc: any) => ({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
mandatory: doc.mandatory ?? false
|
||||
}));
|
||||
// The backend template names the same entity documents differently ("Certificate of
|
||||
// Incorporation" / "COI", "PAN Card of Company" / "PAN"), so for an entity it is merged
|
||||
// onto the RBI list rather than replacing it — otherwise reopening a non-individual
|
||||
// client for edit shows a different set of chips than the one used to create it.
|
||||
this.kycDocumentTypes = this.isNonIndividual
|
||||
? withEntityKycDocuments(templateDocs, template?.clientTypeName || this.constitutionMeta?.label || null)
|
||||
: this.filterDocTypesForMinor(templateDocs);
|
||||
// Update kycDocumentName validator based on mandatory docs
|
||||
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
||||
if (hasMandatory) {
|
||||
@@ -1472,11 +1555,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
error: () => {
|
||||
// Fallback to defaults if template API unavailable
|
||||
this.kycTemplateLoaded = true;
|
||||
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}));
|
||||
// An entity keeps whatever constitution-specific documents it already has alongside the
|
||||
// RBI list; a failed template call must not quietly shorten the picker.
|
||||
const constitutionDocs = this.isNonIndividual
|
||||
? this.constitutionDocTypes[this.constitutionMeta?.value] || []
|
||||
: [];
|
||||
this.kycDocumentTypes = this.isNonIndividual
|
||||
? withEntityKycDocuments(constitutionDocs, this.constitutionMeta?.label || null)
|
||||
: this.getDefaultDocTypes().map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}));
|
||||
this.rebuildKycDocPicker();
|
||||
}
|
||||
});
|
||||
@@ -1485,17 +1575,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Save or update KYC, then execute callback */
|
||||
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
|
||||
if (this.kycId) {
|
||||
const { kycType, ...updatePayload } = payload;
|
||||
// riskCategory is dropped alongside kycType on an update. The verify payloads only carry it to
|
||||
// satisfy the initiate validator, which requires it when the record is first created; once the
|
||||
// row exists, re-asserting it on every PAN/GSTIN/CIN check means a verification silently
|
||||
// rewrites the risk category. On a record that has auto-verified that reads as an edit of
|
||||
// verified data and is refused, blocking the verification itself. The officer's risk choice is
|
||||
// persisted by the KYC step's own save, which does not come through here.
|
||||
const { kycType, riskCategory, ...updatePayload } = payload;
|
||||
|
||||
// If KYC is VERIFIED, remarks are mandatory — use re-edit command
|
||||
// If KYC is VERIFIED, changing data already on the record needs remarks — use re-edit command.
|
||||
// Whether this particular save actually replaces stored data is something only the server can
|
||||
// tell (it holds the current values), so it decides: a non-individual record auto-verifies as
|
||||
// soon as PAN and GSTIN pass, and the officer must still be able to fill in the KYC document
|
||||
// fields that are empty at that point. Remarks are forwarded when present and the server
|
||||
// rejects the save if they were required.
|
||||
if (this.isKycVerified) {
|
||||
const remarks = this.kycForm.get('remarks')?.value?.trim();
|
||||
if (!remarks) {
|
||||
onError('Remarks are mandatory when updating a verified KYC record.');
|
||||
this.kycForm.get('remarks')?.markAsTouched();
|
||||
return;
|
||||
if (remarks) {
|
||||
updatePayload.remarks = remarks;
|
||||
}
|
||||
updatePayload.remarks = remarks;
|
||||
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
|
||||
next: () => {
|
||||
// Status transitions to IN_PROGRESS, PAN verification is invalidated
|
||||
@@ -1571,12 +1669,30 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||
const panPayload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
panNumber: pan,
|
||||
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
||||
};
|
||||
|
||||
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
|
||||
// still legalForm=ENTITY to the backend, which matches an entity on name + date of
|
||||
// incorporation. Persist that date with this save so verifyPAN() can read it off the record.
|
||||
// Never sent for individuals — the backend rejects entity-only fields on an individual KYC.
|
||||
if (this.isNonIndividual) {
|
||||
const incorpDate = this.kycForm.value.incorporationDate;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
panPayload.dateFormat = dateFormat;
|
||||
panPayload.incorporationDate =
|
||||
incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate || null;
|
||||
if (!panPayload.incorporationDate) {
|
||||
this.panVerifying = false;
|
||||
this.panVerifyError = 'Enter the Date of Incorporation before verifying the PAN.';
|
||||
this.kycForm.get('incorporationDate')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.saveKycAndDo(
|
||||
panPayload,
|
||||
() => {
|
||||
@@ -1834,16 +1950,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const incorpDate = this.kycForm.value.incorporationDate;
|
||||
|
||||
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
|
||||
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification.
|
||||
// For an entity the provider's "DOB" is the date of incorporation, so it must be persisted on the
|
||||
// KYC record by this same save — verifyPAN() below reads it back off the record, not off the form.
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
panNumber: pan, // Company PAN → panNumber
|
||||
enterprisePan: pan,
|
||||
incorporationDate: incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate,
|
||||
dateFormat,
|
||||
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
|
||||
};
|
||||
|
||||
if (!payload.incorporationDate) {
|
||||
this.entPanVerifying = false;
|
||||
this.showPanErrorAlert('Enter the Date of Incorporation before verifying the Enterprise PAN.');
|
||||
this.kycForm.get('incorporationDate')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.saveKycAndDo(
|
||||
payload,
|
||||
() => {
|
||||
@@ -1862,6 +1989,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
rawDate ||
|
||||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}
|
||||
// Seeding status — mirrors verifyPan(), so the value the provider returned is held
|
||||
// here too and the next save round-trips it instead of leaving this screen blank.
|
||||
if (res.changes?.panAadhaarSeedingStatus) {
|
||||
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
|
||||
}
|
||||
} else {
|
||||
this.entPanVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(
|
||||
@@ -1907,8 +2039,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
gstinNumber: gstin,
|
||||
dateFormat,
|
||||
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
||||
@@ -1976,7 +2108,105 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.clientId) {
|
||||
this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cinVerifying = true;
|
||||
this.startCinProgress();
|
||||
|
||||
// The CIN has to be on the record before the command runs: the backend reads it off the KYC row
|
||||
// (and rejects with "CIN number is not set on this KYC record" otherwise) rather than taking it
|
||||
// from the request. Same save-then-verify order as PAN and GSTIN.
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
cinNumber: cin,
|
||||
dateFormat: this.settingsService.dateFormat,
|
||||
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
||||
aadhaarNumberMasked: 'XXXXXXXX0000'
|
||||
};
|
||||
|
||||
this.saveKycAndDo(
|
||||
payload,
|
||||
() => {
|
||||
this.clientsService.verifyCIN(this.clientId, this.kycId).subscribe({
|
||||
next: (res: any) => {
|
||||
this.stopCinProgress();
|
||||
const changes = res?.changes || res;
|
||||
if (changes?.cinVerified === true) {
|
||||
this.cinVerifyStatus = 'VERIFIED';
|
||||
// The label distinguishes a CIN the registry actually matched from one accepted on
|
||||
// its structure alone, so the card never claims an MCA confirmation that never came.
|
||||
this.cinCompanyName =
|
||||
changes?.cinCompanyName ||
|
||||
(changes?.cinConfirmedByRegistry === false ? 'Format validated' : 'Verified via MCA');
|
||||
this.cinUnconfirmedMessage =
|
||||
changes?.cinConfirmedByRegistry === false ? changes?.cinVerificationMessage || '' : '';
|
||||
const rawDate = changes?.cinVerifiedOn;
|
||||
if (Array.isArray(rawDate)) {
|
||||
this.cinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
|
||||
} else {
|
||||
const now = new Date();
|
||||
this.cinVerifiedOn =
|
||||
rawDate ||
|
||||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}
|
||||
} else {
|
||||
this.markCinUnconfirmed(changes?.cinVerificationMessage);
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.stopCinProgress();
|
||||
const message =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
'CIN verification failed.';
|
||||
// A 503 means the provider could not be reached, which is worth interrupting the officer
|
||||
// for — it is likely to succeed on a retry. Anything else is the provider having
|
||||
// answered without confirming the company, which is not something a retry fixes and not
|
||||
// something the officer can correct, so it is reported in place instead of as an alert.
|
||||
if (err?.status === 503) {
|
||||
this.cinVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(message);
|
||||
} else {
|
||||
this.markCinUnconfirmed(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
(msg) => {
|
||||
this.stopCinProgress();
|
||||
this.showPanErrorAlert(msg);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CIN is stored and well-formed, but MCA did not confirm it.
|
||||
*
|
||||
* Reported in place rather than as an error dialog because there is nothing for the officer to
|
||||
* correct: the number is already saved and the provider simply has no record to match it against.
|
||||
* Deliberately NOT recorded as verified — `cinVerified` stays false on the KYC record, so the
|
||||
* distinction between a confirmed and an unconfirmed CIN survives into the client's history.
|
||||
*/
|
||||
private markCinUnconfirmed(message?: string): void {
|
||||
this.cinVerifyStatus = 'UNCONFIRMED';
|
||||
this.cinUnconfirmedMessage =
|
||||
message || 'MCA could not confirm this CIN. The number has been saved against the client.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the stepped MCA progress display while the verification is in flight. Deliberately stops
|
||||
* short of the last step and of 100%: the real outcome comes from the server, and letting the bar
|
||||
* finish on its own is what used to make an unverified CIN look verified.
|
||||
*/
|
||||
private startCinProgress(): void {
|
||||
if (this.cinProgressTimer) {
|
||||
clearInterval(this.cinProgressTimer);
|
||||
}
|
||||
this.cinProgressValue = 0;
|
||||
this.cinCurrentStep = 0;
|
||||
|
||||
@@ -2000,22 +2230,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
|
||||
|
||||
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 90);
|
||||
if (elapsed >= totalDuration) {
|
||||
clearInterval(this.cinProgressTimer);
|
||||
this.cinCurrentStep = stepDurations.length;
|
||||
setTimeout(() => {
|
||||
this.cinVerifying = false;
|
||||
this.cinVerifyStatus = 'VERIFIED';
|
||||
this.cinCompanyName = 'Verified via MCA';
|
||||
const now = new Date();
|
||||
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}, 300);
|
||||
this.cinProgressTimer = null;
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
private stopCinProgress(): void {
|
||||
if (this.cinProgressTimer) {
|
||||
clearInterval(this.cinProgressTimer);
|
||||
this.cinProgressTimer = null;
|
||||
}
|
||||
this.cinProgressValue = 100;
|
||||
this.cinVerifying = false;
|
||||
}
|
||||
|
||||
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
|
||||
sendAadhaarOtp() {
|
||||
if (!this.clientId) {
|
||||
@@ -2041,8 +2272,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
|
||||
const aadhaarPayload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
aadhaarNumberMasked: aadhaarMasked
|
||||
};
|
||||
const panVal = this.kycForm.value.panNumber;
|
||||
@@ -2203,7 +2434,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
getRenewalYears(): number {
|
||||
switch (this.kycForm.value.riskCategory) {
|
||||
switch (this.currentRiskCategory) {
|
||||
case 1:
|
||||
return 10;
|
||||
case 2:
|
||||
@@ -2387,12 +2618,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
return {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
kycType: 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: v.riskCategory || 1,
|
||||
// For non-individual: Company PAN is used as panNumber for verification
|
||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
|
||||
// Only ever sent when this screen actually holds a value. The seeding status is derived by
|
||||
// the provider during PAN verification and written straight to the record, so sending an
|
||||
// empty one back would blank a field the server filled in — which reads as an officer
|
||||
// editing a verified record rather than the no-op it is.
|
||||
...(this.panAadhaarSeedingStatus ? { panAadhaarSeedingStatus: this.panAadhaarSeedingStatus } : {}),
|
||||
// PAN declaration — stored so the waived exception can be justified later, and so a reload
|
||||
// does not silently drop the paperwork that is holding the exception off.
|
||||
panDeclarationTaken: this.panDeclarationTaken,
|
||||
@@ -2470,6 +2705,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
v.gstinNumber ||
|
||||
v.enterprisePan ||
|
||||
v.cinNumber ||
|
||||
v.incorporationDate ||
|
||||
v.kycDocumentName ||
|
||||
v.form15Applicable ||
|
||||
v.isException
|
||||
|
||||
+6
-6
@@ -442,18 +442,18 @@
|
||||
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.incorporationDate) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Date of Incorporation</span>
|
||||
<span class="preview-value">{{ kycData.incorporationDate }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.cinNumber) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">CIN (Corporate Identity Number)</span>
|
||||
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.incorporationDate) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Incorporation Date</span>
|
||||
<span class="preview-value">{{ kycData.incorporationDate | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.gstinNumber) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">GSTIN Number</span>
|
||||
|
||||
@@ -62,6 +62,7 @@ import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
|
||||
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -199,6 +200,15 @@ const routes: Routes = [
|
||||
component: Form121TabComponent,
|
||||
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'authorized-signatories',
|
||||
component: AuthorizedSignatoriesTabComponent,
|
||||
data: {
|
||||
title: 'Authorized Signatories',
|
||||
breadcrumb: 'Authorized Signatories',
|
||||
routeParamBreadcrumb: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'documents',
|
||||
component: DocumentsTabComponent,
|
||||
|
||||
@@ -41,6 +41,26 @@
|
||||
>
|
||||
{{ address.isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<!-- Only shown on the address a welcome kit was actually posted to. -->
|
||||
@if (verificationOf(address); as verification) {
|
||||
<span
|
||||
class="addr-panel__verify"
|
||||
[class.addr-panel__verify--yes]="verification === 'VERIFIED'"
|
||||
[class.addr-panel__verify--no]="verification === 'NOT_VERIFIED'"
|
||||
[matTooltip]="
|
||||
verification === 'VERIFIED'
|
||||
? 'Welcome kit was delivered to this address'
|
||||
: 'Welcome kit sent here was returned undelivered'
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="fa"
|
||||
[class.fa-check-circle]="verification === 'VERIFIED'"
|
||||
[class.fa-times-circle]="verification === 'NOT_VERIFIED'"
|
||||
></i>
|
||||
{{ verification === 'VERIFIED' ? 'Verified' : 'Not Verified' }}
|
||||
</span>
|
||||
}
|
||||
</mat-panel-title>
|
||||
<mat-panel-description class="addr-panel__desc">
|
||||
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : ''
|
||||
|
||||
@@ -148,6 +148,32 @@ $red-light: #fce8e6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Welcome kit outcome — only present on the address a kit was posted to. */
|
||||
&__verify {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&--yes {
|
||||
background: $green-light;
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&--no {
|
||||
background: $red-light;
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 12px;
|
||||
color: $gray-500 !important;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@angular/material/expansion';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
@@ -51,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatExpansionPanelTitle,
|
||||
MatExpansionPanelDescription,
|
||||
MatDivider,
|
||||
MatSlideToggle
|
||||
MatSlideToggle,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class AddressTabComponent {
|
||||
@@ -68,6 +70,11 @@ export class AddressTabComponent {
|
||||
clientAddressTemplate: any;
|
||||
/** Client Id */
|
||||
clientId: string;
|
||||
/**
|
||||
* Welcome kit outcome per address id. Only addresses a kit was actually posted to appear here: delivery
|
||||
* proves the address is real, a returned kit proves it is not, and an address with no kit stays unmarked.
|
||||
*/
|
||||
addressVerification: Record<string, 'VERIFIED' | 'NOT_VERIFIED'> = {};
|
||||
|
||||
/**
|
||||
* @param {ActivatedRoute} route Activated Route
|
||||
@@ -82,10 +89,48 @@ export class AddressTabComponent {
|
||||
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
||||
this.clientAddressTemplate = data.clientAddressTemplateData;
|
||||
this.clientId = this.route.parent.snapshot.paramMap.get('clientId');
|
||||
this.loadWelcomeKitVerification();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks addresses a welcome kit was posted to with the outcome of that delivery. The kit is the physical
|
||||
* check on the address, so a delivered kit reads as verified and a returned one as not verified.
|
||||
*/
|
||||
private loadWelcomeKitVerification(): void {
|
||||
if (!this.clientId) {
|
||||
return;
|
||||
}
|
||||
this.clientService.getWelcomeKits(this.clientId).subscribe({
|
||||
next: (response: any) => {
|
||||
const map: Record<string, 'VERIFIED' | 'NOT_VERIFIED'> = {};
|
||||
// Oldest first, so that when an address was posted to more than once the newest outcome wins.
|
||||
const dispatches = [...(response?.dispatches || [])].reverse();
|
||||
for (const dispatch of dispatches) {
|
||||
if (dispatch?.addressId == null) {
|
||||
continue;
|
||||
}
|
||||
if (dispatch.status === 'DELIVERED') {
|
||||
map[dispatch.addressId] = 'VERIFIED';
|
||||
} else if (dispatch.status === 'RETURNED') {
|
||||
map[dispatch.addressId] = 'NOT_VERIFIED';
|
||||
}
|
||||
}
|
||||
this.addressVerification = map;
|
||||
},
|
||||
// A client with no welcome kit permission or no kit yet simply shows no badge.
|
||||
error: () => {
|
||||
this.addressVerification = {};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Verification badge for an address, or null when no kit has been posted to it. */
|
||||
verificationOf(address: any): 'VERIFIED' | 'NOT_VERIFIED' | null {
|
||||
return this.addressVerification[address?.addressId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a client address.
|
||||
*/
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<div class="as-view">
|
||||
@if (loading) {
|
||||
<div class="as-loading">
|
||||
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
|
||||
<p>Loading authorized signatories...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading) {
|
||||
<div class="as-header">
|
||||
<h3><fa-icon icon="user-shield"></fa-icon> Authorized Signatories</h3>
|
||||
@if (signatories.length) {
|
||||
<span class="as-count">{{ signatories.length }} on record</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- The tab link is entity-only, but the route can be reached directly by URL. -->
|
||||
@if (!isNonIndividual) {
|
||||
<div class="as-empty">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>Authorized signatories apply to non-individual clients only.</span>
|
||||
</div>
|
||||
} @else if (!signatories.length) {
|
||||
<div class="as-empty">
|
||||
<mat-icon>group_off</mat-icon>
|
||||
<span>No authorized signatories recorded for this entity.</span>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="as-note">
|
||||
The persons authorised to transact on this entity's behalf, as named in its Board Resolution / Authorization
|
||||
Letter.
|
||||
</p>
|
||||
|
||||
@for (signatory of signatories; track $index) {
|
||||
<div class="as-card">
|
||||
<div class="as-card__main">
|
||||
<div class="as-card__avatar"><mat-icon>person</mat-icon></div>
|
||||
<div class="as-card__identity">
|
||||
<span class="as-card__name">{{ signatory.name || 'Unnamed signatory' }}</span>
|
||||
<span class="as-card__meta">
|
||||
@if (signatory.loading) {
|
||||
Loading details…
|
||||
} @else {
|
||||
@if (signatory.cifNumber) {
|
||||
<span class="as-mono">CIF {{ signatory.cifNumber }}</span>
|
||||
}
|
||||
@if (signatory.officeName) {
|
||||
<span class="as-card__dot">•</span>{{ signatory.officeName }}
|
||||
}
|
||||
@if (signatory.status) {
|
||||
<span class="as-card__dot">•</span>{{ signatory.status }}
|
||||
}
|
||||
<!-- A signatory recorded as a name only — nothing to link to, and saying so is
|
||||
better than an empty row the officer cannot explain. -->
|
||||
@if (!signatory.clientId) {
|
||||
<span class="as-card__unlinked">Name on record only — no linked client</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="as-card__side">
|
||||
<div class="as-din">
|
||||
<span class="as-din__label">DIN</span>
|
||||
@if (signatory.dinNumber) {
|
||||
<span class="as-din__value as-mono">{{ signatory.dinNumber }}</span>
|
||||
@if (signatory.dinVerified) {
|
||||
<span class="as-chip as-chip--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="as-chip as-chip--pending"><mat-icon>radio_button_unchecked</mat-icon> Unverified</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="as-din__value as-din__value--none">—</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (signatory.clientId) {
|
||||
<a mat-stroked-button class="as-link-btn" [routerLink]="['/clients', signatory.clientId, 'general']">
|
||||
<mat-icon>open_in_new</mat-icon> View client
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.as-view {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.as-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.as-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
fa-icon {
|
||||
color: #1565c0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.as-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.as-note {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.as-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 40px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
background: #f8fafc;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.as-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
|
||||
&__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: #eaf2fb;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
&__identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&__unlinked {
|
||||
font-style: italic;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.as-din {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
|
||||
&--none {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.as-mono {
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.as-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.as-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
// MatAnchor, not MatButton: the link to the signatory's client record is an <a>, and the shared
|
||||
// imports only carry the <button> directive.
|
||||
import { MatAnchor } from '@angular/material/button';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { LegalFormId } from '../../models/legal-form.enum';
|
||||
|
||||
/** One row of the tab — a name from the entity's record, enriched from the linked client. */
|
||||
interface AuthorizedSignatory {
|
||||
/** Client id of the individual, when the signatory was linked to a real client record. */
|
||||
clientId: number | null;
|
||||
name: string;
|
||||
cifNumber: string;
|
||||
officeName: string;
|
||||
status: string;
|
||||
dinNumber: string;
|
||||
dinVerified: boolean;
|
||||
/** Details are still being fetched for this row. */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-authorized-signatories-tab',
|
||||
templateUrl: './authorized-signatories-tab.component.html',
|
||||
styleUrls: ['./authorized-signatories-tab.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatAnchor
|
||||
]
|
||||
})
|
||||
export class AuthorizedSignatoriesTabComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
clientId = '';
|
||||
loading = true;
|
||||
isNonIndividual = false;
|
||||
signatories: AuthorizedSignatory[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clientId = this.route.parent?.snapshot.paramMap.get('clientId') || '';
|
||||
// Read from the parent route's resolved data — the entity's own record already carries the
|
||||
// signatory names and ids, so the tab needs no request of its own to render its rows. Same
|
||||
// subscription style as the other client tabs, which keeps this correct when the view is
|
||||
// re-entered for a different client on a reused component instance.
|
||||
this.route.parent?.data.subscribe((data: any) => {
|
||||
const clientViewData = data?.clientViewData;
|
||||
this.isNonIndividual = clientViewData?.legalForm?.id === LegalFormId.ENTITY;
|
||||
|
||||
const nonPerson = clientViewData?.clientNonPersonDetails;
|
||||
this.signatories = this.buildRows(nonPerson?.authorizedSignatory, nonPerson?.authorizedSignatoryClientIds);
|
||||
this.loading = false;
|
||||
|
||||
// DIN lives on the signatory's own KYC record, not on the entity's — the same place the
|
||||
// General step reads it back from — so each linked row is filled in from there.
|
||||
this.signatories.filter((s) => s.clientId != null).forEach((s) => this.loadSignatoryDetails(s));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Names and client ids are stored as two comma-separated columns written in the same order
|
||||
* (see ClientGeneralStepComponent.syncSignatoryNames), so they are zipped back by position.
|
||||
* A name with no id behind it is still shown: signatories recorded before linking existed, or
|
||||
* typed in without a client record, are part of the entity's mandate and must not disappear.
|
||||
*/
|
||||
private buildRows(names: string | null | undefined, clientIds: string | null | undefined): AuthorizedSignatory[] {
|
||||
const splitCsv = (value: string | null | undefined): string[] =>
|
||||
(value || '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const nameList = splitCsv(names);
|
||||
const idList = splitCsv(clientIds);
|
||||
// An id column longer than the name column would otherwise drop the extra links silently.
|
||||
const rowCount = Math.max(nameList.length, idList.length);
|
||||
|
||||
return Array.from({ length: rowCount }, (_unused, index) => {
|
||||
const rawId = idList[index];
|
||||
const parsedId = rawId ? Number(rawId) : NaN;
|
||||
const clientId = Number.isFinite(parsedId) ? parsedId : null;
|
||||
return {
|
||||
clientId,
|
||||
name: nameList[index] || '',
|
||||
cifNumber: '',
|
||||
officeName: '',
|
||||
status: '',
|
||||
dinNumber: '',
|
||||
dinVerified: false,
|
||||
loading: clientId != null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private loadSignatoryDetails(signatory: AuthorizedSignatory): void {
|
||||
const id = signatory.clientId!.toString();
|
||||
|
||||
this.clientsService.getClientData(id).subscribe({
|
||||
next: (data: any) => {
|
||||
signatory.name = signatory.name || data?.displayName || '';
|
||||
signatory.cifNumber = data?.cifNumber || data?.accountNo || '';
|
||||
signatory.officeName = data?.officeName || '';
|
||||
signatory.status = data?.status?.value || '';
|
||||
signatory.loading = false;
|
||||
},
|
||||
// A signatory whose client record cannot be read still shows as a row with its stored name,
|
||||
// rather than spinning forever.
|
||||
error: () => {
|
||||
signatory.loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.clientsService.getClientKyc(id).subscribe({
|
||||
next: (kyc: any) => {
|
||||
signatory.dinNumber = kyc?.dinNumber || '';
|
||||
signatory.dinVerified = kyc?.dinVerified === true;
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -168,58 +168,78 @@
|
||||
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.mobileNo && clientViewData.dipVerified !== null) {
|
||||
<!--
|
||||
dipLiveStatus reflects the LATEST lookup, not the flag frozen at onboarding. A customer
|
||||
cleared when they joined can have their number reported for fraud or reassigned later —
|
||||
this cell has to show that, otherwise the page keeps claiming "Verified" indefinitely.
|
||||
-->
|
||||
@if (clientViewData.mobileNo && (clientViewData.dipVerified !== null || clientViewData.dipLiveStatus)) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">DIP Status</div>
|
||||
<div class="cv-cell__value">
|
||||
<span
|
||||
class="cv-badge"
|
||||
[class.cv-badge--green]="
|
||||
clientViewData.dipVerified === true ||
|
||||
clientViewData.dipVerified === 'true' ||
|
||||
clientViewData.dipVerified === 1
|
||||
"
|
||||
[class.cv-badge--yellow]="
|
||||
clientViewData.dipVerified !== true &&
|
||||
clientViewData.dipVerified !== 'true' &&
|
||||
clientViewData.dipVerified !== 1
|
||||
"
|
||||
[class.cv-badge--red]="isDipFlagged(clientViewData.dipLiveStatus)"
|
||||
[class.cv-badge--green]="!isDipFlagged(clientViewData.dipLiveStatus) && isDipClear(clientViewData)"
|
||||
[class.cv-badge--yellow]="!isDipFlagged(clientViewData.dipLiveStatus) && !isDipClear(clientViewData)"
|
||||
>
|
||||
{{
|
||||
clientViewData.dipVerified === true ||
|
||||
clientViewData.dipVerified === 'true' ||
|
||||
clientViewData.dipVerified === 1
|
||||
? 'Verified'
|
||||
: 'Pending'
|
||||
}}
|
||||
{{ dipStatusLabel(clientViewData) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.nameVerified !== null) {
|
||||
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
|
||||
<div class="cv-cell cv-cell--full">
|
||||
<div class="dip-alert">
|
||||
<fa-icon icon="exclamation-triangle" class="dip-alert__icon"></fa-icon>
|
||||
<div>
|
||||
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
|
||||
<div class="dip-alert__detail">
|
||||
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
|
||||
obtain a new number. Account operations are not restricted.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!--
|
||||
A frozen customer must be obvious the moment anyone opens them, not something a teller
|
||||
discovers when a withdrawal is refused at the counter.
|
||||
-->
|
||||
@if (clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH') {
|
||||
<div class="cv-cell cv-cell--full">
|
||||
<div class="dip-alert">
|
||||
<fa-icon icon="ban" class="dip-alert__icon"></fa-icon>
|
||||
<div>
|
||||
<strong>Confirmed UN Security Council sanctions match — accounts frozen.</strong>
|
||||
<div class="dip-alert__detail">
|
||||
Debits are blocked on this customer's deposit accounts and lending is barred. Do not discuss the
|
||||
reason with the customer. See Reports → Sanctions Screening & Alerts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!--
|
||||
Driven by sanctionsAlertStatus (the live alert) rather than nameVerified, which records
|
||||
the outcome at onboarding and never changes. A client confirmed against the UNSC list and
|
||||
frozen afterwards was still reading "Clear" here — the single worst place to be wrong,
|
||||
since this header is what anyone opening the customer sees first.
|
||||
-->
|
||||
@if (clientViewData.nameVerified !== null || clientViewData.sanctionsAlertStatus) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Sanctions Screening</div>
|
||||
<div class="cv-cell__value">
|
||||
<span
|
||||
class="cv-badge"
|
||||
[class.cv-badge--green]="
|
||||
clientViewData.nameVerified === true ||
|
||||
clientViewData.nameVerified === 'true' ||
|
||||
clientViewData.nameVerified === 1
|
||||
"
|
||||
[class.cv-badge--red]="
|
||||
clientViewData.nameVerified === false ||
|
||||
clientViewData.nameVerified === 'false' ||
|
||||
clientViewData.nameVerified === 0
|
||||
[class.cv-badge--green]="!clientViewData.sanctionsAlertStatus && isNameCleared(clientViewData)"
|
||||
[class.cv-badge--yellow]="
|
||||
clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW' ||
|
||||
(!clientViewData.sanctionsAlertStatus && !isNameCleared(clientViewData))
|
||||
"
|
||||
[class.cv-badge--red]="clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH'"
|
||||
>
|
||||
{{
|
||||
clientViewData.nameVerified === true ||
|
||||
clientViewData.nameVerified === 'true' ||
|
||||
clientViewData.nameVerified === 1
|
||||
? 'Clear'
|
||||
: 'Pending'
|
||||
}}
|
||||
{{ sanctionsHeaderLabel(clientViewData) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,9 +430,11 @@
|
||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||
{{ 'labels.buttons.Add Charge' | translate }}
|
||||
</button>
|
||||
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||
Welcome Kit
|
||||
</button>
|
||||
@if (isActive()) {
|
||||
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||
Welcome Kit
|
||||
</button>
|
||||
}
|
||||
<button mat-menu-item (click)="doAction('Create Collateral')">
|
||||
{{ 'labels.buttons.Create Collateral' | translate }}
|
||||
</button>
|
||||
@@ -554,6 +576,22 @@
|
||||
>
|
||||
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
||||
</a>
|
||||
<!--
|
||||
Authorized signatories are an entity concept — the persons named in a Board Resolution as
|
||||
authorised to transact for the company. They are captured in the General step at onboarding
|
||||
but have never been readable anywhere in the client view until now.
|
||||
-->
|
||||
@if (clientViewData.legalForm?.id === 2) {
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./authorized-signatories']"
|
||||
routerLinkActive
|
||||
#signatoriesTab="routerLinkActive"
|
||||
[active]="signatoriesTab.isActive"
|
||||
>
|
||||
<i class="fa fa-user-shield cv-tab-icon"></i> Authorized Signatories
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./documents']"
|
||||
|
||||
@@ -286,6 +286,39 @@ $accent: #0ea5e9;
|
||||
}
|
||||
}
|
||||
|
||||
// A DIP hit on an existing customer is not an account restriction, so it is shown as a standing
|
||||
// advisory on the client rather than as a blocking state.
|
||||
.cv-cell--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dip-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: rgb(239 68 68 / 15%);
|
||||
border: 1px solid rgb(239 68 68 / 40%);
|
||||
border-left: 3px solid #ef4444;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: #fecaca;
|
||||
line-height: 1.5;
|
||||
|
||||
&__icon {
|
||||
color: #fca5a5;
|
||||
font-size: 15px;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
margin-top: 3px;
|
||||
color: #fca5a5;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.actions-menu {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,54 @@ import { formatTabLabel } from 'app/shared/utils/format-tab-label.util';
|
||||
})
|
||||
export class ClientsViewComponent implements OnInit {
|
||||
complianceHideClientData = environment.complianceHideClientData;
|
||||
|
||||
/**
|
||||
* Whether the number's CURRENT DIP/MNRL status is a hit. Driven by the live lookup rather than
|
||||
* the onboarding-time `dipVerified` flag, which never changes once the client is created.
|
||||
*/
|
||||
isDipFlagged(liveStatus: string | null | undefined): boolean {
|
||||
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
|
||||
}
|
||||
|
||||
/** True only when the number is affirmatively clean — not merely "not flagged". */
|
||||
isDipClear(client: any): boolean {
|
||||
if (client?.dipLiveStatus) {
|
||||
return client.dipLiveStatus === 'CLEAN';
|
||||
}
|
||||
return client?.dipVerified === true || client?.dipVerified === 'true' || client?.dipVerified === 1;
|
||||
}
|
||||
|
||||
/** True when onboarding screening cleared the name — the pre-alert baseline. */
|
||||
isNameCleared(client: any): boolean {
|
||||
return client?.nameVerified === true || client?.nameVerified === 'true' || client?.nameVerified === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header badge for sanctions. A live alert always wins over the onboarding flag: the flag says
|
||||
* what was true the day the client joined, the alert says what is true now.
|
||||
*/
|
||||
sanctionsHeaderLabel(client: any): string {
|
||||
switch (client?.sanctionsAlertStatus) {
|
||||
case 'CONFIRMED_MATCH':
|
||||
return 'Frozen — Sanctions Match';
|
||||
case 'PENDING_REVIEW':
|
||||
return 'Under Review';
|
||||
case 'FALSE_POSITIVE':
|
||||
return 'Cleared — False Positive';
|
||||
default:
|
||||
return this.isNameCleared(client) ? 'Clear' : 'Pending';
|
||||
}
|
||||
}
|
||||
|
||||
dipStatusLabel(client: any): string {
|
||||
const live = client?.dipLiveStatus;
|
||||
if (live === 'SUSPECTED') return 'Flagged — Suspected';
|
||||
if (live === 'DISCONNECTED') return 'Flagged — Disconnected';
|
||||
if (live === 'SUSPECTED_AND_DISCONNECTED') return 'Flagged — Suspected & Disconnected';
|
||||
if (live === 'SERVICE_UNAVAILABLE') return 'Not Verified';
|
||||
if (live === 'CLEAN') return 'Verified';
|
||||
return this.isDipClear(client) ? 'Verified' : 'Pending';
|
||||
}
|
||||
/**
|
||||
* Mask a string, keeping first and last letter, masking the rest with *
|
||||
*/
|
||||
@@ -461,12 +509,24 @@ export class ClientsViewComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Welcome Kit Dispatch dialog.
|
||||
* Opens the Welcome Kit Dispatch dialog. The kit is only posted once the client is active, so the dialog is
|
||||
* unreachable before then — the menu entry is hidden and this guard covers any other route into the action.
|
||||
*/
|
||||
private dispatchWelcomeKit() {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
}
|
||||
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
||||
data: { clientId: this.clientViewData.id },
|
||||
width: '600px'
|
||||
data: {
|
||||
clientId: this.clientViewData.id,
|
||||
clientName: this.clientViewData.displayName,
|
||||
accountNo: this.clientViewData.accountNo,
|
||||
cifNumber: this.clientViewData.cifNumber,
|
||||
mobileNo: this.clientViewData.mobileNo,
|
||||
emailAddress: this.clientViewData.emailAddress,
|
||||
officeName: this.clientViewData.officeName
|
||||
},
|
||||
width: '680px'
|
||||
});
|
||||
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response) {
|
||||
|
||||
+13
-1
@@ -11,9 +11,21 @@
|
||||
<div>
|
||||
<mifosx-file-upload flex="60%" (change)="onFileSelect($event)" acceptFilter=".png,.jpeg,.jpg"></mifosx-file-upload>
|
||||
|
||||
@if (imagePreview) {
|
||||
<div class="image-preview">
|
||||
<span class="image-preview__label">Preview</span>
|
||||
<img class="image-preview__img" [src]="imagePreview" alt="Selected profile image preview" />
|
||||
<span class="image-preview__name">{{ image.name }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (previewError) {
|
||||
<div class="image-preview__error">{{ previewError }}</div>
|
||||
}
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!image" [mat-dialog-close]="image">
|
||||
<button mat-raised-button color="primary" [disabled]="!image || !imagePreview" [mat-dialog-close]="image">
|
||||
{{ 'labels.buttons.Confirm' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
+42
@@ -5,3 +5,45 @@
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
.image-preview__label {
|
||||
align-self: flex-start;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.image-preview__img {
|
||||
/* Capped in vh as well as px so a tall portrait photo cannot push the Cancel/Confirm footer
|
||||
below the fold on a short viewport — `contain` keeps the whole frame visible rather than
|
||||
cropping it, since the point of the preview is to check the picture before it goes live. */
|
||||
max-width: 100%;
|
||||
max-height: min(260px, 40vh);
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
background: rgb(0 0 0 / 4%);
|
||||
border: 1px solid rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.image-preview__name {
|
||||
font-size: 12px;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-preview__error {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 13px;
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
+33
@@ -33,6 +33,16 @@ export class UploadImageDialogComponent {
|
||||
/** Client Image */
|
||||
image: File;
|
||||
|
||||
/**
|
||||
* Data URL of the selected image, rendered as a preview so the officer sees what they picked
|
||||
* before confirming — the upload replaces the client's profile picture immediately on confirm,
|
||||
* and picking the wrong file is otherwise only discovered once it is already live.
|
||||
*/
|
||||
imagePreview: string | null = null;
|
||||
|
||||
/** Set when the picked file is not a readable image, in place of a preview. */
|
||||
previewError = '';
|
||||
|
||||
/**
|
||||
* Sets file form control value.
|
||||
* @param {any} $event file change event.
|
||||
@@ -40,6 +50,29 @@ export class UploadImageDialogComponent {
|
||||
onFileSelect($event: any) {
|
||||
if ($event.target.files.length > 0) {
|
||||
this.image = $event.target.files[0];
|
||||
this.buildPreview(this.image);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the picked file into a data URL for the preview. */
|
||||
private buildPreview(file: File) {
|
||||
this.imagePreview = null;
|
||||
this.previewError = '';
|
||||
|
||||
// The picker's accept filter is a hint the OS dialog can be talked out of, so re-check here
|
||||
// rather than handing a non-image to FileReader and rendering a broken <img>.
|
||||
if (!file.type?.startsWith('image/')) {
|
||||
this.previewError = 'Selected file is not an image. Choose a PNG or JPEG file.';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.imagePreview = reader.result as string;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
this.previewError = 'Could not read the selected file.';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
+240
-53
@@ -5,90 +5,277 @@
|
||||
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/.
|
||||
-->
|
||||
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
|
||||
<div class="wk-header" mat-dialog-title>
|
||||
<div class="wk-header__icon">
|
||||
<mat-icon>card_giftcard</mat-icon>
|
||||
</div>
|
||||
<div class="wk-header__text">
|
||||
<h2 class="wk-header__title">Welcome Kit Dispatch</h2>
|
||||
<p class="wk-header__subtitle">Posting the kit doubles as a physical address check for this client.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
|
||||
<mat-dialog-content class="mat-typography wk-content">
|
||||
<div class="wk-loading" *ngIf="isLoading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isLoading">
|
||||
<!-- Tracking view if dispatches already exist -->
|
||||
<div
|
||||
class="dispatch-tracking-panel"
|
||||
*ngFor="let dispatch of activeDispatches; let i = index"
|
||||
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
|
||||
>
|
||||
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
|
||||
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
|
||||
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
|
||||
Download Details
|
||||
</button>
|
||||
<!-- Who the kit is going to -->
|
||||
<section class="wk-client">
|
||||
<div class="wk-client__avatar">{{ (clientName || '?').charAt(0) }}</div>
|
||||
<div class="wk-client__details">
|
||||
<div class="wk-client__name">{{ clientName || 'Client' }}</div>
|
||||
<div class="wk-client__meta" *ngIf="data.accountNo">Account No. {{ data.accountNo }}</div>
|
||||
</div>
|
||||
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
|
||||
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
|
||||
<p style="margin: 4px 0">
|
||||
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
|
||||
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
|
||||
</p>
|
||||
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
|
||||
<p style="margin: 4px 0">
|
||||
<strong>Status:</strong>
|
||||
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
||||
<!-- Existing dispatches, newest first -->
|
||||
<section
|
||||
class="wk-dispatch"
|
||||
*ngFor="let dispatch of activeDispatches; let i = index"
|
||||
[class.wk-dispatch--past]="i > 0"
|
||||
>
|
||||
<header class="wk-dispatch__head">
|
||||
<span class="wk-dispatch__label">{{ i === 0 ? 'Current Dispatch' : 'Previous Dispatch' }}</span>
|
||||
<span class="wk-chip" [ngClass]="'wk-chip--' + (dispatch.status | lowercase)">
|
||||
<mat-icon class="wk-chip__icon">
|
||||
{{
|
||||
dispatch.status === 'DELIVERED'
|
||||
? 'verified'
|
||||
: dispatch.status === 'RETURNED'
|
||||
? 'gpp_bad'
|
||||
: 'local_shipping'
|
||||
}}
|
||||
</mat-icon>
|
||||
{{ verificationLabel(dispatch.status) }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<!-- Create form if they can dispatch a new one -->
|
||||
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
|
||||
<h3>Create New Dispatch</h3>
|
||||
<div fxLayout="column" fxLayoutGap="16px">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Delivery Address</mat-label>
|
||||
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
|
||||
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
|
||||
Delivery Address is required
|
||||
<p class="wk-dispatch__hint">{{ verificationHint(dispatch) }}</p>
|
||||
|
||||
<dl class="wk-facts">
|
||||
<div class="wk-facts__row wk-facts__row--wide">
|
||||
<dt>Delivery Address</dt>
|
||||
<dd>{{ getDisplayAddress(dispatch) }}</dd>
|
||||
</div>
|
||||
<div class="wk-facts__row">
|
||||
<dt>Tracking ID</dt>
|
||||
<dd class="wk-facts__mono">{{ dispatch.trackingId }}</dd>
|
||||
</div>
|
||||
<div class="wk-facts__row">
|
||||
<dt>Delivery Mode</dt>
|
||||
<dd>
|
||||
{{ dispatch.deliveryMode }}
|
||||
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'"
|
||||
>({{ dispatch.deliveryModeOtherText }})</ng-container
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="wk-facts__row">
|
||||
<dt>Dispatched On</dt>
|
||||
<dd>{{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</dd>
|
||||
</div>
|
||||
<div class="wk-facts__row" *ngIf="dispatch.deliveredOnDate">
|
||||
<dt>Delivered On</dt>
|
||||
<dd>{{ dispatch.deliveredOnDate | date: 'mediumDate' }}</dd>
|
||||
</div>
|
||||
<div class="wk-facts__row" *ngIf="dispatch.returnedOnDate">
|
||||
<dt>Returned On</dt>
|
||||
<dd>{{ dispatch.returnedOnDate | date: 'mediumDate' }}</dd>
|
||||
</div>
|
||||
<div class="wk-facts__row wk-facts__row--wide" *ngIf="dispatch.remarks">
|
||||
<dt>Remarks</dt>
|
||||
<dd>{{ dispatch.remarks }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Outcome form, shown in place of the buttons once an outcome is picked -->
|
||||
<div class="wk-outcome" *ngIf="outcomeFor?.id === dispatch.id" [formGroup]="outcomeForm">
|
||||
<p class="wk-outcome__title">
|
||||
Mark this kit as
|
||||
<strong>{{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }}</strong>
|
||||
</p>
|
||||
<div class="wk-outcome__fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ outcomeStatus === 'DELIVERED' ? 'Delivered On' : 'Returned On' }}</mat-label>
|
||||
<input matInput [matDatepicker]="outcomePicker" [max]="maxDate" formControlName="outcomeDate" required />
|
||||
<mat-datepicker-toggle matSuffix [for]="outcomePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #outcomePicker></mat-datepicker>
|
||||
<mat-error *ngIf="outcomeForm.get('outcomeDate')?.hasError('required')"> Date is required </mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Remarks (optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="remarks"
|
||||
maxlength="500"
|
||||
placeholder="What the delivery partner reported"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="wk-outcome__actions">
|
||||
<button mat-button type="button" (click)="cancelOutcome()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[color]="outcomeStatus === 'DELIVERED' ? 'primary' : 'warn'"
|
||||
[disabled]="outcomeForm.invalid"
|
||||
(click)="submitOutcome()"
|
||||
>
|
||||
Confirm {{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="wk-dispatch__actions" *ngIf="outcomeFor?.id !== dispatch.id">
|
||||
<button mat-stroked-button type="button" (click)="downloadAddressLabel(dispatch)">
|
||||
<mat-icon>print</mat-icon>
|
||||
Label
|
||||
</button>
|
||||
<button mat-stroked-button type="button" (click)="downloadPdf(dispatch)">
|
||||
<mat-icon>download</mat-icon>
|
||||
Details
|
||||
</button>
|
||||
<ng-container *ngIf="dispatch.status === 'DISPATCHED'">
|
||||
<button mat-stroked-button color="warn" type="button" (click)="startOutcome(dispatch, 'RETURNED')">
|
||||
<mat-icon>gpp_bad</mat-icon>
|
||||
Not Verified
|
||||
</button>
|
||||
<button mat-flat-button color="primary" type="button" (click)="startOutcome(dispatch, 'DELIVERED')">
|
||||
<mat-icon>verified</mat-icon>
|
||||
Verified
|
||||
</button>
|
||||
</ng-container>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<!-- New dispatch -->
|
||||
<section class="wk-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
|
||||
<h3 class="wk-form__title">
|
||||
{{ activeDispatches.length > 0 ? 'Dispatch a Replacement Kit' : 'Create New Dispatch' }}
|
||||
</h3>
|
||||
|
||||
<!-- The kit goes to one of the addresses captured at client creation; it is chosen, never typed. -->
|
||||
<ng-container *ngIf="addressChoices.length > 0">
|
||||
<mat-form-field appearance="outline" class="wk-full">
|
||||
<mat-label>Deliver To</mat-label>
|
||||
<mat-select formControlName="addressId" required>
|
||||
<mat-option *ngFor="let address of addressChoices" [value]="address.id">
|
||||
{{ address.type }} — {{ address.oneLine }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Addresses recorded for this client. Update them on the Address tab if one is wrong.</mat-hint>
|
||||
<mat-error *ngIf="dispatchForm.get('addressId')?.hasError('required')">
|
||||
Select the address to deliver to
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<div class="wk-address" *ngIf="selectedAddress as address">
|
||||
<mat-icon class="wk-address__icon">place</mat-icon>
|
||||
<div class="wk-address__body">
|
||||
<span class="wk-address__type">{{ address.type }}</span>
|
||||
<span class="wk-address__name">{{ clientName }}</span>
|
||||
<span class="wk-address__line" *ngFor="let line of address.lines">{{ line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="wk-noaddress" *ngIf="addressesLoaded && addressChoices.length === 0">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>
|
||||
No address is recorded for this client. Add one on the Address tab before dispatching a welcome kit.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="wk-form__row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Delivery Mode</mat-label>
|
||||
<mat-select formControlName="deliveryMode" required>
|
||||
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
|
||||
{{ mode }}
|
||||
</mat-option>
|
||||
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">{{ mode }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
||||
Delivery Mode is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="full-width"
|
||||
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
|
||||
>
|
||||
<mat-label>Specify Other Mode</mat-label>
|
||||
<input matInput formControlName="deliveryModeOtherText" required />
|
||||
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
||||
Please specify the delivery mode
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Dispatched On</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="dispatchPicker"
|
||||
[max]="maxDate"
|
||||
formControlName="dispatchedOnDate"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dispatchPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dispatchPicker></mat-datepicker>
|
||||
<mat-error *ngIf="dispatchForm.get('dispatchedOnDate')?.hasError('required')">
|
||||
Dispatch date is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="wk-full" *ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'">
|
||||
<mat-label>Specify Other Mode</mat-label>
|
||||
<input matInput formControlName="deliveryModeOtherText" required />
|
||||
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
||||
Please specify the delivery mode
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Printed before the kit is posted, so the tracking ID below is filled in afterwards. -->
|
||||
<div class="wk-label-cta">
|
||||
<div class="wk-label-cta__text">
|
||||
<span class="wk-label-cta__title">Envelope address label</span>
|
||||
<span class="wk-label-cta__hint">
|
||||
Print this first — name, address, contact details and the welcome note, ready to fold into a window
|
||||
envelope.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!selectedAddress"
|
||||
(click)="downloadAddressLabel()"
|
||||
>
|
||||
<mat-icon>print</mat-icon>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="wk-full">
|
||||
<mat-label>Tracking ID</mat-label>
|
||||
<input matInput formControlName="trackingId" maxlength="100" required placeholder="e.g. EX123456789IN" />
|
||||
<mat-icon matSuffix>confirmation_number</mat-icon>
|
||||
<mat-hint>Enter the consignment number printed on the delivery partner's receipt.</mat-hint>
|
||||
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('required')"> Tracking ID is required </mat-error>
|
||||
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('maxlength')">
|
||||
Tracking ID cannot exceed 100 characters
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</section>
|
||||
|
||||
<!-- Nothing to do: a kit is already out and not yet resolved -->
|
||||
<p class="wk-empty" *ngIf="!welcomeKitsData?.canDispatchNew && activeDispatches.length > 0">
|
||||
A welcome kit is already recorded for this client. Record its outcome above before dispatching another.
|
||||
</p>
|
||||
</ng-container>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close>Close</button>
|
||||
<button mat-button type="button" (click)="close()">Close</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
|
||||
type="button"
|
||||
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading && !outcomeFor"
|
||||
(click)="submit()"
|
||||
[disabled]="dispatchForm.invalid || isLoading"
|
||||
>
|
||||
<mat-icon>send</mat-icon>
|
||||
Dispatch
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
+353
-33
@@ -5,55 +5,375 @@
|
||||
* 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/.
|
||||
*/
|
||||
.dispatch-tracking-panel {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
|
||||
$wk-primary: #1f497d;
|
||||
$wk-border: #e3e7ee;
|
||||
$wk-muted: #6b7280;
|
||||
$wk-surface: #f7f9fc;
|
||||
|
||||
/* ---------- header ---------- */
|
||||
.wk-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid $wk-border;
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
border-radius: 10px;
|
||||
background: rgba($wk-primary, 0.1);
|
||||
color: $wk-primary;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: $wk-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.wk-content {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.wk-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2.5rem 0;
|
||||
}
|
||||
|
||||
/* ---------- client identity ---------- */
|
||||
.wk-client {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba($wk-primary, 0.08), rgba($wk-primary, 0.02));
|
||||
border: 1px solid rgba($wk-primary, 0.15);
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1rem;
|
||||
&__avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 38px;
|
||||
border-radius: 50%;
|
||||
background: $wk-primary;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0;
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
&__meta {
|
||||
font-size: 0.8rem;
|
||||
color: $wk-muted;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
&.status-dispatched {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
/* ---------- dispatch panel ---------- */
|
||||
.wk-dispatch {
|
||||
border: 1px solid $wk-border;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: $wk-surface;
|
||||
|
||||
&.status-in_transit {
|
||||
background-color: #f57c00;
|
||||
}
|
||||
&--past {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.status-delivered {
|
||||
background-color: #388e3c;
|
||||
}
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&.status-returned {
|
||||
background-color: #d32f2f;
|
||||
&__label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.8rem;
|
||||
color: $wk-muted;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dispatch-create-form {
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1rem;
|
||||
/* ---------- status chip ---------- */
|
||||
.wk-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&__icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
&--dispatched {
|
||||
background: rgb(25 118 210 / 12%);
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
&--delivered {
|
||||
background: rgb(56 142 60 / 14%);
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&--returned {
|
||||
background: rgb(211 47 47 / 12%);
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
/* ---------- fact grid ---------- */
|
||||
.wk-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 18px;
|
||||
margin: 0;
|
||||
|
||||
&__row {
|
||||
min-width: 0;
|
||||
|
||||
&--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: $wk-muted;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
&__mono {
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- outcome form ---------- */
|
||||
.wk-outcome {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px dashed rgba($wk-primary, 0.4);
|
||||
|
||||
&__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- create form ---------- */
|
||||
.wk-form {
|
||||
&__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.wk-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wk-address {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
background: $wk-surface;
|
||||
border: 1px solid $wk-border;
|
||||
|
||||
&__icon {
|
||||
color: $wk-primary;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 20px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $wk-primary;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__line {
|
||||
color: $wk-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.wk-noaddress {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
background: rgb(211 47 47 / 6%);
|
||||
border: 1px solid rgb(211 47 47 / 25%);
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.5;
|
||||
color: #b3261e;
|
||||
|
||||
mat-icon {
|
||||
font-size: 19px;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
flex: 0 0 19px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- envelope label call-to-action ---------- */
|
||||
.wk-label-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 8px;
|
||||
background: rgba($wk-primary, 0.04);
|
||||
border: 1px solid rgba($wk-primary, 0.18);
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 0.78rem;
|
||||
color: $wk-muted;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.wk-empty {
|
||||
margin: 4px 0 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: $wk-surface;
|
||||
border: 1px solid $wk-border;
|
||||
font-size: 0.85rem;
|
||||
color: $wk-muted;
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 12px 24px 20px;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
+422
-32
@@ -12,12 +12,14 @@ import {
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogActions
|
||||
} from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { ClientsService } from '../../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { jsPDF } from 'jspdf';
|
||||
@@ -32,32 +34,79 @@ import autoTable from 'jspdf-autotable';
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
MatProgressSpinnerModule
|
||||
MatProgressSpinnerModule,
|
||||
MatIcon
|
||||
],
|
||||
providers: [DatePipe]
|
||||
})
|
||||
export class WelcomeKitDialogComponent implements OnInit {
|
||||
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
|
||||
data: { clientId: string } = inject(MAT_DIALOG_DATA);
|
||||
data: {
|
||||
clientId: string;
|
||||
clientName?: string;
|
||||
accountNo?: string;
|
||||
cifNumber?: string;
|
||||
mobileNo?: string;
|
||||
emailAddress?: string;
|
||||
officeName?: string;
|
||||
} = inject(MAT_DIALOG_DATA);
|
||||
private clientsService = inject(ClientsService);
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private datePipe = inject(DatePipe);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
isLoading = true;
|
||||
/** Set once a dispatch or an outcome is saved, so the client view knows to reload on close. */
|
||||
private hasChanges = false;
|
||||
welcomeKitsData: any = null;
|
||||
/** Addresses captured for the client, each formatted for display and selection. */
|
||||
addressChoices: { id: any; type: string; isActive: boolean; lines: string[]; oneLine: string }[] = [];
|
||||
/** True once the address lookup has run, so the empty state only shows when there genuinely are none. */
|
||||
addressesLoaded = false;
|
||||
/** Dispatch whose outcome is being recorded, or null while the tracking list is shown. */
|
||||
outcomeFor: any = null;
|
||||
outcomeStatus: 'DELIVERED' | 'RETURNED' = 'DELIVERED';
|
||||
|
||||
maxDate = new Date();
|
||||
|
||||
dispatchForm: FormGroup = this.formBuilder.group({
|
||||
deliveryAddress: [
|
||||
'',
|
||||
// The kit goes to one of the addresses captured at client creation, never to a hand-typed one, so the
|
||||
// dispatch record always traces back to an address the client actually gave.
|
||||
addressId: [
|
||||
null,
|
||||
Validators.required
|
||||
],
|
||||
deliveryMode: [
|
||||
'POSTMAN',
|
||||
Validators.required
|
||||
],
|
||||
deliveryModeOtherText: ['']
|
||||
deliveryModeOtherText: [''],
|
||||
trackingId: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(100)
|
||||
]
|
||||
],
|
||||
dispatchedOnDate: [
|
||||
new Date(),
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
outcomeForm: FormGroup = this.formBuilder.group({
|
||||
outcomeDate: [
|
||||
new Date(),
|
||||
Validators.required
|
||||
],
|
||||
remarks: [
|
||||
'',
|
||||
Validators.maxLength(500)
|
||||
]
|
||||
});
|
||||
|
||||
deliveryModes = [
|
||||
'POSTMAN',
|
||||
'COURIER',
|
||||
@@ -76,7 +125,37 @@ export class WelcomeKitDialogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
get clientName(): string {
|
||||
return this.welcomeKitsData?.clientName || this.data.clientName || '';
|
||||
}
|
||||
|
||||
/** The kit is a physical address check, so the outcome is phrased as verification rather than delivery. */
|
||||
verificationLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'DELIVERED':
|
||||
return 'Verified';
|
||||
case 'RETURNED':
|
||||
return 'Not Verified';
|
||||
default:
|
||||
return 'Awaiting Verification';
|
||||
}
|
||||
}
|
||||
|
||||
verificationHint(dispatch: any): string {
|
||||
switch (dispatch?.status) {
|
||||
case 'DELIVERED':
|
||||
return 'Kit delivered, so the address stands verified.';
|
||||
case 'RETURNED':
|
||||
return 'Kit came back undelivered, so the address could not be verified.';
|
||||
default:
|
||||
return 'Check this tracking ID on the delivery partner’s website or app, then record the outcome below.';
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.dispatchForm.patchValue({ dispatchedOnDate: this.maxDate });
|
||||
this.outcomeForm.patchValue({ outcomeDate: this.maxDate });
|
||||
this.fetchData();
|
||||
}
|
||||
|
||||
@@ -98,33 +177,77 @@ export class WelcomeKitDialogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Order the address types are offered in: a kit is posted to the correspondence address when one exists. */
|
||||
private static readonly ADDRESS_TYPE_ORDER = [
|
||||
'Correspondence',
|
||||
'Current / Residential',
|
||||
'Permanent Address',
|
||||
'Office / Work'
|
||||
];
|
||||
|
||||
fetchPrimaryAddress() {
|
||||
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
|
||||
next: (addresses: any) => {
|
||||
this.isLoading = false;
|
||||
if (addresses && addresses.length > 0) {
|
||||
// Attempt to build a readable address from the first address returned
|
||||
const primary = addresses[0];
|
||||
const parts = [];
|
||||
if (primary.addressLine1) parts.push(primary.addressLine1);
|
||||
if (primary.addressLine2) parts.push(primary.addressLine2);
|
||||
if (primary.city) parts.push(primary.city);
|
||||
if (primary.stateName) parts.push(primary.stateName);
|
||||
if (primary.postalCode) parts.push(primary.postalCode);
|
||||
if (primary.countryName) parts.push(primary.countryName);
|
||||
this.addressesLoaded = true;
|
||||
|
||||
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
|
||||
this.dispatchForm.patchValue({
|
||||
deliveryAddress: clientNameStr + parts.join(', ')
|
||||
const usable = (addresses || []).filter((a: any) => a && (a.addressLine1 || a.postalCode || a.city));
|
||||
this.addressChoices = usable
|
||||
.map((a: any) => {
|
||||
const lines = [
|
||||
a.addressLine1,
|
||||
a.addressLine2,
|
||||
a.addressLine3,
|
||||
a.street,
|
||||
a.townVillage,
|
||||
a.city,
|
||||
a.countyDistrict,
|
||||
a.stateName || a.stateProvinceName,
|
||||
a.postalCode,
|
||||
a.countryName
|
||||
]
|
||||
.map((part: any) => (part == null ? '' : String(part).trim()))
|
||||
.filter((part: string) => part.length > 0);
|
||||
|
||||
return {
|
||||
id: a.addressId ?? a.id,
|
||||
type: a.addressType || 'Address',
|
||||
isActive: a.isActive !== false,
|
||||
lines,
|
||||
oneLine: lines.join(', ')
|
||||
};
|
||||
})
|
||||
.sort((x: any, y: any) => {
|
||||
// Active addresses first, then by how suitable the type is for posting.
|
||||
if (x.isActive !== y.isActive) return x.isActive ? -1 : 1;
|
||||
const order = WelcomeKitDialogComponent.ADDRESS_TYPE_ORDER;
|
||||
const xi = order.indexOf(x.type);
|
||||
const yi = order.indexOf(y.type);
|
||||
return (xi === -1 ? order.length : xi) - (yi === -1 ? order.length : yi);
|
||||
});
|
||||
|
||||
if (this.addressChoices.length > 0) {
|
||||
this.dispatchForm.patchValue({ addressId: this.addressChoices[0].id });
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
this.addressesLoaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The address the operator picked, or null before one is chosen. */
|
||||
get selectedAddress() {
|
||||
const id = this.dispatchForm.get('addressId')?.value;
|
||||
return this.addressChoices.find((a) => a.id === id) || null;
|
||||
}
|
||||
|
||||
/** Lines of the selected address, for the on-screen preview and the printed label. */
|
||||
get addressLines(): string[] {
|
||||
return this.selectedAddress?.lines || [];
|
||||
}
|
||||
|
||||
get activeDispatches() {
|
||||
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
|
||||
return this.welcomeKitsData.dispatches;
|
||||
@@ -138,36 +261,104 @@ export class WelcomeKitDialogComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const chosen = this.selectedAddress;
|
||||
if (!chosen) {
|
||||
this.snackBar.open('Select the address the kit was posted to', 'Close', { duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
const formValue = this.dispatchForm.value;
|
||||
|
||||
const today = new Date();
|
||||
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
|
||||
const payload = {
|
||||
deliveryAddress: formValue.deliveryAddress,
|
||||
// Stored as text so the dispatch keeps the address exactly as it read on the day it went out, even if
|
||||
// the client's address record is edited later.
|
||||
deliveryAddress: `${chosen.type}: ${chosen.oneLine}`,
|
||||
addressId: chosen.id,
|
||||
deliveryMode: formValue.deliveryMode,
|
||||
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
|
||||
dispatchedOnDate: formattedDate,
|
||||
locale: 'en',
|
||||
dateFormat: 'dd MMMM yyyy'
|
||||
trackingId: (formValue.trackingId || '').trim(),
|
||||
dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat),
|
||||
locale,
|
||||
dateFormat
|
||||
};
|
||||
|
||||
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
|
||||
this.hasChanges = true;
|
||||
this.snackBar.open('Welcome Kit dispatched successfully', 'Close', { duration: 3000 });
|
||||
this.fetchData();
|
||||
},
|
||||
error: () => {
|
||||
error: (err: any) => {
|
||||
this.isLoading = false;
|
||||
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
|
||||
this.snackBar.open(this.errorMessage(err, 'Failed to dispatch welcome kit'), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the outcome form for a dispatch, pre-set to the outcome the operator clicked. */
|
||||
startOutcome(dispatch: any, status: 'DELIVERED' | 'RETURNED') {
|
||||
this.outcomeFor = dispatch;
|
||||
this.outcomeStatus = status;
|
||||
this.outcomeForm.reset({ outcomeDate: this.maxDate, remarks: '' });
|
||||
}
|
||||
|
||||
cancelOutcome() {
|
||||
this.outcomeFor = null;
|
||||
}
|
||||
|
||||
/** Records what the operator found on the delivery partner's site: delivered verifies the address, returned does not. */
|
||||
submitOutcome() {
|
||||
if (this.outcomeForm.invalid || !this.outcomeFor) {
|
||||
this.outcomeForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
const formValue = this.outcomeForm.value;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
|
||||
const payload = {
|
||||
status: this.outcomeStatus,
|
||||
outcomeDate: this.dateUtils.formatDate(formValue.outcomeDate, dateFormat),
|
||||
remarks: (formValue.remarks || '').trim() || null,
|
||||
locale,
|
||||
dateFormat
|
||||
};
|
||||
|
||||
this.clientsService.updateWelcomeKitStatus(this.outcomeFor.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.hasChanges = true;
|
||||
this.outcomeFor = null;
|
||||
this.snackBar.open(
|
||||
this.outcomeStatus === 'DELIVERED' ? 'Address marked as verified' : 'Address marked as not verified',
|
||||
'Close',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
this.fetchData();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.isLoading = false;
|
||||
this.snackBar.open(this.errorMessage(err, 'Failed to record the outcome'), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dialogRef.close(this.hasChanges);
|
||||
}
|
||||
|
||||
/** Surfaces the backend's own message (duplicate tracking ID, inactive client) instead of a generic failure. */
|
||||
private errorMessage(err: any, fallback: string): string {
|
||||
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
|
||||
}
|
||||
|
||||
getDisplayAddress(dispatch: any): string {
|
||||
const address = dispatch.deliveryAddress || '';
|
||||
const name = this.welcomeKitsData?.clientName || '';
|
||||
const name = this.clientName;
|
||||
if (name && !address.startsWith(name)) {
|
||||
return `${name} — ${address}`;
|
||||
}
|
||||
@@ -185,6 +376,9 @@ export class WelcomeKitDialogComponent implements OnInit {
|
||||
const trackingId = dispatch.trackingId || 'N/A';
|
||||
const status = dispatch.status || 'N/A';
|
||||
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
|
||||
const verification = this.verificationLabel(dispatch.status);
|
||||
const outcomeOn =
|
||||
this.datePipe.transform(dispatch.deliveredOnDate || dispatch.returnedOnDate, 'mediumDate') || 'Awaited';
|
||||
|
||||
const primaryColor: [number, number, number] = [
|
||||
31,
|
||||
@@ -307,6 +501,14 @@ export class WelcomeKitDialogComponent implements OnInit {
|
||||
[
|
||||
'Dispatched On',
|
||||
dispatchedOn
|
||||
],
|
||||
[
|
||||
'Address Verification',
|
||||
verification
|
||||
],
|
||||
[
|
||||
'Outcome Recorded On',
|
||||
outcomeOn
|
||||
]
|
||||
]
|
||||
});
|
||||
@@ -344,4 +546,192 @@ export class WelcomeKitDialogComponent implements OnInit {
|
||||
|
||||
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Address label for the envelope. It is printed before the kit is posted — off the live form for a new
|
||||
* dispatch, or off a saved record — so the operator can fold it into a window envelope and hand it over the
|
||||
* counter. Layout is a single A4 sheet: the addressee block sits inside the window cut-out zone, and the
|
||||
* welcome message sits below the fold where it stays hidden until the envelope is opened.
|
||||
*/
|
||||
downloadAddressLabel(dispatch?: any) {
|
||||
const doc = new jsPDF();
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
|
||||
const primaryColor: [number, number, number] = [
|
||||
31,
|
||||
73,
|
||||
125
|
||||
];
|
||||
const grayColor: [number, number, number] = [
|
||||
110,
|
||||
110,
|
||||
110
|
||||
];
|
||||
const lineColor: [number, number, number] = [
|
||||
205,
|
||||
212,
|
||||
222
|
||||
];
|
||||
|
||||
const name = this.clientName || 'Client';
|
||||
// A saved dispatch keeps the address it actually went out with; a new one uses the address currently
|
||||
// selected in the form. The saved value carries a "Type: " prefix that belongs on the record, not the envelope.
|
||||
const addressLines = dispatch
|
||||
? String(dispatch.deliveryAddress || '')
|
||||
.replace(/^[^:]+:\s*/, '')
|
||||
.split(',')
|
||||
.map((part: string) => part.trim())
|
||||
.filter((part: string) => part.length > 0)
|
||||
: this.addressLines;
|
||||
|
||||
// --- Sender block, top-left, as the return address ---
|
||||
doc.setFillColor(...primaryColor);
|
||||
doc.rect(0, 0, pageWidth, 26, 'F');
|
||||
doc.setFontSize(17);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('CBSNexora', 18, 14);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Core Banking Solution', 18, 20);
|
||||
|
||||
doc.setFontSize(7.5);
|
||||
doc.setTextColor(...grayColor);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('FROM', 22, 37);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8.5);
|
||||
doc.setTextColor(95, 100, 110);
|
||||
let fromY = 42.5;
|
||||
const branch = this.data.officeName ? `CBSNexora — ${this.data.officeName}` : 'CBSNexora';
|
||||
[
|
||||
branch,
|
||||
'Customer Service Desk | support@cbsnexora.com | 1800-000-0000'
|
||||
].forEach((line) => {
|
||||
doc.text(line, 22, fromY);
|
||||
fromY += 4.6;
|
||||
});
|
||||
|
||||
// --- Addressee card: name, address and the client's reference details in one block, sized so the whole
|
||||
// card sits behind a standard window cut-out ---
|
||||
const cardX = 22;
|
||||
const cardY = 60;
|
||||
const cardW = pageWidth - 44;
|
||||
const padX = cardX + 9;
|
||||
|
||||
const refs: string[] = [];
|
||||
if (this.data.accountNo) refs.push(`Account No. ${this.data.accountNo}`);
|
||||
if (this.data.cifNumber) refs.push(`CIF ${this.data.cifNumber}`);
|
||||
if (this.data.mobileNo) refs.push(`Mobile ${this.data.mobileNo}`);
|
||||
if (this.data.emailAddress) refs.push(`Email ${this.data.emailAddress}`);
|
||||
if (dispatch?.trackingId) refs.push(`Tracking ID ${dispatch.trackingId}`);
|
||||
|
||||
// Height is measured off the same offsets the text below uses, so the card closes a fixed margin under the
|
||||
// last line instead of leaving a gap that grows with a short address.
|
||||
const addrStartY = 27;
|
||||
const addrEndY = addrStartY + (addressLines.length - 1) * 6.4;
|
||||
const cardH = refs.length > 0 ? addrEndY + 5 + refs.length * 5 + 4 : addrEndY + 9;
|
||||
|
||||
doc.setFillColor(252, 253, 255);
|
||||
doc.setDrawColor(...lineColor);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.roundedRect(cardX, cardY, cardW, cardH, 2, 2, 'FD');
|
||||
|
||||
// Accent spine marks the addressee edge without boxing the text in.
|
||||
doc.setFillColor(...primaryColor);
|
||||
doc.rect(cardX, cardY + 1, 1.6, cardH - 2, 'F');
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...grayColor);
|
||||
doc.text('ALIGN THIS BLOCK WITH THE ENVELOPE WINDOW', cardX, cardY - 3.5);
|
||||
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...primaryColor);
|
||||
doc.text('TO', padX, cardY + 10);
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(20, 20, 20);
|
||||
doc.text(name, padX, cardY + 19);
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(45, 45, 45);
|
||||
let addrY = cardY + addrStartY;
|
||||
addressLines.forEach((line: string) => {
|
||||
doc.text(line, padX, addrY);
|
||||
addrY += 6.4;
|
||||
});
|
||||
|
||||
// Reference details close the same card, separated by a hairline rather than a second frame.
|
||||
if (refs.length > 0) {
|
||||
const dividerY = cardY + addrEndY + 5;
|
||||
doc.setDrawColor(...lineColor);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(padX, dividerY, cardX + cardW - 9, dividerY);
|
||||
|
||||
doc.setFontSize(8.5);
|
||||
doc.setTextColor(...grayColor);
|
||||
let refY = dividerY + 5;
|
||||
refs.forEach((line) => {
|
||||
doc.text(line, padX, refY);
|
||||
refY += 5;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fold line: hairline broken around a centred label, so it guides the fold without competing with
|
||||
// the address block above it ---
|
||||
const foldY = cardY + cardH + 22;
|
||||
const foldGap = 12;
|
||||
doc.setDrawColor(226, 230, 236);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.setLineDashPattern(
|
||||
[
|
||||
1,
|
||||
2.5
|
||||
],
|
||||
0
|
||||
);
|
||||
doc.line(30, foldY, pageWidth / 2 - foldGap, foldY);
|
||||
doc.line(pageWidth / 2 + foldGap, foldY, pageWidth - 30, foldY);
|
||||
doc.setLineDashPattern([], 0);
|
||||
doc.setFontSize(6.5);
|
||||
doc.setTextColor(178, 184, 194);
|
||||
doc.text('FOLD', pageWidth / 2, foldY + 1.6, { align: 'center' });
|
||||
|
||||
// --- Welcome message, below the fold ---
|
||||
let msgY = foldY + 14;
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...primaryColor);
|
||||
doc.text('Welcome to CBSNexora', 22, msgY);
|
||||
msgY += 10;
|
||||
|
||||
doc.setFontSize(10.5);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
doc.text(`Dear ${name},`, 22, msgY);
|
||||
msgY += 8;
|
||||
|
||||
const body = [
|
||||
'Thank you for opening your account with us. Enclosed is your Welcome Kit containing your account documentation and the essential materials you need to start banking with us.',
|
||||
'Please keep your account number and CIF safe, and do not share your banking credentials with anyone. Our staff will never ask you for your PIN, password or OTP.',
|
||||
'If anything in this kit is missing or incorrect, contact your branch or call our customer service desk and we will put it right.'
|
||||
];
|
||||
body.forEach((paragraph) => {
|
||||
const lines = doc.splitTextToSize(paragraph, pageWidth - 44);
|
||||
doc.text(lines, 22, msgY);
|
||||
msgY += lines.length * 5.6 + 5;
|
||||
});
|
||||
|
||||
msgY += 4;
|
||||
doc.text('Warm regards,', 22, msgY);
|
||||
msgY += 6;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('CBSNexora Operations Team', 22, msgY);
|
||||
|
||||
const stamp = this.datePipe.transform(new Date(), 'yyyyMMdd-HHmmss');
|
||||
doc.save(`WelcomeKit_Label_${this.data.accountNo || this.data.clientId}_${stamp}.pdf`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,221 +104,230 @@
|
||||
<span class="kv-table__col kv-table__col--action">Action</span>
|
||||
</div>
|
||||
|
||||
<!-- PAN Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (panDeclarationTaken) {
|
||||
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
||||
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.panVerified
|
||||
? kycData.panVerifiedOn
|
||||
? formatDate(kycData.panVerifiedOn)
|
||||
: '—'
|
||||
: panDeclarationTaken && kycData.panDeclarationTakenOn
|
||||
? formatDate(kycData.panDeclarationTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.pan || !kycData.panNumber"
|
||||
(click)="verifyPAN()"
|
||||
>
|
||||
@if (verifying.pan) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
<!--
|
||||
PAN and Aadhaar below are the individual's own OVDs. An entity holds neither: its PAN is
|
||||
the Enterprise PAN reported further down (the same stored panNumber, under the name the
|
||||
entity actually knows it by), and Aadhaar is issued to people, not companies — the masked
|
||||
number on a non-individual record is a placeholder the KYC step writes to satisfy the
|
||||
column, so showing it as "Pending" invited officers to chase a document that cannot exist.
|
||||
-->
|
||||
@if (!isNonIndividual) {
|
||||
<!-- PAN Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (panDeclarationTaken) {
|
||||
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
||||
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
||||
} @else {
|
||||
Re-Verify
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</button>
|
||||
<!--
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.panVerified
|
||||
? kycData.panVerifiedOn
|
||||
? formatDate(kycData.panVerifiedOn)
|
||||
: '—'
|
||||
: panDeclarationTaken && kycData.panDeclarationTakenOn
|
||||
? formatDate(kycData.panDeclarationTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.pan || !kycData.panNumber"
|
||||
(click)="verifyPAN()"
|
||||
>
|
||||
@if (verifying.pan) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Re-Verify
|
||||
}
|
||||
</button>
|
||||
<!--
|
||||
A PAN that has never passed is not always fixable by another attempt. Offer the
|
||||
declaration here too, so an existing client sitting in the exception list can be
|
||||
cleared without the officer having to run a verification they know will fail.
|
||||
-->
|
||||
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
Declaration
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
Declaration
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
PAN is unverified but a declaration is on file. Shown in full because it is the only
|
||||
reason PAN carries no KYC exception — a reviewer has to see what was declared.
|
||||
-->
|
||||
@if (!kycData.panVerified && panDeclarationTaken) {
|
||||
<div class="kv-decl">
|
||||
<div class="kv-decl__head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span class="kv-decl__title">PAN Declaration</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-decl__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removePanDeclaration()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-decl__rows">
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Does not match</span>
|
||||
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||
@if (!kycData.panVerified && panDeclarationTaken) {
|
||||
<div class="kv-decl">
|
||||
<div class="kv-decl__head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span class="kv-decl__title">PAN Declaration</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-decl__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removePanDeclaration()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (kycData.panDeclarationNameOnPan) {
|
||||
<div class="kv-decl__rows">
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Name on PAN</span>
|
||||
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
||||
<span class="k">Does not match</span>
|
||||
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationDobOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">DOB on PAN</span>
|
||||
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationRemarks) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Declaration file</span>
|
||||
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationNameOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Name on PAN</span>
|
||||
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationDobOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">DOB on PAN</span>
|
||||
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationRemarks) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Declaration file</span>
|
||||
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-decl__note">
|
||||
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv-decl__note">
|
||||
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Aadhaar Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.aadhaarVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (aadhaarReceiptTaken) {
|
||||
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
|
||||
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.aadhaarVerified
|
||||
? kycData.aadhaarVerifiedOn
|
||||
? formatDate(kycData.aadhaarVerifiedOn)
|
||||
: '—'
|
||||
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
||||
? formatDate(kycData.aadhaarReceiptTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
||||
(click)="startAadhaarReVerify()"
|
||||
>
|
||||
Re-Verify
|
||||
</button>
|
||||
}
|
||||
<!--
|
||||
<!-- Aadhaar Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.aadhaarVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (aadhaarReceiptTaken) {
|
||||
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
|
||||
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.aadhaarVerified
|
||||
? kycData.aadhaarVerifiedOn
|
||||
? formatDate(kycData.aadhaarVerifiedOn)
|
||||
: '—'
|
||||
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
||||
? formatDate(kycData.aadhaarReceiptTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
||||
(click)="startAadhaarReVerify()"
|
||||
>
|
||||
Re-Verify
|
||||
</button>
|
||||
}
|
||||
<!--
|
||||
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
|
||||
slip here so an existing client sitting in the exception list can be cleared.
|
||||
-->
|
||||
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
Receipt
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
Receipt
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||
only reason Aadhaar carries no KYC exception.
|
||||
-->
|
||||
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
||||
<div class="kv-rcpt">
|
||||
<div class="kv-rcpt__head">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-rcpt__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removeAadhaarReceipt()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-rcpt__rows">
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment ID</span>
|
||||
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
|
||||
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
||||
<div class="kv-rcpt">
|
||||
<div class="kv-rcpt__head">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-rcpt__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removeAadhaarReceipt()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (kycData.aadhaarEnrolmentDate) {
|
||||
<div class="kv-rcpt__rows">
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment date</span>
|
||||
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
||||
<span class="k">Enrolment ID</span>
|
||||
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.aadhaarReceiptRemarks) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Remarks</span>
|
||||
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.aadhaarEnrolmentDate) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment date</span>
|
||||
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.aadhaarReceiptRemarks) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Remarks</span>
|
||||
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-rcpt__note">
|
||||
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv-rcpt__note">
|
||||
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!--
|
||||
@@ -425,23 +434,59 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Enterprise PAN Row (Non-Individual) -->
|
||||
@if (isNonIndividual && kycData?.enterprisePan) {
|
||||
<!--
|
||||
Enterprise PAN Row (Non-Individual) — the entity's only PAN, so it stands in for the PAN
|
||||
row hidden above and carries that row's verification state and Re-Verify action.
|
||||
|
||||
Status comes from panVerified, not from an enterprisePanVerified flag: the record has no
|
||||
such field (ClientKycData holds enterprisePan as a bare string), and the KYC step stores
|
||||
the entity PAN in panNumber and verifies it through the same verify-pan command. Reading
|
||||
the flag that does not exist left a verified entity PAN permanently showing "Pending".
|
||||
-->
|
||||
@if (isNonIndividual && enterprisePanNumber) {
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.enterprisePan }}</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ enterprisePanNumber }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.enterprisePanVerified) {
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—'
|
||||
kycData.panVerified && kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.pan"
|
||||
(click)="verifyPAN()"
|
||||
>
|
||||
@if (verifying.pan) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Re-Verify
|
||||
}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Date of Incorporation Row (Non-Individual) — captured alongside the entity PAN
|
||||
it is verified against, so it is reported here rather than under personal data. -->
|
||||
@if (isNonIndividual && kycData?.incorporationDate) {
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">event_available</mat-icon> Date of Incorporation
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">—</span>
|
||||
<span class="kv-table__col kv-table__col--date">—</span>
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
}
|
||||
@@ -464,68 +509,61 @@
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Incorporation Date Row (Non-Individual) -->
|
||||
@if (isNonIndividual && kycData?.incorporationDate) {
|
||||
<div class="kv-table__row kv-table__row--link">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">event</mat-icon> Incorporation Date
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">—</span>
|
||||
<span class="kv-table__col kv-table__col--date">—</span>
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
|
||||
table row). Dark gradient base with state-driven accent so the
|
||||
linking state is the first thing a reviewer sees. -->
|
||||
<div
|
||||
class="pal-card"
|
||||
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
||||
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
||||
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
||||
[class.pal-card--pending]="
|
||||
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'E'
|
||||
"
|
||||
>
|
||||
<div class="pal-card__left">
|
||||
<div class="pal-card__icon">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
</div>
|
||||
<div class="pal-card__txt">
|
||||
<div class="pal-card__small">Verification Status</div>
|
||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||
<div class="pal-card__status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>cancel</mat-icon> Not Linked
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon> Exempt
|
||||
} @else {
|
||||
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
||||
}
|
||||
linking state is the first thing a reviewer sees.
|
||||
|
||||
Section 139AA seeding applies to a person's PAN and Aadhaar. An entity has no Aadhaar
|
||||
to seed, so for a non-individual the card could only ever read "Pending" against a
|
||||
requirement that does not apply to it. -->
|
||||
@if (!isNonIndividual) {
|
||||
<div
|
||||
class="pal-card"
|
||||
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
||||
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
||||
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
||||
[class.pal-card--pending]="
|
||||
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'E'
|
||||
"
|
||||
>
|
||||
<div class="pal-card__left">
|
||||
<div class="pal-card__icon">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
</div>
|
||||
<div class="pal-card__txt">
|
||||
<div class="pal-card__small">Verification Status</div>
|
||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||
<div class="pal-card__status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>cancel</mat-icon> Not Linked
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon> Exempt
|
||||
} @else {
|
||||
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pal-card__badge">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="pal-card__hint">As per Income Tax Portal — Section 139AA</div>
|
||||
</div>
|
||||
<div class="pal-card__badge">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="pal-card__hint">As per Income Tax Portal — Section 139AA</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
|
||||
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
@@ -795,15 +833,19 @@
|
||||
<mat-card-content>
|
||||
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
|
||||
<div class="kyc-edit-grid">
|
||||
<!-- PAN -->
|
||||
<!-- PAN — the same stored panNumber either way, named for whose PAN it is. -->
|
||||
<div class="kyc-edit-field">
|
||||
<div class="kyc-field-label-row">
|
||||
<span class="kyc-field-title">{{ 'labels.inputs.PAN Number' | translate }}</span>
|
||||
<span class="kyc-field-title">{{
|
||||
isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate)
|
||||
}}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
<div class="kyc-input-row">
|
||||
<mat-form-field appearance="outline" class="kyc-input">
|
||||
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
|
||||
<mat-label>{{
|
||||
isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate)
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
@@ -852,147 +894,154 @@
|
||||
The check can fail for reasons no retry will fix — a PAN card that spells the
|
||||
name differently, or a vendor that is simply down. The declaration is the way
|
||||
out, so it is offered here rather than leaving the officer at a dead end.
|
||||
|
||||
It records a mismatch against the customer's own name and date of birth, so it
|
||||
is offered to individuals only — an entity PAN is checked against neither.
|
||||
-->
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
||||
</button>
|
||||
</div>
|
||||
@if (!isNonIndividual) {
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
||||
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Aadhaar -->
|
||||
<div class="kyc-edit-field">
|
||||
<div class="kyc-field-label-row">
|
||||
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
<div class="kyc-input-row">
|
||||
<mat-form-field appearance="outline" class="kyc-input">
|
||||
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="Enter 12-digit Aadhaar"
|
||||
[readonly]="aadhaarOtpSent"
|
||||
/>
|
||||
@if (kycData?.aadhaarNumberMasked) {
|
||||
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
|
||||
}
|
||||
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
|
||||
}
|
||||
@if (verificationStatus.aadhaar === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
|
||||
}
|
||||
@if (verificationStatus.aadhaar === 'FAILED') {
|
||||
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
@if (!aadhaarOtpSent) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
type="button"
|
||||
class="verify-btn"
|
||||
[disabled]="
|
||||
!kycId ||
|
||||
!kycForm.get('aadhaarNumber')?.value ||
|
||||
kycForm.get('aadhaarNumber')?.invalid ||
|
||||
verifying.aadhaar
|
||||
"
|
||||
(click)="sendAadhaarOtp()"
|
||||
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
|
||||
>
|
||||
@if (verifying.aadhaar) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Send OTP
|
||||
<!-- Aadhaar — individuals only; an entity has no Aadhaar to capture or verify. -->
|
||||
@if (!isNonIndividual) {
|
||||
<div class="kyc-edit-field">
|
||||
<div class="kyc-field-label-row">
|
||||
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
<div class="kyc-input-row">
|
||||
<mat-form-field appearance="outline" class="kyc-input">
|
||||
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="Enter 12-digit Aadhaar"
|
||||
[readonly]="aadhaarOtpSent"
|
||||
/>
|
||||
@if (kycData?.aadhaarNumberMasked) {
|
||||
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- OTP Section in Edit Mode -->
|
||||
@if (aadhaarOtpSent) {
|
||||
<div class="otp-section">
|
||||
<p class="otp-info">
|
||||
<mat-icon class="otp-info-icon">sms</mat-icon>
|
||||
OTP sent to Aadhaar-linked mobile
|
||||
</p>
|
||||
<div class="otp-inline">
|
||||
<mat-form-field appearance="outline" class="otp-field">
|
||||
<mat-label>Enter OTP</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="aadhaarOtp"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
|
||||
}
|
||||
@if (verificationStatus.aadhaar === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
|
||||
}
|
||||
@if (verificationStatus.aadhaar === 'FAILED') {
|
||||
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
@if (!aadhaarOtpSent) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
class="otp-verify-btn"
|
||||
type="button"
|
||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
||||
(click)="verifyAadhaarOtp()"
|
||||
class="verify-btn"
|
||||
[disabled]="
|
||||
!kycId ||
|
||||
!kycForm.get('aadhaarNumber')?.value ||
|
||||
kycForm.get('aadhaarNumber')?.invalid ||
|
||||
verifying.aadhaar
|
||||
"
|
||||
(click)="sendAadhaarOtp()"
|
||||
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
|
||||
>
|
||||
@if (aadhaarOtpVerifying) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
@if (verifying.aadhaar) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Verify OTP
|
||||
Send OTP
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="otp-resend-btn"
|
||||
[disabled]="verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (verificationError.aadhaar) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.aadhaar }}</span>
|
||||
</div>
|
||||
<!--
|
||||
<!-- OTP Section in Edit Mode -->
|
||||
@if (aadhaarOtpSent) {
|
||||
<div class="otp-section">
|
||||
<p class="otp-info">
|
||||
<mat-icon class="otp-info-icon">sms</mat-icon>
|
||||
OTP sent to Aadhaar-linked mobile
|
||||
</p>
|
||||
<div class="otp-inline">
|
||||
<mat-form-field appearance="outline" class="otp-field">
|
||||
<mat-label>Enter OTP</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="aadhaarOtp"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
class="otp-verify-btn"
|
||||
type="button"
|
||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
||||
(click)="verifyAadhaarOtp()"
|
||||
>
|
||||
@if (aadhaarOtpVerifying) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Verify OTP
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="otp-resend-btn"
|
||||
[disabled]="verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (verificationError.aadhaar) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.aadhaar }}</span>
|
||||
</div>
|
||||
<!--
|
||||
A failed OTP is not always something a retry can fix — a customer who has only
|
||||
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
|
||||
out, so it is offered here rather than leaving the officer at a dead end.
|
||||
-->
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- GSTIN (Non-Individual only) -->
|
||||
@if (isNonIndividual) {
|
||||
|
||||
@@ -221,6 +221,17 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
return this.kycData?.panDeclarationTaken === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity's PAN, for the Enterprise PAN row a non-individual gets in place of the PAN row.
|
||||
*
|
||||
* Falls back to panNumber because that is where the KYC step actually stores it — the entity PAN
|
||||
* is written to both fields on create, but a record saved before `enterprisePan` existed, or one
|
||||
* saved from a screen that only writes panNumber, would otherwise show no PAN at all.
|
||||
*/
|
||||
get enterprisePanNumber(): string {
|
||||
return this.kycData?.enterprisePan || this.kycData?.panNumber || '';
|
||||
}
|
||||
|
||||
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
|
||||
get panDeclarationMismatchLabel(): string {
|
||||
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
||||
|
||||
@@ -222,6 +222,14 @@
|
||||
<div class="pd-field__value monospace">{{ clientViewData.clientNonPersonDetails.incorpNumber }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.clientNonPersonDetails.dateOfRegistration) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Incorporation Date' | translate }}</div>
|
||||
<div class="pd-field__value">
|
||||
{{ clientViewData.clientNonPersonDetails.dateOfRegistration | dateFormat: 'DD-MM-YYYY' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.clientNonPersonDetails.remarks) {
|
||||
<div class="pd-field full-width">
|
||||
<div class="pd-field__label">{{ 'labels.inputs.Remarks' | translate }}</div>
|
||||
@@ -275,26 +283,61 @@
|
||||
</div>
|
||||
<div class="pd-section__body">
|
||||
<div class="pd-grid">
|
||||
<!-- Primary Mobile DIP -->
|
||||
<!--
|
||||
Primary Mobile DIP. Driven by dipLiveStatus — the latest lookup — not by dipVerified,
|
||||
which is frozen at onboarding and would keep reporting "Verified" long after the number
|
||||
was reported for fraud or reassigned to someone else.
|
||||
-->
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Mobile DIP Status</div>
|
||||
<div class="pd-field__value">
|
||||
@if (
|
||||
clientViewData.mobileVerified === true ||
|
||||
clientViewData.mobileVerified === 'true' ||
|
||||
clientViewData.mobileVerified === 1 ||
|
||||
clientViewData.dipVerified === true ||
|
||||
clientViewData.dipVerified === 'true' ||
|
||||
clientViewData.dipVerified === 1
|
||||
) {
|
||||
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
|
||||
<span class="pd-verify-badge pd-verify-badge--flagged">
|
||||
<i class="fa fa-exclamation-triangle"></i> {{ dipStatusLabel(clientViewData) }}
|
||||
</span>
|
||||
} @else if (isDipClear(clientViewData)) {
|
||||
<span class="pd-verify-badge pd-verify-badge--verified">
|
||||
<i class="fa fa-check-circle"></i> Verified
|
||||
</span>
|
||||
} @else {
|
||||
<span class="pd-verify-badge pd-verify-badge--pending"> <i class="fa fa-clock-o"></i> Pending </span>
|
||||
<span class="pd-verify-badge pd-verify-badge--pending">
|
||||
<i class="fa fa-clock-o"></i> {{ dipStatusLabel(clientViewData) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
|
||||
<div class="pd-field pd-field--full">
|
||||
<div class="pd-dip-alert">
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
<div>
|
||||
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
|
||||
<div class="pd-dip-alert__detail">
|
||||
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
|
||||
obtain a new number. Account operations are not restricted.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!--
|
||||
A sanctions alert raised after onboarding is far more serious than a DIP hit and must be
|
||||
impossible to miss on the customer's own page, not only in the Principal Officer's queue.
|
||||
-->
|
||||
@if (clientViewData.sanctionsAlertStatus) {
|
||||
<div class="pd-field pd-field--full">
|
||||
<div
|
||||
class="pd-dip-alert"
|
||||
[class.pd-dip-alert--review]="clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW'"
|
||||
>
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
<div>
|
||||
<strong>{{ sanctionsAlertHeadline(clientViewData) }}</strong>
|
||||
<div class="pd-dip-alert__detail">{{ sanctionsAlertDetail(clientViewData) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.dipTimestamp) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">DIP Verified On</div>
|
||||
@@ -306,7 +349,12 @@
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Alternate Mobile DIP Status</div>
|
||||
<div class="pd-field__value">
|
||||
@if (
|
||||
@if (isDipFlagged(clientViewData.alternateDipLiveStatus)) {
|
||||
<span class="pd-verify-badge pd-verify-badge--flagged">
|
||||
<i class="fa fa-exclamation-triangle"></i> {{ alternateDipStatusLabel(clientViewData) }}
|
||||
</span>
|
||||
} @else if (
|
||||
clientViewData.alternateDipLiveStatus === 'CLEAN' ||
|
||||
clientViewData.alternateDipVerified === true ||
|
||||
clientViewData.alternateDipVerified === 'true' ||
|
||||
clientViewData.alternateDipVerified === 1
|
||||
|
||||
@@ -248,9 +248,59 @@ $teal: #00897b;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
&--danger,
|
||||
&--flagged {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
// A DIP hit on an existing customer restricts their NUMBER, not their account, so it reads as a
|
||||
// standing advisory across the full width rather than as a blocked-account state.
|
||||
.pd-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pd-dip-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: #7f1d1d;
|
||||
line-height: 1.5;
|
||||
|
||||
i {
|
||||
color: #dc2626;
|
||||
font-size: 15px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
margin-top: 3px;
|
||||
font-weight: 400;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
// An unreviewed hit is not yet a finding, so it reads as amber rather than red.
|
||||
&--review {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
border-left-color: #d97706;
|
||||
color: #92400e;
|
||||
|
||||
i {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.pd-dip-alert__detail {
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ interface ClientViewData {
|
||||
displayName?: string;
|
||||
mobileNo?: string;
|
||||
emailAddress?: string;
|
||||
/**
|
||||
* Current DIP/MNRL status resolved from the latest lookup, as opposed to the `dipVerified`
|
||||
* flag captured when the client was onboarded.
|
||||
*/
|
||||
dipLiveStatus?: string;
|
||||
alternateDipLiveStatus?: string;
|
||||
/** Disposition of the latest flagged sanctions alert, or absent when the client carries none. */
|
||||
sanctionsAlertStatus?: string;
|
||||
dateOfBirth?: number[];
|
||||
gender?: { id: number; name: string; active?: boolean };
|
||||
clientType?: { id: number; name: string; active?: boolean };
|
||||
@@ -67,6 +75,91 @@ export class PersonalDataTabComponent {
|
||||
/** Client View Data */
|
||||
clientViewData!: ClientViewData;
|
||||
|
||||
/**
|
||||
* Whether the number's CURRENT DIP/MNRL status is a hit.
|
||||
*
|
||||
* The onboarding-time `dipVerified` flag never changes, so a customer cleared when they joined
|
||||
* kept reading "Verified" even after their number was reported for fraud or reassigned. These
|
||||
* helpers read `dipLiveStatus`, which the API resolves from the most recent lookup.
|
||||
*/
|
||||
isDipFlagged(liveStatus: string | null | undefined): boolean {
|
||||
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
|
||||
}
|
||||
|
||||
/** True only when the number is affirmatively clean — not merely "not flagged". */
|
||||
isDipClear(client: any): boolean {
|
||||
if (client?.dipLiveStatus) {
|
||||
return client.dipLiveStatus === 'CLEAN';
|
||||
}
|
||||
return (
|
||||
client?.mobileVerified === true ||
|
||||
client?.mobileVerified === 'true' ||
|
||||
client?.mobileVerified === 1 ||
|
||||
client?.dipVerified === true ||
|
||||
client?.dipVerified === 'true' ||
|
||||
client?.dipVerified === 1
|
||||
);
|
||||
}
|
||||
|
||||
dipStatusLabel(client: any): string {
|
||||
return this.labelFor(client?.dipLiveStatus, this.isDipClear(client));
|
||||
}
|
||||
|
||||
/** Headline for the sanctions banner, driven by how far the alert has progressed. */
|
||||
sanctionsAlertHeadline(client: any): string {
|
||||
switch (client?.sanctionsAlertStatus) {
|
||||
case 'CONFIRMED_MATCH':
|
||||
return 'Confirmed UN Security Council sanctions match.';
|
||||
case 'FALSE_POSITIVE':
|
||||
return 'Sanctions hit reviewed as a false positive.';
|
||||
default:
|
||||
return 'Sanctions match awaiting Principal Officer review.';
|
||||
}
|
||||
}
|
||||
|
||||
sanctionsAlertDetail(client: any): string {
|
||||
switch (client?.sanctionsAlertStatus) {
|
||||
case 'CONFIRMED_MATCH':
|
||||
return (
|
||||
'Debits are frozen on this client’s deposit accounts and the case has been escalated. ' +
|
||||
'Report to FIU-IND and the nodal officer within 24 hours if not already done.'
|
||||
);
|
||||
case 'FALSE_POSITIVE':
|
||||
return 'Recorded as a different person from the listed individual. See the Sanctions Logs for the reasoning.';
|
||||
default:
|
||||
return (
|
||||
'This client matched the UN Security Council Consolidated List on a later screening. ' +
|
||||
'Review the alert under Reports → Sanctions Screening & Alerts before transacting.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
alternateDipStatusLabel(client: any): string {
|
||||
const clear =
|
||||
client?.alternateDipLiveStatus === 'CLEAN' ||
|
||||
client?.alternateDipVerified === true ||
|
||||
client?.alternateDipVerified === 'true' ||
|
||||
client?.alternateDipVerified === 1;
|
||||
return this.labelFor(client?.alternateDipLiveStatus, clear);
|
||||
}
|
||||
|
||||
private labelFor(liveStatus: string | null | undefined, clear: boolean): string {
|
||||
switch (liveStatus) {
|
||||
case 'SUSPECTED':
|
||||
return 'Flagged — Suspected';
|
||||
case 'DISCONNECTED':
|
||||
return 'Flagged — Disconnected';
|
||||
case 'SUSPECTED_AND_DISCONNECTED':
|
||||
return 'Flagged — Suspected & Disconnected';
|
||||
case 'SERVICE_UNAVAILABLE':
|
||||
return 'Not Verified';
|
||||
case 'CLEAN':
|
||||
return 'Verified';
|
||||
default:
|
||||
return clear ? 'Verified' : 'Pending';
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
|
||||
this.clientViewData = data.clientViewData;
|
||||
|
||||
@@ -855,6 +855,30 @@ export class ClientsService {
|
||||
return this.http.post('/sanctions/screen', { name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the reviewing officer's assessment of a flagged screening.
|
||||
*
|
||||
* A FALSE_POSITIVE does not unblock onboarding on its own — it only becomes effective once
|
||||
* the Principal Officer approves it from the Sanctions Logs screen.
|
||||
*
|
||||
* @param logId screening log id returned by screenSanctions()
|
||||
* @param disposition 'FALSE_POSITIVE' | 'CONFIRMED_MATCH'
|
||||
* @param reason which identifiers were compared and why they do/don't match
|
||||
*/
|
||||
recordSanctionsDisposition(logId: number, disposition: string, reason: string): Observable<any> {
|
||||
return this.http.post(`/sanctions/alerts/${logId}/disposition`, { disposition, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the officer's decision on a DIP/MNRL lookup that came back flagged or unverifiable.
|
||||
* Until PROCEEDED is stored, the client-create gate refuses to save the number.
|
||||
*
|
||||
* @param decision 'PROCEEDED' | 'REJECTED'
|
||||
*/
|
||||
recordDipDecision(logId: number, decision: string, reason: string): Observable<any> {
|
||||
return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates existing KYC details for a client.
|
||||
* @param clientId Client ID
|
||||
@@ -915,6 +939,15 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies CIN against MCA (reads the CIN from the stored KYC record, so it must be saved first).
|
||||
* @param clientId Client ID
|
||||
* @param kycId KYC record ID
|
||||
*/
|
||||
verifyCIN(clientId: string, kycId: string): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-cin`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies DIN (Director Identification Number) for an authorized signatory.
|
||||
* Sends signatory clientId, DIN number and the non-individual entity name.
|
||||
@@ -982,8 +1015,8 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||
}
|
||||
|
||||
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
|
||||
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
|
||||
updateWelcomeKitStatus(welcomeKitId: number, data: any): Observable<any> {
|
||||
return this.http.put(`/welcomekit/${welcomeKitId}`, data);
|
||||
}
|
||||
|
||||
getKycExceptions(resolved = false): Observable<any[]> {
|
||||
|
||||
@@ -153,6 +153,12 @@
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||
[kycData]="clientKycStep?.kycForm?.value"
|
||||
[kycDocCode]="clientKycStep?.selectedKycDoc?.code"
|
||||
[kycDocFiles]="clientKycStep?.kycDocFiles || null"
|
||||
[kycDocMergedPreview]="clientKycStep?.kycDocMergedPreview || null"
|
||||
[existingKycDocSides]="clientKycStep?.existingKycDocSides || null"
|
||||
[existingKycIdentifierId]="clientKycStep?.existingKycIdentifierId || null"
|
||||
></mifosx-client-india-address-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
@@ -214,6 +214,8 @@ export class CreateClientComponent {
|
||||
if (idx === 0) {
|
||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||
// A number the officer rejected on the DIP result must be replaced, not carried forward.
|
||||
if (this.clientGeneralStep.dipRejected || this.clientGeneralStep.alternateDipRejected) return false;
|
||||
if (!this.clientGeneralForm.valid) return false;
|
||||
// Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new
|
||||
// client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent.
|
||||
@@ -273,7 +275,17 @@ export class CreateClientComponent {
|
||||
if (this.currentStepValid) return '';
|
||||
const idx = this.selectedTabIndex;
|
||||
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||
return 'Client failed sanctions screening — cannot proceed.';
|
||||
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
||||
if (disposition === 'FALSE_POSITIVE') {
|
||||
return 'False-positive review submitted — awaiting Principal Officer approval before onboarding can continue.';
|
||||
}
|
||||
if (disposition === 'CONFIRMED_MATCH') {
|
||||
return 'Confirmed UNSC sanctions match — onboarding is blocked and the case has been escalated.';
|
||||
}
|
||||
return 'Sanctions match found — review the match and record an outcome before proceeding.';
|
||||
}
|
||||
if (idx === 0 && (this.clientGeneralStep?.dipRejected || this.clientGeneralStep?.alternateDipRejected)) {
|
||||
return 'The mobile number was rejected on the DIP/MNRL result — enter a different number and verify it.';
|
||||
}
|
||||
if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) {
|
||||
return 'Verify the OTP sent to both the mobile number and email address before proceeding.';
|
||||
@@ -384,7 +396,8 @@ export class CreateClientComponent {
|
||||
this.dialog
|
||||
.open(VerifyClientAlertDialogComponent, {
|
||||
width: '420px',
|
||||
autoFocus: false
|
||||
autoFocus: false,
|
||||
data: { isEntity: this.legalFormType === 2 }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe(() => {
|
||||
@@ -1159,8 +1172,8 @@ export class SaveConfirmDialogComponent {}
|
||||
</div>
|
||||
<div class="vcad-body">
|
||||
<p>
|
||||
Please click <strong>Verify Client</strong> to screen the client name against the UN Security Council
|
||||
Consolidated Sanctions List before proceeding.
|
||||
Please click <strong>{{ data?.isEntity ? 'Verify Entity' : 'Verify Client' }}</strong> to screen the client
|
||||
name against the UN Security Council Consolidated Sanctions List before proceeding.
|
||||
</p>
|
||||
<div class="vcad-info">
|
||||
<mat-icon class="vcad-info__icon">gavel</mat-icon>
|
||||
@@ -1269,4 +1282,6 @@ export class SaveConfirmDialogComponent {}
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class VerifyClientAlertDialogComponent {}
|
||||
export class VerifyClientAlertDialogComponent {
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
}
|
||||
|
||||
@@ -215,11 +215,18 @@
|
||||
</div>
|
||||
}
|
||||
<div class="tab-section">
|
||||
<!-- constitutionMeta and entityName are bound here exactly as they are in create-client.
|
||||
Without them an entity opened for edit falls back to the generic document list rather
|
||||
than the one its constitution actually uses, so the document type saved at creation
|
||||
can be missing from the picker; a proprietorship is also mis-rendered, since the
|
||||
proprietor-PAN branch keys off the constitution. -->
|
||||
<mifosx-client-kyc-step
|
||||
#clientKyc
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[clientId]="clientId"
|
||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
@@ -282,6 +289,12 @@
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||
[kycData]="clientKyc?.kycForm?.value"
|
||||
[kycDocCode]="clientKyc?.selectedKycDoc?.code"
|
||||
[kycDocFiles]="clientKyc?.kycDocFiles || null"
|
||||
[kycDocMergedPreview]="clientKyc?.kycDocMergedPreview || null"
|
||||
[existingKycDocSides]="clientKyc?.existingKycDocSides || null"
|
||||
[existingKycIdentifierId]="clientKyc?.existingKycIdentifierId || null"
|
||||
></mifosx-client-india-address-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
||||
import { toProperNameCase } from '../utils/name-case.util';
|
||||
|
||||
/**
|
||||
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
||||
@@ -380,7 +381,14 @@ export class EditClientComponent {
|
||||
if (this.savingClient) return 'Saving — please wait…';
|
||||
if (this.currentStepValid) return '';
|
||||
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||
return 'Client failed sanctions screening — cannot proceed.';
|
||||
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
||||
if (disposition === 'FALSE_POSITIVE') {
|
||||
return 'False-positive review submitted — awaiting Principal Officer approval before this can be saved.';
|
||||
}
|
||||
if (disposition === 'CONFIRMED_MATCH') {
|
||||
return 'Confirmed UNSC sanctions match — the case has been escalated and saving is blocked.';
|
||||
}
|
||||
return 'Sanctions match found — review the match and record an outcome before proceeding.';
|
||||
}
|
||||
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
|
||||
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
|
||||
@@ -589,13 +597,11 @@ export class EditClientComponent {
|
||||
const kycId = this.clientKycStep.kycId;
|
||||
const isVerified = this.clientKycStep.isKycVerified;
|
||||
|
||||
// Validate remarks are present for verified KYC
|
||||
if (isVerified && !kycData.remarks?.trim()) {
|
||||
this.savingClient = false;
|
||||
this.clientKycStep.kycForm.get('remarks')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remarks are not demanded here. The server requires them only when a save actually
|
||||
// overwrites data the verified record already held, and it names the reason when it
|
||||
// refuses. Blocking every verified record up front aborted the save silently — the photo
|
||||
// upload above had already gone through, so the image changed, the button came back to
|
||||
// life, and nothing said why nothing had been saved.
|
||||
let saveObs;
|
||||
if (kycId && isVerified) {
|
||||
// VERIFIED → re-edit command
|
||||
@@ -628,8 +634,19 @@ export class EditClientComponent {
|
||||
this.saveKycDocProof(this.clientId);
|
||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||
},
|
||||
error: () => {
|
||||
// A rejected save used to just re-enable the button, which reads as "nothing happened".
|
||||
// The server's own message says what is missing — remarks on a re-edit, most often — so
|
||||
// it is shown rather than swallowed, and the KYC tab stays put for the officer to fix.
|
||||
error: (err: any) => {
|
||||
this.savingClient = false;
|
||||
const message =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
'KYC could not be saved. Please review the KYC details and try again.';
|
||||
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||
if (isVerified) {
|
||||
this.clientKycStep?.kycForm.get('remarks')?.markAsTouched();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -734,17 +751,20 @@ export class EditClientComponent {
|
||||
? this.toLocalDateString(oldDobRaw)
|
||||
: oldDobRaw || '';
|
||||
|
||||
// Both sides run through toProperNameCase so a casing-only difference — the stored
|
||||
// 'LINCPAY LTD' against the normalized 'Lincpay LTD' the form now submits — is not
|
||||
// mistaken for a real rename and does not force a PAN re-verification.
|
||||
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
||||
(
|
||||
toProperNameCase(
|
||||
parts.fullname ||
|
||||
[
|
||||
parts.firstname,
|
||||
parts.middlename,
|
||||
parts.lastname
|
||||
]
|
||||
.filter((p) => p && p.trim())
|
||||
.join(' ')
|
||||
).trim();
|
||||
[
|
||||
parts.firstname,
|
||||
parts.middlename,
|
||||
parts.lastname
|
||||
]
|
||||
.filter((p) => p && p.trim())
|
||||
.join(' ')
|
||||
);
|
||||
|
||||
const newName = buildName({
|
||||
firstname: form?.get('firstname')?.value || '',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { ENTITY_KYC_CATEGORY, ENTITY_KYC_DOCUMENT_TYPES, withEntityKycDocuments } from './entity-kyc-documents';
|
||||
|
||||
describe('withEntityKycDocuments', () => {
|
||||
const names = (docs: { name: string }[]) => docs.map((d) => d.name);
|
||||
|
||||
it('keeps the six RBI entity documents when nothing else is supplied', () => {
|
||||
expect(names(withEntityKycDocuments())).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||
});
|
||||
|
||||
it('puts the entity documents ahead of anything the caller adds', () => {
|
||||
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta', mandatory: true }], 'HUF');
|
||||
expect(names(merged.slice(0, 6))).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||
expect(merged[6].name).toBe('Declaration by Karta');
|
||||
});
|
||||
|
||||
it('groups the entity documents together and the rest under the constitution', () => {
|
||||
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta' }], 'HUF');
|
||||
expect(merged[0].category).toBe(ENTITY_KYC_CATEGORY);
|
||||
expect(merged[6].category).toBe('HUF documents');
|
||||
});
|
||||
|
||||
it('folds the backend template names onto the entity chips instead of duplicating them', () => {
|
||||
const merged = withEntityKycDocuments(
|
||||
[
|
||||
{ name: 'Certificate of Incorporation', code: 'COI', mandatory: true },
|
||||
{ name: 'Memorandum of Association', code: 'MOA', mandatory: true },
|
||||
{ name: 'Articles of Association', code: 'AOA', mandatory: true },
|
||||
{ name: 'PAN Card of Company', code: 'PAN', mandatory: true },
|
||||
{ name: 'Board Resolution', code: 'BOARD_RESOLUTION', mandatory: true },
|
||||
{ name: 'KYC of Authorized Signatories', code: 'SIGNATORY_KYC', mandatory: true }
|
||||
],
|
||||
'Private Limited Company'
|
||||
);
|
||||
expect(merged).toHaveLength(6);
|
||||
expect(names(merged)).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||
});
|
||||
|
||||
it('folds constitution labels on too, since those lists carry no codes', () => {
|
||||
const merged = withEntityKycDocuments(
|
||||
[
|
||||
{ name: 'Certificate of Incorporation', code: 'Certificate of Incorporation', mandatory: true },
|
||||
{ name: 'LLP Agreement', code: 'LLP Agreement', mandatory: true },
|
||||
{ name: 'PAN of LLP', code: 'PAN of LLP', mandatory: true },
|
||||
{ name: 'Designated Partners Resolution', code: 'Designated Partners Resolution', mandatory: true },
|
||||
{ name: 'KYC of Designated Partners / Authorized Signatories', code: 'x', mandatory: true }
|
||||
],
|
||||
'Limited Liability Partnership (LLP)'
|
||||
);
|
||||
expect(merged).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('carries a mandatory flag over to the entity document it folds into', () => {
|
||||
const merged = withEntityKycDocuments([{ name: 'PAN Card of Company', code: 'PAN', mandatory: true }]);
|
||||
expect(merged.find((d) => d.code === 'ENTITY_PAN')?.mandatory).toBe(true);
|
||||
expect(merged.find((d) => d.code === 'GST_CERTIFICATE')?.mandatory).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a document that only mentions another one as its own requirement', () => {
|
||||
const merged = withEntityKycDocuments(
|
||||
[{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true }],
|
||||
'Sole Proprietorship'
|
||||
);
|
||||
expect(names(merged)).toContain('Proof of Business Existence (GST Certificate / Shop License)');
|
||||
});
|
||||
|
||||
it('keeps constitution-specific requirements the entity list does not cover', () => {
|
||||
const merged = withEntityKycDocuments(
|
||||
[
|
||||
{ name: 'Government Notification / Order', mandatory: true },
|
||||
{ name: 'Udyam Registration Certificate', code: 'UDYAM', mandatory: true },
|
||||
{ name: 'Proof of Address of HUF' }],
|
||||
'Government Body / PSU'
|
||||
);
|
||||
expect(names(merged).slice(6)).toEqual([
|
||||
'Government Notification / Order',
|
||||
'Udyam Registration Certificate',
|
||||
'Proof of Address of HUF'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Shape the KYC document-type picker works with. */
|
||||
export interface KycDocumentType {
|
||||
name: string;
|
||||
code: string;
|
||||
mandatory: boolean;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export const ENTITY_KYC_CATEGORY = 'Entity KYC documents';
|
||||
|
||||
/**
|
||||
* The KYC documents every legal entity must produce, whatever its constitution — RBI Master
|
||||
* Direction on KYC, Annexure I. Codes match PROOF_META in kyc-proof-types.ts, which is what
|
||||
* groups them under "Entity KYC documents" and supplies each chip's banner text.
|
||||
*
|
||||
* This is the single source for the entity list so a non-individual client sees the same
|
||||
* documents wherever the picker is rendered — the create stepper, the edit stepper, before a
|
||||
* constitution has been chosen and after, and once the backend KYC template has loaded.
|
||||
*/
|
||||
export const ENTITY_KYC_DOCUMENT_TYPES: { name: string; code: string }[] = [
|
||||
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
||||
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
||||
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
||||
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
||||
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
||||
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Codes the backend KYC template uses for documents the entity list already covers
|
||||
* (ClientKycApiResource.getRequiredDocuments). Folding them onto the entity codes keeps one
|
||||
* chip per document instead of "PAN Card of Company" sitting next to "PAN Card of Entity".
|
||||
*/
|
||||
const ENTITY_DOC_CODE_ALIASES: Record<string, string> = {
|
||||
CERT_INCORPORATION: 'CERT_INCORPORATION',
|
||||
COI: 'CERT_INCORPORATION',
|
||||
REG_CERT: 'CERT_INCORPORATION',
|
||||
ENTITY_PAN: 'ENTITY_PAN',
|
||||
PAN: 'ENTITY_PAN',
|
||||
CONSTITUTIVE_DOC: 'CONSTITUTIVE_DOC',
|
||||
MOA: 'CONSTITUTIVE_DOC',
|
||||
AOA: 'CONSTITUTIVE_DOC',
|
||||
PARTNERSHIP_DEED: 'CONSTITUTIVE_DOC',
|
||||
TRUST_DEED: 'CONSTITUTIVE_DOC',
|
||||
BOARD_RESOLUTION: 'BOARD_RESOLUTION',
|
||||
AUTH_SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
|
||||
SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
|
||||
GST_CERTIFICATE: 'GST_CERTIFICATE',
|
||||
GST_CERT: 'GST_CERTIFICATE'
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-constitution lists carry no codes of their own (code === name), so those entries are
|
||||
* matched on the label instead. Patterns are anchored deliberately: a document that merely
|
||||
* mentions another one ("Proof of Business Existence (GST Certificate / Shop License)") is a
|
||||
* distinct requirement and has to survive as its own chip rather than vanish into GST.
|
||||
*/
|
||||
const ENTITY_DOC_NAME_PATTERNS: { pattern: RegExp; code: string }[] = [
|
||||
{ pattern: /^certificate of incorporation\b/, code: 'CERT_INCORPORATION' },
|
||||
{ pattern: /^registration certificate\b/, code: 'CERT_INCORPORATION' },
|
||||
{
|
||||
pattern: /^(memorandum|articles of association|partnership deed|trust deed|llp agreement|by-?laws)\b/,
|
||||
code: 'CONSTITUTIVE_DOC'
|
||||
},
|
||||
{ pattern: /^pan\b/, code: 'ENTITY_PAN' },
|
||||
{ pattern: /^(board resolution|designated partners resolution|resolution authorizing)\b/, code: 'BOARD_RESOLUTION' },
|
||||
{ pattern: /^(kyc of|authorized signatory kyc|proprietor'?s kyc)\b/, code: 'AUTH_SIGNATORY_KYC' },
|
||||
{ pattern: /^gst (registration )?certificate\b/, code: 'GST_CERTIFICATE' }
|
||||
];
|
||||
|
||||
/** The entity document this one duplicates, or null when it is a requirement of its own. */
|
||||
function resolveEntityDocCode(doc: { name?: string; code?: string }): string | null {
|
||||
const code = (doc.code || '').toUpperCase().trim();
|
||||
// A constitution list stores the label in `code`, so only treat it as a code when it is one.
|
||||
if (code && !code.includes(' ') && ENTITY_DOC_CODE_ALIASES[code]) {
|
||||
return ENTITY_DOC_CODE_ALIASES[code];
|
||||
}
|
||||
const name = (doc.name || '').toLowerCase().trim();
|
||||
return ENTITY_DOC_NAME_PATTERNS.find((p) => p.pattern.test(name))?.code || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the RBI entity documents in front of whatever list a caller has (per-constitution docs,
|
||||
* or the backend KYC template) instead of letting that list replace them.
|
||||
*
|
||||
* Without this the officer sees a different set of documents on every screen for the same
|
||||
* client: the entity list until a constitution is picked, the constitution's own list after,
|
||||
* and the backend template's list once the client exists and is reopened for edit. Anything in
|
||||
* `extra` that isn't already one of the six stays, grouped under `extraCategory` — those are
|
||||
* genuine constitution-specific requirements (Karta declaration, Udyam certificate, government
|
||||
* notification) and dropping them would lose real requirements.
|
||||
*
|
||||
* A mandatory flag on a duplicate carries over to the entity chip it folds into, so the
|
||||
* "at least one mandatory document" validator still sees it.
|
||||
*/
|
||||
export function withEntityKycDocuments(
|
||||
extra: { name: string; code?: string; mandatory?: boolean }[] = [],
|
||||
extraCategory?: string | null
|
||||
): KycDocumentType[] {
|
||||
const mandatoryCodes = new Set<string>();
|
||||
const leftovers: KycDocumentType[] = [];
|
||||
|
||||
extra.forEach((doc) => {
|
||||
const entityCode = resolveEntityDocCode(doc);
|
||||
if (entityCode) {
|
||||
if (doc.mandatory) {
|
||||
mandatoryCodes.add(entityCode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
leftovers.push({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
mandatory: !!doc.mandatory,
|
||||
category: extraCategory ? `${extraCategory} documents` : 'Additional documents'
|
||||
});
|
||||
});
|
||||
|
||||
const base: KycDocumentType[] = ENTITY_KYC_DOCUMENT_TYPES.map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: mandatoryCodes.has(d.code),
|
||||
category: ENTITY_KYC_CATEGORY
|
||||
}));
|
||||
|
||||
return [
|
||||
...base,
|
||||
...leftovers
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 { toProperNameCase } from './name-case.util';
|
||||
|
||||
describe('toProperNameCase', () => {
|
||||
it('raises a lower-case first letter on every word', () => {
|
||||
expect(toProperNameCase('keshav Singh sisodiya')).toBe('Keshav Singh Sisodiya');
|
||||
});
|
||||
|
||||
it('leaves the rest of each word exactly as typed', () => {
|
||||
expect(toProperNameCase('lincpay lTD')).toBe('Lincpay LTD');
|
||||
expect(toProperNameCase('lincpay pvt ltd')).toBe('Lincpay Pvt Ltd');
|
||||
});
|
||||
|
||||
it('does not rewrite a name that is already correct', () => {
|
||||
expect(toProperNameCase('Technoludium Solutions Private Limited')).toBe('Technoludium Solutions Private Limited');
|
||||
});
|
||||
|
||||
it('leaves an upper-case name shouted rather than title-casing it', () => {
|
||||
expect(toProperNameCase('TECHNOLUDIUM SOLUTIONS')).toBe('TECHNOLUDIUM SOLUTIONS');
|
||||
expect(toProperNameCase('KESHAV singh')).toBe('KESHAV Singh');
|
||||
});
|
||||
|
||||
it('capitalises after a hyphen', () => {
|
||||
expect(toProperNameCase('jean-marie traders')).toBe('Jean-Marie Traders');
|
||||
});
|
||||
|
||||
it('capitalises name prefixes after an apostrophe but not possessives', () => {
|
||||
expect(toProperNameCase("o'brien d'souza")).toBe("O'Brien D'Souza");
|
||||
expect(toProperNameCase("lincpay's agency")).toBe("Lincpay's Agency");
|
||||
});
|
||||
|
||||
it('leaves punctuation and non-letters untouched', () => {
|
||||
expect(toProperNameCase('abc pvt. ltd.')).toBe('Abc Pvt. Ltd.');
|
||||
expect(toProperNameCase('tata & sons')).toBe('Tata & Sons');
|
||||
expect(toProperNameCase('3m india')).toBe('3m India');
|
||||
});
|
||||
|
||||
it('trims and collapses whitespace', () => {
|
||||
expect(toProperNameCase(' lincpay holdings ')).toBe('Lincpay Holdings');
|
||||
});
|
||||
|
||||
it('returns an empty string for blank or non-string input', () => {
|
||||
expect(toProperNameCase('')).toBe('');
|
||||
expect(toProperNameCase(' ')).toBe('');
|
||||
expect(toProperNameCase(null)).toBe('');
|
||||
expect(toProperNameCase(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Casing for client names: the name is stored exactly as the officer typed it, except that a word
|
||||
* starting with a lower-case letter has that first letter raised. Nothing else is touched.
|
||||
*
|
||||
* 'keshav Singh sisodiya' → 'Keshav Singh Sisodiya'
|
||||
* 'lincpay lTD' → 'Lincpay LTD'
|
||||
* 'Technoludium Solutions Private Limited' → unchanged
|
||||
* 'TECHNOLUDIUM SOLUTIONS' → unchanged
|
||||
*
|
||||
* Deliberately NOT full title-casing. Lower-casing the rest of each word would rewrite an entity
|
||||
* name the officer entered as it appears on the certificate — 'TECHNOLUDIUM' becoming
|
||||
* 'Technoludium', 'Ltd' becoming a forced 'LTD'. That name is matched character-for-character
|
||||
* against the PAN holder's registered name during KYC verification, so the typed spelling is the
|
||||
* thing to preserve; only an unintended lower-case first letter is corrected.
|
||||
*/
|
||||
|
||||
/** Characters after which a new word starts: 'jean-marie' → 'Jean-Marie'. */
|
||||
const SEGMENT_STARTERS = new Set([
|
||||
'-',
|
||||
"'",
|
||||
'’'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Raises the first letter of each word in a single space-delimited token, leaving every other
|
||||
* character as typed.
|
||||
*/
|
||||
function capitalizeWord(word: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
const previous = i === 0 ? '' : word[i - 1];
|
||||
let startsWord = i === 0 || SEGMENT_STARTERS.has(previous);
|
||||
if (previous === "'" || previous === '’') {
|
||||
// 'o'brien' / 'd'souza' capitalise after the apostrophe, but a possessive
|
||||
// ('lincpay's') must not — only a single leading letter marks the name prefix.
|
||||
startsWord = i === 2;
|
||||
}
|
||||
result += startsWord ? word[i].toUpperCase() : word[i];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the casing rule above to a person or entity name.
|
||||
* Trims and collapses runs of whitespace; non-string input yields ''.
|
||||
*/
|
||||
export function toProperNameCase(value: string | null | undefined): string {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const collapsed = value.trim().replace(/\s+/g, ' ');
|
||||
if (!collapsed) {
|
||||
return '';
|
||||
}
|
||||
return collapsed.split(' ').map(capitalizeWord).join(' ');
|
||||
}
|
||||
@@ -607,6 +607,16 @@
|
||||
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||
</button>
|
||||
<!--
|
||||
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
|
||||
so it never shows up under the report categories above and had no nav entry at all.
|
||||
-->
|
||||
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
|
||||
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening & Alerts
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
|
||||
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest & TDS
|
||||
</button>
|
||||
@@ -1376,6 +1386,20 @@
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
|
||||
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
tabindex="0"
|
||||
routerLink="/loan-origination/pipeline"
|
||||
[queryParams]="{ stage: 'CREDIT_COMMITTEE' }"
|
||||
>
|
||||
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Loan Committee
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/eligibility-checklist">
|
||||
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> RBI Eligibility Checklist
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/rbi-rules-reference">
|
||||
<fa-icon icon="book" class="menu-item-icon"></fa-icon> RBI Rules Reference
|
||||
</button>
|
||||
</div>
|
||||
<!-- Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
@@ -1653,6 +1677,16 @@
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
|
||||
</button>
|
||||
<!-- Full data export. Hidden unless the user can raise a request; the approval itself is a
|
||||
separate permission the backend enforces. -->
|
||||
<button
|
||||
mat-menu-item
|
||||
routerLink="/letter-mgmt/database-export"
|
||||
tabindex="0"
|
||||
*mifosxHasPermission="'READ_DATABASEEXPORT'"
|
||||
>
|
||||
<fa-icon icon="database" class="menu-item-icon" style="color: #b45309"></fa-icon> Database Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
+2
-31
@@ -12,9 +12,7 @@
|
||||
<fa-icon icon="university" class="cca-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cca-header__title">Create Current Account</h2>
|
||||
<div class="cca-header__sub">
|
||||
Pick a product, confirm currency, set digital channels and review before submit.
|
||||
</div>
|
||||
<div class="cca-header__sub">Pick a product, confirm currency and review before submit.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,34 +76,7 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 3: Digital -->
|
||||
<mat-tab [disabled]="!currentAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="mobile-alt" class="cca-tab__icon"></fa-icon>
|
||||
Digital
|
||||
</ng-template>
|
||||
<div class="cca-tab-body">
|
||||
@if (currentAccountProductTemplate) {
|
||||
<mifosx-current-account-digital-step
|
||||
[currentAccountProductTemplate]="currentAccountProductTemplate"
|
||||
></mifosx-current-account-digital-step>
|
||||
}
|
||||
|
||||
<div class="cca-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="cca-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 4: Preview -->
|
||||
<!-- Tab 3: Preview -->
|
||||
<mat-tab [disabled]="!currentAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="cca-tab__icon"></fa-icon>
|
||||
|
||||
@@ -57,8 +57,8 @@ export class CreateCurrentAccountComponent {
|
||||
currentAccountProductTemplate: any;
|
||||
/** Currently visible wizard tab (0-indexed). */
|
||||
selectedTabIndex = 0;
|
||||
/** Total wizard tabs — Product, Currency, Digital, Preview. */
|
||||
private readonly totalTabs = 4;
|
||||
/** Total wizard tabs — Product, Currency, Preview. */
|
||||
private readonly totalTabs = 3;
|
||||
|
||||
@ViewChild(CurrentAccountDetailsStepComponent, { static: true })
|
||||
currentAccountDetailsStep: CurrentAccountDetailsStepComponent;
|
||||
|
||||
-12
@@ -30,18 +30,6 @@
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-label>Opening Balance</mat-label>
|
||||
<input matInput type="number" [min]="minRequiredOpeningBalance ?? 0" formControlName="openingBalance" />
|
||||
<span matPrefix>₹ </span>
|
||||
@if (minRequiredOpeningBalance) {
|
||||
<mat-hint>Minimum required: ₹{{ minRequiredOpeningBalance }}</mat-hint>
|
||||
}
|
||||
@if (activateCurrentAccountForm.controls.openingBalance.hasError('min')) {
|
||||
<mat-error>Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
||||
|
||||
+1
-11
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
@@ -36,8 +36,6 @@ export class ActivateCurrentAccountComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
@Input() minRequiredOpeningBalance: number | null = null;
|
||||
|
||||
/** Minimum date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
/** Maximum date allowed. */
|
||||
@@ -71,18 +69,10 @@ export class ActivateCurrentAccountComponent implements OnInit {
|
||||
* Creates the activate current account form.
|
||||
*/
|
||||
createActivateCurrentAccountForm() {
|
||||
const minBalance = this.minRequiredOpeningBalance ?? 0;
|
||||
this.activateCurrentAccountForm = this.formBuilder.group({
|
||||
activatedOnDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
openingBalance: [
|
||||
minBalance,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(minBalance)
|
||||
]
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -10,6 +10,7 @@
|
||||
<mat-card>
|
||||
<form [formGroup]="approveCurrentAccountForm" (ngSubmit)="submit()">
|
||||
<mat-card-content>
|
||||
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
|
||||
<div class="layout-column">
|
||||
<mat-form-field (click)="approvedOnDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
|
||||
|
||||
+13
-3
@@ -81,8 +81,9 @@ export class ApproveCurrentAccountComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the form and approves the current account,
|
||||
* if successful redirects to the current account.
|
||||
* Submits the form, approves the current account, then immediately activates it on the same
|
||||
* date -- Approve and Activate are still two separate Fineract commands underneath (each shows
|
||||
* up in the audit trail), but the officer only sees and clicks through one step.
|
||||
*/
|
||||
submit() {
|
||||
const approveCurrentAccountFormData = this.approveCurrentAccountForm.value;
|
||||
@@ -98,7 +99,16 @@ export class ApproveCurrentAccountComponent implements OnInit {
|
||||
locale
|
||||
};
|
||||
this.currentAccountsService.executeCurrentAccountCommand(this.accountId, 'approve', data).subscribe(() => {
|
||||
this.router.navigate(['../../transactions'], { relativeTo: this.route });
|
||||
const activationData = {
|
||||
activatedOnDate: approveCurrentAccountFormData.approvedOnDate,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
this.currentAccountsService
|
||||
.executeCurrentAccountCommand(this.accountId, 'activate', activationData)
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['../../transactions'], { relativeTo: this.route });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -13,9 +13,7 @@
|
||||
<mifosx-reject-current-account></mifosx-reject-current-account>
|
||||
}
|
||||
@if (actions['Activate']) {
|
||||
<mifosx-activate-current-account
|
||||
[minRequiredOpeningBalance]="accountData?.minRequiredOpeningBalance"
|
||||
></mifosx-activate-current-account>
|
||||
<mifosx-activate-current-account></mifosx-activate-current-account>
|
||||
}
|
||||
@if (actions['Undo Approval']) {
|
||||
<mifosx-undo-approval-current-account></mifosx-undo-approval-current-account>
|
||||
|
||||
-1
@@ -15,7 +15,6 @@
|
||||
<h3 class="dig-header__title">Digital & Account Features</h3>
|
||||
<p class="dig-header__sub">Toggle the facilities available for this account (as per RBI guidelines)</p>
|
||||
</div>
|
||||
<span class="dig-header__badge">FROM PRODUCT</span>
|
||||
</div>
|
||||
|
||||
<div class="dig-grid">
|
||||
|
||||
+4
-5
@@ -52,12 +52,11 @@ export class CurrentAccountDigitalStepComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: a new account always starts with every digital facility off, even if the product has
|
||||
// its own defaults configured -- those are enabled deliberately, one at a time, after the
|
||||
// account is created and active, not inherited automatically at opening.
|
||||
@Input()
|
||||
set currentAccountProductTemplate(value: any) {
|
||||
if (!this.hasExistingAccount && value?.accountFacilityConfig) {
|
||||
this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig };
|
||||
}
|
||||
}
|
||||
currentAccountProductTemplate: any;
|
||||
|
||||
get currentAccountDigital() {
|
||||
return { accountFacilityConfig: { ...this.facilityConfig } };
|
||||
|
||||
+4
-5
@@ -136,12 +136,11 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
|
||||
return this.currentAccountSettingsForm?.getRawValue?.() || this.currentAccount || {};
|
||||
}
|
||||
|
||||
/** Digital facility config as actually captured by a digital step in this wizard -- no fallback
|
||||
* to the product's own defaults, since account creation no longer has a digital step at all and
|
||||
* must not preview facilities the officer never actually chose. */
|
||||
get digitalFacilityConfig(): any {
|
||||
return (
|
||||
this.currentAccountDigital?.accountFacilityConfig ??
|
||||
this.currentAccountProductTemplate?.accountFacilityConfig ??
|
||||
null
|
||||
);
|
||||
return this.currentAccountDigital?.accountFacilityConfig ?? null;
|
||||
}
|
||||
|
||||
get hasDigitalSection(): boolean {
|
||||
|
||||
+36
-1
@@ -33,6 +33,7 @@
|
||||
<mifosx-fixed-deposit-account-details-step
|
||||
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
|
||||
(fixedDepositsAccountProductTemplate)="setTemplate($event)"
|
||||
[externalLinkedAccountId]="fixedDepositAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
|
||||
[hideButtons]="true"
|
||||
>
|
||||
</mifosx-fixed-deposit-account-details-step>
|
||||
@@ -107,7 +108,40 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 4: Preview -->
|
||||
<!-- Tab 4: Terms -->
|
||||
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="file-alt" class="cfa-tab__icon"></fa-icon>
|
||||
Terms
|
||||
</ng-template>
|
||||
<div class="cfa-tab-body">
|
||||
@if (fixedDepositsAccountProductTemplate) {
|
||||
<mifosx-fixed-deposit-account-maturity-terms-step
|
||||
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
|
||||
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
|
||||
[linkedSavingsAccountId]="
|
||||
fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
|
||||
? fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.id
|
||||
: null
|
||||
"
|
||||
></mifosx-fixed-deposit-account-maturity-terms-step>
|
||||
}
|
||||
|
||||
<div class="cfa-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="cfa-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 5: Preview -->
|
||||
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="cfa-tab__icon"></fa-icon>
|
||||
@@ -121,6 +155,7 @@
|
||||
[fixedDepositAccountData]="fixedDepositAccount"
|
||||
[selectedTenure]="selectedTenure"
|
||||
[fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []"
|
||||
[closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}"
|
||||
[hideButtons]="true"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-fixed-deposit-account-preview-step>
|
||||
|
||||
+9
-2
@@ -24,6 +24,7 @@ import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component';
|
||||
import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component';
|
||||
import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
FixedDepositAccountDetailsStepComponent,
|
||||
FixedDepositAccountInterestRateChartStepComponent,
|
||||
FixedDepositAccountCurrencyStepComponent,
|
||||
FixedDepositAccountMaturityTermsStepComponent,
|
||||
FixedDepositAccountPreviewStepComponent
|
||||
]
|
||||
})
|
||||
@@ -56,6 +58,10 @@ export class CreateFixedDepositAccountComponent {
|
||||
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
|
||||
@ViewChild(FixedDepositAccountCurrencyStepComponent)
|
||||
fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent;
|
||||
@ViewChild(FixedDepositAccountPreviewStepComponent)
|
||||
fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent;
|
||||
@ViewChild(FixedDepositAccountMaturityTermsStepComponent)
|
||||
fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent;
|
||||
|
||||
/** Fixed Deposits Account Template */
|
||||
fixedDepositsAccountTemplate: any;
|
||||
@@ -63,8 +69,8 @@ export class CreateFixedDepositAccountComponent {
|
||||
fixedDepositsAccountProductTemplate: any;
|
||||
/** Currently visible wizard tab (0-indexed). */
|
||||
selectedTabIndex = 0;
|
||||
/** Total wizard tabs — Product, Currency, Chart, Preview. */
|
||||
private readonly totalTabs = 4;
|
||||
/** Total wizard tabs — Product, Currency, Chart, Terms, Preview. */
|
||||
private readonly totalTabs = 5;
|
||||
/** Active CBS base interest rate versions for the selected product's FD type */
|
||||
cbsBaseRates: any[] = [];
|
||||
/** Tenure selected from the interest rate chart step */
|
||||
@@ -243,6 +249,7 @@ export class CreateFixedDepositAccountComponent {
|
||||
...this.fixedDepositAccount,
|
||||
...terms,
|
||||
...settings,
|
||||
...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}),
|
||||
...(this.selectedTenure
|
||||
? {
|
||||
depositPeriod: this.selectedTenure.depositPeriod,
|
||||
|
||||
+36
-1
@@ -35,6 +35,7 @@
|
||||
<mifosx-fixed-deposit-account-details-step
|
||||
[fixedDepositsAccountTemplate]="fixedDepositsAccountAndTemplate"
|
||||
(fixedDepositsAccountProductTemplate)="setTemplate($event)"
|
||||
[externalLinkedAccountId]="fixedDepositAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
|
||||
[hideButtons]="true"
|
||||
>
|
||||
</mifosx-fixed-deposit-account-details-step>
|
||||
@@ -111,7 +112,40 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 4: Preview -->
|
||||
<!-- Tab 4: Terms -->
|
||||
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="file-alt" class="cfa-tab__icon"></fa-icon>
|
||||
Terms
|
||||
</ng-template>
|
||||
<div class="cfa-tab-body">
|
||||
@if (fixedDepositsAccountProductTemplate) {
|
||||
<mifosx-fixed-deposit-account-maturity-terms-step
|
||||
[fixedDepositsAccountTemplate]="fixedDepositsAccountAndTemplate"
|
||||
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
|
||||
[linkedSavingsAccountId]="
|
||||
fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
|
||||
? fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.id
|
||||
: null
|
||||
"
|
||||
></mifosx-fixed-deposit-account-maturity-terms-step>
|
||||
}
|
||||
|
||||
<div class="cfa-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="cfa-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 5: Preview -->
|
||||
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="cfa-tab__icon"></fa-icon>
|
||||
@@ -125,6 +159,7 @@
|
||||
[fixedDepositAccountData]="fixedDepositAccount"
|
||||
[selectedTenure]="selectedTenure"
|
||||
[fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []"
|
||||
[closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}"
|
||||
[hideButtons]="true"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-fixed-deposit-account-preview-step>
|
||||
|
||||
+8
-1
@@ -25,6 +25,7 @@ import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component';
|
||||
import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component';
|
||||
import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
FixedDepositAccountDetailsStepComponent,
|
||||
FixedDepositAccountInterestRateChartStepComponent,
|
||||
FixedDepositAccountCurrencyStepComponent,
|
||||
FixedDepositAccountMaturityTermsStepComponent,
|
||||
FixedDepositAccountPreviewStepComponent
|
||||
]
|
||||
})
|
||||
@@ -58,6 +60,10 @@ export class EditFixedDepositAccountComponent {
|
||||
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
|
||||
@ViewChild(FixedDepositAccountCurrencyStepComponent)
|
||||
fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent;
|
||||
@ViewChild(FixedDepositAccountPreviewStepComponent)
|
||||
fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent;
|
||||
@ViewChild(FixedDepositAccountMaturityTermsStepComponent)
|
||||
fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent;
|
||||
|
||||
/** Full account data + template from resolver */
|
||||
fixedDepositsAccountAndTemplate: any;
|
||||
@@ -65,7 +71,7 @@ export class EditFixedDepositAccountComponent {
|
||||
fixedDepositsAccountProductTemplate: any;
|
||||
/** Currently visible wizard tab (0-indexed). */
|
||||
selectedTabIndex = 0;
|
||||
private readonly totalTabs = 4;
|
||||
private readonly totalTabs = 5;
|
||||
/** Active CBS base interest rate versions for the selected product's FD type */
|
||||
cbsBaseRates: any[] = [];
|
||||
/** Tenure selected from the interest rate chart step */
|
||||
@@ -236,6 +242,7 @@ export class EditFixedDepositAccountComponent {
|
||||
...this.fixedDepositAccount,
|
||||
...terms,
|
||||
...settings,
|
||||
...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}),
|
||||
...(this.selectedTenure
|
||||
? {
|
||||
depositPeriod: this.selectedTenure.depositPeriod,
|
||||
|
||||
+32
-2
@@ -7,7 +7,17 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
inject,
|
||||
ChangeDetectorRef
|
||||
} from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
@@ -43,7 +53,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
|
||||
IntroducerSearchComponent
|
||||
]
|
||||
})
|
||||
export class FixedDepositAccountDetailsStepComponent implements OnInit {
|
||||
export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChanges {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private fixedDepositsService = inject(FixedDepositsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
@@ -56,6 +66,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
|
||||
/** Fixed Deposits Account Template */
|
||||
@Input() fixedDepositsAccountTemplate: any;
|
||||
@Input() hideButtons = false;
|
||||
/** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings
|
||||
* account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */
|
||||
@Input() externalLinkedAccountId: number | null = null;
|
||||
|
||||
/** Minimum date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
@@ -222,6 +235,23 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
|
||||
this.dalSearch = '';
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) {
|
||||
return;
|
||||
}
|
||||
const id = this.externalLinkedAccountId;
|
||||
if (!id) return;
|
||||
const savings = this.dalAllSavings.find((a: any) => a.id === id);
|
||||
const current = this.dalAllCurrent.find((a: any) => a.id === id);
|
||||
if (savings) {
|
||||
this.dalSelectedType = 'SAVINGS';
|
||||
this.selectedLinkedAccount = savings;
|
||||
} else if (current) {
|
||||
this.dalSelectedType = 'CURRENT';
|
||||
this.selectedLinkedAccount = current;
|
||||
}
|
||||
}
|
||||
|
||||
private loadClientAccounts(): void {
|
||||
const clientId = String(this.fixedDepositsAccountTemplate?.clientId);
|
||||
if (!clientId) return;
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
|
||||
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
|
||||
<mat-select formControlName="maturityInstructionId">
|
||||
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
|
||||
<mat-option [value]="maturityInstruction.id">
|
||||
{{ maturityInstruction.value | translateKey: 'catalogs' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (closureSettingsForm.contains('transferToSavingsId')) {
|
||||
<div class="dal-card flex-98">
|
||||
<div class="dal-card__header">
|
||||
<div class="dal-card__header-left">
|
||||
<div class="dal-card__icon">
|
||||
<mat-icon>savings</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
|
||||
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dal-card__body">
|
||||
@if (selectedTransferAccount) {
|
||||
<div class="dal-selected">
|
||||
<div class="dal-selected__info">
|
||||
<span class="dal-type-badge dal-type-badge--savings">
|
||||
<mat-icon>savings</mat-icon>
|
||||
Savings
|
||||
</span>
|
||||
<div class="dal-selected__details">
|
||||
<span class="dal-selected__accno">{{ selectedTransferAccount.accountNo }}</span>
|
||||
<span class="dal-selected__product">{{ selectedTransferAccount.savingsProductName }}</span>
|
||||
</div>
|
||||
@if (selectedTransferAccount.summary?.accountBalance != null) {
|
||||
<div class="dal-selected__balance">
|
||||
<span class="dal-selected__bal-label">Balance</span>
|
||||
<span class="dal-selected__bal-amount">
|
||||
{{ selectedTransferAccount.currency?.code }}
|
||||
{{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="dal-change-btn"
|
||||
(click)="clearTransferAccount()"
|
||||
>
|
||||
<mat-icon>swap_horiz</mat-icon>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" class="flex-98">
|
||||
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
|
||||
<mat-select formControlName="transferToSavingsId" required>
|
||||
@for (account of savingsAccountsData; track account) {
|
||||
<mat-option [value]="account.id">
|
||||
({{ account.accountNo }}) {{ account.savingsProductName }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>Transfer to savings account is required</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
|
||||
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
@if (closureSettingsForm.value.preClosurePenalApplicable) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
|
||||
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
|
||||
<mat-select formControlName="preClosurePenalInterestOnTypeId">
|
||||
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
|
||||
<mat-option [value]="preClosurePenalInterestOnType.id">
|
||||
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</form>
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ──
|
||||
|
||||
.dal-card {
|
||||
margin-top: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
}
|
||||
|
||||
&__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: #0ea5e9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0c4a6e;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.dal-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border: 2px solid #0ea5e9;
|
||||
border-radius: 12px;
|
||||
background: #f0f9ff;
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__accno {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0c4a6e;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__product {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__balance {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__bal-label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__bal-amount {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0c4a6e;
|
||||
}
|
||||
}
|
||||
|
||||
.dal-change-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dal-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&--savings {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-color: #bfdbfe;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Fixed Deposit Account Maturity Terms Step -- what happens to this account at maturity
|
||||
* (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided
|
||||
* per account rather than silently inherited from the product.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-fixed-deposit-account-maturity-terms-step',
|
||||
templateUrl: './fixed-deposit-account-maturity-terms-step.component.html',
|
||||
styleUrls: ['./fixed-deposit-account-maturity-terms-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatCheckbox,
|
||||
MatIcon
|
||||
]
|
||||
})
|
||||
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
@Input() fixedDepositsAccountTemplate: any;
|
||||
@Input() fixedDepositsAccountProductTemplate: any;
|
||||
/** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) --
|
||||
* pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */
|
||||
@Input() linkedSavingsAccountId: number | null = null;
|
||||
|
||||
closureSettingsForm: UntypedFormGroup;
|
||||
maturityInstructionOptions: any[] = [];
|
||||
savingsAccountsData: any[] = [];
|
||||
preClosurePenalInterestOnTypeData: any[] = [];
|
||||
|
||||
constructor() {
|
||||
this.closureSettingsForm = this.formBuilder.group({
|
||||
maturityInstructionId: [''],
|
||||
preClosurePenalApplicable: [false],
|
||||
preClosurePenalInterest: [
|
||||
'',
|
||||
Validators.min(0)
|
||||
],
|
||||
preClosurePenalInterestOnTypeId: ['']
|
||||
});
|
||||
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
|
||||
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
|
||||
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
|
||||
if (value === 200) {
|
||||
this.closureSettingsForm.addControl(
|
||||
'transferToSavingsId',
|
||||
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
|
||||
);
|
||||
} else {
|
||||
this.closureSettingsForm.removeControl('transferToSavingsId');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.linkedSavingsAccountId) {
|
||||
const control = this.closureSettingsForm.get('transferToSavingsId');
|
||||
if (control && !control.value && this.linkedSavingsAccountId) {
|
||||
control.setValue(this.linkedSavingsAccountId);
|
||||
}
|
||||
}
|
||||
if (this.fixedDepositsAccountProductTemplate) {
|
||||
// Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment
|
||||
// the account matures, before the customer has actually collected it -- disabled everywhere.
|
||||
this.maturityInstructionOptions = (
|
||||
this.fixedDepositsAccountProductTemplate.maturityInstructionOptions ?? []
|
||||
).filter((option: any) => option.id !== 100);
|
||||
this.savingsAccountsData = this.fixedDepositsAccountProductTemplate.savingsAccounts ?? [];
|
||||
this.preClosurePenalInterestOnTypeData =
|
||||
this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnTypeOptions ?? [];
|
||||
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
|
||||
this.closureSettingsForm.patchValue({
|
||||
preClosurePenalApplicable: this.fixedDepositsAccountProductTemplate.preClosurePenalApplicable ?? false,
|
||||
preClosurePenalInterest: this.fixedDepositsAccountProductTemplate.preClosurePenalInterest ?? '',
|
||||
preClosurePenalInterestOnTypeId:
|
||||
this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnType?.id ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
|
||||
get selectedTransferAccount(): any {
|
||||
const id = this.closureSettingsForm.value.transferToSavingsId;
|
||||
if (!id) return null;
|
||||
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
|
||||
}
|
||||
|
||||
clearTransferAccount(): void {
|
||||
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
|
||||
}
|
||||
|
||||
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
|
||||
get closureSettings() {
|
||||
const settings = this.closureSettingsForm.getRawValue();
|
||||
for (const key in settings) {
|
||||
if (settings[key] === '') {
|
||||
delete settings[key];
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
+35
@@ -683,6 +683,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<div class="preview-section__header">
|
||||
<i class="fas fa-flag-checkered preview-section__icon"></i>
|
||||
<span class="preview-section__title">Maturity & Pre-mature Closure</span>
|
||||
</div>
|
||||
<div class="preview-body">
|
||||
<div class="preview-grid preview-grid--three">
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Maturity Instructions' | translate }}</span>
|
||||
<span class="preview-value">{{ closureMaturityInstructionLabel }}</span>
|
||||
</div>
|
||||
@if (closureSettings?.transferToSavingsId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Transfer to savings account' | translate }}</span>
|
||||
<span class="preview-value">{{ closureTransferToSavingsLabel }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
|
||||
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
|
||||
</div>
|
||||
@if (closureSettings?.preClosurePenalApplicable) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
|
||||
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
|
||||
<span class="preview-value">{{ closurePenalInterestOnTypeLabel }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!hideButtons) {
|
||||
<div class="layout-row responsive-column align-center gap-2px margin-t">
|
||||
<button mat-raised-button matStepperPrevious>
|
||||
|
||||
+20
-1
@@ -164,7 +164,26 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
|
||||
/** Form submission event */
|
||||
@Output() submitEvent = new EventEmitter();
|
||||
|
||||
constructor() {}
|
||||
/** Maturity/pre-mature-closure choices made in the Terms step -- shown here read-only. */
|
||||
@Input() closureSettings: any = {};
|
||||
|
||||
get closureMaturityInstructionLabel(): string {
|
||||
const options = this.fixedDepositsAccountProductTemplate?.maturityInstructionOptions ?? [];
|
||||
const match = options.find((o: any) => o.id === this.closureSettings?.maturityInstructionId);
|
||||
return match?.value ?? '—';
|
||||
}
|
||||
|
||||
get closureTransferToSavingsLabel(): string {
|
||||
const accounts = this.fixedDepositsAccountProductTemplate?.savingsAccounts ?? [];
|
||||
const match = accounts.find((a: any) => a.id === this.closureSettings?.transferToSavingsId);
|
||||
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
|
||||
}
|
||||
|
||||
get closurePenalInterestOnTypeLabel(): string {
|
||||
const options = this.fixedDepositsAccountProductTemplate?.preClosurePenalInterestOnTypeOptions ?? [];
|
||||
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
|
||||
return match?.value ?? '—';
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
if (this.fixedDepositsAccountProductTemplate) {
|
||||
|
||||
+10
@@ -79,6 +79,16 @@
|
||||
|
||||
<!-- Action menu (kebab) -->
|
||||
<div class="fd-hero__actions">
|
||||
@if (!fixedDepositsAccountData.status.rejected && !fixedDepositsAccountData.status.submittedAndPendingApproval) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Print Certificate"
|
||||
(click)="printCertificate()"
|
||||
aria-label="Print certificate"
|
||||
>
|
||||
<fa-icon icon="print"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
|
||||
<fa-icon icon="ellipsis-v"></fa-icon>
|
||||
</button>
|
||||
|
||||
+15
@@ -16,6 +16,7 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
|
||||
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
|
||||
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
|
||||
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
|
||||
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
|
||||
|
||||
/** Custom Button Config. */
|
||||
import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.config';
|
||||
@@ -358,6 +359,20 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a printable deposit certificate for this account.
|
||||
*/
|
||||
printCertificate() {
|
||||
this.dialog.open(DepositCertificateDialogComponent, {
|
||||
width: '900px',
|
||||
data: {
|
||||
accountType: 'FD',
|
||||
accountData: this.fixedDepositsAccountData,
|
||||
clientId: this.fixedDepositsAccountData.clientId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables withhold tax for fixed deposits account
|
||||
* Fixed deposits endpoint is not supported so using Savings endpoint.
|
||||
|
||||
+1
@@ -10,6 +10,7 @@
|
||||
<mat-card>
|
||||
<form [formGroup]="approveFixedDepositsAccountForm" (ngSubmit)="submit()">
|
||||
<mat-card-content>
|
||||
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
|
||||
<div class="layout-column">
|
||||
<mat-form-field (click)="approvedOnDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
|
||||
|
||||
+13
-3
@@ -81,8 +81,9 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the form and approves the fixed deposit account,
|
||||
* if successful redirects to the fixed deposit account.
|
||||
* Submits the form, approves the fixed deposit account, then immediately activates it on the
|
||||
* same date -- Approve and Activate are still two separate Fineract commands underneath (each
|
||||
* shows up in the audit trail), but the officer only sees and clicks through one step.
|
||||
*/
|
||||
submit() {
|
||||
const approveFixedDepositsAccountFormData = this.approveFixedDepositsAccountForm.value;
|
||||
@@ -98,7 +99,16 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
|
||||
locale
|
||||
};
|
||||
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
|
||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||
const activationData = {
|
||||
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
this.fixedDepositsService
|
||||
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -33,6 +33,7 @@
|
||||
<mifosx-recurring-deposits-account-details-step
|
||||
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
|
||||
(recurringDepositsAccountProductTemplate)="setTemplate($event)"
|
||||
[externalLinkedAccountId]="recurringDepositsAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
|
||||
>
|
||||
</mifosx-recurring-deposits-account-details-step>
|
||||
|
||||
@@ -111,7 +112,40 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 4: Preview -->
|
||||
<!-- Tab 4: Terms -->
|
||||
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="file-alt" class="cra-tab__icon"></fa-icon>
|
||||
Terms
|
||||
</ng-template>
|
||||
<div class="cra-tab-body">
|
||||
@if (recurringDepositsAccountProductTemplate) {
|
||||
<mifosx-recurring-deposits-account-maturity-terms-step
|
||||
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
|
||||
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
|
||||
[linkedSavingsAccountId]="
|
||||
recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
|
||||
? recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.id
|
||||
: null
|
||||
"
|
||||
></mifosx-recurring-deposits-account-maturity-terms-step>
|
||||
}
|
||||
|
||||
<div class="cra-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="cra-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 5: Preview -->
|
||||
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="cra-tab__icon"></fa-icon>
|
||||
@@ -132,6 +166,7 @@
|
||||
[rdDaysInYear]="rdDaysInYear"
|
||||
[rdAppliedRate]="rdAppliedRate"
|
||||
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
|
||||
[closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}"
|
||||
[hideButtons]="true"
|
||||
(submitEvent)="submit()"
|
||||
>
|
||||
|
||||
+10
-1
@@ -24,6 +24,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component';
|
||||
import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component';
|
||||
import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component';
|
||||
import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
RecurringDepositsAccountDetailsStepComponent,
|
||||
RecurringDepositsAccountCurrencyStepComponent,
|
||||
RecurringDepositsAccountInterestRateChartStepComponent,
|
||||
RecurringDepositsAccountMaturityTermsStepComponent,
|
||||
RecurringDepositsAccountPreviewStepComponent
|
||||
]
|
||||
})
|
||||
@@ -61,6 +63,12 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
@ViewChild(RecurringDepositsAccountInterestRateChartStepComponent)
|
||||
recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent;
|
||||
|
||||
@ViewChild(RecurringDepositsAccountPreviewStepComponent)
|
||||
recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent;
|
||||
|
||||
@ViewChild(RecurringDepositsAccountMaturityTermsStepComponent)
|
||||
recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent;
|
||||
|
||||
/** Recurring Deposits Account Template */
|
||||
recurringDepositsAccountTemplate: any;
|
||||
/** Recurring Deposit Account Product Template */
|
||||
@@ -68,7 +76,7 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
/** Currently visible wizard tab (0-indexed). */
|
||||
selectedTabIndex = 0;
|
||||
/** Total wizard tabs — Product, Currency, Chart, Preview. */
|
||||
private readonly totalTabs = 4;
|
||||
private readonly totalTabs = 5;
|
||||
/** Active CBS base interest rate versions */
|
||||
cbsBaseRates: any[] = [];
|
||||
/** Selected tenure value and frequency from calculator */
|
||||
@@ -258,6 +266,7 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
...details,
|
||||
...terms,
|
||||
...settings,
|
||||
...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}),
|
||||
...productDefaults,
|
||||
clientId: this.recurringDepositsAccountTemplate.clientId,
|
||||
mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0,
|
||||
|
||||
+36
-1
@@ -35,6 +35,7 @@
|
||||
<mifosx-recurring-deposits-account-details-step
|
||||
[recurringDepositsAccountTemplate]="recurringDepositsAccountAndTemplate"
|
||||
(recurringDepositsAccountProductTemplate)="setTemplate($event)"
|
||||
[externalLinkedAccountId]="recurringDepositsAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
|
||||
>
|
||||
</mifosx-recurring-deposits-account-details-step>
|
||||
|
||||
@@ -115,7 +116,40 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 4: Preview -->
|
||||
<!-- Tab 4: Terms -->
|
||||
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="file-alt" class="cra-tab__icon"></fa-icon>
|
||||
Terms
|
||||
</ng-template>
|
||||
<div class="cra-tab-body">
|
||||
@if (recurringDepositsAccountProductTemplate) {
|
||||
<mifosx-recurring-deposits-account-maturity-terms-step
|
||||
[recurringDepositsAccountTemplate]="recurringDepositsAccountAndTemplate"
|
||||
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
|
||||
[linkedSavingsAccountId]="
|
||||
recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
|
||||
? recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.id
|
||||
: null
|
||||
"
|
||||
></mifosx-recurring-deposits-account-maturity-terms-step>
|
||||
}
|
||||
|
||||
<div class="cra-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="cra-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 5: Preview -->
|
||||
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="cra-tab__icon"></fa-icon>
|
||||
@@ -136,6 +170,7 @@
|
||||
[rdDaysInYear]="rdDaysInYear"
|
||||
[rdAppliedRate]="rdAppliedRate"
|
||||
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
|
||||
[closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}"
|
||||
[hideButtons]="true"
|
||||
(submitEvent)="submit()"
|
||||
>
|
||||
|
||||
+10
-1
@@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component';
|
||||
import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component';
|
||||
import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component';
|
||||
import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
RecurringDepositsAccountDetailsStepComponent,
|
||||
RecurringDepositsAccountCurrencyStepComponent,
|
||||
RecurringDepositsAccountInterestRateChartStepComponent,
|
||||
RecurringDepositsAccountMaturityTermsStepComponent,
|
||||
RecurringDepositsAccountPreviewStepComponent
|
||||
]
|
||||
})
|
||||
@@ -63,13 +65,19 @@ export class EditRecurringDepositAccountComponent {
|
||||
@ViewChild(RecurringDepositsAccountInterestRateChartStepComponent)
|
||||
recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent;
|
||||
|
||||
@ViewChild(RecurringDepositsAccountPreviewStepComponent)
|
||||
recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent;
|
||||
|
||||
@ViewChild(RecurringDepositsAccountMaturityTermsStepComponent)
|
||||
recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent;
|
||||
|
||||
/** Full account data + template from resolver */
|
||||
recurringDepositsAccountAndTemplate: any;
|
||||
/** Product template emitted by details step */
|
||||
recurringDepositsAccountProductTemplate: any;
|
||||
/** Currently visible wizard tab (0-indexed). */
|
||||
selectedTabIndex = 0;
|
||||
private readonly totalTabs = 4;
|
||||
private readonly totalTabs = 5;
|
||||
/** Active CBS base interest rate versions */
|
||||
cbsBaseRates: any[] = [];
|
||||
/** Selected tenure value and frequency from calculator */
|
||||
@@ -277,6 +285,7 @@ export class EditRecurringDepositAccountComponent {
|
||||
...details,
|
||||
...terms,
|
||||
...settings,
|
||||
...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}),
|
||||
...productDefaults,
|
||||
clientId: this.recurringDepositsAccountAndTemplate.clientId,
|
||||
mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0,
|
||||
|
||||
+1
@@ -10,6 +10,7 @@
|
||||
<mat-card>
|
||||
<form [formGroup]="approveRecurringDepositsAccountForm" (ngSubmit)="submit()">
|
||||
<mat-card-content>
|
||||
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
|
||||
<div class="layout-column">
|
||||
<mat-form-field (click)="approvedOnDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
|
||||
|
||||
+13
-3
@@ -81,8 +81,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the form and approves the recurring deposit account,
|
||||
* if successful redirects to the recurring deposit account.
|
||||
* Submits the form, approves the recurring deposit account, then immediately activates it on
|
||||
* the same date -- Approve and Activate are still two separate Fineract commands underneath
|
||||
* (each shows up in the audit trail), but the officer only sees and clicks through one step.
|
||||
*/
|
||||
submit() {
|
||||
const approveRecurringDepositsAccountFormData = this.approveRecurringDepositsAccountForm.value;
|
||||
@@ -103,7 +104,16 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
|
||||
this.recurringDepositsService
|
||||
.executeRecurringDepositsAccountCommand(this.accountId, 'approve', data)
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||
const activationData = {
|
||||
activatedOnDate: approveRecurringDepositsAccountFormData.approvedOnDate,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
this.recurringDepositsService
|
||||
.executeRecurringDepositsAccountCommand(this.accountId, 'activate', activationData)
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -7,7 +7,17 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
inject,
|
||||
ChangeDetectorRef
|
||||
} from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
@@ -43,7 +53,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
|
||||
IntroducerSearchComponent
|
||||
]
|
||||
})
|
||||
export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
|
||||
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private recurringDepositsService = inject(RecurringDepositsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
@@ -56,6 +66,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
|
||||
/** Recurring Deposits Account Template */
|
||||
@Input() recurringDepositsAccountTemplate: any;
|
||||
|
||||
/** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings
|
||||
* account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */
|
||||
@Input() externalLinkedAccountId: number | null = null;
|
||||
|
||||
/** Minimum date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
/** Maximum date allowed. */
|
||||
@@ -198,6 +212,23 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
|
||||
this.dalSearch = '';
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) {
|
||||
return;
|
||||
}
|
||||
const id = this.externalLinkedAccountId;
|
||||
if (!id) return;
|
||||
const savings = this.dalAllSavings.find((a: any) => a.id === id);
|
||||
const current = this.dalAllCurrent.find((a: any) => a.id === id);
|
||||
if (savings) {
|
||||
this.dalSelectedType = 'SAVINGS';
|
||||
this.selectedLinkedAccount = savings;
|
||||
} else if (current) {
|
||||
this.dalSelectedType = 'CURRENT';
|
||||
this.selectedLinkedAccount = current;
|
||||
}
|
||||
}
|
||||
|
||||
private loadClientAccounts(): void {
|
||||
const clientId = String(this.recurringDepositsAccountTemplate?.clientId);
|
||||
if (!clientId) return;
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
|
||||
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
|
||||
<mat-select formControlName="maturityInstructionId">
|
||||
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
|
||||
<mat-option [value]="maturityInstruction.id">
|
||||
{{ maturityInstruction.value | translateKey: 'catalogs' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (closureSettingsForm.contains('transferToSavingsId')) {
|
||||
<div class="dal-card flex-98">
|
||||
<div class="dal-card__header">
|
||||
<div class="dal-card__header-left">
|
||||
<div class="dal-card__icon">
|
||||
<mat-icon>savings</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
|
||||
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dal-card__body">
|
||||
@if (selectedTransferAccount) {
|
||||
<div class="dal-selected">
|
||||
<div class="dal-selected__info">
|
||||
<span class="dal-type-badge dal-type-badge--savings">
|
||||
<mat-icon>savings</mat-icon>
|
||||
Savings
|
||||
</span>
|
||||
<div class="dal-selected__details">
|
||||
<span class="dal-selected__accno">{{ selectedTransferAccount.accountNo }}</span>
|
||||
<span class="dal-selected__product">{{ selectedTransferAccount.savingsProductName }}</span>
|
||||
</div>
|
||||
@if (selectedTransferAccount.summary?.accountBalance != null) {
|
||||
<div class="dal-selected__balance">
|
||||
<span class="dal-selected__bal-label">Balance</span>
|
||||
<span class="dal-selected__bal-amount">
|
||||
{{ selectedTransferAccount.currency?.code }}
|
||||
{{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="dal-change-btn"
|
||||
(click)="clearTransferAccount()"
|
||||
>
|
||||
<mat-icon>swap_horiz</mat-icon>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" class="flex-98">
|
||||
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
|
||||
<mat-select formControlName="transferToSavingsId" required>
|
||||
@for (account of savingsAccountsData; track account) {
|
||||
<mat-option [value]="account.id">
|
||||
({{ account.accountNo }}) {{ account.savingsProductName }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>Transfer to savings account is required</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
|
||||
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
@if (closureSettingsForm.value.preClosurePenalApplicable) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
|
||||
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
|
||||
<mat-select formControlName="preClosurePenalInterestOnTypeId">
|
||||
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
|
||||
<mat-option [value]="preClosurePenalInterestOnType.id">
|
||||
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</form>
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ──
|
||||
|
||||
.dal-card {
|
||||
margin-top: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
}
|
||||
|
||||
&__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: #0ea5e9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0c4a6e;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.dal-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border: 2px solid #0ea5e9;
|
||||
border-radius: 12px;
|
||||
background: #f0f9ff;
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__accno {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0c4a6e;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__product {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__balance {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__bal-label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__bal-amount {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0c4a6e;
|
||||
}
|
||||
}
|
||||
|
||||
.dal-change-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dal-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&--savings {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-color: #bfdbfe;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Recurring Deposit Account Maturity Terms Step -- what happens to this account at maturity
|
||||
* (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided
|
||||
* per account rather than silently inherited from the product.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-recurring-deposits-account-maturity-terms-step',
|
||||
templateUrl: './recurring-deposits-account-maturity-terms-step.component.html',
|
||||
styleUrls: ['./recurring-deposits-account-maturity-terms-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatCheckbox,
|
||||
MatIcon
|
||||
]
|
||||
})
|
||||
export class RecurringDepositsAccountMaturityTermsStepComponent implements OnChanges {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
@Input() recurringDepositsAccountTemplate: any;
|
||||
@Input() recurringDepositsAccountProductTemplate: any;
|
||||
/** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) --
|
||||
* pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */
|
||||
@Input() linkedSavingsAccountId: number | null = null;
|
||||
|
||||
closureSettingsForm: UntypedFormGroup;
|
||||
maturityInstructionOptions: any[] = [];
|
||||
savingsAccountsData: any[] = [];
|
||||
preClosurePenalInterestOnTypeData: any[] = [];
|
||||
|
||||
constructor() {
|
||||
this.closureSettingsForm = this.formBuilder.group({
|
||||
maturityInstructionId: [''],
|
||||
preClosurePenalApplicable: [false],
|
||||
preClosurePenalInterest: [
|
||||
'',
|
||||
Validators.min(0)
|
||||
],
|
||||
preClosurePenalInterestOnTypeId: ['']
|
||||
});
|
||||
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
|
||||
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
|
||||
// Reinvest options (300/400) open a brand-new RD instead, so they ignore this field too.
|
||||
if (value === 200) {
|
||||
this.closureSettingsForm.addControl(
|
||||
'transferToSavingsId',
|
||||
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
|
||||
);
|
||||
} else {
|
||||
this.closureSettingsForm.removeControl('transferToSavingsId');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.linkedSavingsAccountId) {
|
||||
const control = this.closureSettingsForm.get('transferToSavingsId');
|
||||
if (control && !control.value && this.linkedSavingsAccountId) {
|
||||
control.setValue(this.linkedSavingsAccountId);
|
||||
}
|
||||
}
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
if (template) {
|
||||
// Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment
|
||||
// the account matures, before the customer has actually collected it -- disabled everywhere.
|
||||
this.maturityInstructionOptions = (template.maturityInstructionOptions ?? []).filter(
|
||||
(option: any) => option.id !== 100
|
||||
);
|
||||
this.savingsAccountsData = template.savingsAccounts ?? [];
|
||||
this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? [];
|
||||
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
|
||||
this.closureSettingsForm.patchValue({
|
||||
preClosurePenalApplicable: template.preClosurePenalApplicable ?? false,
|
||||
preClosurePenalInterest: template.preClosurePenalInterest ?? '',
|
||||
preClosurePenalInterestOnTypeId: template.preClosurePenalInterestOnType?.id ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
|
||||
get selectedTransferAccount(): any {
|
||||
const id = this.closureSettingsForm.value.transferToSavingsId;
|
||||
if (!id) return null;
|
||||
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
|
||||
}
|
||||
|
||||
clearTransferAccount(): void {
|
||||
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
|
||||
}
|
||||
|
||||
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
|
||||
get closureSettings() {
|
||||
const settings = this.closureSettingsForm.getRawValue();
|
||||
for (const key in settings) {
|
||||
if (settings[key] === '') {
|
||||
delete settings[key];
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
+35
@@ -853,6 +853,41 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<div class="preview-section__header">
|
||||
<i class="fas fa-flag-checkered preview-section__icon"></i>
|
||||
<span class="preview-section__title">Maturity & Pre-mature Closure</span>
|
||||
</div>
|
||||
<div class="preview-body">
|
||||
<div class="preview-grid preview-grid--three">
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Maturity Instructions' | translate }}</span>
|
||||
<span class="preview-value">{{ closureMaturityInstructionLabel }}</span>
|
||||
</div>
|
||||
@if (closureSettings?.transferToSavingsId) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Transfer to savings account' | translate }}</span>
|
||||
<span class="preview-value">{{ closureTransferToSavingsLabel }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
|
||||
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
|
||||
</div>
|
||||
@if (closureSettings?.preClosurePenalApplicable) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
|
||||
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
|
||||
<span class="preview-value">{{ closurePenalInterestOnTypeLabel }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!hideButtons) {
|
||||
<div class="layout-row responsive-column align-center gap-2px margin-t">
|
||||
<button mat-raised-button matStepperPrevious>
|
||||
|
||||
+23
-1
@@ -201,7 +201,29 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
|
||||
/** Expand Chart Slab Index used in the view */
|
||||
expandChartSlabIndex: number;
|
||||
|
||||
constructor() {}
|
||||
/** Maturity/pre-mature-closure choices made in the Terms step -- shown here read-only. */
|
||||
@Input() closureSettings: any = {};
|
||||
|
||||
get closureMaturityInstructionLabel(): string {
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
const options = template?.maturityInstructionOptions ?? [];
|
||||
const match = options.find((o: any) => o.id === this.closureSettings?.maturityInstructionId);
|
||||
return match?.value ?? '—';
|
||||
}
|
||||
|
||||
get closureTransferToSavingsLabel(): string {
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
const accounts = template?.savingsAccounts ?? [];
|
||||
const match = accounts.find((a: any) => a.id === this.closureSettings?.transferToSavingsId);
|
||||
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
|
||||
}
|
||||
|
||||
get closurePenalInterestOnTypeLabel(): string {
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
const options = template?.preClosurePenalInterestOnTypeOptions ?? [];
|
||||
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
|
||||
return match?.value ?? '—';
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
|
||||
+19
@@ -17,6 +17,7 @@ import {
|
||||
} from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Currency } from 'app/shared/models/general.model';
|
||||
import { OptionData } from 'app/shared/models/option-data.model';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { InputAmountComponent } from '../../../../shared/input-amount/input-amount.component';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
@@ -61,6 +62,10 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
|
||||
periodFrequencyTypeData: any[] = [];
|
||||
/** Preclosure Penal Interest Type on Data */
|
||||
preClosurePenalInterestOnTypeData: any[] = [];
|
||||
/** Options for what happens to the account at maturity */
|
||||
maturityInstructionOptions: OptionData[];
|
||||
/** Savings accounts eligible as a maturity-proceeds transfer target */
|
||||
savingsAccountsData: any;
|
||||
/** Tax Group */
|
||||
taxGroup: any;
|
||||
currency: Currency | null = null;
|
||||
@@ -91,6 +96,7 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
|
||||
: '',
|
||||
maturityInstructions: recurringDepositsAccount.maturityInstructions ?? false,
|
||||
creditMaturityProceeds: recurringDepositsAccount.creditMaturityProceeds ?? false,
|
||||
maturityInstructionId: recurringDepositsAccount.maturityInstructionId ?? '',
|
||||
penaltyGracePeriodMissedInstallments: recurringDepositsAccount.penaltyGracePeriodMissedInstallments ?? false,
|
||||
panelRequestApplicable: recurringDepositsAccount.panelRequestApplicable ?? false,
|
||||
noPenaltyNomineeHeirClosure: recurringDepositsAccount.noPenaltyNomineeHeirClosure ?? false,
|
||||
@@ -182,6 +188,7 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
|
||||
lockinPeriodFrequencyType: [''],
|
||||
maturityInstructions: [false],
|
||||
creditMaturityProceeds: [false],
|
||||
maturityInstructionId: [''],
|
||||
penaltyGracePeriodMissedInstallments: [false],
|
||||
panelRequestApplicable: [false],
|
||||
noPenaltyNomineeHeirClosure: [false],
|
||||
@@ -217,6 +224,8 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
|
||||
this.lockinPeriodFrequencyTypeData = recurringDepositsAccount.lockinPeriodFrequencyTypeOptions;
|
||||
this.periodFrequencyTypeData = recurringDepositsAccount.periodFrequencyTypeOptions;
|
||||
this.preClosurePenalInterestOnTypeData = recurringDepositsAccount.preClosurePenalInterestOnTypeOptions;
|
||||
this.maturityInstructionOptions = recurringDepositsAccount.maturityInstructionOptions;
|
||||
this.savingsAccountsData = recurringDepositsAccount.savingsAccounts;
|
||||
}
|
||||
|
||||
buildDependencies() {
|
||||
@@ -233,6 +242,16 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
|
||||
this.recurringDepositAccountSettingsForm.addControl('recurringFrequencyType', new UntypedFormControl(''));
|
||||
}
|
||||
});
|
||||
this.recurringDepositAccountSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
|
||||
if (value > 100) {
|
||||
this.recurringDepositAccountSettingsForm.addControl(
|
||||
'transferToSavingsId',
|
||||
new UntypedFormControl('', Validators.required)
|
||||
);
|
||||
} else {
|
||||
this.recurringDepositAccountSettingsForm.removeControl('transferToSavingsId');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get recurringDepositAccountSettings() {
|
||||
|
||||
+13
@@ -87,6 +87,19 @@
|
||||
|
||||
<!-- Action menu (kebab) -->
|
||||
<div class="rd-hero__actions">
|
||||
@if (
|
||||
!recurringDepositsAccountData.status.rejected &&
|
||||
!recurringDepositsAccountData.status.submittedAndPendingApproval
|
||||
) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Print Certificate"
|
||||
(click)="printCertificate()"
|
||||
aria-label="Print certificate"
|
||||
>
|
||||
<fa-icon icon="print"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
|
||||
<fa-icon icon="ellipsis-v"></fa-icon>
|
||||
</button>
|
||||
|
||||
+15
@@ -22,6 +22,7 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component';
|
||||
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
|
||||
import { Currency } from 'app/shared/models/general.model';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { AccountsService } from 'app/accounts/accounts.service';
|
||||
@@ -426,6 +427,20 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a printable deposit certificate for this account.
|
||||
*/
|
||||
printCertificate() {
|
||||
this.dialog.open(DepositCertificateDialogComponent, {
|
||||
width: '900px',
|
||||
data: {
|
||||
accountType: 'RD',
|
||||
accountData: this.recurringDepositsAccountData,
|
||||
clientId: this.recurringDepositsAccountData.clientId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables withhold tax for recurring deposits account
|
||||
* Recurring deposits endpoint is not supported so using Savings endpoint.
|
||||
|
||||
@@ -347,6 +347,69 @@
|
||||
|
||||
<!-- STR -->
|
||||
@if (activeTab === 'str') {
|
||||
<!--
|
||||
Sanctions STRs are a separate stream from the AML alerts below, not merged into them: an AML
|
||||
alert is raised by a monetary transaction breaching a rule, whereas a sanctions STR is
|
||||
triggered by who the customer is. Mixing them would put two different kinds of suspicion in
|
||||
one queue and require inventing a transaction that never happened.
|
||||
-->
|
||||
@if (sanctionsStrCases.length) {
|
||||
<div class="fiu-section">
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Sanctions STRs (UAPA s.51-A)</h3>
|
||||
<p>
|
||||
Confirmed matches against the UN Security Council Consolidated List. Accounts are frozen; the STR must
|
||||
reach FIU-IND within 7 days of confirmation.
|
||||
</p>
|
||||
</div>
|
||||
<select class="fiu-inp" [(ngModel)]="sanctionsStrFilter" (ngModelChange)="loadSanctionsStrCases()">
|
||||
<option value="">All</option>
|
||||
<option value="PENDING_FILING">Pending filing</option>
|
||||
<option value="FILED">Filed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alert</th>
|
||||
<th>Client</th>
|
||||
<th>Screened Name</th>
|
||||
<th>Listed As</th>
|
||||
<th>Confirmed On</th>
|
||||
<th>Due</th>
|
||||
<th>Status</th>
|
||||
<th>STR Reference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (str of sanctionsStrCases; track str.id) {
|
||||
<tr>
|
||||
<td class="mono">#{{ str.id }}</td>
|
||||
<td>
|
||||
@if (str.clientId) {
|
||||
<a [routerLink]="['/clients', str.clientId, 'general']">{{ str.clientName || str.clientId }}</a>
|
||||
} @else {
|
||||
<span class="muted">Not onboarded</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ str.screenedName }}</td>
|
||||
<td>{{ str.matchedNames || '—' }}</td>
|
||||
<td>{{ str.approvedOn | date: 'mediumDate' }}</td>
|
||||
<td [class.str-overdue]="isStrOverdue(str)">{{ str.strDueDate || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="strStatusClass(str)">{{ strStatusLabel(str) }}</span>
|
||||
</td>
|
||||
<td class="mono">{{ str.fiuStrReference || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
||||
<div class="fiu-form-meta">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import {
|
||||
BRANCHES,
|
||||
@@ -130,6 +131,7 @@ export class FiuComponent implements OnInit {
|
||||
private static readonly SEED_VERSION = '2026-01-cash-threshold';
|
||||
|
||||
private amlService = inject(AmlService);
|
||||
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
/** Active tab */
|
||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||
@@ -176,6 +178,13 @@ export class FiuComponent implements OnInit {
|
||||
|
||||
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
|
||||
amlAlerts: any[] = [];
|
||||
|
||||
/**
|
||||
* Confirmed UNSC sanctions matches carrying an STR obligation. Held separately from amlAlerts
|
||||
* because they are identity-driven rather than transaction-driven — see the template comment.
|
||||
*/
|
||||
sanctionsStrCases: any[] = [];
|
||||
sanctionsStrFilter = '';
|
||||
amlAlertStatusFilter = '';
|
||||
amlReviewNotes: Record<number, string> = {};
|
||||
|
||||
@@ -320,6 +329,37 @@ export class FiuComponent implements OnInit {
|
||||
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
|
||||
}
|
||||
|
||||
/** Loads confirmed sanctions matches that owe an STR. */
|
||||
loadSanctionsStrCases() {
|
||||
this.sanctionsLogsService.getStrCases(this.sanctionsStrFilter || undefined).subscribe({
|
||||
next: (rows: any) => (this.sanctionsStrCases = rows || []),
|
||||
// A failure here must not blank the AML side of the tab, which is unrelated.
|
||||
error: () => (this.sanctionsStrCases = [])
|
||||
});
|
||||
}
|
||||
|
||||
strStatusLabel(str: any): string {
|
||||
if (str?.strStatus === 'FILED') {
|
||||
return 'FILED';
|
||||
}
|
||||
return this.isStrOverdue(str) ? 'OVERDUE' : 'PENDING FILING';
|
||||
}
|
||||
|
||||
strStatusClass(str: any): string {
|
||||
if (str?.strStatus === 'FILED') {
|
||||
return 'fiu-pill fiu-pill--ok';
|
||||
}
|
||||
return this.isStrOverdue(str) ? 'fiu-pill fiu-pill--danger' : 'fiu-pill fiu-pill--warn';
|
||||
}
|
||||
|
||||
/** Past its 7-day PMLA deadline and still unfiled — the state that actually needs chasing. */
|
||||
isStrOverdue(str: any): boolean {
|
||||
if (!str?.strDueDate || str.strStatus === 'FILED') {
|
||||
return false;
|
||||
}
|
||||
return new Date(str.strDueDate) < new Date(new Date().toDateString());
|
||||
}
|
||||
|
||||
get isPrincipalOfficer(): boolean {
|
||||
return this.authenticationService.isPrincipalOfficerUser();
|
||||
}
|
||||
@@ -547,6 +587,7 @@ export class FiuComponent implements OnInit {
|
||||
*/
|
||||
loadAmlAlerts(): void {
|
||||
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
||||
this.loadSanctionsStrCases();
|
||||
}
|
||||
|
||||
loadAmlCtrReports(): void {
|
||||
|
||||
@@ -7,556 +7,121 @@
|
||||
-->
|
||||
|
||||
<div class="alm-dlg">
|
||||
<!-- HEADER -->
|
||||
<div class="alm-head">
|
||||
<!-- Window chrome. Screen only — the print takes the statement's own heading, not this bar. -->
|
||||
<div class="alm-head no-print">
|
||||
<div class="alm-head-left">
|
||||
<div class="alm-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
|
||||
<div>
|
||||
<h2 class="alm-title">Asset Liability Management — Structural Liquidity</h2>
|
||||
<h2 class="alm-title">Average Liabilities and Assets</h2>
|
||||
<p class="alm-sub">
|
||||
{{ bank.name }} · <strong>{{ bank.formCode }}</strong> · {{ bank.quarter }} · As on
|
||||
<strong>{{ bank.asOnDate }}</strong>
|
||||
{{ bank.name }} · {{ branchName }} · FY {{ bank.financialYear }} · Month-wise during the
|
||||
year
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alm-head-actions">
|
||||
<span class="alm-overall" [ngClass]="gap14dWithinTolerance && gap28dWithinTolerance ? 'on-target' : 'below'">
|
||||
<span class="alm-overall-l">14d Tolerance</span>
|
||||
<span class="alm-overall-v">{{ gap14dPct.toFixed(2) }}%</span>
|
||||
<span class="alm-overall-t">/ -{{ bank.tolerance14d }}%</span>
|
||||
</span>
|
||||
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Report parameters and actions, the way a report is run in the core system -->
|
||||
<div class="alm-params no-print">
|
||||
<label class="alm-param">
|
||||
<span>Financial year</span>
|
||||
<output class="alm-param-static">{{ bank.financialYear }}</output>
|
||||
</label>
|
||||
|
||||
<label class="alm-param">
|
||||
<span>Up to month</span>
|
||||
<select [(ngModel)]="upToIndex" (ngModelChange)="runReport()">
|
||||
<option *ngFor="let m of monthOptions" [ngValue]="m.index">{{ m.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="alm-param">
|
||||
<span>Months included</span>
|
||||
<output class="alm-param-static">{{ statementRows.length }}</output>
|
||||
</label>
|
||||
|
||||
<span class="alm-params-sp"></span>
|
||||
|
||||
<button type="button" class="alm-btn" (click)="print()"><fa-icon icon="print"></fa-icon> Print</button>
|
||||
<button type="button" class="alm-btn" (click)="exportXls()">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="alm-note no-print">
|
||||
<fa-icon icon="info-circle"></fa-icon> <strong>{{ unmappedColumns }}</strong> are not mapped in the
|
||||
core-system extract and print as 0.00. Total and Average cover the {{ statementRows.length }} month(s) shown.
|
||||
</p>
|
||||
|
||||
<!-- ═══ THE STATEMENT — what goes on paper ═══ -->
|
||||
<div class="alm-body">
|
||||
<div class="stmt" #statement>
|
||||
<div class="stmt-bank">{{ statementHeading }}</div>
|
||||
<div class="stmt-title">Average Liabilities and Assets Month-wise during the year</div>
|
||||
|
||||
<div class="stmt-tbl-wrap">
|
||||
<table class="stmt-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="col-month">Month</th>
|
||||
<th rowspan="2">Capital</th>
|
||||
<th rowspan="2">Reserve/Prov<br />Exclude OIR</th>
|
||||
<th rowspan="2">Deposits</th>
|
||||
<th rowspan="2">Borrowings</th>
|
||||
<th rowspan="2">Other Liabilities</th>
|
||||
<th rowspan="2">Cash on Hand</th>
|
||||
<th colspan="2" class="col-grp">Bank Balances</th>
|
||||
<th rowspan="2">Investments<br />including</th>
|
||||
<th rowspan="2">Advances</th>
|
||||
<th rowspan="2">Other Assets<br />Exclude IRA</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Current</th>
|
||||
<th>FDs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let r of statementRows">
|
||||
<td class="col-month">
|
||||
<strong>{{ r.label }}</strong
|
||||
><br /><span class="stmt-yr">{{ r.sub }}</span>
|
||||
</td>
|
||||
<td>{{ r.capital | number: '1.2-2' }}</td>
|
||||
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||
<td>{{ r.deposits | number: '1.2-2' }}</td>
|
||||
<td>{{ r.borrowings | number: '1.2-2' }}</td>
|
||||
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||
<td>{{ r.bankFds | number: '1.2-2' }}</td>
|
||||
<td>{{ r.investments | number: '1.2-2' }}</td>
|
||||
<td>{{ r.advances | number: '1.2-2' }}</td>
|
||||
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="stmt-sum" *ngFor="let r of [totalRow, averageRow]">
|
||||
<td class="col-month">{{ r.label }}</td>
|
||||
<td>{{ r.capital | number: '1.2-2' }}</td>
|
||||
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||
<td>{{ r.deposits | number: '1.2-2' }}</td>
|
||||
<td>{{ r.borrowings | number: '1.2-2' }}</td>
|
||||
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||
<td>{{ r.bankFds | number: '1.2-2' }}</td>
|
||||
<td>{{ r.investments | number: '1.2-2' }}</td>
|
||||
<td>{{ r.advances | number: '1.2-2' }}</td>
|
||||
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="stmt-foot">
|
||||
<div>Print Date : {{ generatedAt }}</div>
|
||||
<div>Report Ref No : {{ bank.reportRefNo }} User Code:{{ statementUserCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABS -->
|
||||
<div class="alm-tabs">
|
||||
<button class="alm-tab" [class.active]="activeTab === 'dashboard'" (click)="setTab('dashboard')">
|
||||
<fa-icon icon="chart-pie"></fa-icon> Dashboard
|
||||
</button>
|
||||
<button class="alm-tab" [class.active]="activeTab === 'ladder'" (click)="setTab('ladder')">
|
||||
<fa-icon icon="layer-group"></fa-icon> Maturity Ladder
|
||||
</button>
|
||||
<button class="alm-tab" [class.active]="activeTab === 'monthly'" (click)="setTab('monthly')">
|
||||
<fa-icon icon="calendar-alt"></fa-icon> Monthly Snapshot
|
||||
</button>
|
||||
<button class="alm-tab" [class.active]="activeTab === 'avgbal'" (click)="setTab('avgbal')">
|
||||
<fa-icon icon="table"></fa-icon> Avg. Liabilities & Assets
|
||||
</button>
|
||||
<button class="alm-tab" [class.active]="activeTab === 'returns'" (click)="setTab('returns')">
|
||||
<fa-icon icon="file-invoice"></fa-icon> Form ALM-1
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="alm-body">
|
||||
<!-- ════════════ DASHBOARD ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'dashboard'">
|
||||
<div class="alm-stats">
|
||||
<div class="alm-stat-card alm-stat-gauge">
|
||||
<div
|
||||
class="gauge"
|
||||
[style.--pct.%]="totalGapPct > 0 ? (totalGapPct > 100 ? 100 : totalGapPct) : 0"
|
||||
[ngClass]="totalGap >= 0 ? 'on-target' : 'below'"
|
||||
>
|
||||
<div class="gauge-inner">
|
||||
<div class="gauge-pct">{{ totalGapPct.toFixed(1) }}%</div>
|
||||
<div class="gauge-label">Net Gap / Outflow</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alm-stat-card">
|
||||
<div class="alm-stat-l">NDTL</div>
|
||||
<div class="alm-stat-v">₹ {{ bank.ndtl / 100 | number: '1.2-2' }} Cr</div>
|
||||
<div class="alm-stat-meta">Net Demand & Time Liabilities</div>
|
||||
</div>
|
||||
<div class="alm-stat-card">
|
||||
<div class="alm-stat-l">Total Outflows</div>
|
||||
<div class="alm-stat-v">₹ {{ totalOutflow / 100 | number: '1.2-2' }} Cr</div>
|
||||
<div class="alm-stat-meta">All maturity buckets</div>
|
||||
</div>
|
||||
<div class="alm-stat-card">
|
||||
<div class="alm-stat-l">Total Inflows</div>
|
||||
<div class="alm-stat-v">₹ {{ totalInflow / 100 | number: '1.2-2' }} Cr</div>
|
||||
<div class="alm-stat-meta" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
||||
Net: {{ totalGap >= 0 ? '+' : '' }}{{ totalGap / 100 | number: '1.2-2' }} Cr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOLERANCE BARS -->
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">RBI Tolerance Limits — Cumulative Gap</h3>
|
||||
<span class="alm-circular">RBI MD: {{ bank.rbiCircular }}</span>
|
||||
</div>
|
||||
<div class="alm-tolerance">
|
||||
<div class="alm-tol-row" [ngClass]="gap14dWithinTolerance ? 'good' : 'bad'">
|
||||
<div class="alm-tol-h">
|
||||
<span class="alm-tol-l"
|
||||
><fa-icon icon="hourglass-half"></fa-icon> Cumulative Gap up to 14 days</span
|
||||
>
|
||||
<span class="alm-tol-p">
|
||||
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
|
||||
<span class="alm-tol-lim">limit -{{ bank.tolerance14d }}%</span>
|
||||
<span class="alm-tol-st">{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="alm-tol-track">
|
||||
<div
|
||||
class="alm-tol-fill"
|
||||
[style.width.%]="gap14dPct >= 0 ? 100 : 100 + (gap14dPct / bank.tolerance14d) * 100"
|
||||
></div>
|
||||
<div class="alm-tol-marker"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alm-tol-row" [ngClass]="gap28dWithinTolerance ? 'good' : 'bad'">
|
||||
<div class="alm-tol-h">
|
||||
<span class="alm-tol-l"><fa-icon icon="clock"></fa-icon> Cumulative Gap up to 30 days</span>
|
||||
<span class="alm-tol-p">
|
||||
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
|
||||
<span class="alm-tol-lim">limit -{{ bank.tolerance28d }}%</span>
|
||||
<span class="alm-tol-st">{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="alm-tol-track">
|
||||
<div
|
||||
class="alm-tol-fill"
|
||||
[style.width.%]="gap28dPct >= 0 ? 100 : 100 + (gap28dPct / bank.tolerance28d) * 100"
|
||||
></div>
|
||||
<div class="alm-tol-marker"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BUCKET MINI HEATMAP -->
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">Bucket-wise Mismatch Heatmap</h3>
|
||||
</div>
|
||||
<div class="alm-heatmap">
|
||||
<div
|
||||
*ngFor="let b of buckets"
|
||||
class="heat-cell"
|
||||
[ngClass]="bucketStatus(b)"
|
||||
[matTooltip]="b.label + ' — Gap: ' + bucketGapPct(b).toFixed(2) + '%'"
|
||||
>
|
||||
<div class="heat-code">{{ b.code }}</div>
|
||||
<div class="heat-pct">{{ bucketGapPct(b).toFixed(1) }}%</div>
|
||||
<div class="heat-lbl">{{ b.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">Reporting Snapshot</h3>
|
||||
</div>
|
||||
<div class="alm-info-grid">
|
||||
<div class="alm-info">
|
||||
<div class="alm-info-l">Reporting Bank</div>
|
||||
<div class="alm-info-v">{{ bank.name }}</div>
|
||||
</div>
|
||||
<div class="alm-info">
|
||||
<div class="alm-info-l">Branch</div>
|
||||
<div class="alm-info-v">{{ bank.branch }}</div>
|
||||
</div>
|
||||
<div class="alm-info">
|
||||
<div class="alm-info-l">Principal Officer</div>
|
||||
<div class="alm-info-v">{{ bank.principalOfficer }}</div>
|
||||
</div>
|
||||
<div class="alm-info">
|
||||
<div class="alm-info-l">Form Code</div>
|
||||
<div class="alm-info-v">{{ bank.formCode }}</div>
|
||||
</div>
|
||||
<div class="alm-info">
|
||||
<div class="alm-info-l">Report Ref No</div>
|
||||
<div class="alm-info-v mono">{{ bank.reportRefNo }}</div>
|
||||
</div>
|
||||
<div class="alm-info">
|
||||
<div class="alm-info-l">User Code</div>
|
||||
<div class="alm-info-v mono">{{ bank.userCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ MATURITY LADDER ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'ladder'">
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">Statement of Structural Liquidity — Maturity Ladder</h3>
|
||||
<div class="alm-sec-actions">
|
||||
<select class="alm-sel" [(ngModel)]="filterBucket">
|
||||
<option value="">All Buckets</option>
|
||||
<option value="short">Short Term (≤ 30 days)</option>
|
||||
<option value="medium">Medium Term (1–12 months)</option>
|
||||
<option value="long">Long Term (> 1 year)</option>
|
||||
</select>
|
||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportLadderCsv()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alm-tbl-wrap">
|
||||
<table class="alm-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Time Period</th>
|
||||
<th class="ta-r">Outflow (Liab)</th>
|
||||
<th class="ta-r">Inflow (Assets)</th>
|
||||
<th class="ta-r">Gap</th>
|
||||
<th class="ta-r">Gap %</th>
|
||||
<th class="ta-c">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let b of filteredBuckets">
|
||||
<td class="mono">{{ b.code }}</td>
|
||||
<td>{{ b.label }}</td>
|
||||
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
|
||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
||||
<strong>{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
||||
{{ bucketGapPct(b) >= 0 ? '+' : '' }}{{ bucketGapPct(b).toFixed(2) }}%
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span class="status-pill" [ngClass]="bucketStatus(b)">
|
||||
<ng-container *ngIf="bucketStatus(b) === 'surplus'">SURPLUS</ng-container>
|
||||
<ng-container *ngIf="bucketStatus(b) === 'deficit'">DEFICIT</ng-container>
|
||||
<ng-container *ngIf="bucketStatus(b) === 'balanced'">BALANCED</ng-container>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="row-total">
|
||||
<td colspan="2"><strong>TOTAL</strong></td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
||||
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
||||
<strong>{{ totalGapPct >= 0 ? '+' : '' }}{{ totalGapPct.toFixed(2) }}%</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Cumulative table -->
|
||||
<h4 class="form-iii-t">Cumulative Position</h4>
|
||||
<div class="alm-tbl-wrap">
|
||||
<table class="alm-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Through Bucket</th>
|
||||
<th class="ta-r">Cum. Outflow</th>
|
||||
<th class="ta-r">Cum. Inflow</th>
|
||||
<th class="ta-r">Cum. Gap</th>
|
||||
<th class="ta-r">Cum. Gap %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let b of buckets; let i = index">
|
||||
<td>{{ b.code }} — {{ b.label }}</td>
|
||||
<td class="ta-r">{{ cumulativeOutflow(i) | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ cumulativeInflow(i) | number: '1.2-2' }}</td>
|
||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
||||
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
||||
<strong>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ MONTHLY ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'monthly'">
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">Monthly Asset / Liability Snapshot — FY {{ bank.fiscalYear }}</h3>
|
||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportMonthlyXls()">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export Excel
|
||||
</button>
|
||||
</div>
|
||||
<div class="alm-tbl-wrap">
|
||||
<table class="alm-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">Month</th>
|
||||
<th colspan="4" class="ta-c grp-liab">Liabilities (₹ Lakh)</th>
|
||||
<th colspan="4" class="ta-c grp-asset">Assets (₹ Lakh)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="ta-r">Reserve/Prov</th>
|
||||
<th class="ta-r">Borrowings</th>
|
||||
<th class="ta-r">FDs</th>
|
||||
<th class="ta-r">Total Liab</th>
|
||||
<th class="ta-r">Cash</th>
|
||||
<th class="ta-r">Investments</th>
|
||||
<th class="ta-r">Other Assets</th>
|
||||
<th class="ta-r">Total Assets</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let m of monthly">
|
||||
<td class="mono">{{ m.month }}</td>
|
||||
<td class="ta-r">{{ m.reserveProv | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ m.borrowings | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ m.fds | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ monthTotalLiab(m) | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r">{{ m.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ m.investments | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ m.otherAssets | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ monthTotalAssets(m) | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="row-total">
|
||||
<td><strong>AVERAGE</strong></td>
|
||||
<td class="ta-r">{{ avg('reserveProv') | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ avg('borrowings') | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ avg('fds') | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ avg('reserveProv') + avg('borrowings') + avg('fds') | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r">{{ avg('cashOnHand') | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ avg('investments') | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ avg('otherAssets') | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ avg('cashOnHand') + avg('investments') + avg('otherAssets') | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ AVERAGE LIABILITIES AND ASSETS ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'avgbal'">
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">Average Liabilities and Assets Month-wise during the year</h3>
|
||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportAvgBalancesXls()">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printed-statement banner: institution and office come off the session, not the report. -->
|
||||
<div class="alm-stmt">
|
||||
<div class="alm-stmt-bank">{{ statementHeading }}</div>
|
||||
<div class="alm-stmt-title">Average Liabilities and Assets Month-wise during the year</div>
|
||||
|
||||
<div class="alm-tbl-wrap">
|
||||
<table class="alm-tbl alm-stmt-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">Month</th>
|
||||
<th rowspan="2" class="ta-r">Capital</th>
|
||||
<th rowspan="2" class="ta-r">Reserve/Prov<br />Exclude OIR</th>
|
||||
<th rowspan="2" class="ta-r">Deposits</th>
|
||||
<th rowspan="2" class="ta-r">Borrowings</th>
|
||||
<th rowspan="2" class="ta-r">Other Liabilities</th>
|
||||
<th rowspan="2" class="ta-r">Cash on Hand</th>
|
||||
<th colspan="2" class="ta-c">Bank Balances</th>
|
||||
<th rowspan="2" class="ta-r">Investments<br />including</th>
|
||||
<th rowspan="2" class="ta-r">Advances</th>
|
||||
<th rowspan="2" class="ta-r">Other Assets<br />Exclude IRA</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="ta-r">Current</th>
|
||||
<th class="ta-r">FDs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let r of avgBalanceRows">
|
||||
<td class="mono">
|
||||
<strong>{{ r.label }}</strong
|
||||
><br /><span class="alm-stmt-sub">{{ r.sub }}</span>
|
||||
</td>
|
||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="row-total" *ngFor="let r of [avgBalanceTotal, avgBalanceAverage]">
|
||||
<td>{{ r.label }}</td>
|
||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="alm-stmt-foot">
|
||||
<div>Print Date : {{ statementPrintedAt }}</div>
|
||||
<div>Report Ref No : {{ bank.reportRefNo }} User Code:{{ statementUserCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ RETURNS / FORM ALM-1 ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'returns'">
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">{{ bank.formCode }} — Statement of Structural Liquidity</h3>
|
||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportFormAlm()">
|
||||
<fa-icon icon="file-excel"></fa-icon> Generate {{ bank.formCode }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-iii">
|
||||
<div class="form-iii-h">
|
||||
<div class="form-iii-bank">
|
||||
<strong>{{ bank.name }}</strong> — {{ bank.branch }}
|
||||
</div>
|
||||
<div class="form-iii-meta">
|
||||
<span><fa-icon icon="calendar"></fa-icon> As On {{ bank.asOnDate }}</span>
|
||||
<span><fa-icon icon="university"></fa-icon> {{ bank.quarter }}</span>
|
||||
<span class="mono"><fa-icon icon="hashtag"></fa-icon> Ref: {{ bank.reportRefNo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="form-iii-t">A. NDTL & Reference</h4>
|
||||
<table class="alm-tbl form-tbl">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Net Demand & Time Liabilities (NDTL) (₹ Lakhs)</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ bank.ndtl | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RBI Master Direction</td>
|
||||
<td class="ta-r mono">{{ bank.rbiCircular }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 class="form-iii-t">B. Maturity Ladder (₹ in Lakhs)</h4>
|
||||
<table class="alm-tbl form-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Time Period</th>
|
||||
<th class="ta-r">Outflows</th>
|
||||
<th class="ta-r">Inflows</th>
|
||||
<th class="ta-r">Mismatch</th>
|
||||
<th class="ta-r">Cum. Mismatch</th>
|
||||
<th class="ta-r">Cum. % of Outflow</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let b of buckets; let i = index">
|
||||
<td class="mono">{{ b.code }}</td>
|
||||
<td>{{ b.label }}</td>
|
||||
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
|
||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
||||
{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
||||
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="ta-r">
|
||||
<strong [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'"
|
||||
>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="form-total">
|
||||
<td colspan="2"><strong>TOTAL</strong></td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
||||
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 class="form-iii-t">C. RBI Tolerance Compliance</h4>
|
||||
<table class="alm-tbl form-tbl">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cumulative Negative Gap up to 14 days</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
|
||||
</td>
|
||||
<td class="ta-r">Limit: -{{ bank.tolerance14d }}%</td>
|
||||
<td class="ta-c">
|
||||
<span class="status-pill" [ngClass]="gap14dWithinTolerance ? 'on-target' : 'below'">
|
||||
{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cumulative Negative Gap up to 30 days</td>
|
||||
<td class="ta-r">
|
||||
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
|
||||
</td>
|
||||
<td class="ta-r">Limit: -{{ bank.tolerance28d }}%</td>
|
||||
<td class="ta-c">
|
||||
<span class="status-pill" [ngClass]="gap28dWithinTolerance ? 'on-target' : 'below'">
|
||||
{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="form-iii-foot">
|
||||
<div class="cert">
|
||||
<div class="cert-l">Certified by</div>
|
||||
<div class="cert-v">{{ bank.principalOfficer }}</div>
|
||||
<div class="cert-r">Principal Officer — {{ bank.name }}</div>
|
||||
</div>
|
||||
<div class="cert">
|
||||
<div class="cert-l">Submitted under</div>
|
||||
<div class="cert-v mono">{{ bank.rbiCircular }}</div>
|
||||
<div class="cert-r">RBI Master Direction on Asset Liability Management</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, ElementRef, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule, DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
@@ -16,42 +16,34 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
|
||||
interface MaturityBucket {
|
||||
code: string;
|
||||
label: string;
|
||||
/** Outflows / Liabilities (₹ Lakhs) */
|
||||
outflow: number;
|
||||
/** Inflows / Assets (₹ Lakhs) */
|
||||
inflow: number;
|
||||
}
|
||||
|
||||
interface MonthSnapshot {
|
||||
/**
|
||||
* One month of the average liabilities and assets extract, column for column as the core system
|
||||
* prints it.
|
||||
*/
|
||||
interface MonthlyAlmRow {
|
||||
/** e.g. 'APR-2025' */
|
||||
month: string;
|
||||
reserveProv: number;
|
||||
borrowings: number;
|
||||
fds: number;
|
||||
cashOnHand: number;
|
||||
investments: number;
|
||||
otherAssets: number;
|
||||
// ── Columns the month-wise average statement adds to the snapshot above ──
|
||||
// The statement is a full balance sheet averaged over each month, so it needs the own-funds
|
||||
// and deposit sides the liquidity tabs do not use, and it splits bank balances from cash.
|
||||
/** Paid-up share capital. */
|
||||
capital: number;
|
||||
/** Total deposits — the FD component above is part of this figure. */
|
||||
reserveProv: number;
|
||||
/** Total deposits — demand and time. */
|
||||
deposits: number;
|
||||
borrowings: number;
|
||||
otherLiabilities: number;
|
||||
cashOnHand: number;
|
||||
/** Balances with other banks, current accounts. */
|
||||
bankCurrent: number;
|
||||
/** Balances with other banks, held as fixed deposits. */
|
||||
bankFds: number;
|
||||
investments: number;
|
||||
advances: number;
|
||||
otherAssets: number;
|
||||
}
|
||||
|
||||
/** One row of the month-wise average statement — a month, or the Total / Average line. */
|
||||
interface AvgBalanceRow {
|
||||
/** One printed line — a month, or the Total / Average line the statement closes with. */
|
||||
interface StatementRow {
|
||||
label: string;
|
||||
/** Sub-label under the month, e.g. the year, as the printed statement prints it. */
|
||||
/** Sub-label under the month, the year, as the printed statement sets it. */
|
||||
sub: string;
|
||||
capital: number;
|
||||
reserveProv: number;
|
||||
@@ -66,6 +58,18 @@ interface AvgBalanceRow {
|
||||
otherAssets: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Average Liabilities and Assets Month-wise during the year.
|
||||
*
|
||||
* One report, one statement, the way the core system prints it — a parameter line, the statement,
|
||||
* and the print footer. It is deliberately not a dashboard: an ALM screen in a bank is a report
|
||||
* you run and print, not a set of tabs, and figures that go out over the bank's name have to look
|
||||
* the same on screen as they do on paper.
|
||||
*
|
||||
* Month rows are the figures as extracted. Investments, Advances, Bank-FD balances and Other
|
||||
* Assets are not mapped in the extract and print as 0.00 — stated on screen rather than filled in,
|
||||
* because a nil that is really "not captured" must not read as a real balance.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-alm-report-dialog',
|
||||
standalone: true,
|
||||
@@ -86,310 +90,226 @@ export class AlmReportDialogComponent {
|
||||
private branchScope = inject(BranchScopeService);
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns' = 'dashboard';
|
||||
/** The rendered statement, handed to the print window as-is. */
|
||||
@ViewChild('statement') private statementRef?: ElementRef<HTMLElement>;
|
||||
|
||||
bank = {
|
||||
readonly bank = {
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
asOnDate: '31-03-2026',
|
||||
quarter: 'Q4 FY 2025-26',
|
||||
fiscalYear: '2025-26',
|
||||
rbiCircular: 'DCBR.BPD.(PCB).Cir.No.4/12.05.001/2018-19',
|
||||
principalOfficer: 'Karthik Kamath',
|
||||
formCode: 'Form ALM-1',
|
||||
financialYear: '2025-26',
|
||||
reportRefNo: '1036',
|
||||
userCode: 'CBSN01',
|
||||
/** Net Demand & Time Liabilities — denominator for ratios */
|
||||
ndtl: 740000, // ₹7,400 Cr in Lakhs
|
||||
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
|
||||
tolerance14d: 5,
|
||||
tolerance28d: 10
|
||||
userCode: 'CBSN01'
|
||||
};
|
||||
|
||||
/** Standard RBI ALM time buckets per Master Direction */
|
||||
buckets: MaturityBucket[] = [
|
||||
{ code: 'B01', label: 'Day 1', outflow: 1840, inflow: 2120 },
|
||||
{ code: 'B02', label: '2 to 7 days', outflow: 6420, inflow: 5880 },
|
||||
{ code: 'B03', label: '8 to 14 days', outflow: 4180, inflow: 4360 },
|
||||
{ code: 'B04', label: '15 to 30 days', outflow: 8240, inflow: 7920 },
|
||||
{ code: 'B05', label: '31 days to 2 months', outflow: 12480, inflow: 13160 },
|
||||
{ code: 'B06', label: '> 2 months to 3 months', outflow: 9620, inflow: 10240 },
|
||||
{ code: 'B07', label: '> 3 months to 6 months', outflow: 28640, inflow: 32180 },
|
||||
{ code: 'B08', label: '> 6 months to 1 year', outflow: 64280, inflow: 68420 },
|
||||
{ code: 'B09', label: '> 1 year to 3 years', outflow: 142800, inflow: 156400 },
|
||||
{ code: 'B10', label: '> 3 years to 5 years', outflow: 96400, inflow: 108200 },
|
||||
{ code: 'B11', label: 'Over 5 years', outflow: 124800, inflow: 138400 }
|
||||
];
|
||||
|
||||
/** Monthly snapshot — mirrors the format of the legacy ALM Excel */
|
||||
monthly: MonthSnapshot[] = [
|
||||
/**
|
||||
* The financial year, April to March. A part-year print simply stops at the month asked for, which
|
||||
* is why `upToIndex` exists rather than the row list being cut to size here.
|
||||
*/
|
||||
monthly: MonthlyAlmRow[] = [
|
||||
{
|
||||
month: 'APR-2025',
|
||||
reserveProv: 18420,
|
||||
borrowings: 24180,
|
||||
fds: 462800,
|
||||
cashOnHand: 8420,
|
||||
investments: 184600,
|
||||
otherAssets: 12480,
|
||||
capital: 24200,
|
||||
deposits: 602800,
|
||||
otherLiabilities: 28400,
|
||||
bankCurrent: 18900,
|
||||
bankFds: 46200,
|
||||
advances: 372400
|
||||
capital: 264.66,
|
||||
reserveProv: 720.83,
|
||||
deposits: 6108.63,
|
||||
borrowings: 24.47,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 172.44,
|
||||
bankCurrent: 190.09,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'MAY-2025',
|
||||
reserveProv: 18640,
|
||||
borrowings: 23980,
|
||||
fds: 468200,
|
||||
cashOnHand: 8680,
|
||||
investments: 186400,
|
||||
otherAssets: 12620,
|
||||
capital: 24280,
|
||||
deposits: 608600,
|
||||
otherLiabilities: 28560,
|
||||
bankCurrent: 19240,
|
||||
bankFds: 46600,
|
||||
advances: 376800
|
||||
capital: 270.73,
|
||||
reserveProv: 722.11,
|
||||
deposits: 6181.15,
|
||||
borrowings: 26.36,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 129.0,
|
||||
bankCurrent: 218.27,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'JUN-2025',
|
||||
reserveProv: 18820,
|
||||
borrowings: 23640,
|
||||
fds: 472400,
|
||||
cashOnHand: 9120,
|
||||
investments: 188200,
|
||||
otherAssets: 12840,
|
||||
capital: 24360,
|
||||
deposits: 613400,
|
||||
otherLiabilities: 28720,
|
||||
bankCurrent: 19580,
|
||||
bankFds: 47000,
|
||||
advances: 381200
|
||||
capital: 272.61,
|
||||
reserveProv: 722.11,
|
||||
deposits: 6291.01,
|
||||
borrowings: 33.0,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 115.01,
|
||||
bankCurrent: 322.77,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'JUL-2025',
|
||||
reserveProv: 19080,
|
||||
borrowings: 23420,
|
||||
fds: 478600,
|
||||
cashOnHand: 9280,
|
||||
investments: 190400,
|
||||
otherAssets: 13020,
|
||||
capital: 24460,
|
||||
deposits: 619800,
|
||||
otherLiabilities: 28880,
|
||||
bankCurrent: 19860,
|
||||
bankFds: 47400,
|
||||
advances: 385600
|
||||
capital: 274.06,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6299.45,
|
||||
borrowings: 50.07,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 84.82,
|
||||
bankCurrent: 267.13,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'AUG-2025',
|
||||
reserveProv: 19280,
|
||||
borrowings: 23180,
|
||||
fds: 482400,
|
||||
cashOnHand: 9420,
|
||||
investments: 192800,
|
||||
otherAssets: 13280,
|
||||
capital: 24540,
|
||||
deposits: 624200,
|
||||
otherLiabilities: 29040,
|
||||
bankCurrent: 20180,
|
||||
bankFds: 47800,
|
||||
advances: 390200
|
||||
capital: 273.22,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6404.97,
|
||||
borrowings: 26.53,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 111.77,
|
||||
bankCurrent: 253.24,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'SEP-2025',
|
||||
reserveProv: 19460,
|
||||
borrowings: 22940,
|
||||
fds: 488200,
|
||||
cashOnHand: 9640,
|
||||
investments: 195200,
|
||||
otherAssets: 13420,
|
||||
capital: 24620,
|
||||
deposits: 630400,
|
||||
otherLiabilities: 29200,
|
||||
bankCurrent: 20460,
|
||||
bankFds: 48200,
|
||||
advances: 394800
|
||||
capital: 275.34,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6361.83,
|
||||
borrowings: 30.3,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 136.45,
|
||||
bankCurrent: 190.83,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'OCT-2025',
|
||||
reserveProv: 19680,
|
||||
borrowings: 22680,
|
||||
fds: 492600,
|
||||
cashOnHand: 9820,
|
||||
investments: 197600,
|
||||
otherAssets: 13620,
|
||||
capital: 24720,
|
||||
deposits: 635200,
|
||||
otherLiabilities: 29380,
|
||||
bankCurrent: 20740,
|
||||
bankFds: 48600,
|
||||
advances: 399400
|
||||
capital: 274.82,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6304.25,
|
||||
borrowings: 38.0,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 93.77,
|
||||
bankCurrent: 239.01,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'NOV-2025',
|
||||
reserveProv: 19880,
|
||||
borrowings: 22420,
|
||||
fds: 498400,
|
||||
cashOnHand: 9980,
|
||||
investments: 200200,
|
||||
otherAssets: 13820,
|
||||
capital: 24800,
|
||||
deposits: 641000,
|
||||
otherLiabilities: 29540,
|
||||
bankCurrent: 21020,
|
||||
bankFds: 49000,
|
||||
advances: 404200
|
||||
capital: 278.29,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6269.1,
|
||||
borrowings: 52.64,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 92.03,
|
||||
bankCurrent: 215.31,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'DEC-2025',
|
||||
reserveProv: 20120,
|
||||
borrowings: 22180,
|
||||
fds: 504200,
|
||||
cashOnHand: 10240,
|
||||
investments: 202800,
|
||||
otherAssets: 14040,
|
||||
capital: 24900,
|
||||
deposits: 646800,
|
||||
otherLiabilities: 29720,
|
||||
bankCurrent: 21340,
|
||||
bankFds: 49400,
|
||||
advances: 409000
|
||||
capital: 278.97,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6310.57,
|
||||
borrowings: 50.01,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 98.58,
|
||||
bankCurrent: 221.93,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'JAN-2026',
|
||||
reserveProv: 20340,
|
||||
borrowings: 21940,
|
||||
fds: 508800,
|
||||
cashOnHand: 10420,
|
||||
investments: 205200,
|
||||
otherAssets: 14240,
|
||||
capital: 24980,
|
||||
deposits: 651600,
|
||||
otherLiabilities: 29880,
|
||||
bankCurrent: 21620,
|
||||
bankFds: 49800,
|
||||
advances: 413800
|
||||
capital: 280.14,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6358.2,
|
||||
borrowings: 47.85,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 101.36,
|
||||
bankCurrent: 228.47,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'FEB-2026',
|
||||
reserveProv: 20580,
|
||||
borrowings: 21680,
|
||||
fds: 514600,
|
||||
cashOnHand: 10620,
|
||||
investments: 207800,
|
||||
otherAssets: 14460,
|
||||
capital: 25060,
|
||||
deposits: 657400,
|
||||
otherLiabilities: 30040,
|
||||
bankCurrent: 21900,
|
||||
bankFds: 50200,
|
||||
advances: 418600
|
||||
capital: 281.42,
|
||||
reserveProv: 768.4,
|
||||
deposits: 6402.75,
|
||||
borrowings: 44.62,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 104.19,
|
||||
bankCurrent: 233.08,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'MAR-2026',
|
||||
reserveProv: 20840,
|
||||
borrowings: 21420,
|
||||
fds: 520400,
|
||||
cashOnHand: 10820,
|
||||
investments: 210400,
|
||||
otherAssets: 14680,
|
||||
capital: 25160,
|
||||
deposits: 663200,
|
||||
otherLiabilities: 30220,
|
||||
bankCurrent: 22240,
|
||||
bankFds: 50600,
|
||||
advances: 423600
|
||||
capital: 283.05,
|
||||
reserveProv: 768.4,
|
||||
deposits: 6471.38,
|
||||
borrowings: 41.3,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 110.62,
|
||||
bankCurrent: 241.66,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
}
|
||||
];
|
||||
|
||||
filterBucket = '';
|
||||
/**
|
||||
* Last month included in the print. Defaults to the ninth month, DEC-2025, because that is where a
|
||||
* statement taken part-way through the year stops — the months after it have not closed.
|
||||
*/
|
||||
upToIndex = 8;
|
||||
|
||||
// -------------------- Computed --------------------
|
||||
bucketGap(b: MaturityBucket): number {
|
||||
return b.inflow - b.outflow;
|
||||
}
|
||||
bucketGapPct(b: MaturityBucket): number {
|
||||
return b.outflow === 0 ? 0 : +(((b.inflow - b.outflow) / b.outflow) * 100).toFixed(2);
|
||||
}
|
||||
cumulativeOutflow(idx: number): number {
|
||||
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.outflow, 0);
|
||||
}
|
||||
cumulativeInflow(idx: number): number {
|
||||
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.inflow, 0);
|
||||
}
|
||||
cumulativeGap(idx: number): number {
|
||||
return this.cumulativeInflow(idx) - this.cumulativeOutflow(idx);
|
||||
}
|
||||
cumulativeGapPct(idx: number): number {
|
||||
const out = this.cumulativeOutflow(idx);
|
||||
return out === 0 ? 0 : +((this.cumulativeGap(idx) / out) * 100).toFixed(2);
|
||||
}
|
||||
/** Columns the core-system extract does not map. Named on screen, not quietly filled in. */
|
||||
readonly unmappedColumns = 'Investments, Advances, Bank-FD balances and Other Assets';
|
||||
|
||||
get totalOutflow(): number {
|
||||
return this.buckets.reduce((s, b) => s + b.outflow, 0);
|
||||
}
|
||||
get totalInflow(): number {
|
||||
return this.buckets.reduce((s, b) => s + b.inflow, 0);
|
||||
}
|
||||
get totalGap(): number {
|
||||
return this.totalInflow - this.totalOutflow;
|
||||
}
|
||||
get totalGapPct(): number {
|
||||
return this.totalOutflow === 0 ? 0 : +((this.totalGap / this.totalOutflow) * 100).toFixed(2);
|
||||
}
|
||||
|
||||
/** RBI tolerance test: cumulative negative gap up to bucket 3 (14 days) ≤ 5% of cumulative outflow */
|
||||
get gap14dPct(): number {
|
||||
return this.cumulativeGapPct(2);
|
||||
}
|
||||
/** Up to bucket 4 (30 days) ≤ 10% */
|
||||
get gap28dPct(): number {
|
||||
return this.cumulativeGapPct(3);
|
||||
}
|
||||
get gap14dWithinTolerance(): boolean {
|
||||
return this.gap14dPct >= -this.bank.tolerance14d;
|
||||
}
|
||||
get gap28dWithinTolerance(): boolean {
|
||||
return this.gap28dPct >= -this.bank.tolerance28d;
|
||||
}
|
||||
|
||||
bucketStatus(b: MaturityBucket): 'surplus' | 'deficit' | 'balanced' {
|
||||
const gap = this.bucketGap(b);
|
||||
if (gap > 0) return 'surplus';
|
||||
if (gap < 0) return 'deficit';
|
||||
return 'balanced';
|
||||
}
|
||||
|
||||
// ── Average Liabilities and Assets, month-wise ────────────────────────────
|
||||
//
|
||||
// The printed statement carries the institution and office it was taken for, and who took it.
|
||||
// Those come off the session rather than being typed into the report, so a branch login prints
|
||||
// its own office and its own user code instead of whatever the last person hard-coded.
|
||||
/** Stamped when the statement is run, and again whenever the parameters change it. */
|
||||
generatedAt = this.formatPrintDate(new Date());
|
||||
|
||||
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
|
||||
get statementHeading(): string {
|
||||
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** Office the statement was taken for, off the session rather than typed into the report. */
|
||||
get branchName(): string {
|
||||
return this.branchScope.officeName;
|
||||
}
|
||||
|
||||
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
|
||||
get statementUserCode(): string {
|
||||
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
|
||||
}
|
||||
|
||||
/** Stamped when the tab is opened, so the footer records when the figures were taken off. */
|
||||
readonly statementPrintedAt = this.formatPrintDate(new Date());
|
||||
/** Month choices for the "up to" parameter, e.g. 'DEC-2025'. */
|
||||
get monthOptions(): { index: number; label: string }[] {
|
||||
return this.monthly.map((m, index) => ({ index, label: m.month }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Month rows, then the Total and Average lines the statement closes with.
|
||||
*
|
||||
* Average is the mean of the months shown, not Total ÷ 12 — a statement printed part-way
|
||||
* through the year would otherwise divide by months that have not happened yet.
|
||||
*/
|
||||
get avgBalanceRows(): AvgBalanceRow[] {
|
||||
return this.monthly.map((m) => {
|
||||
/** The months the statement prints. */
|
||||
get statementRows(): StatementRow[] {
|
||||
return this.monthly.slice(0, this.upToIndex + 1).map((m) => {
|
||||
const [
|
||||
name,
|
||||
year
|
||||
@@ -412,7 +332,7 @@ export class AlmReportDialogComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
|
||||
private readonly amountColumns: (keyof StatementRow)[] = [
|
||||
'capital',
|
||||
'reserveProv',
|
||||
'deposits',
|
||||
@@ -426,190 +346,92 @@ export class AlmReportDialogComponent {
|
||||
'otherAssets'
|
||||
];
|
||||
|
||||
get avgBalanceTotal(): AvgBalanceRow {
|
||||
const rows = this.avgBalanceRows;
|
||||
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
|
||||
for (const key of this.avgBalanceColumns) {
|
||||
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
|
||||
get totalRow(): StatementRow {
|
||||
const rows = this.statementRows;
|
||||
const out = { label: 'Total', sub: '' } as StatementRow;
|
||||
for (const key of this.amountColumns) {
|
||||
out[key] = +rows.reduce((s, r) => s + (r[key] as number), 0).toFixed(2) as never;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
get avgBalanceAverage(): AvgBalanceRow {
|
||||
const rows = this.avgBalanceRows;
|
||||
const total = this.avgBalanceTotal;
|
||||
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
|
||||
for (const key of this.avgBalanceColumns) {
|
||||
/**
|
||||
* Mean of the months printed, not Total ÷ 12 — a statement that stops at December must divide by
|
||||
* nine, or every average on it is understated.
|
||||
*/
|
||||
get averageRow(): StatementRow {
|
||||
const rows = this.statementRows;
|
||||
const total = this.totalRow;
|
||||
const out = { label: 'Average', sub: '' } as StatementRow;
|
||||
for (const key of this.amountColumns) {
|
||||
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
||||
private formatPrintDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const hours24 = d.getHours();
|
||||
const meridiem = hours24 < 12 ? 'AM' : 'PM';
|
||||
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||||
return (
|
||||
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
||||
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
|
||||
);
|
||||
/** Re-running the statement re-stamps the footer, since the figures on it have changed. */
|
||||
runReport(): void {
|
||||
this.generatedAt = this.formatPrintDate(new Date());
|
||||
}
|
||||
|
||||
/** Monthly totals helpers */
|
||||
monthTotalLiab(m: MonthSnapshot): number {
|
||||
return m.reserveProv + m.borrowings + m.fds;
|
||||
}
|
||||
monthTotalAssets(m: MonthSnapshot): number {
|
||||
return m.cashOnHand + m.investments + m.otherAssets;
|
||||
}
|
||||
/** Average across months */
|
||||
avg<K extends keyof MonthSnapshot>(key: K): number {
|
||||
if (typeof this.monthly[0][key] !== 'number') return 0;
|
||||
const total = this.monthly.reduce((s, m) => s + (m[key] as number), 0);
|
||||
return +(total / this.monthly.length).toFixed(2);
|
||||
}
|
||||
|
||||
// -------------------- Filter --------------------
|
||||
get filteredBuckets(): MaturityBucket[] {
|
||||
if (!this.filterBucket) return this.buckets;
|
||||
if (this.filterBucket === 'short') return this.buckets.slice(0, 4);
|
||||
if (this.filterBucket === 'medium') return this.buckets.slice(4, 8);
|
||||
if (this.filterBucket === 'long') return this.buckets.slice(8);
|
||||
return this.buckets;
|
||||
}
|
||||
|
||||
// -------------------- Excel Exports --------------------
|
||||
|
||||
/** Form ALM-1 — Statement of Structural Liquidity */
|
||||
exportFormAlm(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([
|
||||
this.bank.name,
|
||||
this.bank.branch
|
||||
]);
|
||||
rows.push([
|
||||
`STATEMENT OF STRUCTURAL LIQUIDITY (${this.bank.formCode})`,
|
||||
`As On ${this.bank.asOnDate}`
|
||||
]);
|
||||
rows.push([
|
||||
`RBI Circular: ${this.bank.rbiCircular}`,
|
||||
`Quarter: ${this.bank.quarter}`
|
||||
]);
|
||||
rows.push([
|
||||
`Report Ref: ${this.bank.reportRefNo}`,
|
||||
`User Code: ${this.bank.userCode}`
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'NDTL (₹ Lakhs)',
|
||||
this.bank.ndtl.toString()
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push(['Maturity Ladder (₹ in Lakhs)']);
|
||||
rows.push([
|
||||
'Bucket',
|
||||
'Time Period',
|
||||
'Outflows (Liabilities)',
|
||||
'Inflows (Assets)',
|
||||
'Mismatch (Gap)',
|
||||
'Cum. Outflows',
|
||||
'Cum. Inflows',
|
||||
'Cum. Gap',
|
||||
'Cum. Gap % of Outflow'
|
||||
]);
|
||||
this.buckets.forEach((b, i) => {
|
||||
rows.push([
|
||||
b.code,
|
||||
b.label,
|
||||
b.outflow.toFixed(2),
|
||||
b.inflow.toFixed(2),
|
||||
this.bucketGap(b).toFixed(2),
|
||||
this.cumulativeOutflow(i).toFixed(2),
|
||||
this.cumulativeInflow(i).toFixed(2),
|
||||
this.cumulativeGap(i).toFixed(2),
|
||||
this.cumulativeGapPct(i).toFixed(2) + '%'
|
||||
]);
|
||||
});
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'TOTAL',
|
||||
'',
|
||||
this.totalOutflow.toFixed(2),
|
||||
this.totalInflow.toFixed(2),
|
||||
this.totalGap.toFixed(2)
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push(['RBI Tolerance Compliance']);
|
||||
rows.push([
|
||||
'Cumulative Gap up to 14 days',
|
||||
this.gap14dPct.toFixed(2) + '%',
|
||||
`Limit: -${this.bank.tolerance14d}%`,
|
||||
this.gap14dWithinTolerance ? 'WITHIN' : 'BREACH'
|
||||
]);
|
||||
rows.push([
|
||||
'Cumulative Gap up to 30 days',
|
||||
this.gap28dPct.toFixed(2) + '%',
|
||||
`Limit: -${this.bank.tolerance28d}%`,
|
||||
this.gap28dWithinTolerance ? 'WITHIN' : 'BREACH'
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'Certified by',
|
||||
this.bank.principalOfficer + ' — Principal Officer'
|
||||
]);
|
||||
this.saveTsv(rows, `ALM-StructuralLiquidity-${this.dateTag()}.xls`);
|
||||
}
|
||||
|
||||
/** Monthly snapshot export */
|
||||
exportMonthlyXls(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([
|
||||
`${this.bank.name} — ${this.bank.branch}`,
|
||||
`Monthly ALM Snapshot — FY ${this.bank.fiscalYear}`
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'Month',
|
||||
'Reserve/Prov (Excl. OIR)',
|
||||
'Borrowings',
|
||||
'FDs',
|
||||
'Total Liabilities',
|
||||
'Cash on Hand',
|
||||
'Investments incl. Bal/Repo',
|
||||
'Other Assets (Excl. IRA)',
|
||||
'Total Assets'
|
||||
]);
|
||||
for (const m of this.monthly) {
|
||||
rows.push([
|
||||
m.month,
|
||||
m.reserveProv.toFixed(2),
|
||||
m.borrowings.toFixed(2),
|
||||
m.fds.toFixed(2),
|
||||
this.monthTotalLiab(m).toFixed(2),
|
||||
m.cashOnHand.toFixed(2),
|
||||
m.investments.toFixed(2),
|
||||
m.otherAssets.toFixed(2),
|
||||
this.monthTotalAssets(m).toFixed(2)
|
||||
]);
|
||||
/**
|
||||
* Prints the statement on its own.
|
||||
*
|
||||
* The dialog sits in an overlay on top of the application, so `window.print()` would put the whole
|
||||
* screen behind it on the paper. The rendered statement is handed to a window of its own instead,
|
||||
* which needs no application-wide print stylesheet and cannot pick up anything else on the page.
|
||||
*/
|
||||
print(): void {
|
||||
const node = this.statementRef?.nativeElement;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
rows.push([
|
||||
'AVERAGE',
|
||||
this.avg('reserveProv').toFixed(2),
|
||||
this.avg('borrowings').toFixed(2),
|
||||
this.avg('fds').toFixed(2),
|
||||
(this.avg('reserveProv') + this.avg('borrowings') + this.avg('fds')).toFixed(2),
|
||||
this.avg('cashOnHand').toFixed(2),
|
||||
this.avg('investments').toFixed(2),
|
||||
this.avg('otherAssets').toFixed(2),
|
||||
(this.avg('cashOnHand') + this.avg('investments') + this.avg('otherAssets')).toFixed(2)
|
||||
]);
|
||||
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
|
||||
const win = window.open('', '_blank', 'width=1100,height=760');
|
||||
if (!win) {
|
||||
// Blocked by the browser — fall back to the page print, which the print stylesheet handles.
|
||||
window.print();
|
||||
return;
|
||||
}
|
||||
win.document.write(
|
||||
`<!doctype html><html><head><title>Average Liabilities and Assets — ${this.bank.financialYear}</title>` +
|
||||
`<style>${AlmReportDialogComponent.PRINT_CSS}</style></head><body>${node.outerHTML}</body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
// Let the document lay out before the print dialog measures it.
|
||||
win.setTimeout(() => {
|
||||
win.print();
|
||||
win.close();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/** Average Liabilities and Assets — the printed statement, column for column. */
|
||||
exportAvgBalancesXls(): void {
|
||||
/** Stylesheet for the print window — the statement as the core system lays it out on paper. */
|
||||
private static readonly PRINT_CSS = `
|
||||
@page { size: A4 landscape; margin: 12mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
|
||||
.stmt { padding: 0; }
|
||||
.stmt-bank { font-size: 8.5pt; font-weight: 700; }
|
||||
.stmt-title { margin: 2pt 0 14pt; font-size: 7.5pt; }
|
||||
.stmt-tbl { width: 100%; border-collapse: collapse; font-size: 8pt; }
|
||||
.stmt-tbl th { padding: 0 6pt 7pt; text-align: right; vertical-align: bottom;
|
||||
font-weight: 400; font-size: 7pt; line-height: 1.3; white-space: nowrap; }
|
||||
.stmt-tbl td { padding: 5pt 6pt; text-align: right; font-weight: 700; white-space: nowrap; }
|
||||
.stmt-tbl .col-month { text-align: left; }
|
||||
.stmt-tbl .col-grp { text-align: center; padding-bottom: 2pt; }
|
||||
.stmt-tbl tr { page-break-inside: avoid; }
|
||||
.stmt-sum td { padding-top: 10pt; }
|
||||
.stmt-sum:first-of-type td { border-top: 0.5pt solid #000; }
|
||||
.stmt-sum .col-month { font-weight: 400; }
|
||||
.stmt-foot { margin-top: 24pt; font-size: 7pt; line-height: 1.8; }
|
||||
`;
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
/** Excel copy of the statement, column for column and in the same order. */
|
||||
exportXls(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([this.statementHeading]);
|
||||
rows.push(['Average Liabilities and Assets Month-wise during the year']);
|
||||
@@ -628,7 +450,7 @@ export class AlmReportDialogComponent {
|
||||
'Advances',
|
||||
'Other Assets Exclude IRA'
|
||||
]);
|
||||
const line = (r: AvgBalanceRow): string[] => [
|
||||
const line = (r: StatementRow): string[] => [
|
||||
`${r.label} ${r.sub}`.trim(),
|
||||
r.capital.toFixed(2),
|
||||
r.reserveProv.toFixed(2),
|
||||
@@ -642,71 +464,38 @@ export class AlmReportDialogComponent {
|
||||
r.advances.toFixed(2),
|
||||
r.otherAssets.toFixed(2)
|
||||
];
|
||||
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
|
||||
rows.push(line(this.avgBalanceTotal));
|
||||
rows.push(line(this.avgBalanceAverage));
|
||||
this.statementRows.forEach((r) => rows.push(line(r)));
|
||||
rows.push(line(this.totalRow));
|
||||
rows.push(line(this.averageRow));
|
||||
rows.push([]);
|
||||
rows.push([`Print Date : ${this.statementPrintedAt}`]);
|
||||
rows.push([`Not mapped in this extract: ${this.unmappedColumns}`]);
|
||||
rows.push([`Print Date : ${this.generatedAt}`]);
|
||||
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
|
||||
this.saveTsv(rows, `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`);
|
||||
|
||||
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
|
||||
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Maturity ladder CSV */
|
||||
exportLadderCsv(): void {
|
||||
const headers = [
|
||||
'Bucket',
|
||||
'Time Period',
|
||||
'Outflow (₹ Lakh)',
|
||||
'Inflow (₹ Lakh)',
|
||||
'Gap',
|
||||
'Gap %'
|
||||
];
|
||||
const rows = [headers];
|
||||
for (const b of this.buckets) {
|
||||
rows.push([
|
||||
b.code,
|
||||
b.label,
|
||||
b.outflow.toString(),
|
||||
b.inflow.toString(),
|
||||
this.bucketGap(b).toString(),
|
||||
this.bucketGapPct(b).toFixed(2) + '%'
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, `ALM-Ladder-${this.dateTag()}.csv`);
|
||||
}
|
||||
|
||||
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns'): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
private dateTag(): string {
|
||||
const d = new Date();
|
||||
return `${String(d.getDate()).padStart(2, '0')}${String(d.getMonth() + 1).padStart(2, '0')}${d.getFullYear()}`;
|
||||
}
|
||||
private saveTsv(rows: string[][], filename: string): void {
|
||||
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
|
||||
this.saveBlob(tsv, filename, 'application/vnd.ms-excel');
|
||||
}
|
||||
private saveCsv(rows: string[][], filename: string): void {
|
||||
const escape = (v: string): string => {
|
||||
const s = (v || '').replace(/"/g, '""');
|
||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s}"`;
|
||||
return s;
|
||||
};
|
||||
const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
|
||||
this.saveBlob(csv, filename, 'text/csv');
|
||||
}
|
||||
private saveBlob(content: string, filename: string, mime: string): void {
|
||||
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
||||
private formatPrintDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const hours24 = d.getHours();
|
||||
const meridiem = hours24 < 12 ? 'AM' : 'PM';
|
||||
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||||
return (
|
||||
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
||||
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<fa-icon icon="database" class="header-icon"></fa-icon>
|
||||
<h2>Database Export</h2>
|
||||
</div>
|
||||
|
||||
<!-- The export carries every client's data, so the consequence is stated before the form, not after. -->
|
||||
<div class="warning-banner">
|
||||
<fa-icon icon="exclamation-triangle" class="warning-banner__icon"></fa-icon>
|
||||
<div>
|
||||
<strong>This export contains the complete database</strong>
|
||||
— every client's personal details, KYC documents, accounts and transactions. A request must be approved by an
|
||||
administrator before the archive can be downloaded, and the approver cannot be the person who raised it. The
|
||||
archive can only be downloaded by the user who raised the request, not by the approver, and an approval is valid
|
||||
for a single download — taking another copy means raising a new request. Every request, approval and download is
|
||||
recorded.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raise a request -->
|
||||
<div class="request-card" [formGroup]="requestForm">
|
||||
<h3 class="request-card__title">Request a Full Export</h3>
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Reason for this export</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="reason"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
placeholder="e.g. Statutory audit for FY 2025-26 requested by the RBI inspection team"
|
||||
required
|
||||
></textarea>
|
||||
<mat-hint>Recorded against the request and written into the archive. Minimum 10 characters.</mat-hint>
|
||||
<mat-error *ngIf="requestForm.get('reason')?.hasError('required')">A reason is required</mat-error>
|
||||
<mat-error *ngIf="requestForm.get('reason')?.hasError('minlength')">
|
||||
Please give at least 10 characters of context
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="request-card__actions">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="requestForm.invalid || submitting"
|
||||
(click)="submitRequest()"
|
||||
>
|
||||
<fa-icon icon="paper-plane" class="chip-icon"></fa-icon>
|
||||
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request history -->
|
||||
<div class="filters-row">
|
||||
<div class="filter-chips">
|
||||
<button mat-stroked-button [class.active-filter]="!activeStatus" (click)="filterByStatus(null)">
|
||||
<fa-icon icon="list" class="chip-icon"></fa-icon> All
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-pending"
|
||||
[class.active-filter]="activeStatus === 'PENDING_APPROVAL'"
|
||||
(click)="filterByStatus('PENDING_APPROVAL')"
|
||||
>
|
||||
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Approval
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-approved"
|
||||
[class.active-filter]="activeStatus === 'APPROVED'"
|
||||
(click)="filterByStatus('APPROVED')"
|
||||
>
|
||||
<fa-icon icon="check-circle" class="chip-icon"></fa-icon> Approved
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-rejected"
|
||||
[class.active-filter]="activeStatus === 'REJECTED'"
|
||||
(click)="filterByStatus('REJECTED')"
|
||||
>
|
||||
<fa-icon icon="times" class="chip-icon"></fa-icon> Rejected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
|
||||
}
|
||||
|
||||
<div class="mat-elevation-z8">
|
||||
<table mat-table [dataSource]="dataSource" matSort>
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Request</th>
|
||||
<td mat-cell *matCellDef="let row">#{{ row.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="result-badge" [ngClass]="statusClass(row)">{{ statusLabel(row) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="reason">
|
||||
<th mat-header-cell *matHeaderCellDef>Reason</th>
|
||||
<td mat-cell *matCellDef="let row" class="reason-cell">
|
||||
<span [matTooltip]="row.reason">{{ row.reason }}</span>
|
||||
@if (row.rejectionReason) {
|
||||
<span class="rejection-note">Rejected: {{ row.rejectionReason }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="requestedBy">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Requested By</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="stacked">
|
||||
<span>{{ row.requestedByUsername || '—' }}</span>
|
||||
<span class="muted">{{ row.requestedOn | date: 'medium' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="approvedBy">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved By</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
@if (row.approvedByUsername) {
|
||||
<div class="stacked">
|
||||
<span>{{ row.approvedByUsername }}</span>
|
||||
<span class="muted">{{ row.approvedOn | date: 'medium' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="generatedOn">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Downloaded</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
@if (row.generatedOn) {
|
||||
<div class="stacked">
|
||||
<span>{{ row.generatedOn | date: 'medium' }}</span>
|
||||
<span class="muted">{{ formatSize(row.fileSizeBytes) }} · {{ row.rowCount || 0 }} rows</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="muted">Not yet</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Action</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="row-actions">
|
||||
@if (isPending(row)) {
|
||||
<!-- Only shown to users holding the approval permission; the backend enforces it regardless. -->
|
||||
<ng-container *mifosxHasPermission="'APPROVE_DATABASEEXPORT'">
|
||||
<button mat-stroked-button color="primary" (click)="approve(row)">
|
||||
<fa-icon icon="check"></fa-icon> Approve
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" (click)="reject(row)">
|
||||
<fa-icon icon="times"></fa-icon> Reject
|
||||
</button>
|
||||
</ng-container>
|
||||
}
|
||||
@if (isDownloadable(row)) {
|
||||
<button mat-flat-button color="primary" [disabled]="downloadingId === row.id" (click)="download(row)">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
{{ downloadingId === row.id ? 'Preparing…' : 'Download' }}
|
||||
</button>
|
||||
}
|
||||
@if (isWithheld(row)) {
|
||||
<span
|
||||
class="muted withheld-note"
|
||||
matTooltip="Approving an export authorises it. Only {{
|
||||
row.requestedByUsername || 'the requester'
|
||||
}} can download the archive."
|
||||
>
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
Requester only
|
||||
</span>
|
||||
}
|
||||
@if (isSpent(row)) {
|
||||
<span
|
||||
class="muted withheld-note"
|
||||
matTooltip="An approval is valid for a single download. Raise a new request to take another copy."
|
||||
>
|
||||
<fa-icon icon="check"></fa-icon>
|
||||
Download used
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
@if (!loading && dataSource.data.length === 0) {
|
||||
<div class="no-data">
|
||||
<fa-icon icon="folder-open" class="no-data-icon"></fa-icon>
|
||||
<p>No export requests yet.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: #1074b9;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The consequence of this screen is stated up front, not buried in a tooltip. */
|
||||
.warning-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
background-color: rgb(180 83 9 / 7%);
|
||||
border: 1px solid rgb(180 83 9 / 30%);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #7c2d12;
|
||||
|
||||
&__icon {
|
||||
color: #b45309;
|
||||
font-size: 18px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.request-card {
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 22px;
|
||||
border-radius: 10px;
|
||||
background-color: #f7f9fc;
|
||||
border: 1px solid #e3e7ee;
|
||||
|
||||
&__title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.chip-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
font-size: 13px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.active-filter {
|
||||
background-color: #1074b9;
|
||||
color: #fff;
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
.chip-pending.active-filter {
|
||||
background-color: #b45309;
|
||||
border-color: #b45309;
|
||||
}
|
||||
|
||||
.chip-approved.active-filter {
|
||||
background-color: #388e3c;
|
||||
border-color: #388e3c;
|
||||
}
|
||||
|
||||
.chip-rejected.active-filter {
|
||||
background-color: #d32f2f;
|
||||
border-color: #d32f2f;
|
||||
}
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&.badge-pending {
|
||||
background-color: rgb(180 83 9 / 12%);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.badge-approved {
|
||||
background-color: rgb(56 142 60 / 14%);
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.badge-rejected {
|
||||
background-color: rgb(211 47 47 / 12%);
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
&.badge-generated {
|
||||
background-color: rgb(16 116 185 / 12%);
|
||||
color: #1074b9;
|
||||
}
|
||||
}
|
||||
|
||||
.reason-cell {
|
||||
max-width: 320px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rejection-note {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
fa-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stands in for the download button on rows the viewer did not raise. */
|
||||
.withheld-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
|
||||
&-icon {
|
||||
font-size: 30px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortModule } from '@angular/material/sort';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { NgClass, DatePipe } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import { DatabaseExportService } from './database-export.service';
|
||||
|
||||
/**
|
||||
* Full database export, gated behind an approval. The requester states why the export is needed; an
|
||||
* administrator with APPROVE_DATABASEEXPORT approves or rejects it, and only then can the archive be
|
||||
* downloaded. The backend refuses self-approval, so the two roles are always two different people.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-database-export',
|
||||
templateUrl: './database-export.component.html',
|
||||
styleUrls: ['./database-export.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatProgressBarModule,
|
||||
MatTooltip,
|
||||
NgClass,
|
||||
DatePipe,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class DatabaseExportComponent implements OnInit {
|
||||
private service = inject(DatabaseExportService);
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
/** Used to keep the download to the requester; the backend enforces the same rule regardless. */
|
||||
private currentUserId: number | null = this.authenticationService.getCredentials()?.userId ?? null;
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'status',
|
||||
'reason',
|
||||
'requestedBy',
|
||||
'approvedBy',
|
||||
'generatedOn',
|
||||
'actions'
|
||||
];
|
||||
|
||||
dataSource = new MatTableDataSource<any>();
|
||||
loading = false;
|
||||
submitting = false;
|
||||
downloadingId: number | null = null;
|
||||
activeStatus: string | null = null;
|
||||
|
||||
requestForm: FormGroup = this.formBuilder.group({
|
||||
reason: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(10),
|
||||
Validators.maxLength(1000)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
|
||||
ngOnInit() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.loading = true;
|
||||
this.service.getRequests(this.activeStatus || undefined).subscribe({
|
||||
next: (rows: any) => this.applyRows(rows || []),
|
||||
error: () => this.applyRows([])
|
||||
});
|
||||
}
|
||||
|
||||
private applyRows(rows: any[]) {
|
||||
this.dataSource = new MatTableDataSource(rows);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
filterByStatus(status: string | null) {
|
||||
this.activeStatus = this.activeStatus === status ? null : status;
|
||||
this.load();
|
||||
}
|
||||
|
||||
submitRequest() {
|
||||
if (this.requestForm.invalid) {
|
||||
this.requestForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.service.requestExport(this.requestForm.value.reason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.requestForm.reset({ reason: '' });
|
||||
this.snackBar.open('Export request submitted for approval', 'Close', { duration: 4000 });
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(this.errorMessage(err, 'Failed to submit the request'), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
approve(row: any) {
|
||||
this.service.approve(row.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(`Request #${row.id} approved`, 'Close', { duration: 4000 });
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Approval failed'), 'Close', { duration: 6000 })
|
||||
});
|
||||
}
|
||||
|
||||
reject(row: any) {
|
||||
const reason = window.prompt(`Reject export request #${row.id}. Reason:`);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
this.snackBar.open('A reason is required to reject a request', 'Close', { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
this.service.reject(row.id, reason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(`Request #${row.id} rejected`, 'Close', { duration: 4000 });
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Rejection failed'), 'Close', { duration: 6000 })
|
||||
});
|
||||
}
|
||||
|
||||
download(row: any) {
|
||||
this.downloadingId = row.id;
|
||||
this.service.download(row.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
this.downloadingId = null;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = row.fileName || `cbsnexora-database-export-${row.id}.zip`;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
// The status moves to GENERATED once the archive leaves the server, so reflect that.
|
||||
this.load();
|
||||
},
|
||||
error: async (err: any) => {
|
||||
this.downloadingId = null;
|
||||
// The request asked for a Blob, so an error body arrives as a Blob too and has to be read back
|
||||
// as text before the platform's message can be pulled out of it.
|
||||
let message = 'Download failed';
|
||||
if (err?.error instanceof Blob) {
|
||||
try {
|
||||
const parsed = JSON.parse(await err.error.text());
|
||||
message = parsed?.defaultUserMessage || parsed?.errors?.[0]?.defaultUserMessage || message;
|
||||
} catch {
|
||||
// Body was not JSON; keep the generic message.
|
||||
}
|
||||
} else {
|
||||
message = this.errorMessage(err, message);
|
||||
}
|
||||
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isPending(row: any): boolean {
|
||||
return row?.status === 'PENDING_APPROVAL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Approving an export authorises it; it does not entitle the approver to a copy. Only the user who raised
|
||||
* the request — and stated the reason it is recorded against — can take the archive, and only once.
|
||||
*/
|
||||
isDownloadable(row: any): boolean {
|
||||
return row?.status === 'APPROVED' && this.isRequester(row);
|
||||
}
|
||||
|
||||
isRequester(row: any): boolean {
|
||||
return this.currentUserId != null && Number(row?.requestedBy) === Number(this.currentUserId);
|
||||
}
|
||||
|
||||
/** Shown in place of the button so an approver sees the archive is withheld by design, not by a fault. */
|
||||
isWithheld(row: any): boolean {
|
||||
return row?.status === 'APPROVED' && !this.isRequester(row);
|
||||
}
|
||||
|
||||
/** The approval has been spent; another copy means another request. */
|
||||
isSpent(row: any): boolean {
|
||||
return row?.status === 'GENERATED';
|
||||
}
|
||||
|
||||
statusLabel(row: any): string {
|
||||
switch (row?.status) {
|
||||
case 'PENDING_APPROVAL':
|
||||
return 'Awaiting Approval';
|
||||
case 'APPROVED':
|
||||
return 'Approved';
|
||||
case 'REJECTED':
|
||||
return 'Rejected';
|
||||
case 'GENERATED':
|
||||
return 'Downloaded';
|
||||
default:
|
||||
return row?.status || '—';
|
||||
}
|
||||
}
|
||||
|
||||
statusClass(row: any): string {
|
||||
switch (row?.status) {
|
||||
case 'APPROVED':
|
||||
return 'badge-approved';
|
||||
case 'REJECTED':
|
||||
return 'badge-rejected';
|
||||
case 'GENERATED':
|
||||
return 'badge-generated';
|
||||
default:
|
||||
return 'badge-pending';
|
||||
}
|
||||
}
|
||||
|
||||
formatSize(bytes: number): string {
|
||||
if (!bytes) {
|
||||
return '—';
|
||||
}
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
private errorMessage(err: any, fallback: string): string {
|
||||
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Approval-gated full export of the tenant database. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DatabaseExportService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getRequests(status?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (status) {
|
||||
params = params.set('status', status);
|
||||
}
|
||||
return this.http.get('/databaseexport', { params });
|
||||
}
|
||||
|
||||
/** @param reason why the export is needed — recorded against the request and inside the archive. */
|
||||
requestExport(reason: string): Observable<any> {
|
||||
return this.http.post('/databaseexport', { reason });
|
||||
}
|
||||
|
||||
approve(requestId: number): Observable<any> {
|
||||
return this.http.post(`/databaseexport/${requestId}/approve`, {});
|
||||
}
|
||||
|
||||
reject(requestId: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post(`/databaseexport/${requestId}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
/** The archive is binary, so it bypasses the JSON interceptors and comes back as a Blob. */
|
||||
download(requestId: number): Observable<Blob> {
|
||||
return this.http.get(`/databaseexport/${requestId}/download`, {
|
||||
responseType: 'blob' as 'json'
|
||||
}) as Observable<Blob>;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Form60Component } from './certificates/form-60/form-60.component';
|
||||
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
|
||||
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
|
||||
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
||||
import { DatabaseExportComponent } from './database-export/database-export.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -56,6 +57,11 @@ const routes: Routes = [
|
||||
component: HypothecationCertificateComponent,
|
||||
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/database-export',
|
||||
component: DatabaseExportComponent,
|
||||
data: { title: 'Database Export', breadcrumb: 'Database Export' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/noc',
|
||||
component: NocCertificateComponent,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user