Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa6c2d814 | ||
|
|
227949f8b2 | ||
|
|
abc966d507 | ||
|
|
fa2522e678 | ||
|
|
1b2ef5126e | ||
|
|
b953afe2da | ||
|
|
ff781e50e3 | ||
|
|
7ef75c902d | ||
|
|
c18219fce5 | ||
|
|
2d93f80d50 | ||
|
|
1d1e76497c | ||
|
|
352a65cb5d | ||
|
|
8d67a22f18 | ||
|
|
997e8920e4 | ||
|
|
5581d4180d | ||
|
|
75df8865dd | ||
|
|
5a6fb1f9b5 | ||
|
|
f379b2415c | ||
|
|
c34f6a0131 | ||
|
|
7b895a0e2b | ||
|
|
01e86173c1 | ||
|
|
4281a596af | ||
|
|
9f9c9bfab6 | ||
|
|
513e863bba | ||
|
|
13333088d7 | ||
|
|
89bb76455e | ||
|
|
7e69468995 | ||
|
|
63ab9643ce | ||
|
|
8255c8398d | ||
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
b609f19e9f | ||
|
|
7f836f6f71 | ||
|
|
a5124ca3e8 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
504d7ee7f6 | ||
|
|
26091c68b0 | ||
|
|
f8c532f74a | ||
|
|
44b41e504a | ||
|
|
e8d91c8f60 | ||
|
|
8dd9767bdf | ||
|
|
c868beb344 | ||
|
|
b43ed87771 | ||
|
|
ebe67cc410 | ||
|
|
d7f5bfe5f5 | ||
|
|
be6103e0b3 | ||
|
|
5cd9998cfc | ||
|
|
0ca6842bde | ||
|
|
3030e2f34e | ||
|
|
b4608cbbe4 | ||
|
|
27cdaf7fc2 | ||
|
|
90beea64cb | ||
|
|
1317a93838 | ||
|
|
0832017508 | ||
|
|
8ac4010967 | ||
|
|
1c125d97c5 | ||
|
|
c935e2a62a | ||
|
|
2a9df828ca | ||
|
|
bd753f4983 | ||
|
|
4261f9659e | ||
|
|
63ac900e42 | ||
|
|
fd28788d23 | ||
|
|
b9b37397fe | ||
|
|
f2525080c8 | ||
|
|
a982083b14 | ||
|
|
43e3c546a7 | ||
|
|
b68b25f789 | ||
|
|
1dc378c2dd |
+2
-2
@@ -69,8 +69,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "13mb",
|
||||
"maximumError": "14mb"
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "17mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
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
-3
@@ -15,9 +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: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",
|
||||
@@ -72,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",
|
||||
@@ -105,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",
|
||||
|
||||
@@ -32,6 +32,28 @@ export class AccountingService {
|
||||
return this.http.get('/offices', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* Real net profit for a date range, computed live from posted GL journal entries (not the
|
||||
* demo data shown by the GL Reports module).
|
||||
* @param {string} fromDate Period start, yyyy-MM-dd.
|
||||
* @param {string} toDate Period end, yyyy-MM-dd.
|
||||
* @returns {Observable<any>} `{ totalIncome, totalExpense, netProfit }`.
|
||||
*/
|
||||
getProfitAndLoss(fromDate: string, toDate: string): Observable<any> {
|
||||
const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
|
||||
return this.http.get('/accounting/profitandloss', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* Total share dividend actually paid to members in a date range — derived from the Dividend
|
||||
* Payable GL account's own ledger (no separate "Dividend Paid" account needed).
|
||||
* @returns {Observable<any>} `{ totalPaid, payoutCount, entries[], glAccountConfigured }`.
|
||||
*/
|
||||
getDividendPaidReport(fromDate: string, toDate: string): Observable<any> {
|
||||
const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
|
||||
return this.http.get('/accounting/dividend-paid-report', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} useHttpParams True if http params should be used.
|
||||
* @returns {Observable<any>} Accounting rules data.
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="!glAccountForm.valid"
|
||||
[disabled]="!glAccountForm.valid || submitting"
|
||||
*mifosxHasPermission="'CREATE_GLACCOUNT'"
|
||||
>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
|
||||
+34
-14
@@ -14,6 +14,7 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Custom Services */
|
||||
import { AccountingService } from '../../accounting.service';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { PopoverService } from '../../../configuration-wizard/popover/popover.service';
|
||||
import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service';
|
||||
|
||||
@@ -42,6 +43,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class CreateGlAccountComponent implements OnInit, AfterViewInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private accountingService = inject(AccountingService);
|
||||
private alertService = inject(AlertService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
@@ -50,6 +52,8 @@ export class CreateGlAccountComponent implements OnInit, AfterViewInit {
|
||||
|
||||
/** GL account form. */
|
||||
glAccountForm: UntypedFormGroup;
|
||||
/** Submitting state to prevent duplicate submissions. */
|
||||
submitting = false;
|
||||
/** Chart of accounts data. */
|
||||
chartOfAccountsData: any;
|
||||
/** Account type data. */
|
||||
@@ -166,24 +170,40 @@ export class CreateGlAccountComponent implements OnInit, AfterViewInit {
|
||||
|
||||
/**
|
||||
* Submits the gl account form and creates gl account,
|
||||
* if successful redirects to view created account.
|
||||
* if successful redirects to view created account or back to list if awaiting approval.
|
||||
*/
|
||||
submit() {
|
||||
if (this.glAccountForm.invalid) {
|
||||
if (this.glAccountForm.invalid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.accountingService.createGlAccount(this.glAccountForm.value).subscribe((response: any) => {
|
||||
if (this.configurationWizardService.showChartofAccounts) {
|
||||
this.configurationWizardService.showChartofAccounts = false;
|
||||
this.openDialog();
|
||||
} else {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../view',
|
||||
response.resourceId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
this.submitting = true;
|
||||
this.accountingService.createGlAccount(this.glAccountForm.value).subscribe({
|
||||
next: (response: any) => {
|
||||
if (response?.resourceId) {
|
||||
if (this.configurationWizardService.showChartofAccounts) {
|
||||
this.configurationWizardService.showChartofAccounts = false;
|
||||
this.openDialog();
|
||||
} else {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../view',
|
||||
response.resourceId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
}
|
||||
} else if (response?.rollbackTransaction) {
|
||||
this.alertService.alert({
|
||||
type: 'Success',
|
||||
message: 'GL Account submitted successfully for approval.'
|
||||
});
|
||||
this.router.navigate([this.cancelRoute], { relativeTo: this.route });
|
||||
} else {
|
||||
this.router.navigate([this.cancelRoute], { relativeTo: this.route });
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
const STATE_KEY = 'gl_ledger_filter_state';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -34,6 +34,52 @@ export class AmlService {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ Recurring-alert review queue ═══
|
||||
|
||||
/**
|
||||
* Customers whose transactions repeatedly trip the engine. Each row carries the triggering
|
||||
* alerts, so the reviewer can see why the customer keeps surfacing.
|
||||
*/
|
||||
getRecurringAlerts(minAlerts: number, windowDays: number, onlyNeedingReview: boolean): Observable<any[]> {
|
||||
const params = new HttpParams()
|
||||
.set('minAlerts', String(minAlerts))
|
||||
.set('windowDays', String(windowDays))
|
||||
.set('onlyNeedingReview', String(onlyNeedingReview));
|
||||
return this.http.get<any[]>('/aml/recurring-alerts', { params });
|
||||
}
|
||||
|
||||
/** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */
|
||||
reviewRecurringAlert(
|
||||
clientId: number,
|
||||
outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED',
|
||||
notes: string,
|
||||
windowDays: number
|
||||
): Observable<any> {
|
||||
return this.http.post(`/aml/recurring-alerts/${clientId}/review`, { outcome, notes, windowDays });
|
||||
}
|
||||
|
||||
// ═══ Manual referrals from the general ledger ═══
|
||||
|
||||
/**
|
||||
* Ledger entries a user has already marked, keyed on `journalEntryId`.
|
||||
*
|
||||
* Separate from `getAlerts()` because these are not engine alerts — they carry the
|
||||
* journal entry id rather than a savings or loan transaction id, so the ledger
|
||||
* screen can match them to the rows it is showing.
|
||||
*/
|
||||
getManualReferrals(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/alerts/manual-referrals');
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
|
||||
* case for the Principal Officer, the same road an escalated engine alert takes.
|
||||
* The entry must have a customer behind it or the server rejects it.
|
||||
*/
|
||||
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ STR cases (Principal Officer) ═══
|
||||
|
||||
getStrCases(): Observable<any[]> {
|
||||
|
||||
@@ -64,7 +64,12 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||
import { DeafModule } from './deaf/deaf.module';
|
||||
import { DicgcModule } from './dicgc/dicgc.module';
|
||||
import { FiuModule } from './fiu/fiu.module';
|
||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||
import { SftModule } from './sft/sft.module';
|
||||
import { InvestmentModule } from './investment/investment.module';
|
||||
import { NpaModule } from './npa/npa.module';
|
||||
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||
import { DigitalModule } from './digital/digital.module';
|
||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
@@ -171,7 +176,12 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
DeafModule,
|
||||
DicgcModule,
|
||||
FiuModule,
|
||||
GlReportsModule,
|
||||
SftModule,
|
||||
InvestmentModule,
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">SUB LEDGER · TRANSACTIONS · {{ scopeName | uppercase }}</div>
|
||||
<h1 class="pg-title">Cash / Clearing / Transfer - Summary Report</h1>
|
||||
<p class="pg-sub">
|
||||
Opening, credit, debit and closing position across Cash, Transfer, Clearing and Digital channels as on
|
||||
{{ asOnDate }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
@if (isHeadOffice) {
|
||||
<label class="pg-field">
|
||||
<span>Branch</span>
|
||||
<select class="pg-select" [ngModel]="selectedBranchCode" (ngModelChange)="setBranch($event)">
|
||||
<option value="ALL">All Branches (Consolidated)</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
|
||||
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cctr-grand-summary">
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Opening</span>
|
||||
<span class="cctr-grand-summary__value">₹{{ grandOpening | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Credit</span>
|
||||
<span class="cctr-grand-summary__value col-in">₹{{ grandCredit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Debit</span>
|
||||
<span class="cctr-grand-summary__value col-out">₹{{ grandDebit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Closing</span>
|
||||
<span class="cctr-grand-summary__value">₹{{ grandClosing | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isHeadOffice && selectedBranchCode === 'ALL') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Branch-wise Break-up — click a branch to drill in</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Branch</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-net">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of branchBreakup; track b.code) {
|
||||
<tr class="cctr-branch-row" (click)="setBranch(b.code)">
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="num">₹{{ b.opening | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ b.credit | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">₹{{ b.debit | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ b.closing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>All Branches</td>
|
||||
<td class="num">₹{{ grandOpening | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandCredit | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandDebit | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandClosing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'cash'"
|
||||
(click)="setTab('cash')"
|
||||
>
|
||||
Cash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'transfer'"
|
||||
(click)="setTab('transfer')"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'clearing'"
|
||||
(click)="setTab('clearing')"
|
||||
>
|
||||
Clearing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'digital'"
|
||||
(click)="setTab('digital')"
|
||||
>
|
||||
Digital
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary (Opening | Credit | Debit | Closing) -->
|
||||
@if (!showDetails) {
|
||||
<div class="pg-card">
|
||||
@if (activeTab === 'digital') {
|
||||
<div class="pos-section-label">RTGS · NEFT · UPI · ATM · NACH — Overall Position</div>
|
||||
}
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@if (activeTab !== 'digital') {
|
||||
<th>GL Code</th>
|
||||
}
|
||||
<th>Particulars</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-net">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rowsForActiveTab; track r.code) {
|
||||
<tr>
|
||||
@if (activeTab !== 'digital') {
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.code }}</span>
|
||||
</td>
|
||||
}
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="num">₹{{ r.opening | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ rowCredit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">₹{{ rowDebit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ closing(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td [attr.colspan]="activeTab !== 'digital' ? 2 : 1">Total</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'opening') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'credit') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'debit') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ totalClosing(rowsForActiveTab, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Transaction Details (all txns for the selected category, optionally filtered by GL/channel) -->
|
||||
@if (showDetails) {
|
||||
@if (rowsForActiveTab.length > 1) {
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="glFilter === null"
|
||||
(click)="setGlFilter(null)"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@for (r of rowsForActiveTab; track r.code) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="glFilter === r.code"
|
||||
(click)="setGlFilter(r.code)"
|
||||
>
|
||||
{{ filterLabel(r) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
{{
|
||||
activeTab === 'cash'
|
||||
? 'Cash'
|
||||
: activeTab === 'transfer'
|
||||
? 'Transfer'
|
||||
: activeTab === 'clearing'
|
||||
? 'Clearing'
|
||||
: 'Digital'
|
||||
}}
|
||||
— Transaction Details (Full Double Entry)
|
||||
@if (glFilter !== null) {
|
||||
· {{ glFilter }}
|
||||
}
|
||||
</div>
|
||||
<table class="pg-table cctr-journal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher / Ref No</th>
|
||||
<th>Dr / Cr</th>
|
||||
<th>GL / Account</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredTxns; track t.voucherNo) {
|
||||
<tr class="cctr-journal-row cctr-journal-row--first">
|
||||
<td>{{ asOnDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
||||
</td>
|
||||
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
|
||||
<td>
|
||||
@if (t.debit > 0) {
|
||||
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||
t.accountName
|
||||
}})
|
||||
} @else {
|
||||
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num col-out">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||
<td class="num"></td>
|
||||
<td rowspan="2">
|
||||
<span class="cctr-balanced-chip">✓ Balanced</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="cctr-journal-row cctr-journal-row--second">
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td><span class="cctr-drcr cctr-drcr--cr">Cr</span></td>
|
||||
<td>
|
||||
@if (t.credit > 0) {
|
||||
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||
t.accountName
|
||||
}})
|
||||
} @else {
|
||||
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num"></td>
|
||||
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No transactions for this filter.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
|
||||
<td class="num">
|
||||
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="num">
|
||||
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+138
@@ -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/.
|
||||
*/
|
||||
|
||||
.cctr-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cctr-total-row {
|
||||
td {
|
||||
background: #f1f5f9;
|
||||
font-weight: 800;
|
||||
border-top: 2px solid #cbd5e1;
|
||||
border-bottom: none;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-grand-summary {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
&__item {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-subtext {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cctr-branch-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover td {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
// Both journal lines of one voucher share a tint and sit flush against each other (no dotted
|
||||
// divider between them) so the pair reads as a single entry; a hairline separates one voucher's
|
||||
// pair from the next.
|
||||
.cctr-journal-row {
|
||||
&--first td {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&--second td {
|
||||
border-top: none;
|
||||
padding-top: 4px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
&--first,
|
||||
&--second {
|
||||
&:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-drcr {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&--dr {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&--cr {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-contra {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cctr-balanced-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
+1802
File diff suppressed because it is too large
Load Diff
@@ -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/.
|
||||
*/
|
||||
|
||||
// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts)
|
||||
// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet
|
||||
// names, Blob download) is written once and every cashier report's export behaves the same way.
|
||||
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
export interface ExportSheetSpec {
|
||||
/** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
|
||||
sheetName: string;
|
||||
/** Bold title row, merged across all columns. */
|
||||
title: string;
|
||||
/** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
|
||||
subtitle?: string;
|
||||
headers: string[];
|
||||
rows: (string | number | null)[][];
|
||||
widths?: number[];
|
||||
}
|
||||
|
||||
/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
|
||||
function safeSheetName(name: string): string {
|
||||
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
|
||||
}
|
||||
|
||||
function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
|
||||
const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
|
||||
sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
|
||||
|
||||
const title = sheet.addRow([spec.title]);
|
||||
sheet.mergeCells(title.number, 1, title.number, spec.headers.length);
|
||||
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||
title.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
title.height = 24;
|
||||
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
|
||||
|
||||
if (spec.subtitle) {
|
||||
const meta = sheet.addRow([spec.subtitle]);
|
||||
sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
|
||||
meta.font = { italic: true, size: 10 };
|
||||
}
|
||||
sheet.addRow([]);
|
||||
|
||||
const head = sheet.addRow(spec.headers);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||
head.eachCell((c) => {
|
||||
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||
c.border = { bottom: { style: 'thin' } };
|
||||
});
|
||||
|
||||
for (const r of spec.rows) {
|
||||
const row = sheet.addRow(r);
|
||||
r.forEach((v, idx) => {
|
||||
if (typeof v === 'number') {
|
||||
row.getCell(idx + 1).numFmt = '#,##0.00';
|
||||
}
|
||||
});
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
|
||||
}
|
||||
|
||||
/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
|
||||
export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise<void> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'CBSNexora';
|
||||
wb.created = new Date();
|
||||
sheets.forEach((s) => buildSheet(wb, s));
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -62,6 +62,22 @@ const routes: Routes = [
|
||||
),
|
||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||
},
|
||||
{
|
||||
path: 'share-withdrawal-request',
|
||||
loadComponent: () =>
|
||||
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||
(m) => m.ShareWithdrawalRequestComponent
|
||||
),
|
||||
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||
},
|
||||
{
|
||||
path: 'share-withdrawal-requests',
|
||||
loadComponent: () =>
|
||||
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||
(m) => m.MyShareWithdrawalRequestsComponent
|
||||
),
|
||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities',
|
||||
loadComponent: () =>
|
||||
@@ -101,6 +117,14 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
|
||||
data: { title: "Today's Activity", breadcrumb: "Today's Activity" }
|
||||
},
|
||||
{
|
||||
path: 'dividend-dashboard',
|
||||
loadComponent: () =>
|
||||
import('../clerk/dividend-dashboard/dividend-dashboard.component').then(
|
||||
(m) => m.DividendDashboardComponent
|
||||
),
|
||||
data: { title: 'Dividend Dashboard', breadcrumb: 'Dividend Dashboard' }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -179,6 +203,17 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
|
||||
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
|
||||
},
|
||||
{
|
||||
path: 'cash-clearing-transfer-summary',
|
||||
loadComponent: () =>
|
||||
import('./cash-clearing-transfer-summary/cash-clearing-transfer-summary.component').then(
|
||||
(m) => m.CashClearingTransferSummaryComponent
|
||||
),
|
||||
data: {
|
||||
title: 'Cash / Clearing / Transfer - Summary Report',
|
||||
breadcrumb: 'Cash / Clearing / Transfer - Summary Report'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkShareWithdrawalService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||
}
|
||||
|
||||
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||
if (status) params = params.set('status', status);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||
}
|
||||
|
||||
validate(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@@ -231,8 +231,11 @@
|
||||
<span class="ws-caption">Payment instrument</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Transaction Amount">
|
||||
AMOUNT
|
||||
<label
|
||||
class="ws-f__lbl"
|
||||
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||
>
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
@@ -242,20 +245,48 @@
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
}
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
<span class="ws-f__hint"
|
||||
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (row.typ !== 'Withdrawal Slip') {
|
||||
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.resolutionNumber"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="e.g. BR-2026-014"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
type="date"
|
||||
[(ngModel)]="row.meetingDate"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
} @else if (row.typ !== 'Withdrawal Slip') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||
<input
|
||||
@@ -712,6 +743,32 @@
|
||||
'—'
|
||||
}}</span>
|
||||
</div>
|
||||
@if (selectedRow?.acTyp === 'SHR') {
|
||||
<div class="te-field">
|
||||
<span class="te-label">No. of Shares Held</span
|
||||
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Market Price / Share</span
|
||||
><span class="te-value te-mono"
|
||||
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Resolution No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Savings A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||
</div>
|
||||
@if (accountDetails.linkedLoanAccountNo) {
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Loan A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="te-panel__header te-panel__header--mt">
|
||||
@@ -724,7 +781,13 @@
|
||||
<div class="te-panel__body">
|
||||
<div class="te-balance-row">
|
||||
<span class="te-bal-num">1.</span>
|
||||
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
|
||||
<span class="te-bal-label">{{
|
||||
selectedRow?.acTyp === 'SHR'
|
||||
? 'Share Balance'
|
||||
: accountDetails?.glCode
|
||||
? 'GL Balance'
|
||||
: 'Current Balance'
|
||||
}}</span>
|
||||
<span class="te-bal-value te-bal-value--cr"
|
||||
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -862,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"
|
||||
@@ -1281,7 +1352,7 @@
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@@ -1360,7 +1431,7 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl">
|
||||
AMOUNT
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
@@ -1370,27 +1441,51 @@
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
}
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
<span class="ws-f__hint"
|
||||
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.resolutionNumber"
|
||||
placeholder="e.g. BR-2026-014"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">MEETING DATE</label>
|
||||
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--tok">
|
||||
<label class="ws-f__lbl">TOK. NO.</label>
|
||||
<input
|
||||
@@ -1837,6 +1932,15 @@
|
||||
@for (line of alertModal.lines; track $index) {
|
||||
@if ($index === 0) {
|
||||
<p class="te-alert-box__desc">{{ line }}</p>
|
||||
} @else if (line.startsWith('⚠')) {
|
||||
<div class="te-alert-box__callout">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<span>{{ line.slice(1).trim() }}</span>
|
||||
</div>
|
||||
} @else if (line.includes(':') && $index < alertModal.lines.length - 1) {
|
||||
<div class="te-alert-box__kv">
|
||||
<span class="te-alert-box__kv-k">{{ line.split(':')[0].trim() }}</span>
|
||||
|
||||
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-f__hint {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: $ws-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ws-amt-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
@@ -1995,12 +2003,12 @@ select.te-cell {
|
||||
|
||||
.te-alert-box {
|
||||
display: flex;
|
||||
width: 480px;
|
||||
max-width: 96vw;
|
||||
border-radius: 16px;
|
||||
width: 400px;
|
||||
max-width: 94vw;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 32px 80px rgb(0 0 0 / 35%),
|
||||
0 24px 60px rgb(0 0 0 / 32%),
|
||||
0 0 0 1px rgb(255 255 255 / 8%);
|
||||
animation: te-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
@@ -2010,32 +2018,35 @@ select.te-cell {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 76px;
|
||||
gap: 8px;
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 0;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
&__sidebar-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(255 255 255 / 20%);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 255 255 / 15%),
|
||||
inset 0 1px 2px rgb(255 255 255 / 25%);
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
stroke: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__sidebar-type {
|
||||
font-size: 8px;
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
text-transform: uppercase;
|
||||
writing-mode: vertical-lr;
|
||||
@@ -2045,6 +2056,7 @@ select.te-cell {
|
||||
// ── Right content ─────────────────────────────────────────────────────────
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2054,12 +2066,12 @@ select.te-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 12px;
|
||||
padding: 14px 16px 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -2083,52 +2095,87 @@ select.te-cell {
|
||||
|
||||
&__lines {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.55;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__kv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
margin: 4px 0;
|
||||
gap: 10px;
|
||||
padding: 5px 10px;
|
||||
margin: 3px 0;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border-radius: 7px;
|
||||
border-left: 3px solid #e2e8f0;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
&__kv-k {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__kv-v {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__foot {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
padding: 8px 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
line-height: 1.45;
|
||||
animation: te-pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
background: #fafafa;
|
||||
}
|
||||
@@ -2196,11 +2243,11 @@ select.te-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 20px;
|
||||
min-width: 90px;
|
||||
border-radius: 8px;
|
||||
padding: 7px 16px;
|
||||
min-width: 76px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
|
||||
@@ -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,7 +20,9 @@ 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';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import {
|
||||
@@ -47,6 +49,10 @@ interface TxnRow {
|
||||
routingCode: string;
|
||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||
ddCharges: number | null;
|
||||
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||
resolutionNumber?: string;
|
||||
meetingDate?: string;
|
||||
}
|
||||
|
||||
interface Denomination {
|
||||
@@ -744,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);
|
||||
@@ -752,7 +759,9 @@ 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);
|
||||
readonly router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
@@ -769,9 +778,41 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
loadingAccount = false;
|
||||
accountError = '';
|
||||
|
||||
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
|
||||
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
|
||||
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
|
||||
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||
* Every other account type/mode keeps the full, unfiltered list. */
|
||||
paymentTypesForRow(row: TxnRow): any[] {
|
||||
if (row.acTyp === 'SHR') {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
const isShareSettlementCredit =
|
||||
row.typ === 'Enter Bank Transfer' &&
|
||||
row.dbCr === 'Cr' &&
|
||||
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
|
||||
if (isShareSettlementCredit) {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
return this.paymentTypes;
|
||||
}
|
||||
|
||||
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
||||
rowAccountIds: Record<string, number> = {};
|
||||
rowAccountNames: Record<string, string> = {};
|
||||
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
|
||||
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
|
||||
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
|
||||
rowSharePrices: Record<string, number> = {};
|
||||
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
|
||||
// that isn't necessarily the currently "selected" one.
|
||||
rowClientIds: Record<string, number> = {};
|
||||
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
|
||||
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
|
||||
rowLinkedSavingsAcNo: Record<string, string> = {};
|
||||
// Per-row shares currently held, keyed by acNo — lets submitShareRedemptionTransfer() warn the
|
||||
// clerk when a withdrawal will bring the account's holding to zero (closing it on approval).
|
||||
rowShareHoldings: Record<string, number> = {};
|
||||
|
||||
// Full account + client data
|
||||
accountDetails: any = null;
|
||||
@@ -1205,6 +1246,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
this.rows.push(mirror);
|
||||
this.selectedRow = mirror;
|
||||
if (active.acTyp === 'SHR') {
|
||||
this.syncShareLinkedAmounts();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const row: TxnRow = {
|
||||
@@ -1499,16 +1543,219 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
|
||||
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
|
||||
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
|
||||
* approval both redeems the shares and settles the linked account automatically. */
|
||||
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
|
||||
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
|
||||
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
|
||||
// that filter applied — settlement is always a book-transfer to the linked savings account, and
|
||||
// it's the CR row's payment type that actually governs how that credit posts, never the DR
|
||||
// (Share) row's, which has no real payment-type concept.
|
||||
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
|
||||
if (!savingsRow.paymentTypeId) {
|
||||
this.showAlert('error', 'Settlement Payment Type Required', [
|
||||
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (settlementPaymentType?.isCashPayment) {
|
||||
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||||
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
|
||||
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||||
if (!shareAccountId) {
|
||||
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
if (!this.rowAccountIds[savingsRow.acNo]) {
|
||||
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
|
||||
const enteredAcNo = savingsRow.acNo?.trim();
|
||||
const matchesLinked =
|
||||
!!linkedAcNo &&
|
||||
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
|
||||
if (linkedAcNo && !matchesLinked) {
|
||||
this.showAlert('error', 'Wrong Credit Account', [
|
||||
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
|
||||
`You entered: ${enteredAcNo}`
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!linkedAcNo) {
|
||||
this.showAlert('error', 'No Linked Savings Account', [
|
||||
'This Share account has no linked savings account configured — settlement has nowhere to go.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shares = Number(shareRow.amount) || 0;
|
||||
if (shares <= 0) {
|
||||
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.resolutionNumber?.trim()) {
|
||||
this.showAlert('error', 'Board Resolution Number Required', [
|
||||
'A share withdrawal must be authorised by a Board Resolution.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.meetingDate) {
|
||||
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
|
||||
// these fields are not re-read by the backend, they are the audit record of what was on screen.
|
||||
const settlementRowSnapshot = {
|
||||
branch: savingsRow.br,
|
||||
accountType: savingsRow.acTyp,
|
||||
accountNo: savingsRow.acNo,
|
||||
ecsAccountNo: savingsRow.ecsAcNo || null,
|
||||
dbCr: savingsRow.dbCr,
|
||||
amount: savingsRow.amount ?? null,
|
||||
chequeNo: savingsRow.chequeNo || null,
|
||||
chequeDate: savingsRow.cheqDate || null,
|
||||
tokenNo: savingsRow.tokNo || null,
|
||||
description: savingsRow.description || null,
|
||||
bankName: savingsRow.bankName || null,
|
||||
routingCode: savingsRow.routingCode || null,
|
||||
paymentTypeId: savingsRow.paymentTypeId,
|
||||
paymentTypeName: settlementPaymentType?.name ?? null
|
||||
};
|
||||
|
||||
const payload = {
|
||||
shareAccountId,
|
||||
accountNo: shareRow.acNo,
|
||||
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||||
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||||
requestedShares: shares,
|
||||
requestedDate,
|
||||
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||||
meetingDate: shareRow.meetingDate,
|
||||
description: shareRow.description || null,
|
||||
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
||||
settlementRowSnapshot
|
||||
};
|
||||
|
||||
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
|
||||
const remainingShares = currentHolding - shares;
|
||||
const willCloseAccount = remainingShares <= 0;
|
||||
const confirmLines = [
|
||||
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||||
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
|
||||
...(willCloseAccount
|
||||
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
|
||||
: []),
|
||||
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||||
`Date of Meeting: ${payload.meetingDate}`,
|
||||
`Settlement Credit To: ${linkedAcNo}`,
|
||||
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||||
...(settlementRowSnapshot.ecsAccountNo && settlementRowSnapshot.ecsAccountNo !== linkedAcNo
|
||||
? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`]
|
||||
: []),
|
||||
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
|
||||
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
|
||||
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
|
||||
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||||
'',
|
||||
willCloseAccount
|
||||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||||
);
|
||||
}
|
||||
|
||||
private doSubmitShareRedemptionTransfer(
|
||||
payload: Record<string, unknown>,
|
||||
shareRow: TxnRow,
|
||||
shares: number,
|
||||
linkedAcNo: string
|
||||
): void {
|
||||
this.saving = true;
|
||||
this.clerkShareWithdrawalService
|
||||
.submit(payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Share withdrawal submission failed. Please try again.';
|
||||
this.showAlert('error', 'Submission Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
|
||||
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||||
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
|
||||
willCloseAccount
|
||||
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
|
||||
: 'Pending Manager approval. Track status in "My Share Requests".'
|
||||
]);
|
||||
this.rows = [];
|
||||
this.rowAccountIds = {};
|
||||
this.rowAccountNames = {};
|
||||
this.rowSharePrices = {};
|
||||
this.rowClientIds = {};
|
||||
this.rowLinkedSavingsAcNo = {};
|
||||
this.rowShareHoldings = {};
|
||||
this.addRow();
|
||||
this.selectedRow = this.rows[0];
|
||||
this.accountDetails = null;
|
||||
this.clientDetails = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||||
onTokNoBlur(row: TxnRow): void {
|
||||
if (this.isClerk || !row.tokNo?.trim()) return;
|
||||
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 ??
|
||||
@@ -1521,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';
|
||||
@@ -1535,6 +1800,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
|
||||
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
|
||||
* convenience for filling the paired settlement entry. The Share row itself still can never be
|
||||
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
|
||||
* correct amount to carry over into the separate manual Deposit Slip entry. */
|
||||
syncShareLinkedAmounts(): void {
|
||||
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
|
||||
if (shareRows.length !== 1) return;
|
||||
const shareRow = shareRows[0];
|
||||
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||
const shares = Number(shareRow.amount) || 0;
|
||||
const total = Math.round(shares * price * 100) / 100;
|
||||
this.rows.forEach((r) => {
|
||||
if (r !== shareRow) {
|
||||
r.amount = total;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAcNoBlur(row: TxnRow, index: number): void {
|
||||
if (!row.acNo.trim()) return;
|
||||
this.selectedRow = row;
|
||||
@@ -1633,12 +1917,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
if (token !== this.searchToken) return; // stale result — newer search started
|
||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const f = cfg.matchField;
|
||||
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
|
||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||||
// gated on "exactly one row came back" — it never would be for those types.
|
||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||||
const partialMatch =
|
||||
!exactMatch && allItems.length === 1
|
||||
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
|
||||
: null;
|
||||
const partialMatch = !exactMatch
|
||||
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? null;
|
||||
if (match?.id) {
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
@@ -1659,6 +1945,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: number;
|
||||
txnBase: string;
|
||||
txnSearchSupported: boolean;
|
||||
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
|
||||
* Share accounts — Fineract returns transactions embedded on the account detail response
|
||||
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
|
||||
* "today's transactions" HTTP call entirely instead of hitting a 404. */
|
||||
txnFetchUnsupported?: boolean;
|
||||
/** True when this account type has no standing-instruction "from account type" mapping on
|
||||
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
|
||||
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
|
||||
* sending an fromAccountType value the backend's query-builder silently can't match,
|
||||
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
|
||||
siFetchUnsupported?: boolean;
|
||||
maxLimit: number;
|
||||
skipGlobalSearch: boolean;
|
||||
listQueryParam: string;
|
||||
@@ -1693,6 +1990,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: 4,
|
||||
txnBase: '/accounts/share',
|
||||
txnSearchSupported: false,
|
||||
txnFetchUnsupported: true,
|
||||
siFetchUnsupported: true,
|
||||
maxLimit: 500,
|
||||
skipGlobalSearch: true,
|
||||
listQueryParam: 'accountNo',
|
||||
@@ -1805,6 +2104,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: number;
|
||||
txnBase: string;
|
||||
txnSearchSupported: boolean;
|
||||
txnFetchUnsupported?: boolean;
|
||||
siFetchUnsupported?: boolean;
|
||||
detailParams?: string;
|
||||
},
|
||||
token: number,
|
||||
@@ -1837,13 +2138,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||||
this.rowAccountNames[this.selectedRow.acNo] =
|
||||
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
|
||||
}
|
||||
if (this.selectedRow) {
|
||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
||||
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
|
||||
// is a different, unrelated account (the linked settlement savings account) and must
|
||||
// never be shown here.
|
||||
this.selectedRow.ecsAcNo =
|
||||
acTyp?.toUpperCase() === 'SHR'
|
||||
? (acc.accountNo ?? '')
|
||||
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
|
||||
}
|
||||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||||
this.computeSameDayNeftRtgs(acc.transactions);
|
||||
this.computeSameDayChequeClearing(acc.transactions);
|
||||
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
|
||||
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
|
||||
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
|
||||
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
|
||||
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
|
||||
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
|
||||
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
|
||||
this.rowShareHoldings[this.selectedRow.acNo] = Number(
|
||||
acc.summary?.totalApprovedShares ?? acc.totalApprovedShares ?? 0
|
||||
);
|
||||
this.syncShareLinkedAmounts();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1913,18 +2233,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||||
si: this.http
|
||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||
todayTxns: cfg.txnSearchSupported
|
||||
? this.http
|
||||
.get<any>(
|
||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
si: cfg.siFetchUnsupported
|
||||
? of({ pageItems: [] })
|
||||
: this.http
|
||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||
todayTxns: cfg.txnFetchUnsupported
|
||||
? of({ pageItems: [] })
|
||||
: cfg.txnSearchSupported
|
||||
? this.http
|
||||
.get<any>(
|
||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
: this.http
|
||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
});
|
||||
|
||||
if (clientIdHint) {
|
||||
@@ -2089,6 +2413,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// ── Balance getters ────────────────────────────────────────────────────────
|
||||
|
||||
get currentBalance(): number {
|
||||
if (this.selectedRow?.acTyp === 'SHR') {
|
||||
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
|
||||
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
|
||||
return shares * unitPrice;
|
||||
}
|
||||
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
||||
}
|
||||
|
||||
@@ -2502,6 +2831,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
|
||||
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
|
||||
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
|
||||
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
|
||||
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
|
||||
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
|
||||
if (shareDrRows.length > 0) {
|
||||
if (drRows.length !== 1 || crRows.length !== 1) {
|
||||
this.showAlert('error', 'Unsupported Share Transfer', [
|
||||
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||
const allowedTypes = new Set([
|
||||
'SD',
|
||||
@@ -2705,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.',
|
||||
@@ -2714,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', [
|
||||
@@ -2809,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) {
|
||||
@@ -2954,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 &&
|
||||
@@ -2985,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,
|
||||
@@ -3004,6 +3387,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const accountId = this.accountDetails.id;
|
||||
const cfg = this.accountTypeConfig(row.acTyp);
|
||||
|
||||
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
|
||||
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
|
||||
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
|
||||
// own numeric id, not a real savings account id. Approving that request would call
|
||||
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
|
||||
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
|
||||
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
|
||||
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
|
||||
if (row.acTyp === 'SHR') {
|
||||
this.showAlert('error', 'Not Supported for Share Accounts', [
|
||||
isDeposit
|
||||
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
|
||||
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
|
||||
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// GL withdrawal → direct journal entry (no maker-checker)
|
||||
// GL deposit → falls through to maker-checker block below
|
||||
if (row.acTyp === 'GL' && !isDeposit) {
|
||||
@@ -3029,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.'
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
* have meaningful variety out of the box.
|
||||
*/
|
||||
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
|
||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|4||||||
|
||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|4||||||
|
||||
30|1|C|AAQPS8560J||01|02|||||
|
||||
70|1|10078_Sign.jpeg|09|01|01||||
|
||||
70|1|10078_Photo.jpeg|02|01|01||||
|
||||
70|1|10078_AP.jpg|04|01|01||||
|
||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||
30|2|E|XXXXXXXX2210||01|02|||||
|
||||
70|2|10079_Sign.jpeg|09|01|01||||
|
||||
70|2|10079_Photo.jpeg|02|01|01||||
|
||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||
30|3|C|AGTPD1144K||01|02|||||
|
||||
70|3|10080_Sign.jpeg|09|01|01||||
|
||||
70|3|10080_AP.jpg|04|01|01||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||
|
||||
.inv-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inv-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: auto;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.inv-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--active {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--matured {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--written_off {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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, 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
|
||||
* against a still-locked dividend on the member's behalf, a Bank Manager approves (only possible
|
||||
* when the client has no overdue loan) or rejects it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DividendClaimRequestService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(
|
||||
lockId: number,
|
||||
breakdown: {
|
||||
loanId?: number;
|
||||
loanApplyAmount?: number;
|
||||
savingsAccountId?: number;
|
||||
savingsApplyAmount?: number;
|
||||
cashApplyAmount?: number;
|
||||
tokNo?: string;
|
||||
}
|
||||
): Observable<any> {
|
||||
const body: any = { lockId };
|
||||
if (breakdown.loanId != null) {
|
||||
body.loanId = breakdown.loanId;
|
||||
}
|
||||
if (breakdown.loanApplyAmount != null) {
|
||||
body.loanApplyAmount = String(breakdown.loanApplyAmount);
|
||||
}
|
||||
if (breakdown.savingsAccountId != null) {
|
||||
body.savingsAccountId = breakdown.savingsAccountId;
|
||||
}
|
||||
if (breakdown.savingsApplyAmount != null) {
|
||||
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
list(status = 'PENDING'): Observable<any> {
|
||||
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/dividend-claim-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/dividend-claim-requests/${id}/reject`, { reason });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* HTTP service for the Bank Manager's Dividend Dashboard — members with a locked (3-year)
|
||||
* share dividend who also have an overdue loan EMI, with an action to apply the locked dividend
|
||||
* towards that EMI.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DividendDashboardService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getDashboard(): Observable<any> {
|
||||
return this.http.get('/clerk/dividend-dashboard');
|
||||
}
|
||||
|
||||
applyToLoan(lockId: number, loanId: number, amount: number): Observable<any> {
|
||||
return this.http.post(`/clerk/dividend-dashboard/${lockId}/apply-to-loan`, { loanId, amount: String(amount) });
|
||||
}
|
||||
|
||||
getSummary(): Observable<any> {
|
||||
return this.http.get('/clerk/dividend-dashboard/summary');
|
||||
}
|
||||
|
||||
getDeclarations(): Observable<any> {
|
||||
return this.http.get('/clerk/dividend-dashboard/declarations');
|
||||
}
|
||||
|
||||
getLocks(status?: string): Observable<any> {
|
||||
const params = status && status !== 'ALL' ? { status } : {};
|
||||
return this.http.get('/clerk/dividend-dashboard/locks', { params });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
@if (summary) {
|
||||
<div class="dd-summary-grid">
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Declarations</div>
|
||||
<div class="dd-stat-value">{{ summary.totalDeclarations }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.pendingApprovalDeclarations }} pending approval</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Members Affected</div>
|
||||
<div class="dd-stat-value">{{ summary.membersAffected }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.totalMemberDividends }} member-dividends total</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Total Amount</div>
|
||||
<div class="dd-stat-value">{{ summary.totalAmount | number: '1.2-2' }}</div>
|
||||
<div class="dd-stat-sub">across all declared dividends</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Currently Locked</div>
|
||||
<div class="dd-stat-value">{{ summary.lockedCount }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.lockedAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Applied to Loans</div>
|
||||
<div class="dd-stat-value">{{ summary.appliedToLoanCount }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.appliedToLoanAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Claimed by Member</div>
|
||||
<div class="dd-stat-value">{{ summary.claimedCount }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.claimedAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="dd-stat-card dd-stat-card--forfeited">
|
||||
<div class="dd-stat-label">Forfeited to Profit</div>
|
||||
<div class="dd-stat-value">{{ summary.forfeitedCount }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.forfeitedAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">
|
||||
Refresh
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Period</th>
|
||||
<th class="num">Declared Amount</th>
|
||||
<th class="num">Members</th>
|
||||
<th>Status</th>
|
||||
<th>Approved On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (declarationsLoading) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (declarationsLoaded && declarations.length === 0) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (d of declarations; track d.declarationId) {
|
||||
<tr>
|
||||
<td>{{ d.productName }}</td>
|
||||
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
|
||||
<td class="num">{{ d.declaredAmount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ d.totalMembers }}</td>
|
||||
<td>
|
||||
<span class="dd-badge" [ngClass]="declarationStatusClass(d.status)">{{
|
||||
declarationStatusLabel(d.status)
|
||||
}}</span>
|
||||
</td>
|
||||
<td>{{ d.approvedOn ? (d.approvedOn | date: 'medium') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pos-section-label">Dividend Dashboard — Locked Dividends for Members with Overdue EMI</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th class="num">Locked Dividend</th>
|
||||
<th>Locked Date</th>
|
||||
<th>Release Due</th>
|
||||
<th class="num">Total Overdue EMI</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (loaded && rows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No members with a locked dividend and an overdue EMI right now.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (row of rows; track row.lockId) {
|
||||
<tr>
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
|
||||
<td>{{ row.lockedDate }}</td>
|
||||
<td>{{ row.releaseDueDate }}</td>
|
||||
<td class="num">{{ totalOverdue(row) | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<button type="button" class="pg-btn" (click)="toggleApply(row)">
|
||||
{{ expandedLockId === row.lockId ? 'Cancel' : 'Apply to Loan' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedLockId === row.lockId) {
|
||||
<tr class="dd-apply-row">
|
||||
<td colspan="6">
|
||||
<div class="dd-apply-panel">
|
||||
<label class="inv-form-field">
|
||||
<span>Loan</span>
|
||||
<select [(ngModel)]="selectedLoanId[row.lockId]" (change)="onLoanChange(row)">
|
||||
@for (loan of row.overdueLoans; track loan.loanId) {
|
||||
<option [ngValue]="loan.loanId">
|
||||
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Amount to Apply (max {{ maxAmountFor(row) | number: '1.2-2' }})</span>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="applyAmount[row.lockId]"
|
||||
[max]="maxAmountFor(row)"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
/>
|
||||
</label>
|
||||
@if (applyError) {
|
||||
<div class="dd-apply-error">{{ applyError }}</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dd-apply-confirm"
|
||||
[disabled]="applying"
|
||||
(click)="submitApply(row)"
|
||||
>
|
||||
{{ applying ? 'Applying…' : 'Confirm' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pos-section-label">All Dividend Locks — Full 3-Year Lock History</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px; gap: 8px">
|
||||
<div class="dd-filter-group">
|
||||
@for (opt of ['ALL', 'UNCLAIMED', 'CLAIMED', 'FORFEITED']; track opt) {
|
||||
<button
|
||||
type="button"
|
||||
class="dd-filter-btn"
|
||||
[class.dd-filter-btn--active]="lockStatusFilter === opt"
|
||||
(click)="setLockStatusFilter(opt)"
|
||||
>
|
||||
{{ opt === 'ALL' ? 'All' : (opt | titlecase) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<label class="inv-form-field" style="align-self: end; margin-left: auto">
|
||||
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Savings Account</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Locked Date</th>
|
||||
<th>Release Due</th>
|
||||
<th>Status</th>
|
||||
<th>Applied Loan</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (locksLoading) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (locksLoaded && locks.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (l of locks; track l.lockId) {
|
||||
<tr>
|
||||
<td>{{ l.clientName }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ l.savingsAccountNo }}</span>
|
||||
</td>
|
||||
<td class="num">{{ l.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ l.lockedDate }}</td>
|
||||
<td>{{ l.releaseDueDate }}</td>
|
||||
<td>
|
||||
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
|
||||
</td>
|
||||
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
|
||||
<td>
|
||||
@if (l.status === 'UNCLAIMED') {
|
||||
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
|
||||
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedClaimLockId === l.lockId) {
|
||||
<tr class="dd-apply-row">
|
||||
<td colspan="8">
|
||||
<div class="dd-apply-panel dd-apply-panel--wrap">
|
||||
<div class="dd-apply-panel-title">
|
||||
Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash)
|
||||
</div>
|
||||
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Apply to Overdue Loan</span>
|
||||
<select [(ngModel)]="claimLoanId[l.lockId]">
|
||||
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
|
||||
<option [ngValue]="loan.loanId">
|
||||
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Loan Amount</span>
|
||||
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" />
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pg-empty-inline">No overdue loan for this client.</div>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>Credit to Savings</span>
|
||||
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" />
|
||||
</label>
|
||||
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Savings Account ID (no linked account — enter one to credit)</span>
|
||||
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" />
|
||||
</label>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<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>
|
||||
@if (claimError) {
|
||||
<div class="dd-apply-error">{{ claimError }}</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dd-apply-confirm"
|
||||
[disabled]="claimSubmitting"
|
||||
(click)="submitClaim(l)"
|
||||
>
|
||||
{{ claimSubmitting ? 'Submitting…' : 'Submit Claim Request' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
|
||||
Refresh
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Savings Account</th>
|
||||
<th class="num">To Loan</th>
|
||||
<th class="num">To Savings</th>
|
||||
<th class="num">Cash</th>
|
||||
<th class="num">Unclaimed Balance</th>
|
||||
<th>Client Loan Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (claimRequestsLoading) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">No dividend claim requests pending review.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (req of claimRequests; track req.id) {
|
||||
<tr>
|
||||
<td>{{ req.client_name }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ req.savings_account_no }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
{{
|
||||
req.loan_apply_amount > 0
|
||||
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
|
||||
: '—'
|
||||
}}
|
||||
</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') : '—' }}
|
||||
@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) {
|
||||
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
|
||||
} @else {
|
||||
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
|
||||
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="toggleRejectClaim(req)">
|
||||
{{ expandedRejectClaimId === req.id ? 'Cancel' : 'Reject' }}
|
||||
</button>
|
||||
@if (claimActionError[req.id]) {
|
||||
<div class="dd-retry-error">{{ claimActionError[req.id] }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedRejectClaimId === req.id) {
|
||||
<tr class="dd-apply-row">
|
||||
<td colspan="8">
|
||||
<div class="dd-apply-panel">
|
||||
<label class="inv-form-field">
|
||||
<span>Rejection Reason</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="rejectReason[req.id]"
|
||||
placeholder="Why is this claim being rejected?"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dd-apply-confirm"
|
||||
[disabled]="claimActioningId === req.id"
|
||||
(click)="rejectClaim(req)"
|
||||
>
|
||||
{{ claimActioningId === req.id ? 'Rejecting…' : 'Confirm Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.dd-apply-row td {
|
||||
background: #f8fafc;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dd-apply-panel {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 16px;
|
||||
|
||||
.inv-form-field {
|
||||
min-width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-apply-error {
|
||||
color: #b42318;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dd-apply-panel--wrap {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.dd-apply-panel-title {
|
||||
width: 100%;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dd-apply-panel-total {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dd-apply-confirm {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #2563eb;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-retry-error {
|
||||
margin-top: 4px;
|
||||
color: #b42318;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.dd-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dd-badge--pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pg-empty-inline {
|
||||
color: #94a3b8;
|
||||
font-size: 12.5px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dd-badge--failed {
|
||||
background: #fee2e2;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.dd-badge--posted {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.dd-badge--partial {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* ─── Summary stat cards ─── */
|
||||
.dd-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dd-stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
border-left: 3px solid #94a3b8;
|
||||
|
||||
&--forfeited {
|
||||
border-left-color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-stat-label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dd-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-top: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dd-stat-sub {
|
||||
font-size: 11.5px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ─── Lock status filter buttons ─── */
|
||||
.dd-filter-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dd-filter-btn {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #1e293b;
|
||||
border-color: #1e293b;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 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 { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DividendDashboardService } from '../dividend-dashboard.service';
|
||||
import { DividendClaimRequestService } from '../dividend-claim-request.service';
|
||||
|
||||
/**
|
||||
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year
|
||||
* lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a
|
||||
* proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to
|
||||
* claim their dividend directly (split across loan, savings, or cash).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-dividend-dashboard',
|
||||
templateUrl: './dividend-dashboard.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss',
|
||||
'./dividend-dashboard.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class DividendDashboardComponent implements OnInit {
|
||||
private service = inject(DividendDashboardService);
|
||||
private claimRequestService = inject(DividendClaimRequestService);
|
||||
|
||||
rows: any[] = [];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
expandedLockId: number | null = null;
|
||||
selectedLoanId: Record<number, number | null> = {};
|
||||
applyAmount: Record<number, number | null> = {};
|
||||
applying = false;
|
||||
applyError: string | null = null;
|
||||
|
||||
summary: any = null;
|
||||
summaryLoading = false;
|
||||
|
||||
declarations: any[] = [];
|
||||
declarationsLoading = false;
|
||||
declarationsLoaded = false;
|
||||
|
||||
locks: any[] = [];
|
||||
locksLoading = false;
|
||||
locksLoaded = false;
|
||||
lockStatusFilter = 'ALL';
|
||||
|
||||
expandedClaimLockId: number | null = null;
|
||||
claimOverdueLoans: Record<number, any[]> = {};
|
||||
claimLoanId: Record<number, number | null> = {};
|
||||
claimLoanAmount: Record<number, number | null> = {};
|
||||
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;
|
||||
|
||||
claimRequests: any[] = [];
|
||||
claimRequestsLoading = false;
|
||||
claimRequestsLoaded = false;
|
||||
claimActioningId: number | null = null;
|
||||
claimActionError: Record<number, string> = {};
|
||||
expandedRejectClaimId: number | null = null;
|
||||
rejectReason: Record<number, string> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSummary();
|
||||
this.loadDeclarations();
|
||||
this.load();
|
||||
this.loadLocks();
|
||||
this.loadClaimRequests();
|
||||
}
|
||||
|
||||
toggleClaim(lock: any): void {
|
||||
if (this.expandedClaimLockId === lock.lockId) {
|
||||
this.expandedClaimLockId = null;
|
||||
return;
|
||||
}
|
||||
this.expandedClaimLockId = lock.lockId;
|
||||
this.claimError = null;
|
||||
this.claimLoanId[lock.lockId] = null;
|
||||
this.claimLoanAmount[lock.lockId] = null;
|
||||
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([])))
|
||||
.subscribe((loans: any[]) => {
|
||||
this.claimOverdueLoans[lock.lockId] = loans ?? [];
|
||||
if (loans && loans.length > 0) {
|
||||
this.claimLoanId[lock.lockId] = loans[0].loanId;
|
||||
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
|
||||
this.claimLoanAmount[lock.lockId] = loanAmt;
|
||||
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
claimTotal(lock: any): number {
|
||||
return (
|
||||
Number(this.claimLoanAmount[lock.lockId] || 0) +
|
||||
Number(this.claimSavingsAmount[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);
|
||||
// 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.';
|
||||
return;
|
||||
}
|
||||
if (total > Number(lock.amount)) {
|
||||
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
|
||||
return;
|
||||
}
|
||||
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
|
||||
this.claimError = 'Select a loan to apply the loan amount towards.';
|
||||
return;
|
||||
}
|
||||
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
|
||||
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
|
||||
.submit(lock.lockId, {
|
||||
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
|
||||
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
|
||||
savingsAccountId:
|
||||
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
|
||||
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
|
||||
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
|
||||
tokNo: cashAmount > 0 ? tokNo : undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.claimSubmitting = false;
|
||||
this.expandedClaimLockId = null;
|
||||
this.loadClaimRequests();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimSubmitting = false;
|
||||
this.claimError = err?.error?.message || 'Failed to submit claim request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadClaimRequests(): void {
|
||||
this.claimRequestsLoading = true;
|
||||
this.claimRequestService
|
||||
.list('PENDING')
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.claimRequests = res ?? [];
|
||||
this.claimRequestsLoading = false;
|
||||
this.claimRequestsLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
approveClaim(req: any): void {
|
||||
this.claimActioningId = req.id;
|
||||
delete this.claimActionError[req.id];
|
||||
this.claimRequestService.approve(req.id).subscribe({
|
||||
next: () => {
|
||||
this.claimActioningId = null;
|
||||
this.loadClaimRequests();
|
||||
this.loadLocks();
|
||||
this.loadSummary();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimActioningId = null;
|
||||
this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleRejectClaim(req: any): void {
|
||||
this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id;
|
||||
}
|
||||
|
||||
rejectClaim(req: any): void {
|
||||
const reason = this.rejectReason[req.id];
|
||||
if (!reason || !reason.trim()) {
|
||||
this.claimActionError[req.id] = 'Rejection reason is required.';
|
||||
return;
|
||||
}
|
||||
this.claimActioningId = req.id;
|
||||
delete this.claimActionError[req.id];
|
||||
this.claimRequestService.reject(req.id, reason).subscribe({
|
||||
next: () => {
|
||||
this.claimActioningId = null;
|
||||
this.expandedRejectClaimId = null;
|
||||
this.loadClaimRequests();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimActioningId = null;
|
||||
this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadSummary(): void {
|
||||
this.summaryLoading = true;
|
||||
this.service
|
||||
.getSummary()
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.summary = res;
|
||||
this.summaryLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
loadDeclarations(): void {
|
||||
this.declarationsLoading = true;
|
||||
this.service
|
||||
.getDeclarations()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.declarations = res ?? [];
|
||||
this.declarationsLoading = false;
|
||||
this.declarationsLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
declarationStatusLabel(status: string): string {
|
||||
return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved';
|
||||
}
|
||||
|
||||
declarationStatusClass(status: string): string {
|
||||
return status === 'PENDING_APPROVAL' ? 'dd-badge--pending' : 'dd-badge--posted';
|
||||
}
|
||||
|
||||
loadLocks(): void {
|
||||
this.locksLoading = true;
|
||||
this.service
|
||||
.getLocks(this.lockStatusFilter)
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.locks = res ?? [];
|
||||
this.locksLoading = false;
|
||||
this.locksLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
setLockStatusFilter(status: string): void {
|
||||
this.lockStatusFilter = status;
|
||||
this.loadLocks();
|
||||
}
|
||||
|
||||
lockStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'UNCLAIMED':
|
||||
return 'dd-badge--pending';
|
||||
case 'CLAIMED':
|
||||
return 'dd-badge--posted';
|
||||
case 'FORFEITED':
|
||||
return 'dd-badge--failed';
|
||||
default:
|
||||
return 'dd-badge--pending';
|
||||
}
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.expandedLockId = null;
|
||||
this.service
|
||||
.getDashboard()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.rows = res ?? [];
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
totalOverdue(row: any): number {
|
||||
return (row.overdueLoans || []).reduce((sum: number, l: any) => sum + Number(l.overdueAmount || 0), 0);
|
||||
}
|
||||
|
||||
toggleApply(row: any): void {
|
||||
if (this.expandedLockId === row.lockId) {
|
||||
this.expandedLockId = null;
|
||||
return;
|
||||
}
|
||||
this.expandedLockId = row.lockId;
|
||||
this.applyError = null;
|
||||
const firstLoan = (row.overdueLoans || [])[0];
|
||||
this.selectedLoanId[row.lockId] = firstLoan ? firstLoan.loanId : null;
|
||||
this.applyAmount[row.lockId] = firstLoan
|
||||
? Math.min(Number(row.lockedAmount), Number(firstLoan.overdueAmount))
|
||||
: Number(row.lockedAmount);
|
||||
}
|
||||
|
||||
maxAmountFor(row: any): number {
|
||||
const loanId = this.selectedLoanId[row.lockId];
|
||||
const loan = (row.overdueLoans || []).find((l: any) => l.loanId === loanId);
|
||||
const overdue = loan ? Number(loan.overdueAmount) : Number(row.lockedAmount);
|
||||
return Math.min(Number(row.lockedAmount), overdue);
|
||||
}
|
||||
|
||||
onLoanChange(row: any): void {
|
||||
this.applyAmount[row.lockId] = this.maxAmountFor(row);
|
||||
}
|
||||
|
||||
submitApply(row: any): void {
|
||||
const loanId = this.selectedLoanId[row.lockId];
|
||||
const amount = this.applyAmount[row.lockId];
|
||||
if (!loanId || !amount || amount <= 0) {
|
||||
this.applyError = 'Select a loan and enter a valid amount.';
|
||||
return;
|
||||
}
|
||||
this.applying = true;
|
||||
this.applyError = null;
|
||||
this.service.applyToLoan(row.lockId, loanId, amount).subscribe({
|
||||
next: () => {
|
||||
this.applying = false;
|
||||
this.load();
|
||||
this.loadLocks();
|
||||
this.loadSummary();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.applying = false;
|
||||
this.applyError = err?.error?.message || 'Failed to apply dividend to loan.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CLERK · SHARE WITHDRAWAL</div>
|
||||
<h1 class="pg-title">My Share Requests</h1>
|
||||
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Client</th>
|
||||
<th class="num">Shares</th>
|
||||
<th>Resolution No.</th>
|
||||
<th>Meeting Date</th>
|
||||
<th>Submitted At</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (r of requests; track r.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.account_no }}</span>
|
||||
</td>
|
||||
<td>{{ r.client_name }}</td>
|
||||
<td class="num">{{ r.requested_shares }}</td>
|
||||
<td>{{ r.resolution_number }}</td>
|
||||
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||
|
||||
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||
@Component({
|
||||
selector: 'mifosx-my-share-withdrawal-requests',
|
||||
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkShareWithdrawalService);
|
||||
private router = inject(Router);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = '';
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||
.subscribe((res: any) => {
|
||||
this.requests = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
newRequest(): void {
|
||||
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'inv-badge--active',
|
||||
APPROVED: 'inv-badge--matured',
|
||||
REJECTED: 'inv-badge--written_off'
|
||||
};
|
||||
return `inv-badge ${map[status] ?? ''}`;
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending Approval',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected'
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
formatDateTime(val: any): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
return d.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn" (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Account</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (!isReview) {
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>* Search Account No. / Client Name</span>
|
||||
<mifosx-search-select
|
||||
[options]="accountOptions"
|
||||
[(ngModel)]="selectedAccountId"
|
||||
(ngModelChange)="onAccountSelected($event)"
|
||||
placeholder="Search share account no. or client name…"
|
||||
></mifosx-search-select>
|
||||
</label>
|
||||
}
|
||||
@if (account) {
|
||||
<label class="inv-form-field">
|
||||
<span>Account No</span>
|
||||
<input type="text" [value]="account.accountNo" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Client</span>
|
||||
<input type="text" [value]="account.clientName" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Shares Held</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Unit Price</span>
|
||||
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
}
|
||||
@if (loadingAccount) {
|
||||
<div class="pg-empty">Loading account…</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isReview && reviewRequest) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Client Compliance</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<div class="inv-form-field">
|
||||
<span>KYC Status</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||
>
|
||||
{{ reviewRequest.client_kyc_status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Risk Category</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||
>
|
||||
{{ reviewRequest.client_risk_category_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Open AML Alerts</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||
>
|
||||
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (account) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Withdrawal Details</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>* Shares to Withdraw</span>
|
||||
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Requested Date</span>
|
||||
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Withdrawal Value</span>
|
||||
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Remaining Shares After Withdrawal</span>
|
||||
<input type="text" [value]="remainingShares" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Board Resolution Number</span>
|
||||
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Date of Meeting</span>
|
||||
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Settlement Payment Type</span>
|
||||
@if (!isReview) {
|
||||
<select [(ngModel)]="settlementPaymentTypeId">
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (pt of paymentTypeOptions; track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||
}
|
||||
</label>
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>Description / Remarks</span>
|
||||
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (account && remainingShares === 0) {
|
||||
<div class="swr-close-notice">
|
||||
<mat-icon>lock</mat-icon>
|
||||
<span>
|
||||
This withdraws <strong>all</strong> shares on this account. Approving it will
|
||||
<strong>close the share account automatically</strong> — no separate closure step is needed.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isReview) {
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!isFormValid || submitting"
|
||||
(click)="openConfirmDialog()"
|
||||
>
|
||||
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Verification</div>
|
||||
<div class="swrv-trigger">
|
||||
<div class="swrv-trigger__icon">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
</div>
|
||||
<div class="swrv-trigger__text">
|
||||
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||
<div class="swrv-trigger__sub">
|
||||
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
Validate Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Process Withdrawal
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showValidateModal) {
|
||||
<div class="swrv-modal">
|
||||
<div class="swrv-modal__card">
|
||||
<div class="swrv-modal__header">
|
||||
<div class="swrv-modal__title">
|
||||
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||
Validate Share Withdrawal Request
|
||||
</div>
|
||||
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__body">
|
||||
<p class="swrv-modal__intro">
|
||||
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||
and GL configuration — not just what was captured at submission time.
|
||||
</p>
|
||||
|
||||
@if (backendChecks && !validating && !validateAllPassed) {
|
||||
<div class="swrv-modal__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div>
|
||||
<strong>Some checks need review before approving.</strong>
|
||||
Scroll down to see which ones failed and why.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ol class="swrv-steps">
|
||||
@for (step of validateSteps; track step.label; let i = $index) {
|
||||
<li
|
||||
class="swrv-step"
|
||||
[class.swrv-step--active]="step.status === 'active'"
|
||||
[class.swrv-step--done]="step.status === 'done'"
|
||||
[class.swrv-step--failed]="step.status === 'failed'"
|
||||
>
|
||||
<div class="swrv-step__indicator">
|
||||
@if (step.status === 'done') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (step.status === 'active') {
|
||||
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||
} @else if (step.status === 'failed') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else {
|
||||
<span>{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="swrv-step__content">
|
||||
<div class="swrv-step__label">{{ step.label }}</div>
|
||||
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@if (validating && validateSteps.length === 0) {
|
||||
<div class="pg-empty">Connecting to server…</div>
|
||||
}
|
||||
|
||||
@if (backendChecks && !validating && validateAllPassed) {
|
||||
<div class="swrv-modal__result">
|
||||
<mat-icon>verified</mat-icon>
|
||||
<span>All checks passed — safe to approve.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__footer">
|
||||
@if (!validating && backendChecks) {
|
||||
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Re-run
|
||||
</button>
|
||||
}
|
||||
@if (!validating && backendChecks && validateAllPassed) {
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Process Withdrawal
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||
{{ validating ? 'Validating…' : 'Close' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Settlement</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (reviewRequest?.settlement_amount) {
|
||||
<label class="inv-form-field">
|
||||
<span>Amount Credited</span>
|
||||
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Credited To (Linked Savings A/C)</span>
|
||||
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Savings Transaction Ref.</span>
|
||||
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pg-empty" style="grid-column: span 2">
|
||||
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||
already-approved request — check the linked savings account's transaction history to confirm.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && activityLog.length > 0) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Activity Log</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of activityLog; track entry.timestamp) {
|
||||
<tr>
|
||||
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||
<td>{{ entry.action }}</td>
|
||||
<td>{{ entry.user }}</td>
|
||||
<td>{{ entry.note || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showConfirmDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Confirm Share Withdrawal Request</h3>
|
||||
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Account No / Client</td>
|
||||
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shares to Withdraw</td>
|
||||
<td>
|
||||
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr [class.swr-close-row]="remainingShares === 0">
|
||||
<td>Remaining Shares After Withdrawal</td>
|
||||
<td>
|
||||
{{ remainingShares }}
|
||||
@if (remainingShares === 0) {
|
||||
<span class="swr-close-tag"><mat-icon>lock</mat-icon> Account will be closed on approval</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Requested Date</td>
|
||||
<td>{{ requestedDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Board Resolution Number</td>
|
||||
<td>{{ resolutionNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date of Meeting</td>
|
||||
<td>{{ meetingDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Settlement Payment Type</td>
|
||||
<td>{{ settlementPaymentTypeName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linked Savings Account</td>
|
||||
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
@for (check of verificationChecks; track check.label) {
|
||||
<tr>
|
||||
<td>{{ check.label }}</td>
|
||||
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showRejectDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Reject Share Withdrawal Request</h3>
|
||||
<label class="inv-form-field">
|
||||
<span>* Rejection Reason</span>
|
||||
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||
</label>
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
[disabled]="!rejectionReason.trim()"
|
||||
(click)="confirmRejectReview()"
|
||||
>
|
||||
Confirm Reject
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.swr-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(13 27 42 / 55%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.swr-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&:has(table) {
|
||||
width: 560px;
|
||||
}
|
||||
}
|
||||
|
||||
.swr-check-pass {
|
||||
color: #1a7f37;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swr-check-fail {
|
||||
color: #b42318;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swr-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&--ok {
|
||||
background: #eafaf0;
|
||||
color: #1a7f37;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: #fdecea;
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Account-will-close notice ---------- */
|
||||
.swr-close-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 16px 16px;
|
||||
padding: 10px 16px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #7c2d12;
|
||||
}
|
||||
}
|
||||
|
||||
.swr-close-row td {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.swr-close-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Verification trigger card ---------- */
|
||||
.swrv-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Validate pipeline modal ---------- */
|
||||
@keyframes swrv-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swrv-zoom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swrv-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.swrv-spin {
|
||||
animation: swrv-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.swrv-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
// No backdrop-filter: it would make this element a containing block for any position:fixed
|
||||
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
|
||||
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
|
||||
// caused. Extra opacity compensates for the loss of the blur look.
|
||||
background: rgb(15 25 45 / 65%);
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
animation: swrv-fade 0.2s ease-out;
|
||||
|
||||
&__card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||
animation: swrv-zoom 0.25s ease-out;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid #eef1f5;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__title-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: #b42318;
|
||||
border-color: #b42318;
|
||||
background: #fdecea;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 20px 22px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__intro {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 16px;
|
||||
|
||||
strong {
|
||||
color: #0d1b2a;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__warn {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #fef3e2;
|
||||
border: 1px solid rgb(217 119 6 / 30%);
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: #633806;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: #4d2a05;
|
||||
}
|
||||
}
|
||||
|
||||
&__result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 12px 14px;
|
||||
background: #eafaf0;
|
||||
border: 1px solid rgb(26 127 55 / 25%);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a7f37;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid #eef1f5;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.swrv-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.swrv-step {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 38px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.25s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 5px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&__msg {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
.swrv-step__indicator {
|
||||
background: #3457d5;
|
||||
border-color: #3457d5;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #3457d5;
|
||||
}
|
||||
}
|
||||
|
||||
&--done {
|
||||
.swrv-step__indicator {
|
||||
background: #1a7f37;
|
||||
border-color: #1a7f37;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #1a7f37;
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
&--failed {
|
||||
.swrv-step__indicator {
|
||||
background: #b42318;
|
||||
border-color: #b42318;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.swrv-spin {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.swrv-modal,
|
||||
.swrv-modal__card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||
import { CashierService } from '../../cashier/cashier.service';
|
||||
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||
|
||||
export type ShareWdMode = 'create' | 'review';
|
||||
|
||||
interface ActivityEntry {
|
||||
timestamp: string;
|
||||
action: string;
|
||||
user: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-withdrawal-request',
|
||||
templateUrl: './share-withdrawal-request.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss',
|
||||
'./share-withdrawal-request.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
SearchSelectComponent
|
||||
]
|
||||
})
|
||||
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private sharesService = inject(SharesService);
|
||||
private wdService = inject(ClerkShareWithdrawalService);
|
||||
private cashierService = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||
* events instead of navigating away. */
|
||||
@Input() embedded = false;
|
||||
@Input() overrideMode: ShareWdMode | null = null;
|
||||
@Input() overrideRequestId: number | null = null;
|
||||
@Output() closeRequested = new EventEmitter<void>();
|
||||
@Output() actionCompleted = new EventEmitter<void>();
|
||||
|
||||
mode: ShareWdMode = 'create';
|
||||
requestId: number | null = null;
|
||||
|
||||
submitting = false;
|
||||
loadingAccount = false;
|
||||
|
||||
selectedAccountId = '';
|
||||
accountOptions: SearchSelectOption[] = [];
|
||||
|
||||
account: any = null;
|
||||
|
||||
requestedShares: number | null = null;
|
||||
requestedDate = '';
|
||||
resolutionNumber = '';
|
||||
meetingDate = '';
|
||||
description = '';
|
||||
|
||||
paymentTypes: any[] = [];
|
||||
settlementPaymentTypeId: number | null = null;
|
||||
|
||||
activityLog: ActivityEntry[] = [];
|
||||
reviewRequest: any = null;
|
||||
showRejectDialog = false;
|
||||
rejectionReason = '';
|
||||
showConfirmDialog = false;
|
||||
|
||||
showValidateModal = false;
|
||||
validating = false;
|
||||
validateAllPassed = false;
|
||||
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||
[];
|
||||
|
||||
get remainingShares(): number {
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
return held - (this.requestedShares || 0);
|
||||
}
|
||||
|
||||
get unitPrice(): number {
|
||||
return Number(this.account?.currentMarketPrice ?? 0);
|
||||
}
|
||||
|
||||
get withdrawalValue(): number {
|
||||
return (this.requestedShares || 0) * this.unitPrice;
|
||||
}
|
||||
|
||||
get paymentTypeOptions(): any[] {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
|
||||
get selectedSettlementPaymentType(): any {
|
||||
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||
}
|
||||
|
||||
get settlementPaymentTypeName(): string {
|
||||
if (this.isReview) {
|
||||
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||
}
|
||||
return this.selectedSettlementPaymentType?.name || '—';
|
||||
}
|
||||
|
||||
get isFormValid(): boolean {
|
||||
if (!this.account) return false;
|
||||
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
if (this.requestedShares > held) return false;
|
||||
if (!this.requestedDate) return false;
|
||||
if (!this.resolutionNumber.trim()) return false;
|
||||
if (!this.meetingDate) return false;
|
||||
if (!this.settlementPaymentTypeId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
const shares = this.requestedShares || 0;
|
||||
return [
|
||||
{
|
||||
label: 'Board Resolution Number provided',
|
||||
pass: !!this.resolutionNumber?.trim(),
|
||||
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||
},
|
||||
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||
{
|
||||
label: 'Shares requested within shares held',
|
||||
pass: shares > 0 && shares <= held,
|
||||
detail: `${shares} of ${held} shares held`
|
||||
},
|
||||
{
|
||||
label: 'Linked savings account confirmed',
|
||||
pass: !!this.account?.savingsAccountNumber,
|
||||
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||
},
|
||||
{
|
||||
label: 'Settlement Payment Type selected (non-cash)',
|
||||
pass: this.settlementPaymentTypeName !== '—',
|
||||
detail: this.settlementPaymentTypeName
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get isReview(): boolean {
|
||||
return this.mode === 'review';
|
||||
}
|
||||
|
||||
get pageTitle(): string {
|
||||
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||
}
|
||||
|
||||
get pageSubtitle(): string {
|
||||
return this.isReview
|
||||
? "Approve or reject the clerk's share withdrawal request."
|
||||
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||
this.requestId = idParam ? Number(idParam) : null;
|
||||
|
||||
const today = new Date();
|
||||
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
this.meetingDate = this.requestedDate;
|
||||
|
||||
this.sharesService
|
||||
.getShareAccountSummaries()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((rows: any[]) => {
|
||||
this.accountOptions = (rows || [])
|
||||
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||
.map((r) => ({
|
||||
value: String(r.id),
|
||||
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||
searchText: `${r.accountNo} ${r.clientName}`
|
||||
}));
|
||||
});
|
||||
|
||||
this.cashierService
|
||||
.getPaymentTypes()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||
|
||||
if (this.requestId && this.mode === 'review') {
|
||||
this.loadReviewRequest(this.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
onAccountSelected(accountId: string): void {
|
||||
this.selectedAccountId = accountId;
|
||||
if (!accountId) {
|
||||
this.account = null;
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = true;
|
||||
this.sharesService
|
||||
.getSharesAccountData(accountId, false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => {
|
||||
this.account = acc;
|
||||
this.loadingAccount = false;
|
||||
});
|
||||
}
|
||||
|
||||
private loadReviewRequest(id: number): void {
|
||||
this.wdService.getById(id).subscribe({
|
||||
next: (req: any) => {
|
||||
this.reviewRequest = req;
|
||||
this.requestedShares = Number(req.requested_shares);
|
||||
this.requestedDate = req.requested_date
|
||||
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||
: this.requestedDate;
|
||||
this.resolutionNumber = req.resolution_number ?? '';
|
||||
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||
this.description = req.description ?? '';
|
||||
|
||||
if (req.activity_log) {
|
||||
try {
|
||||
this.activityLog = JSON.parse(req.activity_log);
|
||||
} catch {
|
||||
this.activityLog = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.sharesService
|
||||
.getSharesAccountData(String(req.share_account_id), false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => (this.account = acc));
|
||||
},
|
||||
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||
});
|
||||
}
|
||||
|
||||
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||
openConfirmDialog(): void {
|
||||
if (!this.isFormValid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.showConfirmDialog = true;
|
||||
}
|
||||
|
||||
cancelConfirmDialog(): void {
|
||||
this.showConfirmDialog = false;
|
||||
}
|
||||
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
this.submitting = true;
|
||||
const payload = {
|
||||
shareAccountId: Number(this.selectedAccountId),
|
||||
accountNo: this.account?.accountNo,
|
||||
clientId: this.account?.clientId,
|
||||
clientName: this.account?.clientName,
|
||||
requestedShares: this.requestedShares,
|
||||
requestedDate: this.requestedDate,
|
||||
resolutionNumber: this.resolutionNumber.trim(),
|
||||
meetingDate: this.meetingDate,
|
||||
description: this.description,
|
||||
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||
};
|
||||
this.wdService.submit(payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/clerk/dashboard']);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openValidateModal(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.showValidateModal = true;
|
||||
this.runBackendValidation();
|
||||
}
|
||||
|
||||
closeValidateModal(): void {
|
||||
if (this.validating) return;
|
||||
this.showValidateModal = false;
|
||||
}
|
||||
|
||||
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||
runBackendValidation(): void {
|
||||
if (!this.reviewRequest || this.validating) return;
|
||||
this.validating = true;
|
||||
this.backendChecks = null;
|
||||
this.validateSteps = [];
|
||||
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||
this.backendChecks = checks;
|
||||
this.validateAllPassed = !!res?.allPassed;
|
||||
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||
this.revealValidateSteps(0);
|
||||
},
|
||||
error: (err) => {
|
||||
this.validating = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private revealValidateSteps(index: number): void {
|
||||
if (index >= this.validateSteps.length) {
|
||||
this.validating = false;
|
||||
return;
|
||||
}
|
||||
this.validateSteps[index].status = 'active';
|
||||
setTimeout(() => {
|
||||
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||
this.revealValidateSteps(index + 1);
|
||||
}, 320);
|
||||
}
|
||||
|
||||
approveReview(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
let message =
|
||||
res?.settlementAmount != null
|
||||
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||
if (res?.accountClosed) {
|
||||
message += ' All shares withdrawn — the share account has been closed.';
|
||||
}
|
||||
this.snackBar.open(message, 'Close', { duration: 8000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
openRejectDialog(): void {
|
||||
this.showRejectDialog = true;
|
||||
this.rejectionReason = '';
|
||||
}
|
||||
|
||||
confirmRejectReview(): void {
|
||||
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.showRejectDialog = false;
|
||||
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (this.embedded) {
|
||||
this.closeRequested.emit();
|
||||
return;
|
||||
}
|
||||
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
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="fm-dialog">
|
||||
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px">
|
||||
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||
<span style="font-weight: 500; font-size: 18px">CHILDREN</span>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="childrenForm">
|
||||
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px">
|
||||
@for (child of children.controls; track $index) {
|
||||
<div
|
||||
[formGroupName]="$index"
|
||||
style="
|
||||
position: relative;
|
||||
padding: 24px 16px 16px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
"
|
||||
>
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Child Full Name*</mat-label>
|
||||
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Gender*</mat-label>
|
||||
<mat-select formControlName="genderId" required>
|
||||
@for (g of genderIdOptions; track g.id) {
|
||||
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Nationality</mat-label>
|
||||
<mat-select formControlName="nationality">
|
||||
<mat-option value="INDIAN">Indian</mat-option>
|
||||
<mat-option value="NRI">NRI</mat-option>
|
||||
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
placeholder="12-digit Aadhaar"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>PAN Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
maxlength="10"
|
||||
placeholder="ABCDE1234F"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
$any(children.at($index))
|
||||
.get('panNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (
|
||||
$any(children.at($index)).get('panNumber')?.hasError('pattern') &&
|
||||
$any(children.at($index)).get('panNumber')?.touched
|
||||
) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@if ($index < children.controls.length - 1) {
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
}
|
||||
}
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||
<mat-icon>add</mat-icon> Add Another Child
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||
Add Member
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-family-member-child-dialog',
|
||||
templateUrl: './client-family-member-child-dialog.component.html',
|
||||
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
dialogRef = inject(MatDialogRef<ClientFamilyMemberChildDialogComponent>);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
childrenForm: UntypedFormGroup;
|
||||
genderIdOptions: any[];
|
||||
maxDate: Date = new Date();
|
||||
|
||||
constructor() {
|
||||
this.genderIdOptions = this.data.genderIdOptions;
|
||||
|
||||
this.childrenForm = this.formBuilder.group({
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.data.children && this.data.children.length > 0) {
|
||||
this.data.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.addChild();
|
||||
}
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.childrenForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.childrenForm.valid) {
|
||||
this.dialogRef.close(this.children.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -139,6 +139,21 @@
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||
<div class="fm-readonly-remarks">
|
||||
<span class="fm-readonly-label">Children</span>
|
||||
<div class="fm-children-list">
|
||||
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px">
|
||||
<span>{{ child.fullName }}</span>
|
||||
@if (child.dateOfBirth) {
|
||||
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||
@@ -447,6 +462,25 @@
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Children Section -->
|
||||
@if (isSpouse) {
|
||||
<div class="fm-section-label" style="margin-top: 16px">Children</div>
|
||||
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between">
|
||||
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
color="primary"
|
||||
(click)="openChildrenDialog()"
|
||||
style="margin-left: 16px"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
+143
-2
@@ -17,7 +17,7 @@ import {
|
||||
MatDialogClose,
|
||||
MatDialogContent
|
||||
} from '@angular/material/dialog';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
maxDate = new Date();
|
||||
|
||||
hasChildrenChecked = false;
|
||||
familyMemberForm: UntypedFormGroup;
|
||||
|
||||
/** Client search */
|
||||
@@ -197,6 +199,32 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
// Re-fetch linked client details (profile, signature, KYC status)
|
||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||
}
|
||||
if (m.children && m.children.length > 0) {
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||
m.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
if (this.isViewMode) {
|
||||
this.familyMemberForm.disable();
|
||||
}
|
||||
@@ -225,8 +253,102 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
isAlive: [true],
|
||||
marriageDate: [''],
|
||||
marriageAuthority: [''],
|
||||
linkedClientId: ['']
|
||||
linkedClientId: [''],
|
||||
hasChildren: [false],
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
|
||||
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => {
|
||||
if (value && !this.hasChildrenChecked) {
|
||||
this.openChildrenDialog();
|
||||
} else if (!value) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.children.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
async openChildrenDialog() {
|
||||
// Dynamically import the dialog to avoid a circular dependency between the two dialogs.
|
||||
const { ClientFamilyMemberChildDialogComponent } =
|
||||
await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||
|
||||
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||
data: {
|
||||
children: this.children.value,
|
||||
genderIdOptions: this.data.options.genderIdOptions
|
||||
},
|
||||
width: '500px'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||
if (result) {
|
||||
this.children.clear();
|
||||
result.forEach((child) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||
} else if (this.children.length === 0) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
if (this.children.length === 0) {
|
||||
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle marriage certificate file selection */
|
||||
@@ -481,6 +603,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
member._linkedClient = this.selectedClient;
|
||||
}
|
||||
|
||||
// Format children dates
|
||||
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||
member.children = member.children.map((child: any) => {
|
||||
const c = { ...child };
|
||||
if (c.dateOfBirth instanceof Date) {
|
||||
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||
} else if (!c.dateOfBirth) {
|
||||
delete c.dateOfBirth;
|
||||
}
|
||||
if (c.panNumber) {
|
||||
c.panNumber = c.panNumber.toUpperCase();
|
||||
}
|
||||
return c;
|
||||
});
|
||||
} else {
|
||||
delete member.children;
|
||||
}
|
||||
delete member.hasChildren;
|
||||
|
||||
// Attach marriage certificate file if present
|
||||
if (this.marriageCertFile) {
|
||||
member._marriageCertFile = this.marriageCertFile;
|
||||
|
||||
+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;
|
||||
|
||||
+375
-33
@@ -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,10 +540,41 @@ 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(). */
|
||||
private suppressMonthlyAnnualLink = false;
|
||||
|
||||
/**
|
||||
* @param {FormBuilder} formBuilder Form Builder
|
||||
@@ -652,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 = '';
|
||||
}
|
||||
@@ -709,6 +762,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
const data = this.clientData;
|
||||
this.loadExistingSeniorPwdDocuments();
|
||||
|
||||
// Loading an existing client's independently-saved monthly/annual values must not run
|
||||
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
|
||||
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
|
||||
// instead of keeping the exact historical values as saved.
|
||||
this.suppressMonthlyAnnualLink = true;
|
||||
|
||||
// Patch all base controls that exist on the form
|
||||
this.createClientForm.patchValue({
|
||||
officeId: data.officeId,
|
||||
@@ -743,6 +802,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
? new Date(data.timeline.submittedOnDate)
|
||||
: this.settingsService.businessDate
|
||||
});
|
||||
this.suppressMonthlyAnnualLink = false;
|
||||
|
||||
// Patch name fields (already created by buildDependencies via legalFormId subscription)
|
||||
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
|
||||
@@ -768,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -954,6 +1019,86 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
||||
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
||||
this.linkIncomeToTurnover();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-calculates turnover based on income (doubling monthly income for monthly 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');
|
||||
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||
const annualIncome = this.createClientForm.get('annualIncome');
|
||||
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||
|
||||
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
|
||||
|
||||
if (monthlyIncome && monthlyTurnover && annualTurnover) {
|
||||
monthlyIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
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 && monthlyTurnover) {
|
||||
annualIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
||||
* subscriptions don't trigger each other back and forth.
|
||||
*/
|
||||
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
|
||||
private round2(n: number): string {
|
||||
return (Math.round(n * 100) / 100).toString();
|
||||
}
|
||||
|
||||
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
|
||||
const monthly = this.createClientForm.get(monthlyControlName);
|
||||
const annual = this.createClientForm.get(annualControlName);
|
||||
if (!monthly || !annual) {
|
||||
return;
|
||||
}
|
||||
|
||||
monthly.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
|
||||
});
|
||||
|
||||
annual.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1044,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: ['']
|
||||
})
|
||||
@@ -1164,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
|
||||
@@ -1178,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
|
||||
@@ -1218,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;
|
||||
});
|
||||
@@ -1238,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;
|
||||
});
|
||||
@@ -1265,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.
|
||||
@@ -1272,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;
|
||||
@@ -1284,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 || '';
|
||||
@@ -1333,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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1389,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: () => {
|
||||
@@ -1402,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 = '-';
|
||||
@@ -1600,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 */
|
||||
@@ -1672,6 +1913,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
return salutation ? `${salutation} ${full}` : full;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name PAN and Aadhaar are actually matched against.
|
||||
*
|
||||
* Deliberately not {@link fullNamePreview}: that prefixes the salutation, while
|
||||
* ClientKycWritePlatformServiceJpaRepositoryImpl builds the verification name from firstname +
|
||||
* middlename + lastname only. Values are passed through untouched rather than capitalised, so what
|
||||
* the KYC step displays is exactly what goes to the provider.
|
||||
*/
|
||||
get verificationName(): string {
|
||||
return [
|
||||
this.createClientForm?.get('firstname')?.value,
|
||||
this.createClientForm?.get('middlename')?.value,
|
||||
this.createClientForm?.get('lastname')?.value
|
||||
]
|
||||
.map((part: any) => (part || '').toString().trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** Date of birth as captured, sent to the provider formatted dd/MM/yyyy. */
|
||||
get verificationDateOfBirth(): Date | null {
|
||||
const value = this.createClientForm?.get('dateOfBirth')?.value;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
getSelectedGender(): any {
|
||||
const val = this.createClientForm.get('genderId')?.value;
|
||||
return this.genderOptions?.find((g: any) => g.id === val) || null;
|
||||
@@ -2054,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);
|
||||
}
|
||||
@@ -2123,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(): {
|
||||
@@ -2190,26 +2540,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
}
|
||||
|
||||
// Match monthly income ranges
|
||||
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
||||
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
||||
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
||||
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match turnover ranges
|
||||
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
||||
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
||||
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2228,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
|
||||
});
|
||||
}
|
||||
|
||||
+300
-22
@@ -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';
|
||||
|
||||
@@ -38,10 +39,17 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
}
|
||||
</div>
|
||||
<p class="sanctions-msg">
|
||||
Screening <strong>{{ clientName }}</strong> against<br />
|
||||
<strong>UN Security Council</strong> &
|
||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||
Screening <strong>{{ clientName }}</strong> against the<br />
|
||||
<strong>UN Security Council Consolidated List</strong><br />
|
||||
<span class="sanctions-lists"
|
||||
>ISIL (Da’esh) & Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists</span
|
||||
>
|
||||
</p>
|
||||
@if (listDataAsOf) {
|
||||
<p class="list-as-of">
|
||||
Sanctions list data as of <strong>{{ listDataAsOf }}</strong>
|
||||
</p>
|
||||
}
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -54,8 +62,8 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<h3 class="approve-title">No Sanctions Match Found</h3>
|
||||
<p class="approve-detail">
|
||||
<strong>{{ clientName }}</strong> has been screened against the
|
||||
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
|
||||
match was found.
|
||||
<strong>UN Security Council Consolidated List</strong> (ISIL & Al-Qaida and Taliban sanctions lists). No
|
||||
confirmed match was found.
|
||||
</p>
|
||||
<div class="approve-meta">
|
||||
<div class="meta-row">
|
||||
@@ -111,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>
|
||||
}
|
||||
@@ -134,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>
|
||||
@@ -167,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 {
|
||||
@@ -222,6 +320,20 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-as-of {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
text-align: center;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.sanctions-lists {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.regulatory-note {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
@@ -468,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
.match-list {
|
||||
width: 100%;
|
||||
max-height: unset;
|
||||
overflow: visible;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
@@ -510,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;
|
||||
@@ -524,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,
|
||||
@@ -537,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;
|
||||
@@ -550,16 +745,37 @@ 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
|
||||
* the older Master Direction this screen used to name.
|
||||
*/
|
||||
regulatoryBasis =
|
||||
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
|
||||
'Screened under Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and Chapter IX of the ' +
|
||||
'RBI Know Your Customer Directions, 2025 (amended as on December 29, 2025).';
|
||||
|
||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
progressValue = 0;
|
||||
currentStep = 0;
|
||||
// Steps describe the screening that actually runs against the UNSC consolidated list; there is no
|
||||
// FIU-IND lookup behind this call, so it is no longer claimed here.
|
||||
progressSteps = [
|
||||
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
|
||||
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
|
||||
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
|
||||
{ label: 'Cross-checking listed aliases and entities...', icon: 'database' as any },
|
||||
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
|
||||
];
|
||||
|
||||
@@ -570,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
|
||||
@@ -577,6 +795,9 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
next: (result: any) => {
|
||||
this.apiResult = result;
|
||||
this.apiDone = true;
|
||||
// Surfaced while the screening is still running, so the operator can see which snapshot of
|
||||
// the UNSC list the check is being made against rather than only learning it afterwards.
|
||||
this.listDataAsOf = this.formatListDate(result?.listDataAsOf);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.apiError = err;
|
||||
@@ -652,13 +873,7 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
'Unable to connect to sanctions screening service.';
|
||||
} else {
|
||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||
this.listDataAsOf = this.apiResult?.listDataAsOf
|
||||
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
: null;
|
||||
this.listDataAsOf = this.formatListDate(this.apiResult?.listDataAsOf);
|
||||
if (this.apiResult?.matchFound) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = this.apiResult.matchCount || 0;
|
||||
@@ -668,10 +883,73 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** Formats the date the UNSC snapshot was loaded; null when the backend did not report one. */
|
||||
private formatListDate(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
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({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
@@ -304,6 +318,234 @@
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
// Not verified, but covered by a declaration — deliberately amber rather than green so it never
|
||||
// reads as a passing check.
|
||||
&.declared {
|
||||
background: #fffbeb;
|
||||
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 ────────────────────────────────────────────────
|
||||
.pan-decl-card {
|
||||
margin: -4px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pan-decl-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pan-decl-card__icon {
|
||||
color: #b45309;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.pan-decl-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pan-decl-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pan-decl-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pan-decl-card__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #451a03;
|
||||
word-break: break-word;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl-card__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #fde68a;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pan-decl-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aadhaar enrolment receipt ──────────────────────────────────────────────
|
||||
// Blue rather than the declaration's amber: nothing has failed here, the customer simply does not
|
||||
// hold the document yet.
|
||||
.aad-rcpt-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: -4px 0 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
font-size: 12.5px;
|
||||
color: #1e40af;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
color: #1d4ed8 !important;
|
||||
border-color: #bfdbfe !important;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt-card {
|
||||
margin: -4px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt-card__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.verify-icon-ok {
|
||||
@@ -1025,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 {
|
||||
@@ -2431,3 +2679,358 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The name and DOB that PAN / Aadhaar are matched against, surfaced from the General tab so a
|
||||
mismatch is visible before the check runs rather than only after it fails. */
|
||||
.identity-basis {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.identity-basis--incomplete {
|
||||
border-color: #fdba74;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.identity-basis__icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #1d4ed8;
|
||||
|
||||
.identity-basis--incomplete & {
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.identity-basis__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-basis__legend {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.identity-basis__pairs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 24px;
|
||||
}
|
||||
|
||||
.identity-basis__pair {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.identity-basis__k {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.identity-basis__v {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.identity-basis__hint {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.identity-basis__warn {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #c2410c;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
PAN ↔ Aadhaar premium status card
|
||||
─── Replaces the legacy table-row treatment. Dark gradient with
|
||||
offset glow circles, large gradient icon, and a state-driven
|
||||
accent (green / red / blue / amber).
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.pal-card {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: 14px 18px 30px;
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #111827, #0f172a);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
box-shadow:
|
||||
0 12px 32px rgb(0 0 0 / 35%),
|
||||
inset 0 1px 1px rgb(255 255 255 / 5%);
|
||||
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Decorative glows — colors swap with the state variant */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
top: -110px;
|
||||
right: -70px;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
bottom: -70px;
|
||||
left: -50px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #2563eb, #06b6d4);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&__txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__small {
|
||||
font-size: 10.5px;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 50px;
|
||||
background: rgb(34 197 94 / 12%);
|
||||
border: 1px solid rgb(74 222 128 / 25%);
|
||||
color: #4ade80;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(5px);
|
||||
width: fit-content;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
z-index: 2;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 16px;
|
||||
font-size: 10px;
|
||||
color: rgb(255 255 255 / 35%);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ─── State variants ─── */
|
||||
&--ok {
|
||||
&::before {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-color: rgb(239 68 68 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #ef4444;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #f87171;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(239 68 68 / 14%);
|
||||
border-color: rgb(248 113 113 / 32%);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #ef4444, #f97316);
|
||||
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-color: rgb(59 130 246 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #3b82f6;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #60a5fa;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(59 130 246 / 14%);
|
||||
border-color: rgb(96 165 250 / 32%);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #f59e0b;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #fbbf24;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(245 158 11 / 14%);
|
||||
border-color: rgb(251 191 36 / 32%);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #f59e0b, #d97706);
|
||||
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 540px) {
|
||||
.pal-card {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
|
||||
&__hint {
|
||||
position: static;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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>
|
||||
|
||||
@@ -60,7 +60,9 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
|
||||
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||
import { 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 = [
|
||||
@@ -193,6 +195,20 @@ const routes: Routes = [
|
||||
component: Form60TabComponent,
|
||||
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'form121',
|
||||
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>
|
||||
@@ -545,6 +567,31 @@
|
||||
>
|
||||
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./form121']"
|
||||
routerLinkActive
|
||||
#form121="routerLinkActive"
|
||||
[active]="form121.isActive"
|
||||
>
|
||||
<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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<!--
|
||||
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="f121">
|
||||
@if (loading) {
|
||||
<div class="f121__loading">
|
||||
<mat-spinner [diameter]="36"></mat-spinner>
|
||||
<span>Reading the customer's deposit ledger…</span>
|
||||
</div>
|
||||
} @else if (loadError) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── Header ── -->
|
||||
<div class="f121__head">
|
||||
<div>
|
||||
<h3 class="f121__title">Form 121 & TDS on deposit interest</h3>
|
||||
<div class="f121__sub">
|
||||
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="f121__controls">
|
||||
@if (availableFys.length) {
|
||||
<label class="f121__field">
|
||||
<span>Financial year</span>
|
||||
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
|
||||
@for (year of availableFys; track year) {
|
||||
<option [value]="year">FY {{ year }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<div class="f121__field">
|
||||
<span>Settlement</span>
|
||||
<div
|
||||
class="f121__mode"
|
||||
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
|
||||
>
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!schedule) {
|
||||
<div class="f121__note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>
|
||||
@if (!dateOfBirth) {
|
||||
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
|
||||
Data tab.
|
||||
} @else {
|
||||
No interest has been credited to this customer's deposits yet.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── KPI strip ── -->
|
||||
<div class="f121__kpis">
|
||||
<div class="f121__kpi">
|
||||
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
|
||||
</div>
|
||||
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
|
||||
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">TDS deducted</div>
|
||||
</div>
|
||||
<div class="f121__kpi">
|
||||
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
|
||||
</div>
|
||||
@if (thresholdCrossed) {
|
||||
<div class="f121__kpi f121__kpi--warn">
|
||||
<div class="f121__kpi-n">Crossed</div>
|
||||
<div class="f121__kpi-l">TDS applies this year</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f121__kpi f121__kpi--ok">
|
||||
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">More interest before TDS starts</div>
|
||||
</div>
|
||||
}
|
||||
@if (tdsSaved > 0) {
|
||||
<div class="f121__kpi f121__kpi--ok">
|
||||
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Saved by Form 121</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Alerts ── -->
|
||||
@if (variance !== 0) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
|
||||
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
|
||||
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (declarationNotAppliedToAccounts) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
|
||||
deposits. Turn it off on those accounts, or tax will keep being deducted.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (declarationUselessOnJointAccounts) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
This customer has filed Form 121, but tax on
|
||||
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
|
||||
against the first-named holder. Their declaration does not cover those — the primary holder must file their
|
||||
own.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (shouldSuggestDeclaration) {
|
||||
<div class="f121__note f121__note--tip">
|
||||
<mat-icon>lightbulb_outline</mat-icon>
|
||||
<div>
|
||||
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
|
||||
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
|
||||
reclaiming it later as a refund.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!panWellFormed) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<div>
|
||||
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
|
||||
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (note of schedule.notes; track note) {
|
||||
<div class="f121__note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Declaration panel ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span>Declaration</span>
|
||||
</div>
|
||||
<div class="f121__kv">
|
||||
<div class="f121__kv-row">
|
||||
<span>Status</span>
|
||||
<span>
|
||||
@if (declarationOnRecord) {
|
||||
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
|
||||
} @else {
|
||||
<span class="f121__pill">Not filed</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="f121__kv-row">
|
||||
<span>Form & threshold band</span>
|
||||
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
|
||||
</div>
|
||||
<div class="f121__kv-row">
|
||||
<span>PAN</span>
|
||||
<span class="f121__mono">
|
||||
{{ pan || '—' }}
|
||||
@if (pan && !panWellFormed) {
|
||||
<span class="f121__pill f121__pill--warn">invalid</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (declarationOnRecord && declarationFy) {
|
||||
<div class="f121__kv-row">
|
||||
<span>Declared for</span>
|
||||
<span>FY {{ declarationFy }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (declarationOnRecord && !acceptanceDateKnown) {
|
||||
<div class="f121__kv-row f121__kv-row--muted">
|
||||
<span>Acceptance date</span>
|
||||
<span>
|
||||
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
|
||||
the figures below.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (eligibility) {
|
||||
@for (reason of eligibility.reasons; track reason) {
|
||||
<div class="f121__kv-row f121__kv-row--warn">
|
||||
<span>Eligibility</span>
|
||||
<span>{{ reason }}</span>
|
||||
</div>
|
||||
}
|
||||
@for (note of eligibility.unassessed; track note) {
|
||||
<div class="f121__kv-row f121__kv-row--muted">
|
||||
<span>Not assessed</span>
|
||||
<span>{{ note }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Quarter-wise ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>table_chart</mat-icon>
|
||||
<span>Quarter by quarter</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of schedule.lines; track trackLine($index, line)) {
|
||||
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
|
||||
<td>
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="f121__pill f121__pill--warn">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="f121__pill f121__pill--ok">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="f121__row--total">
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (schedule.shieldedInterest > 0) {
|
||||
<div class="f121__foot">
|
||||
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
|
||||
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Section 1: accounts that count ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
<span>Deposits counted towards this customer's TDS</span>
|
||||
</div>
|
||||
@if (liableAccounts.length) {
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th class="num">TDS deducted</th>
|
||||
<th>Withholding</th>
|
||||
<th>Settles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of liableAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
@if (a.withHoldTax) {
|
||||
<span class="f121__pill f121__pill--warn">On</span>
|
||||
@if (a.taxGroup) {
|
||||
<div class="muted">{{ a.taxGroup }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="f121__pill">Off</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
|
||||
whole, not branch by branch.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Section 2: joint holdings ── -->
|
||||
@if (jointHolderAccounts.length) {
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>group</mat-icon>
|
||||
<span>Deposits where this customer is a joint holder</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th>Deducted against</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td class="muted">
|
||||
First-named holder
|
||||
@if (a.deducteeClientId) {
|
||||
(client {{ a.deducteeClientId }})
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
|
||||
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Section 3: exempt accounts ── -->
|
||||
@if (exemptAccounts.length) {
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>savings</mat-icon>
|
||||
<span>Savings and current accounts</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th>TDS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of exemptAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td><span class="f121__pill">Not applicable</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
|
||||
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.f121 {
|
||||
--f121-line: #e5e7eb;
|
||||
--f121-bg: #f9fafb;
|
||||
--f121-ink: #111827;
|
||||
--f121-muted: #6b7280;
|
||||
--f121-blue: #1d4ed8;
|
||||
--f121-green: #15803d;
|
||||
--f121-amber: #b45309;
|
||||
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--f121-ink);
|
||||
}
|
||||
|
||||
.f121__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 16px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.f121__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.f121__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.f121__sub {
|
||||
margin-top: 4px;
|
||||
color: var(--f121-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.f121__controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.f121__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
> span {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.f121__mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 6px;
|
||||
background: var(--f121-bg);
|
||||
font-weight: 600;
|
||||
cursor: help;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.f121__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.f121__kpi {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-left: 3px solid var(--f121-blue);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
&--ok {
|
||||
border-left-color: var(--f121-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__kpi-n {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.f121__kpi-l {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--f121-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── Notes ── */
|
||||
.f121__note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--f121-blue);
|
||||
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--f121-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--f121-amber);
|
||||
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
|
||||
|
||||
mat-icon {
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
&--tip {
|
||||
border-left-color: var(--f121-green);
|
||||
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
|
||||
|
||||
mat-icon {
|
||||
color: var(--f121-green);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.f121__card {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.f121__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 14px;
|
||||
background: var(--f121-bg);
|
||||
border-bottom: 1px solid var(--f121-line);
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__foot {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--f121-line);
|
||||
background: var(--f121-bg);
|
||||
color: var(--f121-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.f121__empty {
|
||||
padding: 20px 14px;
|
||||
color: var(--f121-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Key/value list ── */
|
||||
.f121__kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 190px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
> span:first-child {
|
||||
color: var(--f121-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--muted > span:last-child {
|
||||
color: var(--f121-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--warn > span:last-child {
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.f121__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.f121__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--f121-line);
|
||||
color: var(--f121-muted);
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--f121-muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.f121__row--warn > td {
|
||||
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
|
||||
}
|
||||
|
||||
.f121__row--total > td {
|
||||
background: var(--f121-bg);
|
||||
border-top: 1px solid var(--f121-line);
|
||||
}
|
||||
|
||||
/* ── Bits ── */
|
||||
.f121__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--f121-line);
|
||||
background: var(--f121-bg);
|
||||
color: var(--f121-muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
& + & {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
|
||||
color: var(--f121-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* 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, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Observable, Subject, forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SavingsService } from 'app/savings/savings.service';
|
||||
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Domain logic */
|
||||
import {
|
||||
Declaration,
|
||||
DeclarationForm,
|
||||
EligibilityResult,
|
||||
FY_TAX_CONFIG,
|
||||
InterestCredit,
|
||||
TdsAccountInput,
|
||||
TdsMode,
|
||||
TdsQuarterLine,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
checkEligibility,
|
||||
collectTdsCredits,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
fyOf,
|
||||
isTdsApplicable,
|
||||
isWellFormedPan,
|
||||
normaliseFy,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor
|
||||
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
|
||||
/** One of the customer's interest-bearing accounts, with its FY figures. */
|
||||
export interface Form121AccountRow {
|
||||
accountNo: string;
|
||||
productName: string;
|
||||
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
|
||||
kind: string;
|
||||
/** Interest credited to this account inside the selected FY. */
|
||||
interest: number;
|
||||
/** Tax Fineract has actually withheld on this account inside the FY. */
|
||||
tdsDeducted: number;
|
||||
/** Whether Fineract is currently withholding tax on this account. */
|
||||
withHoldTax: boolean;
|
||||
taxGroup: string | null;
|
||||
/** When tax on this account settles — from the product, not a preference. */
|
||||
mode: TdsMode;
|
||||
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
|
||||
tdsApplicable: boolean;
|
||||
/** This customer is the deductee — false when they are a joint holder only. */
|
||||
isDeductee: boolean;
|
||||
/** Who the tax is deducted against, when it is not this customer. */
|
||||
deducteeClientId: number | null;
|
||||
}
|
||||
|
||||
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||
function toIso(value: unknown): string | null {
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value as number[];
|
||||
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-form121-tab',
|
||||
templateUrl: './form121-tab.component.html',
|
||||
styleUrls: ['./form121-tab.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class Form121TabComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private clientsService = inject(ClientsService);
|
||||
private savingsService = inject(SavingsService);
|
||||
private fixedDepositsService = inject(FixedDepositsService);
|
||||
private recurringDepositsService = inject(RecurringDepositsService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
clientId = '';
|
||||
loading = true;
|
||||
loadError = false;
|
||||
|
||||
// ── Customer ──
|
||||
clientName = '';
|
||||
dateOfBirth = '';
|
||||
pan = '';
|
||||
panWellFormed = false;
|
||||
|
||||
// ── Declaration on record ──
|
||||
declarationOnRecord = false;
|
||||
/**
|
||||
* The form actually in force for the selected year. From FY 2026-27 a single
|
||||
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
|
||||
* than from the payee's age.
|
||||
*/
|
||||
declarationForm: DeclarationForm = '121';
|
||||
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
|
||||
declarationKind: '15G' | '15H' = '15G';
|
||||
declarationFy = '';
|
||||
/**
|
||||
* The KYC step records only the year, not the date the branch accepted the
|
||||
* declaration, so it has to be treated as covering the whole year. Surfaced on
|
||||
* screen rather than hidden — a mid-year acceptance would change the figures.
|
||||
*/
|
||||
acceptanceDateKnown = false;
|
||||
|
||||
eligibility: EligibilityResult | null = null;
|
||||
|
||||
// ── Selection ──
|
||||
availableFys: string[] = [];
|
||||
fy = '';
|
||||
|
||||
// ── Computed ──
|
||||
accounts: Form121AccountRow[] = [];
|
||||
schedule: TdsSchedule | null = null;
|
||||
actualTds = 0;
|
||||
tdsSaved = 0;
|
||||
|
||||
/** Raw account inputs, kept so the year selector can recompute cheaply. */
|
||||
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
|
||||
/** Withheld-tax postings by FY, from the ledger. */
|
||||
private actualTdsByFy = new Map<string, number>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
|
||||
this.clientId = params['clientId'];
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// ────────────────────────────── Loading ──────────────────────────────
|
||||
|
||||
private load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
|
||||
forkJoin({
|
||||
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
|
||||
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
|
||||
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
|
||||
})
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
switchMap(({ client, kyc, accounts }) => {
|
||||
this.applyClient(client);
|
||||
this.applyKyc(kyc);
|
||||
const deposits = this.depositAccountsOf(accounts);
|
||||
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as any[]);
|
||||
})
|
||||
)
|
||||
.subscribe((details: any[]) => {
|
||||
this.loading = false;
|
||||
this.ingest(details.filter(Boolean));
|
||||
this.recompute();
|
||||
});
|
||||
}
|
||||
|
||||
private applyClient(client: any): void {
|
||||
this.clientName = client?.displayName ?? '';
|
||||
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
|
||||
}
|
||||
|
||||
private applyKyc(kyc: any): void {
|
||||
this.pan = (kyc?.panNumber ?? '').toUpperCase();
|
||||
this.panWellFormed = isWellFormedPan(this.pan);
|
||||
this.declarationOnRecord = !!kyc?.form15Applicable;
|
||||
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
|
||||
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
|
||||
this.declarationFy = kyc?.form15FY ?? '';
|
||||
this.acceptanceDateKnown = false;
|
||||
}
|
||||
|
||||
/** Savings, FD and RD accounts — everything that can credit interest. */
|
||||
private depositAccountsOf(accounts: any): any[] {
|
||||
return (accounts?.savingsAccounts ?? []).filter(
|
||||
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||
);
|
||||
}
|
||||
|
||||
private fetchAccount(a: any): Observable<any> {
|
||||
const id = `${a.id}`;
|
||||
const code = a.depositType?.code ?? '';
|
||||
let call: Observable<any>;
|
||||
if (code.includes('fixedDeposit')) {
|
||||
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||
} else if (code.includes('recurringDeposit')) {
|
||||
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||
} else {
|
||||
call = this.savingsService.getSavingsAccountData(id);
|
||||
}
|
||||
// One unreadable account must not blank the whole tab.
|
||||
return call.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns each account payload into an engine input.
|
||||
*
|
||||
* Joint and minor attributes are read defensively: they are captured on the
|
||||
* account stepper but may not surface on every read endpoint, and an absent
|
||||
* field must default to a single-name account rather than throw.
|
||||
*/
|
||||
private ingest(details: any[]): void {
|
||||
this.accountInputs = [];
|
||||
this.actualTdsByFy = new Map<string, number>();
|
||||
const fys = new Set<string>();
|
||||
const clientIdNum = Number(this.clientId);
|
||||
|
||||
for (const d of details) {
|
||||
const code: string = d.depositType?.code ?? '';
|
||||
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||
|
||||
const ref = {
|
||||
id: d.id,
|
||||
accountNo: d.accountNo ?? `${d.id}`,
|
||||
depositTypeCode: code,
|
||||
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||
clientId: d.clientId ?? clientIdNum,
|
||||
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||
};
|
||||
|
||||
const credits: InterestCredit[] = [];
|
||||
let tdsDeducted = 0;
|
||||
|
||||
for (const t of d.transactions ?? []) {
|
||||
if (t.reversed || t.isReversal) {
|
||||
continue;
|
||||
}
|
||||
const iso = toIso(t.date);
|
||||
if (!iso) {
|
||||
continue;
|
||||
}
|
||||
fys.add(fyOf(iso));
|
||||
|
||||
if (t.transactionType?.interestPosting) {
|
||||
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||
} else if (t.transactionType?.withholdTax) {
|
||||
const amount = t.amount ?? 0;
|
||||
tdsDeducted += amount;
|
||||
const txnFy = fyOf(iso);
|
||||
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
|
||||
}
|
||||
}
|
||||
|
||||
const deductee = tdsDeducteeOf(ref);
|
||||
const meta: Form121AccountRow = {
|
||||
accountNo: ref.accountNo,
|
||||
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
|
||||
kind: code.includes('fixedDeposit')
|
||||
? 'Fixed Deposit'
|
||||
: code.includes('recurringDeposit')
|
||||
? 'Recurring Deposit'
|
||||
: 'Savings',
|
||||
interest: 0,
|
||||
tdsDeducted,
|
||||
withHoldTax: !!d.withHoldTax,
|
||||
taxGroup: d.taxGroup?.name ?? null,
|
||||
mode: tdsModeFor(ref),
|
||||
tdsApplicable: isTdsApplicable(ref),
|
||||
isDeductee: deductee === clientIdNum,
|
||||
deducteeClientId: deductee
|
||||
};
|
||||
|
||||
this.accountInputs.push({ ...ref, credits, meta });
|
||||
}
|
||||
|
||||
this.availableFys = [...fys].sort().reverse();
|
||||
if (!this.availableFys.includes(this.fy)) {
|
||||
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Computation ─────────────────────────────
|
||||
|
||||
/** Re-runs the engine for the selected year. */
|
||||
recompute(): void {
|
||||
this.schedule = null;
|
||||
this.eligibility = null;
|
||||
this.tdsSaved = 0;
|
||||
this.accounts = this.accountInputs.map((a) => a.meta);
|
||||
|
||||
if (!this.fy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const year = normaliseFy(this.fy);
|
||||
|
||||
// Per-account interest for the selected year, for the three lists.
|
||||
for (const a of this.accountInputs) {
|
||||
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
|
||||
}
|
||||
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
|
||||
|
||||
if (!this.dateOfBirth) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The one place rules 8 to 12 are applied: savings interest dropped, joint
|
||||
// deposits attributed to the primary holder, every branch aggregated.
|
||||
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
|
||||
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
|
||||
const declaration = this.declarationForSelectedFy();
|
||||
|
||||
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
|
||||
|
||||
if (declaration) {
|
||||
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
|
||||
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
|
||||
}
|
||||
|
||||
// The branch cannot compute income earned outside the bank, so the income
|
||||
// limb is reported as unassessed rather than silently passed.
|
||||
this.eligibility = checkEligibility(ctx, {
|
||||
estimatedInterest: this.schedule.totalInterest,
|
||||
estimatedIncome: 0,
|
||||
incomeOnRecord: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When tax settles for this customer overall.
|
||||
*
|
||||
* Timing is per account, but the customer needs one view. A quarterly account
|
||||
* anywhere in the book means a deduction falls due at the first quarter end, so
|
||||
* that is the honest headline; the per-account list carries the detail.
|
||||
*/
|
||||
private get overallMode(): TdsMode {
|
||||
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
|
||||
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||
}
|
||||
|
||||
/** The declaration, but only when it was filed for the year being shown. */
|
||||
private declarationForSelectedFy(): Declaration | null {
|
||||
if (!this.declarationOnRecord || !this.fy) {
|
||||
return null;
|
||||
}
|
||||
const year = normaliseFy(this.fy);
|
||||
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: this.declarationKind,
|
||||
// No acceptance date on record — treated as covering the whole year.
|
||||
acceptedOn: `${year.slice(0, 4)}-04-01`,
|
||||
fy: year
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────── Template helpers ─────────────────────────
|
||||
|
||||
get mode(): TdsMode {
|
||||
return this.schedule?.mode ?? 'QUARTERLY';
|
||||
}
|
||||
|
||||
get age(): number {
|
||||
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
|
||||
}
|
||||
|
||||
get formKind(): string {
|
||||
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
|
||||
}
|
||||
|
||||
/** The form the payee would file for the selected year. */
|
||||
get formLabel(): string {
|
||||
if (!this.fy) {
|
||||
return '—';
|
||||
}
|
||||
const form = declarationFormFor(this.fy, this.dateOfBirth);
|
||||
return form === '121' ? 'Form 121' : `Form ${form}`;
|
||||
}
|
||||
|
||||
/** Which deduction trigger applies, now that the forms no longer split by age. */
|
||||
get bandLabel(): string {
|
||||
return this.schedule?.senior ? 'Senior citizen' : 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision and return references for the selected year. Both changed on
|
||||
* 01 April 2026, so the template reads them rather than hard-coding them.
|
||||
*/
|
||||
get statute(): { section: string; statement: string; rule: string; frequency: string } {
|
||||
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
|
||||
return {
|
||||
section: cfg?.interestTdsSection ?? '—',
|
||||
statement: cfg?.tdsStatementForm ?? '—',
|
||||
rule: cfg?.declarationRule ?? '—',
|
||||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
/** Accounts whose interest counts towards this customer's deduction aggregate. */
|
||||
get liableAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
|
||||
}
|
||||
|
||||
/** Deposits where this customer is a joint holder but not the deductee. */
|
||||
get jointHolderAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
|
||||
}
|
||||
|
||||
/** Savings and current accounts — interest outside the charge on time deposits. */
|
||||
get exemptAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => !a.tdsApplicable);
|
||||
}
|
||||
|
||||
/** Engine figure less what the ledger shows was actually withheld. */
|
||||
get variance(): number {
|
||||
return (this.schedule?.totalTds ?? 0) - this.actualTds;
|
||||
}
|
||||
|
||||
/** How much more interest before TDS starts. */
|
||||
get headroom(): number {
|
||||
if (!this.schedule) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
|
||||
}
|
||||
|
||||
get thresholdCrossed(): boolean {
|
||||
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration is on record but deposit accounts are still set to withhold —
|
||||
* the branch has not switched them off.
|
||||
*/
|
||||
get declarationNotAppliedToAccounts(): boolean {
|
||||
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer filed a declaration but holds joint deposits where someone else
|
||||
* is the deductee — their form does nothing for those.
|
||||
*/
|
||||
get declarationUselessOnJointAccounts(): boolean {
|
||||
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
|
||||
}
|
||||
|
||||
/** Worth prompting the branch to collect a declaration. */
|
||||
get shouldSuggestDeclaration(): boolean {
|
||||
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
|
||||
}
|
||||
|
||||
trackLine(_: number, line: TdsQuarterLine): number {
|
||||
return line.quarter;
|
||||
}
|
||||
|
||||
trackAccount(_: number, account: Form121AccountRow): string {
|
||||
return account.accountNo;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,58 @@
|
||||
|
||||
<!-- KYC Summary (read-only when not in edit mode) -->
|
||||
@if (!isEditMode && kycData) {
|
||||
@if (kycData.isException && !kycData.exceptionResolved) {
|
||||
<div
|
||||
class="kyc-exception-banner"
|
||||
style="
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="color: #d97706; margin-top: 2px">warning</mat-icon>
|
||||
<div>
|
||||
<strong style="color: #92400e; font-size: 14px">KYC Exception Active</strong>
|
||||
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0">
|
||||
Account is open under a temporary KYC Exception. Reason:
|
||||
<strong>{{ kycData.exceptionReason || 'Other' }}</strong
|
||||
>. Please contact the customer and complete verification as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
The OVD captured at onboarding has gone stale, or is about to. Sits beside the exception
|
||||
banner because it is the same kind of thing: something staff must act on before the account
|
||||
drifts out of compliance.
|
||||
-->
|
||||
@if (showKycDocumentAlert) {
|
||||
<div class="kyc-doc-alert" [class.kyc-doc-alert--critical]="kycDocumentAlertSeverity === 'critical'">
|
||||
<mat-icon class="kyc-doc-alert__icon">{{
|
||||
kycDocumentAlertSeverity === 'critical' ? 'error' : 'schedule'
|
||||
}}</mat-icon>
|
||||
<div class="kyc-doc-alert__body">
|
||||
<strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong>
|
||||
<p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p>
|
||||
</div>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="kyc-doc-alert__action"
|
||||
(click)="toggleEditMode()"
|
||||
>
|
||||
Update Document
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Verification Table -->
|
||||
<div class="kv-table">
|
||||
<div class="kv-table__header">
|
||||
@@ -52,69 +104,300 @@
|
||||
<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 {
|
||||
<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.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 || !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>
|
||||
</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 {
|
||||
<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.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
</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.aadhaar || !kycData.aadhaarNumberMasked"
|
||||
(click)="startAadhaarReVerify()"
|
||||
[disabled]="verifying.pan || !kycData.panNumber"
|
||||
(click)="verifyPAN()"
|
||||
>
|
||||
Re-Verify
|
||||
@if (verifying.pan) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Re-Verify
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
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>
|
||||
|
||||
<!--
|
||||
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>
|
||||
</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>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!--
|
||||
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>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
||||
<!--
|
||||
The OVD collected at onboarding. It has never appeared here, so the dates driving the
|
||||
renewal alert were invisible — an officer told to update the document could not see which
|
||||
one, nor when it was issued.
|
||||
-->
|
||||
@if (kycData.kycDocumentName) {
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">description</mat-icon> {{ kycData.kycDocumentName }}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.kycDocumentNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@switch (kycDocumentRenewal.state) {
|
||||
@case ('EXPIRED') {
|
||||
<span class="kv-status kv-status--fail"><mat-icon>error</mat-icon> Expired</span>
|
||||
}
|
||||
@case ('EXPIRING_SOON') {
|
||||
<span class="kv-status kv-status--declared"><mat-icon>schedule</mat-icon> Expires Soon</span>
|
||||
}
|
||||
@case ('DUE') {
|
||||
<span class="kv-status kv-status--declared"><mat-icon>update</mat-icon> Update Due</span>
|
||||
}
|
||||
@case ('OK') {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Current</span>
|
||||
}
|
||||
@default {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>help_outline</mat-icon> No dates</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<!--
|
||||
Issued-on, not verified-on: this document is sighted, never API-verified.
|
||||
|
||||
Every document shows an expiry, printed or not: one with no expiry on the card is
|
||||
still only good for five years from issue, so the derived date is shown rather than a
|
||||
blank. It is tagged so a calculated date is never mistaken for one on the document.
|
||||
-->
|
||||
<span class="kv-table__col kv-table__col--date">
|
||||
@if (kycData.kycDocIssueDate || kycData.kycDocExpiryDate) {
|
||||
<span class="kv-doc-dates">
|
||||
@if (kycData.kycDocIssueDate) {
|
||||
<span>Issued {{ formatDate(kycData.kycDocIssueDate) }}</span>
|
||||
}
|
||||
@if (kycDocumentExpiresOn) {
|
||||
<span
|
||||
class="kv-doc-dates__expiry"
|
||||
[class.kv-doc-dates__expiry--derived]="kycDocumentExpiryIsDerived"
|
||||
>
|
||||
Expires {{ kycDocumentExpiresOn }}
|
||||
@if (kycDocumentExpiryIsDerived) {
|
||||
<span
|
||||
class="kv-doc-dates__tag"
|
||||
matTooltip="No expiry printed on this document — calculated as 5 years from the issue date"
|
||||
>5-yr</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button mat-stroked-button class="kv-action-btn" type="button" (click)="toggleEditMode()">Update</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- GSTIN Row (Non-Individual only) -->
|
||||
@if (isNonIndividual) {
|
||||
@@ -151,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>
|
||||
}
|
||||
@@ -190,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) {
|
||||
@@ -334,39 +646,72 @@
|
||||
<!-- KYC Meta Grid -->
|
||||
<div class="kv-meta-grid">
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">KYC Type</span>
|
||||
<span class="kv-meta-value">{{
|
||||
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
|
||||
}}</span>
|
||||
<mat-icon class="kv-meta-icon icon-blue">assignment_ind</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">KYC Type</span>
|
||||
<span class="kv-meta-value">
|
||||
{{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Risk Category</span>
|
||||
<span class="kv-meta-value">{{
|
||||
kycData.riskCategory === 1
|
||||
? 'Low'
|
||||
: kycData.riskCategory === 2
|
||||
? 'Medium'
|
||||
: kycData.riskCategory === 3
|
||||
? 'High'
|
||||
: '—'
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="kv-meta-icon"
|
||||
[ngClass]="{
|
||||
'icon-green': kycData.riskCategory === 1,
|
||||
'icon-orange': kycData.riskCategory === 2,
|
||||
'icon-red': kycData.riskCategory === 3
|
||||
}"
|
||||
>shield</mat-icon
|
||||
>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Risk Category</span>
|
||||
<span class="kv-meta-value">
|
||||
{{
|
||||
kycData.riskCategory === 1
|
||||
? 'Low'
|
||||
: kycData.riskCategory === 2
|
||||
? 'Medium'
|
||||
: kycData.riskCategory === 3
|
||||
? 'High'
|
||||
: '—'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Initiated On</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-grey">calendar_today</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Initiated On</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Last Verified</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-green">check_circle</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Last Verified</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Expiry Date</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-red">history_toggle_off</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Expiry Date</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (kycData.remarks) {
|
||||
<div class="kv-meta-item kv-meta-item--full">
|
||||
<span class="kv-meta-label">Remarks</span>
|
||||
<span class="kv-meta-value">{{ kycData.remarks }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-grey">notes</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Remarks</span>
|
||||
<span class="kv-meta-value">{{ kycData.remarks }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -488,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"
|
||||
@@ -541,119 +890,158 @@
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.pan }}</span>
|
||||
</div>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
@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>
|
||||
}
|
||||
</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"
|
||||
class="otp-resend-btn"
|
||||
[disabled]="verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
Resend
|
||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (verificationError.aadhaar) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.aadhaar }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- GSTIN (Non-Individual only) -->
|
||||
@if (isNonIndividual) {
|
||||
@@ -740,6 +1128,32 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
<div
|
||||
class="full-span exception-controls-row"
|
||||
style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap"
|
||||
>
|
||||
<mat-checkbox formControlName="isException" style="margin-top: 8px">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||
} @else {
|
||||
<mat-option value="Enterprise PAN Verification Failed"
|
||||
>Enterprise PAN Verification Failed</mat-option
|
||||
>
|
||||
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Remarks -->
|
||||
<mat-form-field appearance="outline" class="full-span">
|
||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||
|
||||
@@ -559,6 +559,19 @@
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
// Unverified, covered by a declaration — amber, never green, so it cannot read as a pass.
|
||||
&--declared {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
// Covered by an enrolment slip. Blue rather than amber: nothing failed, the customer simply
|
||||
// does not hold the document yet.
|
||||
&--receipt {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-action-btn {
|
||||
@@ -570,6 +583,265 @@
|
||||
font-weight: 600 !important;
|
||||
color: #1074b9 !important;
|
||||
border-color: #1074b9 !important;
|
||||
|
||||
&.danger {
|
||||
color: #b91c1c !important;
|
||||
border-color: #fca5a5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PAN declaration detail, shown under the PAN row when the check did not pass */
|
||||
.kv-decl {
|
||||
margin: 4px 0 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.kv-decl__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
mat-icon {
|
||||
color: #b45309;
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-decl__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.kv-decl__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-decl__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kv-decl__row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #451a03;
|
||||
word-break: break-word;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-decl__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #fde68a;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* KYC document renewal alert — amber for a stale document, red once it has actually expired */
|
||||
.kyc-doc-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
background: #fef3c7;
|
||||
|
||||
&--critical {
|
||||
border-color: #ef4444;
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: #d97706;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kyc-doc-alert__title {
|
||||
font-size: 14px;
|
||||
color: #92400e;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__text {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__action {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Issue / expiry pair in the document row */
|
||||
.kv-doc-dates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kv-doc-dates__expiry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
|
||||
/* Calculated from the issue date rather than printed on the document. */
|
||||
&--derived {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-doc-dates__tag {
|
||||
padding: 0 5px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Aadhaar enrolment receipt detail, shown under the Aadhaar row when the check did not pass */
|
||||
.kv-rcpt {
|
||||
margin: 4px 0 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.kv-rcpt__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-rcpt__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.kv-rcpt__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-rcpt__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kv-rcpt__row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-rcpt__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* Offer shown beneath a failed PAN verification in edit mode */
|
||||
.kyc-decl-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
background: #fffbeb;
|
||||
font-size: 12.5px;
|
||||
color: #92400e;
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
color: #b45309 !important;
|
||||
border-color: #fcd34d !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* OTP Bar (below table) */
|
||||
@@ -622,27 +894,76 @@
|
||||
.kv-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 2%),
|
||||
0 4px 6px -1px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.kv-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 12px 14px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:nth-child(5n) {
|
||||
border-right: none;
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
border-right: none;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
padding-top: 16px;
|
||||
margin-top: 4px;
|
||||
border-radius: 0;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-meta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kv-meta-icon {
|
||||
font-size: 24px !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.icon-blue {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.icon-green {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
&.icon-orange {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&.icon-red {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.icon-grey {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +977,7 @@
|
||||
|
||||
.kv-meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
@@ -677,14 +998,7 @@
|
||||
|
||||
.kv-meta-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.kv-meta-item:nth-child(5n) {
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.kv-meta-item:nth-child(3n) {
|
||||
border-right: none;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,26 @@
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { catchError, of, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||
import {
|
||||
deriveKycDocumentRenewal,
|
||||
kycDocumentNeedsAttention,
|
||||
KycDocumentRenewalStatus,
|
||||
KYC_DOCUMENT_RENEWAL_YEARS
|
||||
} from '../../kyc-document-renewal.util';
|
||||
import {
|
||||
PanDeclarationDialogComponent,
|
||||
PanDeclarationResult
|
||||
} from '../../shared/pan-declaration-dialog/pan-declaration-dialog.component';
|
||||
import {
|
||||
AadhaarReceiptDialogComponent,
|
||||
AadhaarReceiptResult
|
||||
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -46,6 +62,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
private clientsService = inject(ClientsService);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private dateUtils = inject(Dates);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -121,7 +138,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
'',
|
||||
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
||||
],
|
||||
remarks: ['']
|
||||
remarks: [''],
|
||||
isException: [false],
|
||||
exceptionReason: [[]]
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -157,7 +176,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||
aadhaarNumber: '',
|
||||
gstinNumber: data.gstinNumber || '',
|
||||
remarks: data.remarks || ''
|
||||
remarks: data.remarks || '',
|
||||
isException: data.isException || false,
|
||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -195,6 +216,164 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the stored record carries a signed declaration standing in for the PAN check. */
|
||||
get panDeclarationTaken(): boolean {
|
||||
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;
|
||||
if (!fields || fields === 'NONE') {
|
||||
return 'PAN could not be verified';
|
||||
}
|
||||
return fields
|
||||
.split(',')
|
||||
.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth'))
|
||||
.join(' and ');
|
||||
}
|
||||
|
||||
/** True when the stored record carries a UIDAI enrolment slip standing in for the OTP check. */
|
||||
get aadhaarReceiptTaken(): boolean {
|
||||
return this.kycData?.aadhaarReceiptTaken === true;
|
||||
}
|
||||
|
||||
// ── KYC document renewal ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the OVD captured at onboarding is still current — see `deriveKycDocumentRenewal()`.
|
||||
*
|
||||
* The issue and expiry dates have always been captured on the KYC step but never read back, so
|
||||
* a client onboarded on a ten-year-old licence looked identical to one onboarded yesterday.
|
||||
*/
|
||||
get kycDocumentRenewal(): KycDocumentRenewalStatus {
|
||||
return deriveKycDocumentRenewal({
|
||||
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
|
||||
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
|
||||
today: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/** Only raise the banner for a document that actually exists and actually needs action. */
|
||||
get showKycDocumentAlert(): boolean {
|
||||
return !!this.kycData?.kycDocumentName && kycDocumentNeedsAttention(this.kycDocumentRenewal.state);
|
||||
}
|
||||
|
||||
/** Heading for the alert — the three states call for visibly different things. */
|
||||
get kycDocumentAlertTitle(): string {
|
||||
switch (this.kycDocumentRenewal.state) {
|
||||
case 'EXPIRED':
|
||||
return 'KYC Document Expired';
|
||||
case 'EXPIRING_SOON':
|
||||
return 'KYC Document Expires Soon';
|
||||
default:
|
||||
return 'KYC Document Due for Update';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence staff act on. Each case names the date that triggered it, because "update the
|
||||
* document" without saying why leaves the officer checking the form to work out which clock ran
|
||||
* out.
|
||||
*/
|
||||
get kycDocumentAlertMessage(): string {
|
||||
const status = this.kycDocumentRenewal;
|
||||
const docName = this.kycData?.kycDocumentName || 'The KYC document';
|
||||
// `formatDate()` only understands Fineract's [y, m, d] arrays; these are real Date objects.
|
||||
const dueOn = status.dueOn ? this.dateUtils.formatDate(status.dueOn, 'dd MMMM yyyy') : '';
|
||||
const days = status.daysRemaining ?? 0;
|
||||
|
||||
switch (status.state) {
|
||||
case 'EXPIRED':
|
||||
return `${docName} expired on ${dueOn} and is no longer a valid officially valid document. Collect a renewed document from the customer and update the KYC record.`;
|
||||
case 'EXPIRING_SOON':
|
||||
return days === 0
|
||||
? `${docName} expires today (${dueOn}). Ask the customer for the renewed document before it lapses.`
|
||||
: `${docName} expires on ${dueOn} — ${days} day${days === 1 ? '' : 's'} from now. Ask the customer for the renewed document before it lapses.`;
|
||||
default:
|
||||
return `${docName} was issued more than ${KYC_DOCUMENT_RENEWAL_YEARS} years ago and fell due for update on ${dueOn}. Re-collect the document from the customer and update the KYC record.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drives the banner's colour — an expired document is a harder failure than a stale one. */
|
||||
get kycDocumentAlertSeverity(): 'critical' | 'warning' {
|
||||
return this.kycDocumentRenewal.state === 'EXPIRED' ? 'critical' : 'warning';
|
||||
}
|
||||
|
||||
/**
|
||||
* The date the document stops counting as current, whether or not one is printed on it.
|
||||
*
|
||||
* A Voter ID or NREGA card carries no expiry, but it is not valid forever for KYC purposes —
|
||||
* the five-year rule is its expiry. Showing the derived date rather than a blank means every
|
||||
* document answers "valid until when?", and the officer sees the same date the alert will fire on
|
||||
* instead of having to add five years in their head.
|
||||
*/
|
||||
get kycDocumentExpiresOn(): string {
|
||||
const dueOn = this.kycDocumentRenewal.dueOn;
|
||||
return dueOn ? this.dateUtils.formatDate(dueOn, 'dd MMMM yyyy') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the date above was calculated from the issue date rather than read off the document.
|
||||
* Flagged in the UI so a derived date is never mistaken for one printed on the card.
|
||||
*/
|
||||
get kycDocumentExpiryIsDerived(): boolean {
|
||||
return this.kycDocumentRenewal.reason === 'AGE';
|
||||
}
|
||||
|
||||
/** Compact chip label for the document row in the verification table. */
|
||||
get kycDocumentStatusLabel(): string {
|
||||
switch (this.kycDocumentRenewal.state) {
|
||||
case 'EXPIRED':
|
||||
return 'Expired';
|
||||
case 'EXPIRING_SOON':
|
||||
return 'Expires Soon';
|
||||
case 'DUE':
|
||||
return 'Update Due';
|
||||
case 'OK':
|
||||
return 'Current';
|
||||
default:
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
|
||||
*
|
||||
* A field counts as verified if it was verified in this session OR is already verified on the
|
||||
* stored record; either way the customer has cleared that check. Verifying one of two
|
||||
* outstanding items narrows the exception to the remaining one rather than closing it.
|
||||
*/
|
||||
private buildExceptionFields() {
|
||||
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||
|
||||
return deriveKycExceptionFields({
|
||||
panVerified,
|
||||
// A signed declaration closes the PAN item by paperwork when the API never can.
|
||||
panDeclarationTaken: this.panDeclarationTaken,
|
||||
aadhaarVerified,
|
||||
// An enrolment slip closes the Aadhaar item when there is no number to verify yet.
|
||||
aadhaarReceiptTaken: this.aadhaarReceiptTaken,
|
||||
aadhaarApplicable: !this.isNonIndividual,
|
||||
hadException: this.kycData?.isException === true,
|
||||
previousReason: this.kycData?.exceptionReason || null,
|
||||
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
|
||||
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
|
||||
});
|
||||
}
|
||||
|
||||
saveKyc(): void {
|
||||
if (this.kycForm.invalid) {
|
||||
return;
|
||||
@@ -210,7 +389,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
panNumber: this.kycForm.value.panNumber || null,
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||
remarks: this.kycForm.value.remarks || null
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
if (!this.kycId) {
|
||||
@@ -244,6 +426,38 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
|
||||
if (this.isEditMode) {
|
||||
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
panNumber: this.kycForm.value.panNumber || null,
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
|
||||
next: () => {
|
||||
this.loadKycData();
|
||||
callback();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying[field] = false;
|
||||
this.verificationStatus[field] = 'FAILED';
|
||||
this.verificationError[field] =
|
||||
err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
verifyPAN(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
||||
@@ -254,33 +468,317 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationError.pan = null;
|
||||
this.verificationDetails.pan = null;
|
||||
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.verifyPAN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.pan = false;
|
||||
if (response?.changes?.panVerified) {
|
||||
this.verificationStatus.pan = 'VERIFIED';
|
||||
this.verificationDetails.pan = response.changes;
|
||||
// A PAN that now verifies makes any earlier declaration moot.
|
||||
if (this.panDeclarationTaken) {
|
||||
this.writePanDeclaration(null);
|
||||
} else {
|
||||
this.loadKycData();
|
||||
}
|
||||
} else {
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan = 'PAN verification failed.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.pan = false;
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'PAN verification failed. Please try again.';
|
||||
}
|
||||
});
|
||||
}, 'pan');
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the customer declaration that stands in for a PAN check the client cannot pass.
|
||||
*
|
||||
* Offered for any PAN failure, not just a name/DOB mismatch — an officer facing an unreachable
|
||||
* vendor is just as stuck as one facing a misspelt PAN card, and the same signed paperwork
|
||||
* resolves both. When the failure was not a comparable mismatch there is no PAN card to compare
|
||||
* against, so the dialog only asks why the declaration is being taken.
|
||||
*/
|
||||
takePanDeclaration(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
||||
return;
|
||||
}
|
||||
const failureMessage = this.verificationError.pan || 'PAN has not been verified.';
|
||||
const mismatch = failureMessage.toLowerCase().includes('does not match');
|
||||
|
||||
this.dialog
|
||||
.open(PanDeclarationDialogComponent, {
|
||||
width: '720px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
submittedName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
|
||||
submittedDob: this.clientDateOfBirth || '',
|
||||
panNumber: this.kycForm.value.panNumber || this.kycData?.panNumber || '',
|
||||
failureMessage,
|
||||
// Only a reported name/DOB mismatch gives the officer something to compare against.
|
||||
unverifiable: !mismatch,
|
||||
existing: this.panDeclarationTaken
|
||||
? {
|
||||
mismatchFields: this.kycData?.panDeclarationMismatchFields || 'NONE',
|
||||
nameOnPan: this.kycData?.panDeclarationNameOnPan || null,
|
||||
dobOnPan: this.parseApiDate(this.kycData?.panDeclarationDobOnPan),
|
||||
// The scan itself lives with the client's documents; the record keeps its name.
|
||||
file: null,
|
||||
fileName: this.kycData?.panDeclarationRemarks || ''
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: PanDeclarationResult | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
// The scan has to be filed against the client before the record says a declaration exists —
|
||||
// otherwise the PAN exception is waived by a document nobody can produce.
|
||||
if (result.file) {
|
||||
this.uploadPanDeclarationFile(result.file, () => this.writePanDeclaration(result));
|
||||
} else {
|
||||
this.writePanDeclaration(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Files the signed declaration under the client's documents, then continues with `onDone`. */
|
||||
private uploadPanDeclarationFile(file: File, onDone: () => void): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', 'PAN Declaration');
|
||||
formData.append(
|
||||
'description',
|
||||
`Signed PAN declaration for ${this.kycForm.value.panNumber || this.kycData?.panNumber || 'PAN'}`
|
||||
);
|
||||
|
||||
this.clientsService
|
||||
.verifyPAN(this.clientId, this.kycId)
|
||||
.uploadClientDocument(this.clientId, formData)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.pan = false;
|
||||
if (response?.changes?.panVerified) {
|
||||
this.verificationStatus.pan = 'VERIFIED';
|
||||
this.verificationDetails.pan = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan = 'PAN verification failed.';
|
||||
}
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
onDone();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.pan = false;
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan =
|
||||
this.saving = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
err?.message ||
|
||||
'Could not upload the signed declaration. Please try again.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Officer-initiated removal — the PAN exception is raised again on this same save. */
|
||||
removePanDeclaration(): void {
|
||||
if (this.panDeclarationTaken) {
|
||||
this.writePanDeclaration(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists (or clears) the declaration on the KYC record.
|
||||
*
|
||||
* `buildExceptionFields()` reads the declaration off `kycData`, so that has to be updated before
|
||||
* the payload is built — otherwise the save would carry the declaration and the PAN exception at
|
||||
* the same time, which is exactly the contradiction the declaration exists to prevent.
|
||||
*/
|
||||
private writePanDeclaration(declaration: PanDeclarationResult | null): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const previous = {
|
||||
panDeclarationTaken: this.kycData?.panDeclarationTaken,
|
||||
panDeclarationMismatchFields: this.kycData?.panDeclarationMismatchFields,
|
||||
panDeclarationNameOnPan: this.kycData?.panDeclarationNameOnPan,
|
||||
panDeclarationDobOnPan: this.kycData?.panDeclarationDobOnPan,
|
||||
panDeclarationRemarks: this.kycData?.panDeclarationRemarks,
|
||||
panDeclarationTakenOn: this.kycData?.panDeclarationTakenOn
|
||||
};
|
||||
|
||||
const dateFormat = 'dd MMMM yyyy';
|
||||
const declarationFields = {
|
||||
panDeclarationTaken: !!declaration,
|
||||
panDeclarationMismatchFields: declaration?.mismatchFields || null,
|
||||
panDeclarationNameOnPan: declaration?.nameOnPan || null,
|
||||
panDeclarationDobOnPan: declaration?.dobOnPan
|
||||
? this.dateUtils.formatDate(declaration.dobOnPan, dateFormat)
|
||||
: null,
|
||||
// Names the uploaded declaration filed under the client's documents.
|
||||
panDeclarationRemarks: declaration?.fileName || null,
|
||||
panDeclarationTakenOn: declaration ? this.dateUtils.formatDate(new Date(), dateFormat) : null
|
||||
};
|
||||
|
||||
this.kycData = { ...(this.kycData || {}), ...declarationFields };
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...declarationFields,
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
.updateClientKyc(this.clientId, this.kycId, payload)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.loadKycData();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
// Put the record back so the tab never shows a declaration the server did not accept.
|
||||
this.kycData = { ...(this.kycData || {}), ...previous };
|
||||
this.errorMessage =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'PAN verification failed. Please try again.';
|
||||
'Failed to save the PAN declaration.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one has no number to put through
|
||||
* the OTP flow, so the check can never pass. The slip closes the item instead.
|
||||
*/
|
||||
takeAadhaarReceipt(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AadhaarReceiptDialogComponent, {
|
||||
width: '560px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
clientName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
|
||||
reason:
|
||||
this.verificationError.aadhaar ||
|
||||
'Aadhaar has not been verified. Record the enrolment slip if the customer has applied but not yet received their Aadhaar.',
|
||||
existing: this.aadhaarReceiptTaken
|
||||
? {
|
||||
enrolmentId: this.kycData?.aadhaarEnrolmentId || '',
|
||||
enrolmentDate: this.parseApiDate(this.kycData?.aadhaarEnrolmentDate),
|
||||
remarks: this.kycData?.aadhaarReceiptRemarks || ''
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: AadhaarReceiptResult | undefined) => {
|
||||
if (result) {
|
||||
this.writeAadhaarReceipt(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Officer-initiated removal — the Aadhaar exception is raised again on this same save. */
|
||||
removeAadhaarReceipt(): void {
|
||||
if (this.aadhaarReceiptTaken) {
|
||||
this.writeAadhaarReceipt(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists (or clears) the enrolment receipt on the KYC record.
|
||||
*
|
||||
* `buildExceptionFields()` reads the receipt off `kycData`, so that has to be updated before the
|
||||
* payload is built — otherwise the save would carry the receipt and the Aadhaar exception at the
|
||||
* same time, the contradiction the receipt exists to prevent.
|
||||
*/
|
||||
private writeAadhaarReceipt(receipt: AadhaarReceiptResult | null): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const previous = {
|
||||
aadhaarReceiptTaken: this.kycData?.aadhaarReceiptTaken,
|
||||
aadhaarEnrolmentId: this.kycData?.aadhaarEnrolmentId,
|
||||
aadhaarEnrolmentDate: this.kycData?.aadhaarEnrolmentDate,
|
||||
aadhaarReceiptRemarks: this.kycData?.aadhaarReceiptRemarks,
|
||||
aadhaarReceiptTakenOn: this.kycData?.aadhaarReceiptTakenOn
|
||||
};
|
||||
|
||||
const dateFormat = 'dd MMMM yyyy';
|
||||
const receiptFields = {
|
||||
aadhaarReceiptTaken: !!receipt,
|
||||
aadhaarEnrolmentId: receipt?.enrolmentId || null,
|
||||
aadhaarEnrolmentDate: receipt?.enrolmentDate
|
||||
? this.dateUtils.formatDate(receipt.enrolmentDate, dateFormat)
|
||||
: null,
|
||||
aadhaarReceiptRemarks: receipt?.remarks || null,
|
||||
aadhaarReceiptTakenOn: receipt ? this.dateUtils.formatDate(new Date(), dateFormat) : null
|
||||
};
|
||||
|
||||
this.kycData = { ...(this.kycData || {}), ...receiptFields };
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...receiptFields,
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
.updateClientKyc(this.clientId, this.kycId, payload)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.verificationError.aadhaar = null;
|
||||
this.loadKycData();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
// Put the record back so the tab never shows a receipt the server did not accept.
|
||||
this.kycData = { ...(this.kycData || {}), ...previous };
|
||||
this.errorMessage =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to save the Aadhaar enrolment receipt.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Fineract serialises dates as `[yyyy, M, d]`; the same field can also arrive as a string. */
|
||||
private parseApiDate(raw: any): Date | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length >= 3) {
|
||||
return new Date(raw[0], raw[1] - 1, raw[2]);
|
||||
}
|
||||
const parsed = new Date(raw);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
/** Show Aadhaar input in summary view for re-verification */
|
||||
startAadhaarReVerify(): void {
|
||||
this.showAadhaarReVerify = true;
|
||||
@@ -305,31 +803,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.aadhaarOtpSent = false;
|
||||
this.aadhaarOtp = '';
|
||||
|
||||
this.clientsService
|
||||
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
const refNumber =
|
||||
response?.changes?.aadhaarRefNumber ||
|
||||
response?.aadhaarRefNumber ||
|
||||
response?.changes?.referenceNumber ||
|
||||
response?.resourceId;
|
||||
this.aadhaarOtpSent = true;
|
||||
this.aadhaarOtpReferenceId = refNumber || null;
|
||||
this.verificationStatus.aadhaar = 'PENDING';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to send OTP. Please try again.';
|
||||
}
|
||||
});
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
const refNumber =
|
||||
response?.changes?.aadhaarRefNumber ||
|
||||
response?.aadhaarRefNumber ||
|
||||
response?.changes?.referenceNumber ||
|
||||
response?.resourceId;
|
||||
this.aadhaarOtpSent = true;
|
||||
this.aadhaarOtpReferenceId = refNumber || null;
|
||||
this.verificationStatus.aadhaar = 'PENDING';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to send OTP. Please try again.';
|
||||
}
|
||||
});
|
||||
}, 'aadhaar');
|
||||
}
|
||||
|
||||
/** Step 2: Verify OTP entered by user */
|
||||
@@ -350,7 +850,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationStatus.aadhaar = 'VERIFIED';
|
||||
this.verificationDetails.aadhaar = response.changes;
|
||||
this.resetOtpState();
|
||||
this.loadKycData();
|
||||
// Aadhaar has been issued and verified, so the enrolment slip has served its purpose —
|
||||
// drop it rather than leave the record claiming the customer is still waiting.
|
||||
if (this.aadhaarReceiptTaken) {
|
||||
this.writeAadhaarReceipt(null);
|
||||
} else {
|
||||
this.loadKycData();
|
||||
}
|
||||
} else {
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar = 'OTP verification failed.';
|
||||
@@ -380,31 +886,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationError.gstin = null;
|
||||
this.verificationDetails.gstin = null;
|
||||
|
||||
this.clientsService
|
||||
.verifyGSTIN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.gstin = false;
|
||||
if (response?.changes?.gstinVerified) {
|
||||
this.verificationStatus.gstin = 'VERIFIED';
|
||||
this.verificationDetails.gstin = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.verifyGSTIN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.gstin = false;
|
||||
if (response?.changes?.gstinVerified) {
|
||||
this.verificationStatus.gstin = 'VERIFIED';
|
||||
this.verificationDetails.gstin = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin = 'GSTIN verification failed.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.gstin = false;
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin = 'GSTIN verification failed.';
|
||||
this.verificationError.gstin =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'GSTIN verification failed. Please try again.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.gstin = false;
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'GSTIN verification failed. Please try again.';
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 'gstin');
|
||||
}
|
||||
|
||||
formatDate(date: any): string {
|
||||
@@ -551,13 +1059,43 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
// PAN
|
||||
y = addLine('PAN Number:', kyc.panNumber || '—', y);
|
||||
y = addLine('PAN Status:', kyc.panVerified ? 'Verified' : 'Pending', y);
|
||||
// "Pending" would misdescribe a PAN that was checked, failed, and was settled by declaration —
|
||||
// nothing is pending on that record, and the certificate has to say what actually happened.
|
||||
y = addLine(
|
||||
'PAN Status:',
|
||||
kyc.panVerified ? 'Verified' : this.panDeclarationTaken ? 'Not Verified — Declaration Taken' : 'Pending',
|
||||
y
|
||||
);
|
||||
if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y);
|
||||
if (!kyc.panVerified && this.panDeclarationTaken) {
|
||||
y = addLine('Declared Mismatch:', this.panDeclarationMismatchLabel, y);
|
||||
if (kyc.panDeclarationNameOnPan) y = addLine('Name on PAN:', kyc.panDeclarationNameOnPan, y);
|
||||
if (kyc.panDeclarationDobOnPan) {
|
||||
y = addLine('DOB on PAN:', this.formatDate(kyc.panDeclarationDobOnPan), y);
|
||||
}
|
||||
if (kyc.panDeclarationTakenOn) {
|
||||
y = addLine('Declaration Taken On:', this.formatDate(kyc.panDeclarationTakenOn), y);
|
||||
}
|
||||
}
|
||||
|
||||
// Aadhaar
|
||||
y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y);
|
||||
y = addLine('Aadhaar Status:', kyc.aadhaarVerified ? 'Verified' : 'Pending', y);
|
||||
// As with PAN: "Pending" would misdescribe a client who holds only an enrolment slip.
|
||||
y = addLine(
|
||||
'Aadhaar Status:',
|
||||
kyc.aadhaarVerified ? 'Verified' : this.aadhaarReceiptTaken ? 'Not Verified — Enrolment Receipt' : 'Pending',
|
||||
y
|
||||
);
|
||||
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
|
||||
if (!kyc.aadhaarVerified && this.aadhaarReceiptTaken) {
|
||||
if (kyc.aadhaarEnrolmentId) y = addLine('Enrolment ID:', kyc.aadhaarEnrolmentId, y);
|
||||
if (kyc.aadhaarEnrolmentDate) {
|
||||
y = addLine('Enrolment Date:', this.formatDate(kyc.aadhaarEnrolmentDate), y);
|
||||
}
|
||||
if (kyc.aadhaarReceiptTakenOn) {
|
||||
y = addLine('Receipt Recorded On:', this.formatDate(kyc.aadhaarReceiptTakenOn), y);
|
||||
}
|
||||
}
|
||||
|
||||
// PAN-Aadhaar Link
|
||||
const seedingStatus =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -98,12 +98,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Client ID Column -->
|
||||
<ng-container matColumnDef="accountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- CIF Number Column -->
|
||||
<ng-container matColumnDef="cifNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.CIF Number' | translate }}</th>
|
||||
|
||||
@@ -88,7 +88,6 @@ export class ClientsComponent implements OnInit {
|
||||
|
||||
displayedColumns = [
|
||||
'displayName',
|
||||
'accountNumber',
|
||||
'cifNumber',
|
||||
'legalForm',
|
||||
'status',
|
||||
|
||||
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
|
||||
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
||||
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
|
||||
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
|
||||
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
|
||||
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
|
||||
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
|
||||
ClientPreviewStepComponent,
|
||||
ClientAddressStepComponent,
|
||||
ClientFamilyMemberDialogComponent,
|
||||
ClientFamilyMemberChildDialogComponent,
|
||||
CaptureImageDialogComponent,
|
||||
AddClientCollateralComponent,
|
||||
ClientDatatableStepComponent,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Observable, of, throwError } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
|
||||
import { environment } from 'environments/environment';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||
|
||||
/**
|
||||
@@ -123,6 +123,20 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||
}
|
||||
|
||||
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
|
||||
* Only populated after the Checker has approved and the send succeeded. */
|
||||
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
|
||||
return this.http.get<{
|
||||
emailSubject: string | null;
|
||||
emailBody: string | null;
|
||||
emailSent: boolean;
|
||||
emailSentAt: string | null;
|
||||
emailOpenedAt: string | null;
|
||||
emailFrom: string | null;
|
||||
emailTo: string | null;
|
||||
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
|
||||
}
|
||||
|
||||
getClientDataAndTemplate(clientId: string) {
|
||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||
@@ -795,6 +809,15 @@ export class ClientsService {
|
||||
return this.http.get<any[]>('/kyc/risk-rules');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a rule to the AML catalog. Amount/window details in the payload also create the
|
||||
* matching live-transaction rule server side; the response is the persisted rule in the same
|
||||
* shape as {@link getClientRiskRules}.
|
||||
*/
|
||||
createClientRiskRule(rule: any): Observable<any> {
|
||||
return this.http.post('/kyc/risk-rules', rule);
|
||||
}
|
||||
|
||||
/** Fetch code values by code ID */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||
@@ -832,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
|
||||
@@ -892,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.
|
||||
@@ -958,4 +1014,33 @@ export class ClientsService {
|
||||
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||
}
|
||||
|
||||
updateWelcomeKitStatus(welcomeKitId: number, data: any): Observable<any> {
|
||||
return this.http.put(`/welcomekit/${welcomeKitId}`, data);
|
||||
}
|
||||
|
||||
getKycExceptions(resolved = false): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
|
||||
* WHATSAPP channels — actually transmits the notice.
|
||||
*
|
||||
* The destination is resolved server-side from the client record; it is deliberately not part of
|
||||
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
|
||||
*
|
||||
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
|
||||
* response rather than assuming success, since the log row is written either way.
|
||||
*/
|
||||
saveKycContactLog(
|
||||
clientId: string | number,
|
||||
data: { channel: string; notes: string; officerName?: string }
|
||||
): Observable<any> {
|
||||
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
|
||||
}
|
||||
|
||||
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
[clientId]="createdClientId"
|
||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
@@ -151,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.
|
||||
@@ -223,6 +225,15 @@ export class CreateClientComponent {
|
||||
// KYC tab — block when the kyc form has unfilled required fields
|
||||
// (kycDocumentName / kycDocumentNumber / pattern errors on PAN / Aadhaar etc.)
|
||||
const kf = this.clientKycStep?.kycForm;
|
||||
if (kf && !kf.valid) {
|
||||
const invalid = [];
|
||||
for (const name in kf.controls) {
|
||||
if (kf.controls[name].invalid) {
|
||||
invalid.push({ name, errors: kf.controls[name].errors, value: kf.controls[name].value });
|
||||
}
|
||||
}
|
||||
console.log('--- KYC Form is INVALID. Offending controls:', invalid);
|
||||
}
|
||||
return !kf || kf.valid;
|
||||
}
|
||||
if (this.legalFormType === 2) {
|
||||
@@ -264,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.';
|
||||
@@ -375,7 +396,8 @@ export class CreateClientComponent {
|
||||
this.dialog
|
||||
.open(VerifyClientAlertDialogComponent, {
|
||||
width: '420px',
|
||||
autoFocus: false
|
||||
autoFocus: false,
|
||||
data: { isEntity: this.legalFormType === 2 }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe(() => {
|
||||
@@ -430,8 +452,22 @@ export class CreateClientComponent {
|
||||
}
|
||||
this.validationError = '';
|
||||
// Save KYC
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
|
||||
if (this.clientKycStep?.hasKycData) {
|
||||
if (!this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: (res: any) => {
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
} else {
|
||||
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||
this.clientsService
|
||||
.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload)
|
||||
.subscribe({ error: () => {} });
|
||||
}
|
||||
}
|
||||
// Upload photo & signature
|
||||
if (this.clientKycStep?.photographFile) {
|
||||
@@ -696,12 +732,20 @@ export class CreateClientComponent {
|
||||
// Persist the KYC document picker selection + uploaded files as a client identifier
|
||||
this.saveKycDocProof(clientId);
|
||||
|
||||
// Save KYC only if it wasn't already created during the KYC step
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
// Save/update KYC to ensure latest exception fields are persisted
|
||||
if (this.clientKycStep?.hasKycData) {
|
||||
if (!this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
} else {
|
||||
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
navigateToClient();
|
||||
}
|
||||
@@ -802,6 +846,9 @@ export class CreateClientComponent {
|
||||
* Mirrors identities-tab.createIdentifierWithDocs.
|
||||
*/
|
||||
private saveAddressProof(clientId: string) {
|
||||
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
@@ -817,6 +864,9 @@ export class CreateClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientIndiaAddressStep) {
|
||||
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -833,6 +883,9 @@ export class CreateClientComponent {
|
||||
* + upload each selected file as a document attached to that identifier.
|
||||
*/
|
||||
private saveKycDocProof(clientId: string) {
|
||||
if (this.clientKycStep?.existingKycIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientKycStep?.kycDocPayload;
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
@@ -860,6 +913,9 @@ export class CreateClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -884,8 +940,45 @@ export class CreateClientComponent {
|
||||
return this.clientsService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const targetName = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const target = cleanStr(proofName || proofId);
|
||||
|
||||
// 1. Try exact code/name match
|
||||
let match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode === target || pname === target;
|
||||
});
|
||||
|
||||
// 2. Try loose contains/includes match
|
||||
if (!match) {
|
||||
match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Specific fallbacks for common keywords
|
||||
if (!match && target.includes('voter')) {
|
||||
match = list.find((p: any) => {
|
||||
const name = cleanStr(p.name);
|
||||
return name.includes('voter') || name.includes('epic');
|
||||
});
|
||||
}
|
||||
if (!match && target.includes('aadhaar')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
|
||||
}
|
||||
if (!match && target.includes('passport')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
|
||||
}
|
||||
if (!match && target.includes('driving')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
|
||||
}
|
||||
if (!match && target.includes('pan')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
|
||||
}
|
||||
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
@@ -1079,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>
|
||||
@@ -1189,4 +1282,6 @@ export class SaveConfirmDialogComponent {}
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class VerifyClientAlertDialogComponent {}
|
||||
export class VerifyClientAlertDialogComponent {
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
}
|
||||
|
||||
@@ -84,14 +84,70 @@
|
||||
<mat-icon>phone_iphone</mat-icon>
|
||||
<span>Mobile Number</span>
|
||||
</div>
|
||||
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px">
|
||||
<mat-checkbox
|
||||
[checked]="oldMobileNotAccessible"
|
||||
(change)="onOldMobileNotAccessibleToggle($event.checked)"
|
||||
color="primary"
|
||||
>
|
||||
Old mobile number not accessible (lost / deactivated SIM)
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<div class="contact-verify-panel__grid">
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
[destination]="contactChanges.oldMobile"
|
||||
label="Old Mobile Number"
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
@if (!oldMobileNotAccessible) {
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
[destination]="contactChanges.oldMobile"
|
||||
label="Old Mobile Number"
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
} @else {
|
||||
<div
|
||||
class="fir-upload-box"
|
||||
style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc"
|
||||
>
|
||||
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569">
|
||||
Upload Police Complaint / FIR Copy for Lost Mobile
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<input
|
||||
type="file"
|
||||
(change)="onFirFileSelected($event)"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
#firFileInput
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="firFileInput.click()"
|
||||
[disabled]="uploadingFir"
|
||||
>
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
{{ firFileName ? firFileName : 'Select FIR Document' }}
|
||||
</button>
|
||||
@if (uploadingFir) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
|
||||
}
|
||||
@if (firDocumentId && !uploadingFir) {
|
||||
<span
|
||||
style="
|
||||
color: #16a34a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px">check_circle</mat-icon> FIR
|
||||
Uploaded Successfully
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
@@ -159,11 +215,20 @@
|
||||
</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)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
@@ -224,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>
|
||||
@@ -286,7 +357,6 @@
|
||||
[degreeQualificationOptions]="clientGeneralStep?.degreeQualificationOptions || []"
|
||||
[religionOptions]="clientGeneralStep?.religionOptions || []"
|
||||
[socialCategoryOptions]="clientGeneralStep?.socialCategoryOptions || []"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-client-preview-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
@@ -294,9 +364,11 @@
|
||||
|
||||
<!-- Bottom Action Bar -->
|
||||
<div class="action-bar">
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
}
|
||||
<div class="action-bar-right">
|
||||
@if (selectedTabIndex > 0) {
|
||||
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
|
||||
@@ -305,15 +377,7 @@
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="onNext()"
|
||||
[disabled]="!currentStepValid"
|
||||
[matTooltip]="currentStepBlockerMessage"
|
||||
[matTooltipDisabled]="currentStepValid"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||
Saving...
|
||||
@@ -327,15 +391,10 @@
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
|
||||
<button mat-raised-button color="primary" (click)="submit()" [disabled]="savingClient">
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||
Saving...
|
||||
} @else {
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
}
|
||||
@if (selectedTabIndex === totalTabs - 1) {
|
||||
<button mat-raised-button color="primary" [routerLink]="['../']">
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
Finish
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -33,12 +33,14 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
|
||||
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
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.
|
||||
@@ -51,6 +53,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabsModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatDivider,
|
||||
FaIconComponent,
|
||||
MatProgressSpinner,
|
||||
@@ -108,6 +111,13 @@ export class EditClientComponent {
|
||||
newMobileOtpId: number | null = null;
|
||||
oldMobileVerified = false;
|
||||
newMobileVerified = false;
|
||||
|
||||
// FIR exception flow properties for inaccessible old mobile number
|
||||
oldMobileNotAccessible = false;
|
||||
firFileName = '';
|
||||
firDocumentId: number | null = null;
|
||||
uploadingFir = false;
|
||||
|
||||
oldEmailOtpId: number | null = null;
|
||||
newEmailOtpId: number | null = null;
|
||||
oldEmailVerified = false;
|
||||
@@ -215,11 +225,15 @@ export class EditClientComponent {
|
||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||
}
|
||||
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified. */
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified
|
||||
* (or the FIR exception has been uploaded in place of old-mobile OTP verification). */
|
||||
get contactVerificationComplete(): boolean {
|
||||
const changes = this.contactChanges;
|
||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
||||
return false;
|
||||
if (changes.mobileChanged) {
|
||||
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
|
||||
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||
return false;
|
||||
@@ -227,6 +241,43 @@ export class EditClientComponent {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */
|
||||
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||
if (checked !== undefined) {
|
||||
this.oldMobileNotAccessible = checked;
|
||||
}
|
||||
if (this.oldMobileNotAccessible) {
|
||||
this.oldMobileOtpId = null;
|
||||
this.oldMobileVerified = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */
|
||||
onFirFileSelected(event: any): void {
|
||||
const file: File = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
this.firFileName = file.name;
|
||||
this.uploadingFir = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', 'FIR - Lost Mobile Number');
|
||||
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
|
||||
|
||||
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
||||
next: (res: any) => {
|
||||
this.uploadingFir = false;
|
||||
this.firDocumentId = res?.resourceId || res?.id || null;
|
||||
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: () => {
|
||||
this.uploadingFir = false;
|
||||
this.firDocumentId = null;
|
||||
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onOldMobileOtpState(state: ContactOtpState): void {
|
||||
this.oldMobileOtpId = state.otpId;
|
||||
this.oldMobileVerified = state.verified;
|
||||
@@ -254,6 +305,10 @@ export class EditClientComponent {
|
||||
this.newMobileOtpId = null;
|
||||
this.oldMobileVerified = false;
|
||||
this.newMobileVerified = false;
|
||||
this.oldMobileNotAccessible = false;
|
||||
this.firFileName = '';
|
||||
this.firDocumentId = null;
|
||||
this.uploadingFir = false;
|
||||
this.oldEmailOtpId = null;
|
||||
this.newEmailOtpId = null;
|
||||
this.oldEmailVerified = false;
|
||||
@@ -326,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.';
|
||||
@@ -439,7 +501,11 @@ export class EditClientComponent {
|
||||
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
||||
// state fields for the full naming rationale.
|
||||
if (changedFields.mobileChanged) {
|
||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||
if (this.oldMobileNotAccessible && this.firDocumentId) {
|
||||
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
|
||||
} else {
|
||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||
}
|
||||
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||
}
|
||||
if (changedFields.emailChanged) {
|
||||
@@ -531,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
|
||||
@@ -570,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 {
|
||||
@@ -630,16 +705,36 @@ export class EditClientComponent {
|
||||
|
||||
private detectSensitiveFieldChanges(originalData: any) {
|
||||
const form = this.clientGeneralForm;
|
||||
if (!form || !originalData) {
|
||||
return {
|
||||
nameChanged: false,
|
||||
mobileChanged: false,
|
||||
emailChanged: false,
|
||||
dobChanged: false,
|
||||
oldName: '',
|
||||
newName: '',
|
||||
oldMobile: '',
|
||||
newMobile: '',
|
||||
oldEmail: '',
|
||||
newEmail: '',
|
||||
oldDob: '',
|
||||
newDob: ''
|
||||
};
|
||||
}
|
||||
|
||||
const mobileControl = form.get('mobileNo');
|
||||
const emailControl = form.get('emailAddress');
|
||||
|
||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
||||
// customer's email casing into the saved record or the OTP destination.
|
||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
||||
// *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
|
||||
// customer's saved value or the OTP destination.
|
||||
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||
const newEmail = (emailControl?.value || '').toString().trim();
|
||||
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
||||
@@ -647,8 +742,8 @@ export class EditClientComponent {
|
||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
||||
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||
|
||||
const oldMobile = (originalData.mobileNo || '').trim();
|
||||
const oldEmail = (originalData.emailAddress || '').trim();
|
||||
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
||||
const oldDobRaw = originalData.dateOfBirth;
|
||||
const oldDob = Array.isArray(oldDobRaw)
|
||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||
@@ -656,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 || '',
|
||||
@@ -681,12 +779,25 @@ export class EditClientComponent {
|
||||
fullname: originalData.fullname || ''
|
||||
});
|
||||
|
||||
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
|
||||
const normPhone = (val: string) => val.replace(/\D/g, '');
|
||||
const newMobileNorm = normPhone(newMobile);
|
||||
const oldMobileNorm = normPhone(oldMobile);
|
||||
|
||||
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND
|
||||
// normalized values differ — otherwise a re-render/reformat of the control by the framework
|
||||
// (never actually touched by the user) would falsely surface the dual-OTP verification panel.
|
||||
const mobileChanged =
|
||||
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
const emailChanged =
|
||||
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
|
||||
|
||||
return {
|
||||
nameChanged: newName !== oldName,
|
||||
mobileChanged: newMobile !== oldMobile,
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
||||
mobileChanged,
|
||||
emailChanged,
|
||||
dobChanged: newDob !== oldDob,
|
||||
oldName,
|
||||
newName,
|
||||
@@ -930,6 +1041,9 @@ export class EditClientComponent {
|
||||
* Mirrors identities-tab.createIdentifierWithDocs.
|
||||
*/
|
||||
private saveAddressProof(clientId: string) {
|
||||
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
@@ -945,6 +1059,9 @@ export class EditClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientIndiaAddressStep) {
|
||||
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -965,6 +1082,21 @@ export class EditClientComponent {
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'KYC Document';
|
||||
|
||||
const uploadFiles = (identifierId: string) => {
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
};
|
||||
|
||||
if (this.clientKycStep?.existingKycIdentifierId) {
|
||||
uploadFiles(this.clientKycStep.existingKycIdentifierId);
|
||||
return;
|
||||
}
|
||||
|
||||
// The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR").
|
||||
// The backend ClientIdentifierRequest.documentTypeId is a Long, so a string proofId
|
||||
// trips Jackson → HttpMessageNotReadableException → 400. Resolve to numeric CodeValue
|
||||
@@ -984,12 +1116,10 @@ export class EditClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
|
||||
}
|
||||
uploadFiles(identifierId.toString());
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
@@ -1007,8 +1137,45 @@ export class EditClientComponent {
|
||||
return this.clientsService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const targetName = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const target = cleanStr(proofName || proofId);
|
||||
|
||||
// 1. Try exact code/name match
|
||||
let match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode === target || pname === target;
|
||||
});
|
||||
|
||||
// 2. Try loose contains/includes match
|
||||
if (!match) {
|
||||
match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Specific fallbacks for common keywords
|
||||
if (!match && target.includes('voter')) {
|
||||
match = list.find((p: any) => {
|
||||
const name = cleanStr(p.name);
|
||||
return name.includes('voter') || name.includes('epic');
|
||||
});
|
||||
}
|
||||
if (!match && target.includes('aadhaar')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
|
||||
}
|
||||
if (!match && target.includes('passport')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
|
||||
}
|
||||
if (!match && target.includes('driving')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
|
||||
}
|
||||
if (!match && target.includes('pan')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
|
||||
}
|
||||
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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 {
|
||||
deriveKycDocumentRenewal,
|
||||
kycDocumentNeedsAttention,
|
||||
KYC_DOCUMENT_EXPIRY_WARNING_DAYS,
|
||||
KYC_DOCUMENT_RENEWAL_YEARS
|
||||
} from './kyc-document-renewal.util';
|
||||
|
||||
const TODAY = new Date(2026, 7, 7); // 07 Aug 2026
|
||||
|
||||
const at = (y: number, m: number, d: number) => new Date(y, m - 1, d);
|
||||
|
||||
const check = (issueDate: Date | null, expiryDate: Date | null = null, today: Date = TODAY) =>
|
||||
deriveKycDocumentRenewal({ issueDate, expiryDate, today });
|
||||
|
||||
describe('deriveKycDocumentRenewal', () => {
|
||||
describe('the five-year rule', () => {
|
||||
it('says nothing about a document with neither date captured', () => {
|
||||
const result = check(null, null);
|
||||
expect(result.state).toBe('UNKNOWN');
|
||||
expect(result.dueOn).toBeNull();
|
||||
expect(result.daysRemaining).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a recently issued document alone', () => {
|
||||
const result = check(at(2025, 1, 15));
|
||||
expect(result.state).toBe('OK');
|
||||
expect(result.reason).toBe('AGE');
|
||||
});
|
||||
|
||||
it('falls due exactly five years after issue', () => {
|
||||
const result = check(at(2021, 8, 7));
|
||||
expect(result.dueOn).toEqual(at(2026, 8, 7));
|
||||
expect(result.daysRemaining).toBe(0);
|
||||
// Due today is not yet past — the branch still has the day to act.
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
|
||||
it('becomes DUE the day after the fifth anniversary', () => {
|
||||
const result = check(at(2021, 8, 6));
|
||||
expect(result.state).toBe('DUE');
|
||||
expect(result.reason).toBe('AGE');
|
||||
});
|
||||
|
||||
it('is DUE, not EXPIRED, once past five years — the document is still valid, just stale', () => {
|
||||
const result = check(at(2020, 3, 10));
|
||||
expect(result.state).toBe('DUE');
|
||||
expect(result.reason).toBe('AGE');
|
||||
expect(result.daysRemaining).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('does not warn ahead of the five-year mark — a document one day short is entirely fine', () => {
|
||||
// Due tomorrow on age alone, and still OK: only expiry gets an advance warning.
|
||||
const result = check(at(2021, 8, 8));
|
||||
expect(result.daysRemaining).toBe(1);
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an explicit expiry date', () => {
|
||||
it('warns seven days before expiry', () => {
|
||||
const result = check(at(2025, 6, 1), at(2026, 8, 14));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.daysRemaining).toBe(7);
|
||||
expect(result.state).toBe('EXPIRING_SOON');
|
||||
});
|
||||
|
||||
it('is still OK eight days out', () => {
|
||||
expect(check(at(2025, 6, 1), at(2026, 8, 15)).state).toBe('OK');
|
||||
});
|
||||
|
||||
it('warns on the day of expiry itself', () => {
|
||||
const result = check(at(2025, 6, 1), at(2026, 8, 7));
|
||||
expect(result.daysRemaining).toBe(0);
|
||||
expect(result.state).toBe('EXPIRING_SOON');
|
||||
});
|
||||
|
||||
it('marks a passed expiry as EXPIRED — no longer a valid OVD', () => {
|
||||
const result = check(at(2024, 1, 1), at(2026, 1, 1));
|
||||
expect(result.state).toBe('EXPIRED');
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
});
|
||||
|
||||
it('governs when it falls before the five-year mark', () => {
|
||||
// Issued last year, so the age clock is years away — expiry is what matters.
|
||||
const result = check(at(2025, 6, 1), at(2026, 10, 1));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.dueOn).toEqual(at(2026, 10, 1));
|
||||
});
|
||||
|
||||
it('yields to the five-year rule when it falls later', () => {
|
||||
// A passport issued in 2019 expiring in 2029 is stale long before it expires.
|
||||
const result = check(at(2019, 5, 20), at(2029, 5, 20));
|
||||
expect(result.reason).toBe('AGE');
|
||||
expect(result.dueOn).toEqual(at(2024, 5, 20));
|
||||
expect(result.state).toBe('DUE');
|
||||
});
|
||||
|
||||
it('is judged on expiry alone when no issue date was captured', () => {
|
||||
const result = check(null, at(2027, 1, 1));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('leap-year issue dates', () => {
|
||||
it('pins a 29 February issue to 28 February, not 1 March', () => {
|
||||
// 2020-02-29 + 5 years has no real anniversary; rolling into March would push the
|
||||
// document into the wrong month.
|
||||
const result = check(at(2020, 2, 29));
|
||||
expect(result.dueOn).toEqual(at(2025, 2, 28));
|
||||
});
|
||||
});
|
||||
|
||||
describe('kycDocumentNeedsAttention', () => {
|
||||
it('flags only the states staff must act on', () => {
|
||||
expect(kycDocumentNeedsAttention('UNKNOWN')).toBe(false);
|
||||
expect(kycDocumentNeedsAttention('OK')).toBe(false);
|
||||
expect(kycDocumentNeedsAttention('EXPIRING_SOON')).toBe(true);
|
||||
expect(kycDocumentNeedsAttention('EXPIRED')).toBe(true);
|
||||
expect(kycDocumentNeedsAttention('DUE')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a five-year age window and a seven-day expiry warning', () => {
|
||||
expect(KYC_DOCUMENT_RENEWAL_YEARS).toBe(5);
|
||||
expect(KYC_DOCUMENT_EXPIRY_WARNING_DAYS).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decides when the officially valid document (OVD) captured at KYC needs updating.
|
||||
*
|
||||
* The KYC step records a document type, its number, an issue date and — for documents that carry
|
||||
* one — an expiry date. Nothing has ever read those dates back, so a client onboarded on a
|
||||
* ten-year-old driving licence looked exactly like one onboarded yesterday. This module turns the
|
||||
* two dates into a single answer to "does this document need updating?", so the KYC tab can say so
|
||||
* rather than leaving staff to work it out from a date on a form.
|
||||
*
|
||||
* Two separate clocks run, and they warn differently because they mean different things:
|
||||
*
|
||||
* - **Age.** Five years after issue the document is stale and must be re-collected. It is still a
|
||||
* valid document, so there is nothing to pre-empt — the alert appears once the date passes.
|
||||
* - **Expiry.** A printed expiry date is a hard stop: the day after, the document is not a valid
|
||||
* OVD at all and the account is out of compliance. That one is worth heading off, so it warns
|
||||
* seven days ahead while the customer can still be asked to bring the renewed document in.
|
||||
*/
|
||||
|
||||
/** A document is treated as due for refresh five years after it was issued. */
|
||||
export const KYC_DOCUMENT_RENEWAL_YEARS = 5;
|
||||
|
||||
/** How many days before a printed expiry date the "expires soon" warning starts. */
|
||||
export const KYC_DOCUMENT_EXPIRY_WARNING_DAYS = 7;
|
||||
|
||||
export type KycDocumentRenewalState =
|
||||
/** No dates captured — nothing can be said about this document. */
|
||||
| 'UNKNOWN'
|
||||
/** Within its life, and not close to either threshold. */
|
||||
| 'OK'
|
||||
/** Printed expiry date falls within the next seven days. */
|
||||
| 'EXPIRING_SOON'
|
||||
/** Past its printed expiry date: no longer a valid OVD. */
|
||||
| 'EXPIRED'
|
||||
/** Past the five-year mark: still a valid document, but must be re-collected. */
|
||||
| 'DUE';
|
||||
|
||||
/** Which of the two clocks decided the outcome. */
|
||||
export type KycDocumentRenewalReason = 'EXPIRY' | 'AGE' | null;
|
||||
|
||||
export interface KycDocumentRenewalInput {
|
||||
/** Issue date printed on the document. */
|
||||
issueDate: Date | null;
|
||||
/** Expiry date printed on the document — only some carry one (passport, DL, visa). */
|
||||
expiryDate: Date | null;
|
||||
/** Today, injected so the calculation is testable and matches the tenant's date. */
|
||||
today: Date;
|
||||
}
|
||||
|
||||
export interface KycDocumentRenewalStatus {
|
||||
state: KycDocumentRenewalState;
|
||||
/** The date the document falls due — the earlier of its expiry and its fifth anniversary. */
|
||||
dueOn: Date | null;
|
||||
/** Days until `dueOn`; negative once it has passed. Null when nothing can be judged. */
|
||||
daysRemaining: number | null;
|
||||
reason: KycDocumentRenewalReason;
|
||||
}
|
||||
|
||||
/** Midnight-aligned, so a document due today is not counted as a fraction of a day overdue. */
|
||||
function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds whole years, clamping to the end of the month.
|
||||
*
|
||||
* A 29 February issue date has no fifth anniversary. Letting the Date constructor roll it into
|
||||
* 1 March would push the document into the wrong month, so it is pinned to 28 February instead.
|
||||
*/
|
||||
function addYears(date: Date, years: number): Date {
|
||||
const target = new Date(date.getFullYear() + years, date.getMonth(), date.getDate());
|
||||
if (target.getMonth() !== date.getMonth()) {
|
||||
return new Date(target.getFullYear(), target.getMonth(), 0);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function daysBetween(from: Date, to: Date): number {
|
||||
return Math.round((to.getTime() - from.getTime()) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out whether the captured document needs updating.
|
||||
*
|
||||
* The two dates are not alternatives — whichever falls first governs. A passport issued last year
|
||||
* but expiring in six months is due in six months, not in four years: treating the five-year rule
|
||||
* as the only clock would leave an expired passport on file looking current. A document with no
|
||||
* printed expiry is judged purely on age.
|
||||
*/
|
||||
export function deriveKycDocumentRenewal(input: KycDocumentRenewalInput): KycDocumentRenewalStatus {
|
||||
const today = startOfDay(input.today);
|
||||
const issue = input.issueDate ? startOfDay(input.issueDate) : null;
|
||||
const expiry = input.expiryDate ? startOfDay(input.expiryDate) : null;
|
||||
|
||||
if (!issue && !expiry) {
|
||||
return { state: 'UNKNOWN', dueOn: null, daysRemaining: null, reason: null };
|
||||
}
|
||||
|
||||
const ageDue = issue ? addYears(issue, KYC_DOCUMENT_RENEWAL_YEARS) : null;
|
||||
|
||||
// Whichever comes first governs — see the doc comment above.
|
||||
let dueOn: Date;
|
||||
let reason: KycDocumentRenewalReason;
|
||||
if (expiry && ageDue) {
|
||||
if (expiry.getTime() <= ageDue.getTime()) {
|
||||
dueOn = expiry;
|
||||
reason = 'EXPIRY';
|
||||
} else {
|
||||
dueOn = ageDue;
|
||||
reason = 'AGE';
|
||||
}
|
||||
} else if (expiry) {
|
||||
dueOn = expiry;
|
||||
reason = 'EXPIRY';
|
||||
} else {
|
||||
dueOn = ageDue as Date;
|
||||
reason = 'AGE';
|
||||
}
|
||||
|
||||
const daysRemaining = daysBetween(today, dueOn);
|
||||
|
||||
let state: KycDocumentRenewalState;
|
||||
if (reason === 'EXPIRY') {
|
||||
if (daysRemaining < 0) {
|
||||
state = 'EXPIRED';
|
||||
} else if (daysRemaining <= KYC_DOCUMENT_EXPIRY_WARNING_DAYS) {
|
||||
state = 'EXPIRING_SOON';
|
||||
} else {
|
||||
state = 'OK';
|
||||
}
|
||||
} else {
|
||||
// Age alone: a document one day short of five years is entirely fine, so there is nothing to
|
||||
// warn about in advance. It becomes due the moment the anniversary passes.
|
||||
state = daysRemaining < 0 ? 'DUE' : 'OK';
|
||||
}
|
||||
|
||||
return { state, dueOn, daysRemaining, reason };
|
||||
}
|
||||
|
||||
/** Whether this state warrants surfacing an alert to staff at all. */
|
||||
export function kycDocumentNeedsAttention(state: KycDocumentRenewalState): boolean {
|
||||
return state === 'EXPIRING_SOON' || state === 'EXPIRED' || state === 'DUE';
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 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 {
|
||||
deriveKycExceptionFields,
|
||||
KycExceptionInput,
|
||||
KYC_EXCEPTION_AADHAAR,
|
||||
KYC_EXCEPTION_PAN
|
||||
} from './kyc-exception.util';
|
||||
|
||||
const TODAY = '05 August 2026';
|
||||
|
||||
/** Individual client, nothing previously recorded — the client-creation case. */
|
||||
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: true,
|
||||
hadException: false,
|
||||
previousReason: null,
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
|
||||
/** Individual client that already carries an open exception. */
|
||||
const existingException = (
|
||||
panVerified: boolean,
|
||||
aadhaarVerified: boolean,
|
||||
previousReason: string
|
||||
): KycExceptionInput => ({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: true,
|
||||
hadException: true,
|
||||
previousReason,
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
|
||||
describe('deriveKycExceptionFields', () => {
|
||||
describe('at client creation', () => {
|
||||
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only PAN when Aadhaar verified but PAN did not', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_PAN,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('raises nothing when both verify, and does not mark the client resolved', () => {
|
||||
// A clean client never had an exception, so it must not appear in the Resolved tab either.
|
||||
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('does not raise an Aadhaar exception for a non-individual client', () => {
|
||||
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
panVerified: true,
|
||||
aadhaarVerified: false,
|
||||
aadhaarApplicable: false,
|
||||
hadException: false,
|
||||
previousReason: null,
|
||||
todayFormatted: TODAY
|
||||
}).isException
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PAN declaration', () => {
|
||||
it('waives the PAN exception once a declaration is taken', () => {
|
||||
// The client's name/DOB will never match the PAN record, so the API can never pass. The
|
||||
// signed declaration closes the item instead — the client must not sit in the exception list.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
panDeclarationTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('still raises the PAN exception when no declaration has been taken', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
panDeclarationTaken: false
|
||||
}).exceptionReason
|
||||
).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
|
||||
it('leaves an outstanding Aadhaar untouched — a PAN declaration covers PAN only', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
panDeclarationTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(true);
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_AADHAAR);
|
||||
});
|
||||
|
||||
it('closes an existing PAN exception when a declaration is taken afterwards', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...existingException(false, true, KYC_EXCEPTION_PAN),
|
||||
panDeclarationTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: KYC_EXCEPTION_PAN,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aadhaar enrolment receipt', () => {
|
||||
it('waives the Aadhaar exception once the enrolment receipt is recorded', () => {
|
||||
// The client holds no Aadhaar yet, so there is no number to run the OTP check against. The
|
||||
// UIDAI enrolment slip closes the item instead.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(true, false),
|
||||
aadhaarReceiptTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('still raises the Aadhaar exception when no receipt has been taken', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(true, false),
|
||||
aadhaarReceiptTaken: false
|
||||
}).exceptionReason
|
||||
).toBe(KYC_EXCEPTION_AADHAAR);
|
||||
});
|
||||
|
||||
it('leaves an outstanding PAN untouched — a receipt covers Aadhaar only', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
aadhaarReceiptTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(true);
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
|
||||
it('closes an existing Aadhaar exception when a receipt is taken afterwards', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...existingException(true, false, KYC_EXCEPTION_AADHAAR),
|
||||
aadhaarReceiptTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
|
||||
it('clears both items when a PAN declaration and an Aadhaar receipt are both on file', () => {
|
||||
// Neither check can pass, yet the branch holds paperwork for both — nothing is outstanding.
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
panDeclarationTaken: true,
|
||||
aadhaarReceiptTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(false);
|
||||
expect(result.exceptionReason).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolving', () => {
|
||||
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: both,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows to the outstanding item when only one of two verifies', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
|
||||
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows the other way round too', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opening', () => {
|
||||
it('clears a prior resolution so a new exception returns to Pending', () => {
|
||||
// Regression: without exceptionResolved=false the record still matched the Resolved query
|
||||
// and a brand-new exception appeared as already closed.
|
||||
const result = deriveKycExceptionFields({
|
||||
panVerified: false,
|
||||
aadhaarVerified: true,
|
||||
aadhaarApplicable: true,
|
||||
hadException: false,
|
||||
previousReason: 'PAN Card Verification Failed',
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
expect(result.exceptionResolved).toBe(false);
|
||||
expect(result.exceptionResolvedOn).toBeNull();
|
||||
expect(result.isException).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('officer-entered reasons', () => {
|
||||
it('keeps a manual reason alongside the automatic ones', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(true, true),
|
||||
manualReasons: ['Address proof pending']
|
||||
});
|
||||
expect(result).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: 'Address proof pending',
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a stale automatic reason left in the form once the check passes', () => {
|
||||
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
|
||||
// verified, so it must not survive into the payload.
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(true, true),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
});
|
||||
expect(result.isException).toBe(false);
|
||||
expect(result.exceptionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
});
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives a client's KYC exception state from their verification status.
|
||||
*
|
||||
* A KYC exception is not something an officer should have to remember to tick — it is simply the
|
||||
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
|
||||
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
|
||||
* and Aadhaar are verified at the moment of the save.
|
||||
*
|
||||
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
|
||||
*/
|
||||
|
||||
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
|
||||
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
|
||||
|
||||
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
|
||||
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
|
||||
KYC_EXCEPTION_PAN,
|
||||
KYC_EXCEPTION_AADHAAR
|
||||
];
|
||||
|
||||
export interface KycExceptionInput {
|
||||
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
|
||||
panVerified: boolean;
|
||||
/**
|
||||
* A signed customer declaration has been accepted in place of a passing PAN check.
|
||||
*
|
||||
* The Income Tax check matches on name *and* date of birth, so a client whose PAN card carries a
|
||||
* different spelling — or a different DOB — can never clear it, however many times the officer
|
||||
* retries. Once the branch holds a declaration recording exactly what differs, the outstanding
|
||||
* item is closed by paperwork rather than by the API, and it must not keep the client sitting in
|
||||
* the exception list. PAN still reads as unverified everywhere else; only the exception is waived.
|
||||
*/
|
||||
panDeclarationTaken?: boolean;
|
||||
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
|
||||
aadhaarVerified: boolean;
|
||||
/**
|
||||
* The customer's Aadhaar enrolment slip has been sighted and recorded.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one yet holds only a UIDAI
|
||||
* enrolment receipt. There is no Aadhaar number to put through the OTP flow, so the check cannot
|
||||
* pass however long the branch waits — the same dead end the PAN declaration exists to resolve.
|
||||
* Once the receipt is on file the outstanding item is closed by paperwork, and the client must
|
||||
* not keep sitting in the exception list. Aadhaar still reads as unverified everywhere else.
|
||||
*/
|
||||
aadhaarReceiptTaken?: boolean;
|
||||
/**
|
||||
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
|
||||
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
|
||||
*/
|
||||
aadhaarApplicable: boolean;
|
||||
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
|
||||
hadException: boolean;
|
||||
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
|
||||
previousReason: string | null;
|
||||
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
|
||||
manualReasons?: string[];
|
||||
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
|
||||
todayFormatted: string;
|
||||
}
|
||||
|
||||
export interface KycExceptionFields {
|
||||
isException: boolean;
|
||||
exceptionReason: string | null;
|
||||
exceptionResolved: boolean;
|
||||
exceptionResolvedOn: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps verification status to the four exception columns the API expects.
|
||||
*
|
||||
* Three outcomes:
|
||||
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
|
||||
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
|
||||
* Aadhaar alone, which is what narrows the entry rather than closing it.
|
||||
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
|
||||
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
|
||||
* why the exception was ever raised.
|
||||
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
|
||||
* in either tab.
|
||||
*/
|
||||
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
|
||||
const outstanding: string[] = [];
|
||||
|
||||
// A declaration settles the PAN item without the API ever passing — see `panDeclarationTaken`.
|
||||
if (!input.panVerified && !input.panDeclarationTaken) {
|
||||
outstanding.push(KYC_EXCEPTION_PAN);
|
||||
}
|
||||
// An enrolment receipt settles the Aadhaar item when the OTP check has nothing to run against —
|
||||
// see `aadhaarReceiptTaken`.
|
||||
if (input.aadhaarApplicable && !input.aadhaarVerified && !input.aadhaarReceiptTaken) {
|
||||
outstanding.push(KYC_EXCEPTION_AADHAAR);
|
||||
}
|
||||
|
||||
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
|
||||
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
|
||||
for (const reason of input.manualReasons || []) {
|
||||
const trimmed = (reason || '').trim();
|
||||
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
|
||||
outstanding.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (outstanding.length > 0) {
|
||||
return {
|
||||
isException: true,
|
||||
exceptionReason: outstanding.join(', '),
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
};
|
||||
}
|
||||
|
||||
if (input.hadException) {
|
||||
return {
|
||||
isException: false,
|
||||
exceptionReason: input.previousReason || null,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: input.todayFormatted
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { UntypedFormGroup } from '@angular/forms';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, filter, switchMap, takeUntil, timeout, catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { environment } from 'environments/environment';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { ClientsService } from '../clients.service';
|
||||
import {
|
||||
EXTERNAL_ID_STATUS_KEYS,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="aad-rcpt">
|
||||
<div class="aad-rcpt__header">
|
||||
<mat-icon class="aad-rcpt__header-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<div class="aad-rcpt__title">Aadhaar Enrolment Receipt</div>
|
||||
<div class="aad-rcpt__subtitle">{{ data.clientName || 'Client' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="aad-rcpt__body">
|
||||
<div class="aad-rcpt__reason">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ data.reason }}</span>
|
||||
</div>
|
||||
|
||||
<p class="aad-rcpt__intro">
|
||||
For a customer who has applied for Aadhaar but has not been issued one yet, there is no Aadhaar number to run the
|
||||
OTP check against. Record the UIDAI enrolment slip instead — the client can then be created without an Aadhaar KYC
|
||||
exception being raised.
|
||||
</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__field">
|
||||
<mat-label>Enrolment number (EID)</mat-label>
|
||||
<span matPrefix class="aad-rcpt__prefix"><mat-icon>confirmation_number</mat-icon></span>
|
||||
<input matInput [(ngModel)]="enrolmentId" placeholder="1234/56789/01234" maxlength="20" autocomplete="off" />
|
||||
<mat-hint [class.aad-rcpt__hint--bad]="enrolmentIdEntered && !enrolmentIdValid">
|
||||
{{ enrolmentIdHint }}
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__field">
|
||||
<mat-label>Enrolment date</mat-label>
|
||||
<input matInput [matDatepicker]="enrolPicker" [max]="today" [(ngModel)]="enrolmentDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="enrolPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #enrolPicker></mat-datepicker>
|
||||
<mat-hint>Date printed on the slip</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
@if (monthsSinceEnrolment !== null) {
|
||||
<div class="aad-rcpt__age" [class.aad-rcpt__age--stale]="receiptIsStale">
|
||||
<mat-icon>{{ receiptIsStale ? 'warning' : 'schedule' }}</mat-icon>
|
||||
<span>
|
||||
@if (receiptIsStale) {
|
||||
Enrolled {{ monthsSinceEnrolment }} months ago. An enrolment normally completes well inside six months —
|
||||
chase the customer for the Aadhaar itself. The receipt can still be recorded.
|
||||
} @else {
|
||||
Enrolled {{ monthsSinceEnrolment }} month{{ monthsSinceEnrolment === 1 ? '' : 's' }} ago.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__remarks">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
[(ngModel)]="remarks"
|
||||
maxlength="500"
|
||||
placeholder="What was sighted, and when the Aadhaar is expected"
|
||||
></textarea>
|
||||
<mat-hint>{{ remarks.length }}/500</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox class="aad-rcpt__sighted" [(ngModel)]="sighted">
|
||||
The original UIDAI enrolment slip has been sighted and a copy filed with the account opening form.
|
||||
</mat-checkbox>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="aad-rcpt__actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
Save Receipt
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.aad-rcpt {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.aad-rcpt__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.aad-rcpt__header-icon {
|
||||
color: #2563eb;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.aad-rcpt__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.aad-rcpt__subtitle {
|
||||
font-size: 12px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt__body {
|
||||
padding: 18px 24px !important;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.aad-rcpt__reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aad-rcpt__field,
|
||||
.aad-rcpt__remarks {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aad-rcpt__prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.aad-rcpt__hint--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
// Age of the slip — informational, so it reads as context rather than a blocking error.
|
||||
.aad-rcpt__age {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 2px 0 14px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--stale {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__sighted {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
::ng-deep .mdc-form-field > label {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__actions {
|
||||
padding: 10px 24px 16px !important;
|
||||
margin: 0 !important;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface AadhaarReceiptDialogData {
|
||||
/** Client name, shown so the officer can check the slip belongs to the right person. */
|
||||
clientName: string;
|
||||
/** Message the Aadhaar step came back with, or why the officer is recording a receipt. */
|
||||
reason: string;
|
||||
/** Existing receipt, when the officer re-opens one already taken. */
|
||||
existing?: AadhaarReceiptResult | null;
|
||||
}
|
||||
|
||||
export interface AadhaarReceiptResult {
|
||||
/** 14-digit enrolment number as printed on the slip, separators kept. */
|
||||
enrolmentId: string;
|
||||
enrolmentDate: Date | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one holds only an enrolment slip.
|
||||
* There is no Aadhaar number to send to the OTP flow, so no amount of retrying will clear the
|
||||
* check — the same dead end the PAN declaration resolves. Recording the slip lets the client be
|
||||
* created and waives the Aadhaar exception, while Aadhaar itself stays unverified.
|
||||
*
|
||||
* Only the 14-digit enrolment number is taken. The date and time printed beside it on the slip are
|
||||
* captured by the enrolment-date field, so asking for them again in the same box would just be a
|
||||
* second chance to mistype them.
|
||||
*
|
||||
* The number is validated on digit count rather than an exact layout: slips print it with varying
|
||||
* separators, and rejecting one because of a slash or a space would block a customer over
|
||||
* formatting rather than substance.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aadhaar-receipt-dialog',
|
||||
templateUrl: './aadhaar-receipt-dialog.component.html',
|
||||
styleUrls: ['./aadhaar-receipt-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class AadhaarReceiptDialogComponent {
|
||||
data = inject<AadhaarReceiptDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject(MatDialogRef<AadhaarReceiptDialogComponent>);
|
||||
|
||||
enrolmentId = '';
|
||||
enrolmentDate: Date | null = null;
|
||||
remarks = '';
|
||||
/** The officer confirms the original slip was sighted — nothing is saved without it. */
|
||||
sighted = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
constructor() {
|
||||
const existing = this.data.existing;
|
||||
if (existing) {
|
||||
this.enrolmentId = existing.enrolmentId || '';
|
||||
this.enrolmentDate = existing.enrolmentDate || null;
|
||||
this.remarks = existing.remarks || '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Digits only — separators on the printed slip are cosmetic and vary between forms. */
|
||||
private get enrolmentDigits(): string {
|
||||
return (this.enrolmentId || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
get enrolmentIdEntered(): boolean {
|
||||
return this.enrolmentDigits.length > 0;
|
||||
}
|
||||
|
||||
/** UIDAI's enrolment number is 14 digits — the timestamp beside it is not part of it. */
|
||||
get enrolmentIdValid(): boolean {
|
||||
return this.enrolmentDigits.length === 14;
|
||||
}
|
||||
|
||||
/** How many digits are still missing, so the officer is told what to fix, not just that it failed. */
|
||||
get enrolmentIdHint(): string {
|
||||
const count = this.enrolmentDigits.length;
|
||||
if (!count) {
|
||||
return 'The 14-digit enrolment number only, e.g. 1234/56789/01234';
|
||||
}
|
||||
return `${count} of 14 digits`;
|
||||
}
|
||||
|
||||
/** A slip dated in the future is a keying error, not a valid enrolment. */
|
||||
get enrolmentDateValid(): boolean {
|
||||
return !!this.enrolmentDate && this.enrolmentDate.getTime() <= this.today.getTime();
|
||||
}
|
||||
|
||||
/** Whole months since enrolment — shown because a receipt is temporary by nature. */
|
||||
get monthsSinceEnrolment(): number | null {
|
||||
if (!this.enrolmentDate) {
|
||||
return null;
|
||||
}
|
||||
const months =
|
||||
(this.today.getFullYear() - this.enrolmentDate.getFullYear()) * 12 +
|
||||
(this.today.getMonth() - this.enrolmentDate.getMonth()) -
|
||||
(this.today.getDate() < this.enrolmentDate.getDate() ? 1 : 0);
|
||||
return Math.max(0, months);
|
||||
}
|
||||
|
||||
/**
|
||||
* An enrolment normally completes well inside six months, so an older slip is a prompt to chase
|
||||
* the customer for the Aadhaar itself. It is a warning, not a block: the branch may have good
|
||||
* reason to accept it, and the record shows the age either way.
|
||||
*/
|
||||
get receiptIsStale(): boolean {
|
||||
const months = this.monthsSinceEnrolment;
|
||||
return months !== null && months >= 6;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.enrolmentIdValid && this.enrolmentDateValid && !!this.remarks.trim() && this.sighted;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close({
|
||||
enrolmentId: this.enrolmentId.trim(),
|
||||
enrolmentDate: this.enrolmentDate,
|
||||
remarks: this.remarks.trim()
|
||||
} as AadhaarReceiptResult);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a stacked front-above-back scan back into its two sides.
|
||||
*
|
||||
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
|
||||
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
|
||||
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
|
||||
* completely white rows across the full width — that band is the seam, and it is the only thing
|
||||
* in the file that says where one side ends and the other begins.
|
||||
*
|
||||
* Resolves null when no such seam is found, which is the honest answer for an image that was not
|
||||
* produced by that merge: better to show one scan than to cut a document in half at a guess.
|
||||
*/
|
||||
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
type Sides = { front: Blob; back: Blob } | null;
|
||||
return this.blobToImage(blob)
|
||||
.then((img): Sides | Promise<Sides> => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx || canvas.width === 0 || canvas.height === 0) {
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
|
||||
if (!seam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
this.cropToBlob(img, canvas.width, 0, seam.start),
|
||||
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
|
||||
]).then(
|
||||
([
|
||||
front,
|
||||
back
|
||||
]): Sides => (front && back ? { front, back } : null)
|
||||
);
|
||||
})
|
||||
.catch((): Sides => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tallest run of all-white rows in the middle of the image.
|
||||
*
|
||||
* Confined to the middle half because a scan often has white margins at the top and bottom, and
|
||||
* those are part of the document rather than the join between two of them.
|
||||
*/
|
||||
private findWhiteSeam(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): { start: number; end: number } | null {
|
||||
const from = Math.floor(height * 0.25);
|
||||
const to = Math.ceil(height * 0.75);
|
||||
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
|
||||
|
||||
let bestStart = -1;
|
||||
let bestLength = 0;
|
||||
let runStart = -1;
|
||||
|
||||
for (let y = from; y <= to; y++) {
|
||||
let white = y < to;
|
||||
if (white) {
|
||||
const row = ctx.getImageData(0, y, width, 1).data;
|
||||
for (let x = 0; x < width; x += step) {
|
||||
const i = x * 4;
|
||||
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
|
||||
white = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (white) {
|
||||
if (runStart < 0) {
|
||||
runStart = y;
|
||||
}
|
||||
} else if (runStart >= 0) {
|
||||
if (y - runStart > bestLength) {
|
||||
bestStart = runStart;
|
||||
bestLength = y - runStart;
|
||||
}
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
|
||||
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
|
||||
}
|
||||
|
||||
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
|
||||
if (height <= 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
|
||||
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
|
||||
}
|
||||
|
||||
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
|
||||
return this.fileToImage(blob as File);
|
||||
}
|
||||
|
||||
private fileToImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
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="pan-decl">
|
||||
<div class="pan-decl__header">
|
||||
<mat-icon class="pan-decl__header-icon">assignment_late</mat-icon>
|
||||
<div>
|
||||
<div class="pan-decl__title">PAN Verification Failed — Take Declaration</div>
|
||||
<div class="pan-decl__subtitle">PAN {{ data.panNumber }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="pan-decl__body">
|
||||
<div class="pan-decl__reason">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ data.failureMessage }}</span>
|
||||
</div>
|
||||
|
||||
@if (!data.unverifiable) {
|
||||
<p class="pan-decl__intro">
|
||||
The Income Tax check matches on <strong>name and date of birth together</strong> and does not report which of
|
||||
the two differs. Enter the details exactly as printed on the customer's PAN card so the declaration records what
|
||||
actually does not match.
|
||||
</p>
|
||||
|
||||
<!-- Comparison: what we submitted vs what the PAN card says -->
|
||||
<div class="pan-decl__compare">
|
||||
<div class="pan-decl__compare-head">
|
||||
<span class="lbl"></span>
|
||||
<span class="col">Submitted</span>
|
||||
<span class="col">As on PAN card</span>
|
||||
<span class="res"></span>
|
||||
</div>
|
||||
|
||||
<div class="pan-decl__compare-row" [class.mismatch]="nameMismatch">
|
||||
<span class="lbl">Name</span>
|
||||
<span class="col submitted">{{ data.submittedName || '—' }}</span>
|
||||
<span class="col">
|
||||
<mat-form-field appearance="outline" class="pan-decl__field">
|
||||
<mat-label>Name on PAN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="nameOnPan"
|
||||
(ngModelChange)="onCompare()"
|
||||
placeholder="e.g. RAJESH KUMAR SHARMA"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
<span class="res">
|
||||
@if (compared && nameOnPan.trim()) {
|
||||
@if (nameMismatch) {
|
||||
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pan-decl__compare-row" [class.mismatch]="dobMismatch">
|
||||
<span class="lbl">Date of Birth</span>
|
||||
<span class="col submitted">{{ data.submittedDob || '—' }}</span>
|
||||
<span class="col">
|
||||
<mat-form-field appearance="outline" class="pan-decl__field">
|
||||
<mat-label>DOB on PAN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="dobPicker"
|
||||
[max]="today"
|
||||
[(ngModel)]="dobOnPan"
|
||||
(ngModelChange)="onCompare()"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
<span class="res">
|
||||
@if (compared && dobOnPan) {
|
||||
@if (dobMismatch) {
|
||||
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What the declaration will state -->
|
||||
<div
|
||||
class="pan-decl__verdict"
|
||||
[class.pan-decl__verdict--pending]="!comparisonComplete"
|
||||
[class.pan-decl__verdict--clean]="nothingToDeclare"
|
||||
>
|
||||
<span class="pan-decl__verdict-label">Does not match:</span>
|
||||
<span class="pan-decl__verdict-value">{{ mismatchLabel }}</span>
|
||||
</div>
|
||||
|
||||
@if (!comparisonComplete) {
|
||||
<p class="pan-decl__note">Enter both PAN-card values to see exactly which detail differs.</p>
|
||||
}
|
||||
|
||||
@if (nothingToDeclare) {
|
||||
<p class="pan-decl__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
The PAN-card details you entered are the same as the ones submitted, so there is nothing to declare. Correct
|
||||
the client's name or date of birth on the General step and verify PAN again.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="pan-decl__intro">
|
||||
PAN could not be checked against the Income Tax records. Record the declaration taken from the customer so the
|
||||
client can be created without a KYC exception being raised for PAN.
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- The signed paper is the declaration; it is uploaded, not described. -->
|
||||
<div class="pan-decl__upload">
|
||||
<div class="pan-decl__upload-label">Signed declaration</div>
|
||||
<p class="pan-decl__upload-help">
|
||||
Get the declaration signed by the customer and upload the scanned copy. It is filed against the client as the
|
||||
proof that holds the PAN exception off.
|
||||
</p>
|
||||
|
||||
@if (!hasDeclarationFile) {
|
||||
<label class="pan-decl__dropzone">
|
||||
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
|
||||
<mat-icon class="pan-decl__dropzone-icon">cloud_upload</mat-icon>
|
||||
<span class="pan-decl__dropzone-text">Click to upload signed declaration</span>
|
||||
<span class="pan-decl__dropzone-hint">PDF, JPG or PNG — up to 5 MB</span>
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pan-decl__file">
|
||||
<mat-icon class="pan-decl__file-icon">description</mat-icon>
|
||||
<span class="pan-decl__file-meta">
|
||||
<span class="pan-decl__file-name">{{ fileName }}</span>
|
||||
<span class="pan-decl__file-sub">{{ fileSizeLabel }}</span>
|
||||
</span>
|
||||
<label class="pan-decl__file-replace">
|
||||
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
|
||||
Replace
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pan-decl__file-remove"
|
||||
aria-label="Remove file"
|
||||
(click)="removeDeclarationFile()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (fileError) {
|
||||
<p class="pan-decl__file-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ fileError }}</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="pan-decl__actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
Save Declaration
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.pan-decl {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.pan-decl__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pan-decl__header-icon {
|
||||
color: #d97706;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pan-decl__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pan-decl__subtitle {
|
||||
font-size: 12px;
|
||||
color: #b45309;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.pan-decl__body {
|
||||
padding: 18px 24px !important;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pan-decl__reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// ── Submitted vs PAN-card comparison ───────────────────────────────────────
|
||||
.pan-decl__compare {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pan-decl__compare-head,
|
||||
.pan-decl__compare-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr 1fr 36px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.pan-decl__compare-head {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__compare-row {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// A row the declaration will name — tinted so the officer can see the finding at a glance.
|
||||
&.mismatch {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.lbl {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.submitted {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__field {
|
||||
width: 100%;
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.res {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.res-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.res-icon--ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.res-icon--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
// ── Verdict ────────────────────────────────────────────────────────────────
|
||||
.pan-decl__verdict {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
|
||||
&--pending {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
&--clean {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__verdict-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__verdict-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pan-decl__note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__warn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signed declaration upload ──────────────────────────────────────────────
|
||||
.pan-decl__upload {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.pan-decl__upload-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__upload-help {
|
||||
margin: 4px 0 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 18px 24px;
|
||||
background: #fff;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pan-decl__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.pan-decl__file-icon {
|
||||
color: #16a34a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pan-decl__file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.pan-decl__file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pan-decl__file-sub {
|
||||
font-size: 11px;
|
||||
color: #4d7c69;
|
||||
}
|
||||
|
||||
.pan-decl__file-replace {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__file-remove {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__file-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #b91c1c;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__actions {
|
||||
padding: 10px 24px 16px !important;
|
||||
margin: 0 !important;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Which client details differ from the PAN card. */
|
||||
export type PanMismatchField = 'NAME' | 'DOB';
|
||||
|
||||
export interface PanDeclarationDialogData {
|
||||
/** Name submitted on the General step — the value sent to the Income Tax check. */
|
||||
submittedName: string;
|
||||
/** Date of birth submitted on the General step, in the tenant's display format. */
|
||||
submittedDob: string;
|
||||
/** PAN being declared against. */
|
||||
panNumber: string;
|
||||
/** Message the verification came back with, shown so the officer knows why they are here. */
|
||||
failureMessage: string;
|
||||
/**
|
||||
* True when the check failed on something other than name/DOB (invalid PAN, vendor unreachable).
|
||||
* The PAN-card fields then have nothing to compare against, so the mismatch reads NONE and the
|
||||
* officer only has to record why the declaration is being taken.
|
||||
*/
|
||||
unverifiable?: boolean;
|
||||
/** Existing declaration, when the officer re-opens one already taken. */
|
||||
existing?: PanDeclarationResult | null;
|
||||
}
|
||||
|
||||
export interface PanDeclarationResult {
|
||||
/** 'NAME', 'DOB', 'NAME,DOB', or 'NONE' when PAN could not be checked at all. */
|
||||
mismatchFields: string;
|
||||
nameOnPan: string | null;
|
||||
dobOnPan: Date | null;
|
||||
/**
|
||||
* The scanned signed declaration, to be filed against the client. Null when an existing
|
||||
* declaration was re-opened and the officer did not replace the document already on file.
|
||||
*/
|
||||
file: File | null;
|
||||
/** Name of the declaration document on record — what the KYC record keeps as the reference. */
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the customer declaration that stands in for a passing PAN check.
|
||||
*
|
||||
* The Income Tax API matches on name *and* date of birth and tells us only that the pair failed —
|
||||
* never which half. So rather than show the officer a dead-end error, this dialog asks them to key
|
||||
* in the two values printed on the physical PAN card in front of them. Comparing those against what
|
||||
* was submitted is what produces a specific, defensible "NAME does not match" on the declaration,
|
||||
* and the PAN-card values are kept alongside it as the evidence for that statement.
|
||||
*
|
||||
* Name comparison is deliberately loose about case and internal spacing — "RAJESH KUMAR" and
|
||||
* "Rajesh Kumar" are the same name, and flagging them as a mismatch would put a declaration on file
|
||||
* that says something untrue.
|
||||
*
|
||||
* The declaration itself is taken as an upload rather than typed: what waives the PAN exception is
|
||||
* the paper the customer signed, so the scan of it is the record — an officer's typed note is not.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pan-declaration-dialog',
|
||||
templateUrl: './pan-declaration-dialog.component.html',
|
||||
styleUrls: ['./pan-declaration-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class PanDeclarationDialogComponent {
|
||||
data = inject<PanDeclarationDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject(MatDialogRef<PanDeclarationDialogComponent>);
|
||||
|
||||
/** Name exactly as printed on the PAN card. */
|
||||
nameOnPan = '';
|
||||
/** Date of birth exactly as printed on the PAN card. */
|
||||
dobOnPan: Date | null = null;
|
||||
|
||||
/** The scan the officer just picked. Null while an earlier upload is still the one on file. */
|
||||
declarationFile: File | null = null;
|
||||
/** Name of the document already filed, when re-opening a declaration that was taken before. */
|
||||
existingFileName = '';
|
||||
/** Why the picked file was rejected — shown in place of failing silently. */
|
||||
fileError = '';
|
||||
|
||||
/** Officer has not yet typed anything to compare against. */
|
||||
compared = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
readonly acceptedTypes = '.pdf,.jpg,.jpeg,.png';
|
||||
private readonly maxFileBytes = 5 * 1024 * 1024;
|
||||
private readonly acceptedExtensions = [
|
||||
'pdf',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
const existing = this.data.existing;
|
||||
if (existing) {
|
||||
this.nameOnPan = existing.nameOnPan || '';
|
||||
this.dobOnPan = existing.dobOnPan || null;
|
||||
this.existingFileName = existing.fileName || '';
|
||||
this.compared = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The document the declaration will be saved against — newly picked, or the one already filed. */
|
||||
get fileName(): string {
|
||||
return this.declarationFile?.name || this.existingFileName;
|
||||
}
|
||||
|
||||
get hasDeclarationFile(): boolean {
|
||||
return !!this.fileName;
|
||||
}
|
||||
|
||||
/** Size of the picked scan, or a note that the document on file is being kept as it is. */
|
||||
get fileSizeLabel(): string {
|
||||
if (!this.declarationFile) {
|
||||
return 'Already on file';
|
||||
}
|
||||
const kb = this.declarationFile.size / 1024;
|
||||
return kb < 1024 ? `${Math.max(1, Math.round(kb))} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
onDeclarationFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] || null;
|
||||
input.value = ''; // allow re-picking the same filename
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const extension = (file.name.split('.').pop() || '').toLowerCase();
|
||||
if (!this.acceptedExtensions.includes(extension)) {
|
||||
this.fileError = 'Upload the signed declaration as a PDF, JPG or PNG.';
|
||||
return;
|
||||
}
|
||||
if (file.size > this.maxFileBytes) {
|
||||
this.fileError = 'The file is larger than 5 MB. Scan at a lower resolution and try again.';
|
||||
return;
|
||||
}
|
||||
this.fileError = '';
|
||||
this.declarationFile = file;
|
||||
}
|
||||
|
||||
/** Drops the picked scan — and the one already filed, since neither may be left implied. */
|
||||
removeDeclarationFile(): void {
|
||||
this.declarationFile = null;
|
||||
this.existingFileName = '';
|
||||
this.fileError = '';
|
||||
}
|
||||
|
||||
/** Case- and spacing-insensitive: only a genuinely different name counts as a mismatch. */
|
||||
private normalizeName(value: string): string {
|
||||
return (value || '').trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
private formatDob(value: Date | null): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const dd = value.getDate().toString().padStart(2, '0');
|
||||
const mm = (value.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${dd}/${mm}/${value.getFullYear()}`;
|
||||
}
|
||||
|
||||
get nameMismatch(): boolean {
|
||||
if (this.data.unverifiable || !this.nameOnPan.trim()) {
|
||||
return false;
|
||||
}
|
||||
return this.normalizeName(this.nameOnPan) !== this.normalizeName(this.data.submittedName);
|
||||
}
|
||||
|
||||
get dobMismatch(): boolean {
|
||||
if (this.data.unverifiable || !this.dobOnPan) {
|
||||
return false;
|
||||
}
|
||||
// Compare on the rendered day/month/year so a time component on either value cannot matter.
|
||||
return this.formatDob(this.dobOnPan) !== this.normalizeDisplayDob(this.data.submittedDob);
|
||||
}
|
||||
|
||||
/** The submitted DOB arrives pre-formatted for display; reduce it to dd/MM/yyyy for comparison. */
|
||||
private normalizeDisplayDob(display: string): string {
|
||||
const parsed = new Date(display);
|
||||
return isNaN(parsed.getTime()) ? (display || '').trim() : this.formatDob(parsed);
|
||||
}
|
||||
|
||||
/** The fields the declaration will state as differing. */
|
||||
get mismatchFields(): PanMismatchField[] {
|
||||
const fields: PanMismatchField[] = [];
|
||||
if (this.nameMismatch) {
|
||||
fields.push('NAME');
|
||||
}
|
||||
if (this.dobMismatch) {
|
||||
fields.push('DOB');
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
get mismatchLabel(): string {
|
||||
if (this.data.unverifiable) {
|
||||
return 'PAN could not be verified';
|
||||
}
|
||||
const fields = this.mismatchFields;
|
||||
if (!fields.length) {
|
||||
return this.compared ? 'No difference found' : 'Not yet compared';
|
||||
}
|
||||
return fields.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')).join(' and ');
|
||||
}
|
||||
|
||||
/** Both PAN-card values must be keyed in before we can honestly say what differs. */
|
||||
get comparisonComplete(): boolean {
|
||||
return !!this.nameOnPan.trim() && !!this.dobOnPan;
|
||||
}
|
||||
|
||||
onCompare(): void {
|
||||
this.compared = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration must actually declare something. When the officer keys in PAN-card values that
|
||||
* match what was submitted, the mismatch is somewhere else — nothing to sign off here, and the
|
||||
* right move is to retry the verification rather than paper over it.
|
||||
*/
|
||||
get nothingToDeclare(): boolean {
|
||||
return !this.data.unverifiable && this.comparisonComplete && this.mismatchFields.length === 0;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
// The signed scan is the declaration — without it there is nothing to waive the exception on.
|
||||
if (!this.hasDeclarationFile) {
|
||||
return false;
|
||||
}
|
||||
if (this.data.unverifiable) {
|
||||
return true;
|
||||
}
|
||||
return this.comparisonComplete && this.mismatchFields.length > 0;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
const result: PanDeclarationResult = {
|
||||
mismatchFields: this.data.unverifiable ? 'NONE' : this.mismatchFields.join(','),
|
||||
nameOnPan: this.data.unverifiable ? null : this.nameOnPan.trim(),
|
||||
dobOnPan: this.data.unverifiable ? null : this.dobOnPan,
|
||||
file: this.declarationFile,
|
||||
fileName: this.fileName
|
||||
};
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -12,4 +12,10 @@
|
||||
export interface Alert {
|
||||
type: string;
|
||||
message: string;
|
||||
/**
|
||||
* Errors are shown as a centred modal that has to be dismissed, because a corner toast that
|
||||
* disappears on its own is too easy to miss for something the user has to act on. Anything
|
||||
* without this keeps the unobtrusive snack bar.
|
||||
*/
|
||||
severity?: 'error';
|
||||
}
|
||||
|
||||
@@ -440,6 +440,18 @@ export class AuthenticationService {
|
||||
return roleNames.some((n) => n === 'cashier');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the logged-in user belongs to the Head Office.
|
||||
* Fineract seeds Head Office as officeId 1 with hierarchy "."; office name is
|
||||
* checked too since some deployments rename/reseed the root office.
|
||||
*/
|
||||
isHeadOfficeUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
if (!creds) return false;
|
||||
if (creds.officeId === 1) return true;
|
||||
return (creds.officeName || '').toLowerCase().includes('head office');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user credentials.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 { AuthenticationService } from './authentication/authentication.service';
|
||||
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
|
||||
|
||||
/**
|
||||
* Resolves what branch data the logged-in user is allowed to see.
|
||||
*
|
||||
* Head Office sees every branch and can drill into any one of them; a branch login is
|
||||
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
|
||||
* Summary and the NPA report with slightly different branch code lists; this service is
|
||||
* the single place it lives so new reports cannot drift.
|
||||
*
|
||||
* The logged-in office is matched to a branch by name, since session credentials carry
|
||||
* only officeId and officeName. Office names are seeded to match the GL branch names
|
||||
* exactly (see Liquibase 0442).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BranchScopeService {
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
/** Sentinel used when a branch login cannot be matched to a known branch. */
|
||||
static readonly UNRESOLVED = '__unresolved__';
|
||||
|
||||
get isHeadOffice(): boolean {
|
||||
return this.auth.isHeadOfficeUser();
|
||||
}
|
||||
|
||||
/** The office name on the session, for labelling the scope in report headers. */
|
||||
get officeName(): string {
|
||||
return this.auth.getCredentials()?.officeName || 'Branch Office';
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch code the user is confined to, or null for Head Office.
|
||||
*
|
||||
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
|
||||
* branch when the office cannot be matched: showing an unrecognised branch someone
|
||||
* else's figures would be worse than showing them nothing.
|
||||
*/
|
||||
get ownBranchCode(): string | null {
|
||||
if (this.isHeadOffice) {
|
||||
return null;
|
||||
}
|
||||
const name = this.officeName.toLowerCase().trim();
|
||||
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
|
||||
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
|
||||
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
|
||||
return loose?.code ?? BranchScopeService.UNRESOLVED;
|
||||
}
|
||||
|
||||
/** True when the user may choose which branch to view. */
|
||||
get canSelectBranch(): boolean {
|
||||
return this.isHeadOffice;
|
||||
}
|
||||
|
||||
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
|
||||
get selectableBranches(): GlBranch[] {
|
||||
return this.isHeadOffice ? GL_BRANCHES : [];
|
||||
}
|
||||
|
||||
/** Branch code a report should start on. '' means all branches consolidated. */
|
||||
get defaultBranchCode(): string {
|
||||
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a requested branch code into what the user is actually allowed to see.
|
||||
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
|
||||
* selection cannot widen the user's scope.
|
||||
*/
|
||||
enforce(requested: string): string {
|
||||
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
|
||||
}
|
||||
|
||||
/** True when a branch login's office matched no known branch, so it sees no data. */
|
||||
get isUnresolved(): boolean {
|
||||
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
|
||||
}
|
||||
|
||||
/** Human label for the current scope, for report headers. */
|
||||
scopeLabel(selectedCode: string): string {
|
||||
if (!this.isHeadOffice) {
|
||||
return this.officeName;
|
||||
}
|
||||
if (!selectedCode) {
|
||||
return 'All Branches (Consolidated)';
|
||||
}
|
||||
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
|
||||
}
|
||||
}
|
||||
@@ -88,42 +88,38 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Resolved once and emitted in one place below, so every failure here is consistently tagged
|
||||
// as an error and therefore surfaces as a centred modal rather than a corner toast.
|
||||
let alert: { type: string; message: string } | null = null;
|
||||
if (status === 401 || (environment.oauth.enabled && status === 400)) {
|
||||
this.alertService.alert({ type: 'Authentication Error', message: 'Invalid User Details. Please try again!' });
|
||||
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
|
||||
} else if (status === 403 && errorMessage === 'The provided one time token is invalid') {
|
||||
this.alertService.alert({ type: 'Invalid Token', message: 'Invalid Token. Please try again!' });
|
||||
alert = { type: 'Invalid Token', message: 'Invalid Token. Please try again!' };
|
||||
} else if (status === 400) {
|
||||
this.alertService.alert({
|
||||
type: 'Bad Request',
|
||||
message: errorMessage || 'Invalid parameters were passed in the request!'
|
||||
});
|
||||
alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' };
|
||||
} else if (status === 403) {
|
||||
this.alertService.alert({
|
||||
type: 'Unauthorized Request',
|
||||
message: errorMessage || 'You are not authorized for this request!'
|
||||
});
|
||||
alert = { type: 'Unauthorized Request', message: errorMessage || 'You are not authorized for this request!' };
|
||||
} else if (status === 404) {
|
||||
{
|
||||
this.alertService.alert({
|
||||
type: this.translate.instant('error.resource.not.found'),
|
||||
message: errorMessage || 'Resource does not exist!'
|
||||
});
|
||||
}
|
||||
alert = {
|
||||
type: this.translate.instant('error.resource.not.found'),
|
||||
message: errorMessage || 'Resource does not exist!'
|
||||
};
|
||||
} else if (status === 500) {
|
||||
this.alertService.alert({
|
||||
type: 'Internal Server Error',
|
||||
message: 'Internal Server Error. Please try again later.'
|
||||
});
|
||||
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
|
||||
} else if (status === 501) {
|
||||
this.alertService.alert({
|
||||
alert = {
|
||||
type: this.translate.instant('error.resource.notImplemented.type'),
|
||||
message: this.translate.instant('error.resource.notImplemented.message')
|
||||
});
|
||||
};
|
||||
} else if (status === 503) {
|
||||
this.alertService.alert({
|
||||
alert = {
|
||||
type: 'Service Unavailable',
|
||||
message: errorMessage || 'The server is currently unable to handle the request. Please try after some time.'
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (alert) {
|
||||
this.alertService.alert({ ...alert, severity: 'error' });
|
||||
}
|
||||
|
||||
throw response;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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, OnDestroy } from '@angular/core';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { fromEvent, Subscription, BehaviorSubject } from 'rxjs';
|
||||
import { KeyboardShortcutsConfiguration } from '../../keyboards-shortcut-config';
|
||||
import { AuthenticationService } from '../authentication/authentication.service';
|
||||
import { UserPreferenceService } from './user-preference.service';
|
||||
import { DocumentationLinksService } from '../../shared/services/documentation-links.service';
|
||||
|
||||
export interface ShortcutItem {
|
||||
title: string;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
key: string;
|
||||
route: string;
|
||||
id?: string;
|
||||
custom?: boolean;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class KeyboardShortcutService implements OnDestroy {
|
||||
private router = inject(Router);
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private userPreferenceService = inject(UserPreferenceService);
|
||||
private documentationLinks = inject(DocumentationLinksService);
|
||||
|
||||
private keydownSubscription: Subscription;
|
||||
private activeShortcutsSubject = new BehaviorSubject<ShortcutItem[]>([]);
|
||||
public activeShortcuts$ = this.activeShortcutsSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.keydownSubscription = fromEvent<KeyboardEvent>(window, 'keydown').subscribe((event) => {
|
||||
this.handleKeydown(event);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.keydownSubscription) {
|
||||
this.keydownSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
public initShortcuts(): void {
|
||||
const credentials = this.authenticationService.getCredentials();
|
||||
if (!credentials || !credentials.userId) {
|
||||
this.loadDefaults();
|
||||
return;
|
||||
}
|
||||
|
||||
this.userPreferenceService.getPreference(credentials.userId, 'keyboard_shortcuts').subscribe({
|
||||
next: (data) => {
|
||||
if (data && data.value) {
|
||||
try {
|
||||
const parsed = JSON.parse(data.value);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach((shortcut: ShortcutItem) => {
|
||||
if (shortcut.custom && shortcut.action === 'navigate' && shortcut.route) {
|
||||
shortcut.route = this.normalizeRouteStr(shortcut.route);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.activeShortcutsSubject.next(parsed);
|
||||
} catch (e) {
|
||||
console.error('Error parsing shortcuts', e);
|
||||
this.loadDefaults();
|
||||
}
|
||||
} else {
|
||||
this.loadDefaults();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.loadDefaults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public loadDefaults(): void {
|
||||
const config = new KeyboardShortcutsConfiguration();
|
||||
this.activeShortcutsSubject.next(config.buttonsArray);
|
||||
}
|
||||
|
||||
public updateShortcuts(shortcuts: ShortcutItem[]): void {
|
||||
const credentials = this.authenticationService.getCredentials();
|
||||
if (credentials && credentials.userId) {
|
||||
const cleanedShortcuts = shortcuts.map((s) => {
|
||||
const clean: any = {
|
||||
title: s.title,
|
||||
ctrlKey: s.ctrlKey,
|
||||
shiftKey: s.shiftKey,
|
||||
altKey: s.altKey,
|
||||
key: s.key,
|
||||
route: s.route
|
||||
};
|
||||
if (s.id !== undefined) clean.id = s.id;
|
||||
if (s.custom !== undefined) clean.custom = s.custom;
|
||||
if (s.action !== undefined) clean.action = s.action;
|
||||
return clean;
|
||||
});
|
||||
|
||||
this.userPreferenceService
|
||||
.updatePreference(credentials.userId, 'keyboard_shortcuts', JSON.stringify(cleanedShortcuts))
|
||||
.subscribe(() => {
|
||||
this.activeShortcutsSubject.next(shortcuts);
|
||||
});
|
||||
} else {
|
||||
this.activeShortcutsSubject.next(shortcuts);
|
||||
}
|
||||
}
|
||||
|
||||
public resetToDefaults(): void {
|
||||
const credentials = this.authenticationService.getCredentials();
|
||||
const config = new KeyboardShortcutsConfiguration();
|
||||
if (credentials && credentials.userId) {
|
||||
this.userPreferenceService
|
||||
.updatePreference(credentials.userId, 'keyboard_shortcuts', JSON.stringify(config.buttonsArray))
|
||||
.subscribe(() => {
|
||||
this.activeShortcutsSubject.next(config.buttonsArray);
|
||||
});
|
||||
} else {
|
||||
this.activeShortcutsSubject.next(config.buttonsArray);
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent): void {
|
||||
const activeShortcuts = this.activeShortcutsSubject.getValue();
|
||||
const routeD = activeShortcuts.find(
|
||||
(x) =>
|
||||
x.ctrlKey === event.ctrlKey &&
|
||||
x.shiftKey === event.shiftKey &&
|
||||
x.altKey === event.altKey &&
|
||||
x.key.toLowerCase() === event.key.toLowerCase()
|
||||
);
|
||||
|
||||
if (routeD !== undefined) {
|
||||
event.preventDefault(); // Prevent default browser actions for matched shortcuts
|
||||
|
||||
if (routeD.custom) {
|
||||
if (routeD.action === 'navigate' && routeD.route) {
|
||||
this.router.navigate([routeD.route]);
|
||||
}
|
||||
} else {
|
||||
switch (routeD.id) {
|
||||
case 'logout':
|
||||
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
|
||||
break;
|
||||
case 'help':
|
||||
this.documentationLinks.open('userManual');
|
||||
break;
|
||||
case 'runReport':
|
||||
const runReportElement = document.getElementById('runReport');
|
||||
if (runReportElement) runReportElement.click();
|
||||
break;
|
||||
case 'cancel':
|
||||
const cancelEl = document.querySelector('[data-shortcut-action="cancel"]') as HTMLElement;
|
||||
if (cancelEl) {
|
||||
cancelEl.click();
|
||||
} else {
|
||||
const cancelButtons = Array.from(document.querySelectorAll('button')).filter(
|
||||
(el) => el.textContent && el.textContent.trim() === 'Cancel'
|
||||
);
|
||||
if (cancelButtons.length > 0) cancelButtons[0].click();
|
||||
}
|
||||
break;
|
||||
case 'submit':
|
||||
const submitEl = document.querySelector('[data-shortcut-action="submit"]') as HTMLElement;
|
||||
if (submitEl) {
|
||||
submitEl.click();
|
||||
} else {
|
||||
const submitButtons = Array.from(document.querySelectorAll('button')).filter(
|
||||
(el) => el.textContent && el.textContent.trim() === 'Submit'
|
||||
);
|
||||
if (submitButtons.length > 0) submitButtons[0].click();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.router.navigate([routeD.route], { relativeTo: this.activatedRoute });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public normalizeRouteStr(route: string | undefined | null): string {
|
||||
if (!route) return '';
|
||||
let r = route.trim();
|
||||
|
||||
try {
|
||||
if (r.startsWith('http://') || r.startsWith('https://')) {
|
||||
const url = new URL(r);
|
||||
if (url.hash && url.hash.startsWith('#')) {
|
||||
let path = url.hash.substring(1);
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
if (r.includes('#/')) {
|
||||
r = r.substring(r.indexOf('#/') + 1);
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z0-9]+:/.test(r)) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (!r.startsWith('/')) {
|
||||
r = '/' + r;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user