Compare commits
111
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 | ||
|
|
8c7d8287f9 | ||
|
|
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 | ||
|
|
a560cd6533 | ||
|
|
1dc378c2dd | ||
|
|
0bf337b962 | ||
|
|
1daa12a117 | ||
|
|
f687e1808d | ||
|
|
186a9057a1 | ||
|
|
387bd060e1 | ||
|
|
29f1cdc646 | ||
|
|
1efb520009 | ||
|
|
4ea833ff49 | ||
|
|
5dc284bd1c | ||
|
|
56f13ade67 | ||
|
|
dbd160e7e7 | ||
|
|
468eaa96ab | ||
|
|
f682cb17f6 | ||
|
|
79fedb66e5 | ||
|
|
c1fc95d9b7 | ||
|
|
b422ae6600 | ||
|
|
5fd7e1890e | ||
|
|
0389b1f7c3 | ||
|
|
2e4187c3d2 | ||
|
|
cb4260b7a3 | ||
|
|
349dc91951 | ||
|
|
a9c60083bf | ||
|
|
0c99dc3686 | ||
|
|
733d9d22b2 | ||
|
|
d334dd1669 | ||
|
|
d0536dbb11 | ||
|
|
97d0545f07 | ||
|
|
ca85bae4fb | ||
|
|
a22f8e7e45 | ||
|
|
0207cf85a3 | ||
|
|
3cf01e2733 | ||
|
|
6d205c0ed2 | ||
|
|
1692925d0d |
@@ -1 +0,0 @@
|
||||
{"sessionId":"67d120b4-ff78-435d-be80-f79482a386b4","pid":19832,"acquiredAt":1778067581856}
|
||||
+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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 688 KiB |
@@ -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({
|
||||
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* AML transaction-monitoring API: live alerts raised by the rule engine, the
|
||||
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
|
||||
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
|
||||
* client's risk-category change history.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AmlService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ═══ Alerts (AML Officer) ═══
|
||||
|
||||
getAlerts(status?: string): Observable<any[]> {
|
||||
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||
return this.http.get<any[]>('/aml/alerts', { params });
|
||||
}
|
||||
|
||||
/** disposition: 'WHITELIST' | 'ESCALATE' */
|
||||
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||
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[]> {
|
||||
return this.http.get<any[]>('/aml/str-cases');
|
||||
}
|
||||
|
||||
getStrCase(strCaseId: number): Observable<any> {
|
||||
return this.http.get(`/aml/str-cases/${strCaseId}`);
|
||||
}
|
||||
|
||||
getWeeklyDigest(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
|
||||
}
|
||||
|
||||
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
|
||||
}
|
||||
|
||||
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
|
||||
concludeStrCase(
|
||||
strCaseId: number,
|
||||
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
|
||||
investigationNotes: string,
|
||||
sourceOfFundsNotes: string
|
||||
): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
|
||||
conclusion,
|
||||
investigationNotes,
|
||||
sourceOfFundsNotes
|
||||
});
|
||||
}
|
||||
|
||||
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
|
||||
}
|
||||
|
||||
// ═══ CTR reports (mechanical, threshold-based) ═══
|
||||
|
||||
getCtrReports(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/ctr-reports');
|
||||
}
|
||||
|
||||
/** month format: yyyy-MM */
|
||||
generateCtrReport(month: string): Observable<any> {
|
||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||
}
|
||||
|
||||
// ═══ Client risk-category history ═══
|
||||
|
||||
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import { GroupsModule } from './groups/groups.module';
|
||||
import { CentersModule } from './centers/centers.module';
|
||||
import { AccountingModule } from './accounting/accounting.module';
|
||||
import { ChequeBookModule } from './chequebook/chequebook.module';
|
||||
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
|
||||
import { NachClearingModule } from './nachclearing/nachclearing.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { OrganizationModule } from './organization/organization.module';
|
||||
@@ -62,11 +64,18 @@ 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';
|
||||
import { ElectionModule } from './election/election.module';
|
||||
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
||||
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
||||
import { PortalModule } from '@angular/cdk/portal';
|
||||
|
||||
/** Main Routing Module */
|
||||
@@ -144,6 +153,8 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
CentersModule,
|
||||
AccountingModule,
|
||||
ChequeBookModule,
|
||||
CtsClearingModule,
|
||||
NachClearingModule,
|
||||
SystemModule,
|
||||
ProductsModule,
|
||||
OrganizationModule,
|
||||
@@ -165,11 +176,18 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
DeafModule,
|
||||
DicgcModule,
|
||||
FiuModule,
|
||||
GlReportsModule,
|
||||
SftModule,
|
||||
InvestmentModule,
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
ElectionModule,
|
||||
SponsorDDModule,
|
||||
SponsorDDReconciliationModule,
|
||||
AppRoutingModule,
|
||||
NotFoundComponent,
|
||||
CallbackComponent,
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -218,6 +218,26 @@
|
||||
<span class="mdr-detail__val cell-mono">{{ viewRequest.txn_id }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (viewRequest.dd_face_value != null) {
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">DD Face Value</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_face_value | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">Charges</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_charges | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">GST ({{ viewRequest.dd_gst_percentage }}%)</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_gst_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">Total Collected</span>
|
||||
<span class="mdr-detail__val"
|
||||
><strong>₹{{ viewRequest.amount | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (viewRequest.description) {
|
||||
<div class="mdr-detail mdr-detail--wide">
|
||||
<span class="mdr-detail__lbl">Description</span>
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
<span class="ws-header__title">Transaction Entry</span>
|
||||
</div>
|
||||
<div class="ws-header__right">
|
||||
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search Customer
|
||||
</button>
|
||||
<span class="ws-header__effdt">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
@@ -139,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>
|
||||
@@ -224,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>
|
||||
}
|
||||
@@ -235,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
|
||||
@@ -300,6 +338,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
GST ({{ ddGstPercentage }}%)
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -652,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">
|
||||
@@ -664,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
|
||||
>
|
||||
@@ -802,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"
|
||||
@@ -957,11 +1088,15 @@
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</svg>
|
||||
Denomination Entry
|
||||
@if (denominationTotal > 0) {
|
||||
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||
}
|
||||
@if (isCashPayment && denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge">⚠ Required for Cash</span>
|
||||
@if (isSelectedRowDdCashGl) {
|
||||
<span class="te-mandate-badge">Target ₹{{ ddTargetTotal | number: '1.2-2' }}</span>
|
||||
} @else {
|
||||
@if (denominationTotal > 0) {
|
||||
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||
}
|
||||
@if (isCashPayment && denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge">⚠ Required for Cash</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="te-panel__body">
|
||||
@@ -1011,7 +1146,26 @@
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@if (denominationTotal > 0) {
|
||||
@if (isSelectedRowDdCashGl) {
|
||||
<div class="te-denom-actions">
|
||||
@if (denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge"
|
||||
>⚠ Count notes/coins totalling ₹{{ ddTargetTotal | number: '1.2-2' }}</span
|
||||
>
|
||||
} @else if (ddDenomMatchesTarget()) {
|
||||
<span class="te-denom-match-badge"
|
||||
>✓ Matches Total To Collect ₹{{ ddTargetTotal | number: '1.2-2' }}</span
|
||||
>
|
||||
} @else {
|
||||
<span class="te-denom-required-badge">
|
||||
⚠ Must total ₹{{ ddTargetTotal | number: '1.2-2' }} (counted ₹{{
|
||||
denominationTotal | number: '1.2-2'
|
||||
}})
|
||||
</span>
|
||||
}
|
||||
<button class="te-btn" type="button" (click)="clearDenominations()">Clear</button>
|
||||
</div>
|
||||
} @else if (denominationTotal > 0) {
|
||||
<div class="te-denom-actions">
|
||||
<button class="te-btn te-btn--primary" type="button" (click)="applyDenominationTotal()">
|
||||
Apply ₹{{ denominationTotal | number: '1.2-2' }} to Amount
|
||||
@@ -1140,6 +1294,13 @@
|
||||
<span class="ws-header__title">Transaction Entry</span>
|
||||
</div>
|
||||
<div class="ws-header__right">
|
||||
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search Customer
|
||||
</button>
|
||||
<span class="ws-header__effdt">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
@@ -1191,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>
|
||||
@@ -1270,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>
|
||||
}
|
||||
@@ -1280,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
|
||||
@@ -1325,6 +1510,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
GST ({{ ddGstPercentage }}%)
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1539,8 +1776,26 @@
|
||||
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (confirmData.isDdCashGl) {
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">DD Face Value</span
|
||||
><span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.ddFaceValue | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">Charges</span
|
||||
><span class="te-confirm-val">{{ confirmData.symbol }}{{ confirmData.ddCharges | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">GST ({{ confirmData.ddGstPercentage }}%)</span
|
||||
><span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.ddGstAmount | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="te-confirm-row te-confirm-row--amount">
|
||||
<span class="te-confirm-label">Deposit Amount</span>
|
||||
<span class="te-confirm-label">{{ confirmData.isDdCashGl ? 'Total To Collect' : 'Deposit Amount' }}</span>
|
||||
<span class="te-confirm-val te-cr"
|
||||
>+{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -1677,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%;
|
||||
@@ -1928,6 +1936,19 @@ select.te-cell {
|
||||
animation: te-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Denomination matches DD target badge
|
||||
.te-denom-match-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
@keyframes te-pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -1982,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);
|
||||
|
||||
@@ -1997,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;
|
||||
@@ -2032,6 +2056,7 @@ select.te-cell {
|
||||
// ── Right content ─────────────────────────────────────────────────────────
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2041,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;
|
||||
@@ -2070,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;
|
||||
}
|
||||
@@ -2183,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,9 +20,16 @@ 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 {
|
||||
CustomerSearchDialogComponent,
|
||||
CustomerSearchResult
|
||||
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||
|
||||
interface TxnRow {
|
||||
typ: string;
|
||||
@@ -40,6 +47,12 @@ interface TxnRow {
|
||||
paymentTypeId: number | null;
|
||||
bankName: string;
|
||||
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 {
|
||||
@@ -387,7 +400,12 @@ export class JointHolderDialogComponent implements OnInit {
|
||||
this.kyc = kycArr[0] ?? null;
|
||||
this.photo = photo ?? null;
|
||||
const docs: any[] = Array.isArray(documents) ? documents : [];
|
||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
||||
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||
// the first unordered match.
|
||||
const sigDoc = docs
|
||||
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||
this.loading = false;
|
||||
if (sigDoc) {
|
||||
this.http
|
||||
@@ -732,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);
|
||||
@@ -740,10 +759,18 @@ 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);
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
|
||||
/** GST % on DD issuance charges — loaded from the admin GST/GL Settings (sponsor-dd module). */
|
||||
ddGstPercentage = 18;
|
||||
/** Admin-configured DD limit (sponsor-dd module) — caps a single DD's face value. Null = no limit. */
|
||||
ddLimitAmount: number | null = null;
|
||||
|
||||
today = '';
|
||||
rows: TxnRow[] = [];
|
||||
@@ -751,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;
|
||||
@@ -889,6 +948,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
this.loadCashVaultGl();
|
||||
this.loadDdGstPercentage();
|
||||
|
||||
if (this.reviewMode) {
|
||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||
@@ -973,6 +1033,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin GST rate (set via GST / GL Settings in the sponsor-dd module) applied to DD issuance charges. */
|
||||
private loadDdGstPercentage(): void {
|
||||
this.sponsorDDService
|
||||
.getGlMappings()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((mappings: any[]) => {
|
||||
const mapping = mappings && mappings.length ? mappings[0] : null;
|
||||
this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
|
||||
this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
|
||||
});
|
||||
}
|
||||
|
||||
private loadDayStatus(): void {
|
||||
this.cashierService
|
||||
.getCashierDayStatus()
|
||||
@@ -1139,7 +1211,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
applyDenominationTotal(): void {
|
||||
if (this.selectedRow) {
|
||||
// DD + Cash + GL: amount is derived from Face Value + Charges + GST, not from counted cash —
|
||||
// overwriting it here would feed back into the Charges/GST sum and compound on every click.
|
||||
if (this.selectedRow && !this.isDdCashGlRow(this.selectedRow)) {
|
||||
this.selectedRow.amount = this.denominationTotal;
|
||||
}
|
||||
}
|
||||
@@ -1167,10 +1241,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
tokNo: '',
|
||||
paymentTypeId: this.findTransferPaymentTypeId(),
|
||||
bankName: '',
|
||||
routingCode: ''
|
||||
routingCode: '',
|
||||
ddCharges: null
|
||||
};
|
||||
this.rows.push(mirror);
|
||||
this.selectedRow = mirror;
|
||||
if (active.acTyp === 'SHR') {
|
||||
this.syncShareLinkedAmounts();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const row: TxnRow = {
|
||||
@@ -1188,7 +1266,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
tokNo: '',
|
||||
paymentTypeId: null,
|
||||
bankName: '',
|
||||
routingCode: ''
|
||||
routingCode: '',
|
||||
ddCharges: null
|
||||
};
|
||||
this.rows.push(row);
|
||||
}
|
||||
@@ -1227,6 +1306,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return pt?.id ?? null;
|
||||
}
|
||||
|
||||
/** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
|
||||
isDdCashGlRow(row: TxnRow): boolean {
|
||||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
|
||||
}
|
||||
|
||||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||
ddGstAmountFor(row: TxnRow): number {
|
||||
const charges = Number(row.ddCharges) || 0;
|
||||
if (charges <= 0) return 0;
|
||||
return Math.round(charges * this.ddGstPercentage) / 100;
|
||||
}
|
||||
|
||||
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
|
||||
ddTotalPayableFor(row: TxnRow): number {
|
||||
const amount = Number(row.amount) || 0;
|
||||
const charges = Number(row.ddCharges) || 0;
|
||||
const gst = this.ddGstAmountFor(row);
|
||||
return Math.ceil(amount + charges + gst - 1e-9);
|
||||
}
|
||||
|
||||
get isSelectedRowDdCashGl(): boolean {
|
||||
return !!this.selectedRow && this.isDdCashGlRow(this.selectedRow);
|
||||
}
|
||||
|
||||
/** Cash the cashier must physically count for the selected DD row — Face Value + Charges + GST. */
|
||||
get ddTargetTotal(): number {
|
||||
return this.selectedRow ? this.ddTotalPayableFor(this.selectedRow) : 0;
|
||||
}
|
||||
|
||||
ddDenomMatchesTarget(): boolean {
|
||||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||||
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
||||
@@ -1430,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 ??
|
||||
@@ -1452,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';
|
||||
@@ -1466,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;
|
||||
@@ -1503,6 +1856,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly SEARCH_ACCOUNT_TYPE_TO_AC_TYP: Record<string, string> = {
|
||||
SAVINGS: 'SD',
|
||||
CURRENT: 'CA',
|
||||
FIXED_DEPOSIT: 'FD',
|
||||
RECURRING_DEPOSIT: 'RD'
|
||||
};
|
||||
|
||||
openCustomerSearch(): void {
|
||||
const dialogRef = this.dialog.open(CustomerSearchDialogComponent, {
|
||||
width: '960px',
|
||||
maxWidth: '95vw'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: CustomerSearchResult | undefined) => {
|
||||
if (!result || !result.accountNo) return;
|
||||
// selectedRow is only set once the clerk types into A/C NO. themselves - on a fresh screen
|
||||
// it's still null, so fall back to the first (only) row that addRow() created at startup.
|
||||
const targetRow = this.selectedRow ?? this.rows[0];
|
||||
if (!targetRow) return;
|
||||
|
||||
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[result.accountType || ''] || 'SD';
|
||||
targetRow.acTyp = acTyp;
|
||||
targetRow.acNo = result.accountNo;
|
||||
this.selectedRow = targetRow;
|
||||
this.loadAccountDetails(result.accountNo, acTyp);
|
||||
});
|
||||
}
|
||||
|
||||
private loadAccountDetails(acNo: string, acTyp: string): void {
|
||||
this.lastLoadedAcNo = acNo;
|
||||
this.loadingAccount = true;
|
||||
@@ -1536,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;
|
||||
@@ -1562,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;
|
||||
@@ -1596,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',
|
||||
@@ -1708,6 +2104,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: number;
|
||||
txnBase: string;
|
||||
txnSearchSupported: boolean;
|
||||
txnFetchUnsupported?: boolean;
|
||||
siFetchUnsupported?: boolean;
|
||||
detailParams?: string;
|
||||
},
|
||||
token: number,
|
||||
@@ -1740,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;
|
||||
};
|
||||
|
||||
@@ -1776,7 +2193,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
||||
this.clientPhotoUrl = r.photo ?? null;
|
||||
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
||||
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||
// the first unordered match.
|
||||
const sigDoc = docs
|
||||
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||
if (sigDoc) {
|
||||
this.http
|
||||
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||||
@@ -1811,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) {
|
||||
@@ -1987,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;
|
||||
}
|
||||
|
||||
@@ -2400,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',
|
||||
@@ -2532,20 +2980,55 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
const isCash = !!selectedPtEarly?.isCashPayment;
|
||||
const isDdCashGl = this.isDdCashGlRow(row);
|
||||
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
|
||||
const amount = isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal);
|
||||
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
|
||||
const amount = isDdCashGl
|
||||
? this.ddTotalPayableFor(row)
|
||||
: isCash && !this.clerkMode
|
||||
? this.denominationTotal
|
||||
: (row.amount ?? this.denominationTotal);
|
||||
|
||||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode) {
|
||||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) {
|
||||
this.showAlert('error', 'Denomination Required', [
|
||||
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// DD + Cash + GL: physical cash counted must reconcile with Face Value + Charges + GST
|
||||
if (isDdCashGl && !this.clerkMode) {
|
||||
if (this.denominationTotal <= 0) {
|
||||
this.showAlert('error', 'Denomination Required', [
|
||||
`Cash transaction: Please count notes/coins totalling ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (DD Face Value + Charges + GST).`
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (Math.abs(this.denominationTotal - amount) > 0.005) {
|
||||
this.showAlert('error', 'Denomination Mismatch', [
|
||||
`Cash counted: ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`Total To Collect: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (Face Value + Charges + GST)`,
|
||||
'Adjust the notes/coins breakdown to match the Total To Collect exactly.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
this.showAlert('error', 'Amount Required', ['Please enter a valid transaction amount.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// DD + Cash + GL: reject at cash intake, before the cashier even accepts the money, if the DD
|
||||
// Face Value exceeds the admin-configured DD limit (same limit enforced again at DD issuance).
|
||||
if (isDdCashGl && this.ddLimitAmount != null && this.ddLimitAmount > 0 && Number(row.amount) > this.ddLimitAmount) {
|
||||
this.showAlert('error', 'DD Limit Exceeded', [
|
||||
`DD Face Value (₹${Number(row.amount).toLocaleString('en-IN', { minimumFractionDigits: 2 })}) exceeds the configured DD limit of ₹${this.ddLimitAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
|
||||
'Reduce the amount, or issue this as separate DDs within the limit each.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
|
||||
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
|
||||
@@ -2568,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.',
|
||||
@@ -2577,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', [
|
||||
@@ -2672,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) {
|
||||
@@ -2817,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 &&
|
||||
@@ -2848,7 +3368,12 @@ 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,
|
||||
ddGstPercentage: this.ddGstPercentage,
|
||||
ddGstAmount: this.ddGstAmountFor(row)
|
||||
};
|
||||
this.showConfirmDialog = true;
|
||||
}
|
||||
@@ -2856,12 +3381,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
const row = this.selectedRow!;
|
||||
const amount = row.amount ?? this.denominationTotal;
|
||||
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
const command = isDeposit ? 'deposit' : 'withdrawal';
|
||||
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) {
|
||||
@@ -2887,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.'
|
||||
@@ -3261,7 +3847,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
description: row.description || null,
|
||||
chequeNo: row.chequeNo || null,
|
||||
bankName: row.bankName || null,
|
||||
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null
|
||||
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
|
||||
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
|
||||
// value/charges/GST back once this request is approved, instead of retyping them.
|
||||
...(this.isDdCashGlRow(row)
|
||||
? {
|
||||
ddFaceValue: row.amount ?? 0,
|
||||
ddCharges: row.ddCharges ?? 0,
|
||||
ddGstPercentage: this.ddGstPercentage,
|
||||
ddGstAmount: this.ddGstAmountFor(row)
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
@if (showReceiveForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<div class="global-allocation-note">
|
||||
{{ 'labels.text.Highest leaf serial allocated so far, across every series' | translate }}:
|
||||
<strong>{{ globalHighestAllocated }}</strong>
|
||||
— {{ 'labels.text.next available start' | translate }}:
|
||||
<strong>{{ globalHighestAllocated + 1 }}</strong>
|
||||
</div>
|
||||
<form [formGroup]="receiveForm" (ngSubmit)="submitReceive()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
@@ -82,7 +88,12 @@
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Range Start' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="rangeStart" required />
|
||||
<mat-hint>{{ 'labels.text.Auto-suggested to avoid overlap; editable' | translate }}</mat-hint>
|
||||
<mat-hint
|
||||
>{{ 'labels.text.Auto-suggested to avoid overlap across every series; editable' | translate }} ({{
|
||||
globalHighestAllocated
|
||||
}}
|
||||
+ 1)</mat-hint
|
||||
>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Range End' | translate }}</mat-label>
|
||||
|
||||
@@ -92,6 +92,19 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.global-allocation-note {
|
||||
margin-bottom: 14px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgb(25 118 210 / 8%);
|
||||
color: rgb(0 0 0 / 70%);
|
||||
font-size: 0.85rem;
|
||||
|
||||
strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
|
||||
@@ -281,17 +281,22 @@ export class StockManagementComponent implements OnInit {
|
||||
return start + leaves - 1;
|
||||
}
|
||||
|
||||
/** Highest leaf-serial allocated so far, across every series/office — the actual number the "Range Start"
|
||||
* suggestion is based on. Shown directly in the form (not just silently applied to the field) so the operator
|
||||
* can see and trust the reasoning instead of taking a pre-filled number on faith. */
|
||||
get globalHighestAllocated(): number {
|
||||
return this.allStockLots.reduce((m, l) => Math.max(m, l.rangeEnd || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Next unused serial WITHIN this series, so a fresh receipt continues that series' own numbering —
|
||||
* each series is numbered independently (a separate stationery order from the press), so a different
|
||||
* series' stock is not relevant here.
|
||||
* Next unused serial across EVERY series, not just the one being received — a real bank allocates leaf-serial
|
||||
* numbers from one bank-wide numbering authority, so two different cheque designs never legitimately reuse the
|
||||
* same range. Suggesting a start that only avoids the same series' own lots is exactly what let two series end
|
||||
* up overlapping (and, once both got issued to the same account, an unresolvable serial collision) before the
|
||||
* backend's own overlap check was widened to match (ChequeStockLotRepository.countOverlappingAnySeries).
|
||||
*/
|
||||
private nextFreeSerial(): number {
|
||||
const seriesId = this.receiveForm.get('seriesId')?.value;
|
||||
const maxEnd = this.allStockLots
|
||||
.filter((l) => l.seriesId === seriesId)
|
||||
.reduce((m, l) => Math.max(m, l.rangeEnd || 0), 0);
|
||||
return maxEnd + 1;
|
||||
return this.globalHighestAllocated + 1;
|
||||
}
|
||||
|
||||
onReceiveSeriesChange(): void {
|
||||
|
||||
@@ -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||||
|
||||
|
||||
@@ -446,8 +446,12 @@ export class RecordMasterComponent implements OnInit {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Signature — client document named 'clientSignature'
|
||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
||||
// 2. Signature — client document named 'clientSignature'. Multiple rows can exist for
|
||||
// clients who re-cropped/replaced their signature before uploads became single-slot, so
|
||||
// pick the highest id (latest) rather than the first unordered match.
|
||||
const signatureDoc = (documents as any[])
|
||||
?.filter((d: any) => d?.name === 'clientSignature')
|
||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||
if (signatureDoc) {
|
||||
this.viewDetails.docs.signature = {
|
||||
present: true,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maps a clearing status/decision string to one of the three shared .pill classes
|
||||
* (ok / bad / neutral). Shared by every CTS, NACH and settlement/reconciliation screen so the
|
||||
* colour language stays consistent across the whole clearing feature.
|
||||
*/
|
||||
export function pillClass(status: string | null | undefined): 'ok' | 'bad' | 'neutral' {
|
||||
const s = (status || '').toUpperCase();
|
||||
const ok = [
|
||||
'PASSED',
|
||||
'PASS',
|
||||
'POSTED',
|
||||
'MATCHED',
|
||||
'SETTLED',
|
||||
'SENT',
|
||||
'SENT_TO_CLEARING_HOUSE',
|
||||
'PROCESSED',
|
||||
'VALIDATED',
|
||||
'ACTIVE',
|
||||
'CLEARED',
|
||||
'HONOURED',
|
||||
'COMPLETED'
|
||||
];
|
||||
const bad = [
|
||||
'RETURNED',
|
||||
'RETURN',
|
||||
'REJECTED',
|
||||
'MISMATCH',
|
||||
'DISHONOURED',
|
||||
'CANCELLED',
|
||||
'FAILED',
|
||||
'FAIL',
|
||||
'UNROUTABLE'
|
||||
];
|
||||
if (ok.includes(s)) {
|
||||
return 'ok';
|
||||
}
|
||||
if (bad.includes(s)) {
|
||||
return 'bad';
|
||||
}
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/** Extracts the most specific backend error message from a Fineract-style error response. */
|
||||
export function clearingError(err: any, fallback = 'Request failed'): string {
|
||||
return err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||
}
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Shared clearing services used across CTS and NACH — return-reason-code master, settlement-batch posting,
|
||||
* and reconciliation.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ClearingService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ----- Return reason codes -----
|
||||
|
||||
/** appliesTo: 'CTS' | 'NACH'. Returns a raw array of reason codes. */
|
||||
getReturnReasonCodes(appliesTo: string): Observable<any> {
|
||||
return this.http.get('/clearing/return-reason-codes', {
|
||||
params: new HttpParams().set('appliesTo', appliesTo)
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Settlement batches -----
|
||||
|
||||
/** Server-side paginated — returns { pageItems, totalFilteredRecords }. */
|
||||
getSettlementBatches(clearingType?: string, status?: string, offset = 0, limit = 20): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (clearingType) params = params.set('clearingType', clearingType);
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get('/clearing/settlement-batches', { params });
|
||||
}
|
||||
|
||||
getSettlementBatch(id: string | number): Observable<any> {
|
||||
return this.http.get(`/clearing/settlement-batches/${id}`);
|
||||
}
|
||||
|
||||
/** payload: { clearingType, sourceBatchId }. */
|
||||
prepareSettlement(payload: any): Observable<any> {
|
||||
return this.http.post('/clearing/settlement-batches', payload);
|
||||
}
|
||||
|
||||
postSettlement(id: string | number): Observable<any> {
|
||||
return this.http.post(
|
||||
`/clearing/settlement-batches/${id}`,
|
||||
{},
|
||||
{ params: new HttpParams().set('command', 'post') }
|
||||
);
|
||||
}
|
||||
|
||||
rejectSettlement(id: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/clearing/settlement-batches/${id}`, payload, {
|
||||
params: new HttpParams().set('command', 'reject')
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Reconciliations -----
|
||||
|
||||
getReconciliations(clearingType?: string, offset = 0, limit = 20): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (clearingType) params = params.set('clearingType', clearingType);
|
||||
return this.http.get('/clearing/reconciliations', { params });
|
||||
}
|
||||
|
||||
runReconciliation(payload: any): Observable<any> {
|
||||
return this.http.post('/clearing/reconciliations', payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
export interface JointHolder {
|
||||
clientId: number;
|
||||
cifNumber?: string;
|
||||
displayName: string;
|
||||
sharePercentage?: number;
|
||||
signingAuthority?: string;
|
||||
role?: string;
|
||||
isPrimary?: boolean;
|
||||
isMandatory?: boolean;
|
||||
}
|
||||
|
||||
export interface AccountSignatoryInfo {
|
||||
modeOfOperation: string | null;
|
||||
primaryClientId: number | null;
|
||||
primaryName: string | null;
|
||||
jointHolders: JointHolder[];
|
||||
availableBalance: number | null;
|
||||
onHoldAmount: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches account-holder/signatory data needed to verify a cheque's signature — reusing the exact APIs and
|
||||
* conventions already established in the cashier module's joint-holder detail view
|
||||
* (`transaction-entry.component.ts`), so this isn't a second, divergent implementation of the same lookup.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SignatoryService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
/**
|
||||
* Fetches the savings or current account (with joint holders and mode of operation embedded via
|
||||
* `associations=all` — the same Fineract convention the cashier module already relies on).
|
||||
*/
|
||||
fetchAccountSignatories(accountType: string, accountId: number | string): Observable<AccountSignatoryInfo> {
|
||||
const path =
|
||||
accountType?.toUpperCase() === 'CURRENT' ? `/currentaccounts/${accountId}` : `/savingsaccounts/${accountId}`;
|
||||
return this.http.get<any>(path, { params: new HttpParams().set('associations', 'all') }).pipe(
|
||||
map((acc: any) => ({
|
||||
modeOfOperation: acc?.modeOfOperation ?? null,
|
||||
primaryClientId: acc?.clientId ?? null,
|
||||
primaryName: acc?.clientName ?? acc?.name ?? null,
|
||||
jointHolders: Array.isArray(acc?.jointHolders) ? acc.jointHolders : [],
|
||||
availableBalance: acc?.summary?.availableBalance ?? null,
|
||||
onHoldAmount: acc?.summary?.totalSavingsAmountOnHold ?? acc?.onHoldFundsDerived ?? null
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's stored signature (a document tagged name === 'clientSignature'), as a blob object URL, or null if none uploaded. */
|
||||
fetchClientSignature(clientId: number | string): Observable<string | null> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
|
||||
switchMap((docs: any[]) => {
|
||||
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
|
||||
// their signature before uploads became single-slot, so pick the highest id (latest)
|
||||
// rather than the first unordered match.
|
||||
const sigDoc = (docs || [])
|
||||
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||
if (!sigDoc) {
|
||||
return of(null);
|
||||
}
|
||||
return this.http.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' }).pipe(
|
||||
map((blob: Blob) => URL.createObjectURL(blob)),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's photo (already base64/text from this API), or null if none uploaded. */
|
||||
fetchClientPhoto(clientId: number | string): Observable<string | null> {
|
||||
return this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+16
@@ -62,6 +62,22 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Mother requirement — shown independently of the Father/Caretaker check below -->
|
||||
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasMother) {
|
||||
<div class="fm-step__empty fm-step__empty--required">
|
||||
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||
<span class="fm-step__empty-required">Mother is required.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Father-or-Caretaker requirement — shown independently of the Mother check above -->
|
||||
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasFather && !hasCaretaker) {
|
||||
<div class="fm-step__empty fm-step__empty--required">
|
||||
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||
<span class="fm-step__empty-required">Either Father or a Caretaker is required.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Spouse hint for married clients -->
|
||||
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
||||
<div class="fm-spouse-hint">
|
||||
|
||||
+44
@@ -12,6 +12,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { FamilyMemberAddedDialogComponent } from './family-member-added-dialog.component';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.member) {
|
||||
this.clientFamilyMembers.push(response.member);
|
||||
this.showFamilyMemberAddedConfirmation(response.member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
|
||||
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
|
||||
private showFamilyMemberAddedConfirmation(member: any): void {
|
||||
this.dialog.open(FamilyMemberAddedDialogComponent, {
|
||||
width: '360px',
|
||||
data: {
|
||||
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
|
||||
name: member._linkedClient?.displayName || member.firstName,
|
||||
relationship: this.getRelName(member.relationshipId),
|
||||
dateOfBirth: member.dateOfBirth,
|
||||
isDependent: member.isDependent
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -223,6 +240,33 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
return this.clientFamilyMembers.length > 0;
|
||||
}
|
||||
|
||||
/** True if a family member with the given relationship (case-insensitive) has been added */
|
||||
private hasRelationship(name: string): boolean {
|
||||
const target = name.toLowerCase();
|
||||
return this.clientFamilyMembers.some((m: any) => {
|
||||
const resolved = this.getRelName(m.relationshipId ?? m.relationship).toLowerCase();
|
||||
if (resolved === target) return true;
|
||||
return typeof m.relationship === 'string' && m.relationship.toLowerCase() === target;
|
||||
});
|
||||
}
|
||||
|
||||
get hasMother(): boolean {
|
||||
return this.hasRelationship('mother');
|
||||
}
|
||||
|
||||
get hasFather(): boolean {
|
||||
return this.hasRelationship('father');
|
||||
}
|
||||
|
||||
get hasCaretaker(): boolean {
|
||||
return this.hasRelationship('caretaker');
|
||||
}
|
||||
|
||||
/** Mother is always required; Father is required unless a Caretaker has been added instead. */
|
||||
get familyRelationshipRulesMet(): boolean {
|
||||
return this.hasMother && (this.hasFather || this.hasCaretaker);
|
||||
}
|
||||
|
||||
/** Check if client's marital status is "Married" */
|
||||
get isMarried(): boolean {
|
||||
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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 { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
|
||||
/**
|
||||
* Confirmation popup shown right after a family member is added — surfaces the linked
|
||||
* client's own CBS Client ID (when the member was linked to an existing client rather than
|
||||
* entered manually) alongside name/relationship, since the "Add Member" dialog itself just
|
||||
* closed silently before this with no acknowledgement of what got recorded.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-family-member-added-dialog',
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogActions,
|
||||
MatIcon,
|
||||
MatButton
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title class="fma-title">
|
||||
<mat-icon class="fma-title__icon">check_circle</mat-icon>
|
||||
Family Member Added
|
||||
</h2>
|
||||
<div class="fma-body">
|
||||
@if (data.clientId) {
|
||||
<div class="fma-row fma-row--id">
|
||||
<span class="fma-row__label">Client ID</span>
|
||||
<span class="fma-row__value fma-row__value--id">{{ data.clientId }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="fma-row">
|
||||
<span class="fma-row__label">Name</span>
|
||||
<span class="fma-row__value">{{ data.name }}</span>
|
||||
</div>
|
||||
@if (data.relationship) {
|
||||
<div class="fma-row">
|
||||
<span class="fma-row__label">Relationship</span>
|
||||
<span class="fma-row__value">{{ data.relationship }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.dateOfBirth) {
|
||||
<div class="fma-row">
|
||||
<span class="fma-row__label">Date of Birth</span>
|
||||
<span class="fma-row__value">{{ data.dateOfBirth }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.isDependent) {
|
||||
<div class="fma-row">
|
||||
<span class="fma-row__label">Dependent</span>
|
||||
<span class="fma-row__value">Yes</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-flat-button color="primary" (click)="dialogRef.close()">Done</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.fma-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
padding: 20px 24px 4px;
|
||||
}
|
||||
|
||||
.fma-title__icon {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.fma-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 24px 8px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.fma-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fma-row__label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fma-row__value {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fma-row--id {
|
||||
background: #eff6ff;
|
||||
margin: 0 -24px;
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.fma-row__value--id {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
color: #1353a0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 8px 24px 16px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class FamilyMemberAddedDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<FamilyMemberAddedDialogComponent>>(MatDialogRef);
|
||||
data: {
|
||||
clientId?: string | number | null;
|
||||
name: string;
|
||||
relationship?: string;
|
||||
dateOfBirth?: string;
|
||||
isDependent?: boolean;
|
||||
} = inject(MAT_DIALOG_DATA);
|
||||
}
|
||||
+345
-86
@@ -136,7 +136,14 @@
|
||||
| translate
|
||||
}}
|
||||
</mat-label>
|
||||
<input matInput required formControlName="fullname" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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
|
||||
>
|
||||
@@ -358,11 +387,14 @@
|
||||
@if (isMinor) {
|
||||
<span class="minor-badge">Minor</span>
|
||||
}
|
||||
@if (isSeniorCitizen) {
|
||||
<span class="senior-citizen-badge">Senior Citizen</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||
<div class="guardian-section">
|
||||
<!-- Header bar -->
|
||||
@@ -691,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">
|
||||
@@ -819,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 }}
|
||||
@@ -995,6 +1031,32 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- CLIENT CREATION contact-OTP verification — create mode only. Edit mode already has
|
||||
its own dual old/new OTP panel in EditClientComponent's template; this single
|
||||
"new value" block only makes sense for a brand-new client (no "old" destination to
|
||||
verify against). Each block only appears once its own field passes validation, so
|
||||
staff enter a plausible-looking number/email before being asked to verify it. -->
|
||||
@if (!clientData) {
|
||||
<div class="contact-otp-row">
|
||||
@if (createClientForm.get('mobileNo')?.valid) {
|
||||
<mifosx-client-contact-otp-block
|
||||
channel="MOBILE"
|
||||
[destination]="createClientForm.get('mobileNo')?.value"
|
||||
label="Verify Mobile Number"
|
||||
(stateChange)="onMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
}
|
||||
@if (createClientForm.get('emailAddress')?.valid) {
|
||||
<mifosx-client-contact-otp-block
|
||||
channel="EMAIL"
|
||||
[destination]="createClientForm.get('emailAddress')?.value"
|
||||
label="Verify Email Address"
|
||||
(stateChange)="onEmailOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1006,69 +1068,67 @@
|
||||
Education & Social Details
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<!-- Education Level (non-minor) -->
|
||||
@if (!isMinor) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Education Level -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Religion -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
@@ -1121,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">
|
||||
@@ -1191,21 +1324,25 @@
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Annual Turnover (Last FY)</mat-label>
|
||||
<mat-label>Annual Income</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
<input matInput type="number" formControlName="annualIncome" min="0" />
|
||||
@if (
|
||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('annualTurnover')?.touched
|
||||
createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||
createClientForm.get('annualIncome')?.hasError('min') && createClientForm.get('annualIncome')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width subsection-title">
|
||||
<fa-icon icon="coins" class="subsection-icon"></fa-icon>
|
||||
Other Income
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Agriculture Income</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
@@ -1239,6 +1376,128 @@
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<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" />
|
||||
@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>Yearly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
@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 subsection-title">
|
||||
<fa-icon icon="user-shield" class="subsection-icon"></fa-icon>
|
||||
Senior Citizen / Person with Disability
|
||||
</div>
|
||||
|
||||
@if (isSeniorCitizen) {
|
||||
<div class="full-width senior-citizen-auto-note">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Senior Citizen (60+ years) — detected automatically from Date of Birth
|
||||
</div>
|
||||
|
||||
<div class="full-width doc-upload-field">
|
||||
<label class="doc-upload-label">
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onSeniorCitizenProofSelected($event)" />
|
||||
<fa-icon icon="file-upload"></fa-icon>
|
||||
@if (seniorCitizenProofFile) {
|
||||
<span>{{ seniorCitizenProofFile.name }}</span>
|
||||
} @else if (existingSeniorCitizenProofDoc) {
|
||||
<span>{{ existingSeniorCitizenProofDoc.name }} (uploaded)</span>
|
||||
} @else {
|
||||
<span>Upload Senior Citizen Proof (Age / ID Proof) — optional</span>
|
||||
}
|
||||
</label>
|
||||
@if (seniorCitizenProofFile) {
|
||||
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else if (existingSeniorCitizenProofDoc) {
|
||||
<button type="button" mat-icon-button (click)="viewExistingSeniorCitizenProof()" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button (click)="removeExistingSeniorCitizenProof()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="checkbox-field full-width">
|
||||
<mat-checkbox formControlName="isPwd">Person with Disability (PWD)</mat-checkbox>
|
||||
</div>
|
||||
|
||||
@if (createClientForm.get('isPwd')?.value) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Disability Type</mat-label>
|
||||
<mat-select formControlName="pwdCategoryId">
|
||||
@for (category of pwdCategoryOptions; track category.id) {
|
||||
<mat-option [value]="category.id">{{ category.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (seniorPwdDocsTouched && !createClientForm.get('pwdCategoryId')?.value) {
|
||||
<mat-error>Disability type is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width doc-upload-field">
|
||||
<label
|
||||
class="doc-upload-label"
|
||||
[class.doc-upload-label--missing]="
|
||||
seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc
|
||||
"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
|
||||
<fa-icon icon="file-upload"></fa-icon>
|
||||
@if (pwdCertificateFile) {
|
||||
<span>{{ pwdCertificateFile.name }}</span>
|
||||
} @else if (existingPwdCertificateDoc) {
|
||||
<span>{{ existingPwdCertificateDoc.name }} (uploaded)</span>
|
||||
} @else {
|
||||
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
|
||||
}
|
||||
</label>
|
||||
@if (pwdCertificateFile) {
|
||||
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else if (existingPwdCertificateDoc) {
|
||||
<button type="button" mat-icon-button (click)="viewExistingPwdCertificate()" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button (click)="removeExistingPwdCertificate()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc) {
|
||||
<span class="doc-upload-error">Required when PWD is checked</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+200
@@ -56,6 +56,175 @@ 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;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #1074b9;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.doc-upload-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.doc-upload-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
&.doc-upload-label--missing {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-upload-req {
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doc-upload-error {
|
||||
font-size: 11px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6b7280;
|
||||
margin: 20px 0 4px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
.subsection-icon {
|
||||
color: var(--md-sys-color-primary, #1074b9);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -234,6 +403,18 @@ form {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.senior-citizen-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
color: #1074b9;
|
||||
margin-left: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ═══ Guardian Section (Redesigned) ═══ */
|
||||
.guardian-section {
|
||||
margin-top: 12px;
|
||||
@@ -1493,6 +1674,21 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
/* Create-time mobile/email OTP verification — spans both `.form-grid` columns so the two
|
||||
* blocks sit on their own full-width row instead of disrupting the field/verify-button pairing
|
||||
* above them. */
|
||||
.contact-otp-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 4px;
|
||||
|
||||
mifosx-client-contact-otp-block {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1516,6 +1712,10 @@ form {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-otp-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.guardian-card__avatar.has-photo {
|
||||
|
||||
+821
-25
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
+327
-14
@@ -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,18 @@ 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>
|
||||
}
|
||||
@if (!verifying && !matchFound && !errorOccurred) {
|
||||
@@ -53,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">
|
||||
@@ -69,7 +78,14 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
@if (listDataAsOf) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">List Data As Of</span>
|
||||
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
@@ -96,11 +112,86 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
@if (listDataAsOf) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">List Data As Of</span>
|
||||
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||
</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>
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
@@ -119,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>
|
||||
@@ -152,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 {
|
||||
@@ -207,6 +320,29 @@ 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;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
margin: 8px 0 0;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.approve-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -444,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
.match-list {
|
||||
width: 100%;
|
||||
max-height: unset;
|
||||
overflow: visible;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
@@ -486,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;
|
||||
@@ -500,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,
|
||||
@@ -513,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;
|
||||
@@ -524,14 +743,39 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
errorOccurred = false;
|
||||
errorMessage = '';
|
||||
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 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 }
|
||||
];
|
||||
|
||||
@@ -542,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
|
||||
@@ -549,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;
|
||||
@@ -624,6 +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.formatListDate(this.apiResult?.listDataAsOf);
|
||||
if (this.apiResult?.matchFound) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = this.apiResult.matchCount || 0;
|
||||
@@ -633,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 });
|
||||
}
|
||||
|
||||
+53
-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,25 @@
|
||||
<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>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
|
||||
<input matInput type="date" formControlName="issueDate" />
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
|
||||
+225
-35
@@ -15,9 +15,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-india-address-step',
|
||||
@@ -38,14 +41,36 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
private http = inject(HttpClient);
|
||||
private clientsService = inject(ClientsService);
|
||||
private kycUpload = inject(KycUploadHelperService);
|
||||
private dialog = inject(MatDialog);
|
||||
private dates = inject(Dates);
|
||||
|
||||
/** Client ID — when set, loads existing addresses for edit mode */
|
||||
@Input() clientId: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
/** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own
|
||||
* name — restricts the address-proof picker to Aadhaar/Passport when true. */
|
||||
@Input() isMinor = false;
|
||||
/** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/
|
||||
* 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: [
|
||||
@@ -228,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) {
|
||||
@@ -235,14 +262,121 @@ 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.
|
||||
*
|
||||
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
|
||||
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
|
||||
* re-runs for a second "create new client" session — the previous client's address forms,
|
||||
* selected proof-of-address chip and uploaded files would otherwise leak into the new one.
|
||||
* Called by CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
|
||||
*/
|
||||
resetForNewClient(): void {
|
||||
this.permanentForm.reset({ country: 'India' });
|
||||
this.currentForm.reset({ country: 'India' });
|
||||
this.officeForm.reset({ country: 'India' });
|
||||
this.correspondenceForm.reset({ country: 'India' });
|
||||
this.activeTab = 0;
|
||||
this.sameAsPermanent = false;
|
||||
this.pinStatus = {
|
||||
permanent: { loading: false, found: false, error: false, message: '' },
|
||||
current: { loading: false, found: false, error: false, message: '' },
|
||||
office: { loading: false, found: false, error: false, message: '' },
|
||||
correspondence: { loading: false, found: false, error: false, message: '' }
|
||||
};
|
||||
this.localityOptions = { permanent: [], current: [], office: [], correspondence: [] };
|
||||
this.selectedAddrProof = null;
|
||||
this.addrFiles = { front: null, back: null };
|
||||
this.addrMergedFile = null;
|
||||
this.addrMergedPreview = null;
|
||||
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.
|
||||
this.loadProofTypes();
|
||||
}
|
||||
|
||||
private loadProofTypes(): void {
|
||||
this.loadingProofs = true;
|
||||
this.clientsService.getKycProofTypes().subscribe({
|
||||
next: (res: any) => {
|
||||
const activeFilter = (p: any) => p?.active !== false;
|
||||
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
|
||||
this.addressProofs = this.filterAddressProofsForNonIndividual(
|
||||
this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType))
|
||||
);
|
||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||
this.loadingProofs = false;
|
||||
this.tryLinkKycData();
|
||||
},
|
||||
error: () => {
|
||||
this.loadingProofs = false;
|
||||
@@ -250,6 +384,41 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
/** Minors don't hold utility bills, bank statements, rent agreements, property tax receipts,
|
||||
* employer letters or pension orders in their own name, and Voter ID/Driving Licence both
|
||||
* require adult age — Aadhaar and Passport are the only OVDs left for proof of address. */
|
||||
private static readonly MINOR_ELIGIBLE_ADDRESS_PROOF_CODES = new Set([
|
||||
'AADHAAR',
|
||||
'PASSPORT'
|
||||
]);
|
||||
|
||||
private filterAddressProofsForMinor(proofs: ProofType[]): ProofType[] {
|
||||
if (!this.isMinor) return proofs;
|
||||
return proofs.filter((p) =>
|
||||
ClientIndiaAddressStepComponent.MINOR_ELIGIBLE_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/** Personal OVDs — not valid proof of a registered office/entity address. */
|
||||
private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([
|
||||
'AADHAAR',
|
||||
'PASSPORT',
|
||||
'VOTER_ID',
|
||||
'DRIVING_LICENSE',
|
||||
'NREGA_JOB_CARD',
|
||||
'NPR_LETTER',
|
||||
'BIRTH_CERTIFICATE',
|
||||
'SCHOOL_ID_CARD'
|
||||
]);
|
||||
|
||||
private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] {
|
||||
if (!this.isNonIndividual) return proofs;
|
||||
return proofs.filter(
|
||||
(p) =>
|
||||
!ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
selectAddrProof(proof: ProofType): void {
|
||||
this.selectedAddrProof = proof;
|
||||
this.permanentForm.get('proofId')?.setValue(proof.id);
|
||||
@@ -330,16 +499,28 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
files.push({ slot: 'Back', file: this.addrFiles.back });
|
||||
}
|
||||
}
|
||||
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||
return {
|
||||
proof: this.selectedAddrProof,
|
||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||
issueDate: this.permanentForm.get('issueDate')?.value || '',
|
||||
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||
files
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -355,6 +536,25 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changes['isMinor'] && !changes['isMinor'].firstChange && this.addressProofs.length) {
|
||||
const filtered = this.filterAddressProofsForMinor(this.addressProofs);
|
||||
if (filtered.length !== this.addressProofs.length) {
|
||||
this.addressProofs = filtered;
|
||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||
if (this.selectedAddrProof && !filtered.some((p) => p.id === this.selectedAddrProof?.id)) {
|
||||
this.selectedAddrProof = null;
|
||||
this.permanentForm.get('proofId')?.setValue('');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legal form toggled on an already-initialized instance — re-fetch rather than filter
|
||||
// `addressProofs` in place, since the non-individual filter only ever removes entries and
|
||||
// toggling back to individual could never recover what a prior pass had stripped out.
|
||||
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) {
|
||||
this.selectedAddrProof = null;
|
||||
this.permanentForm.get('proofId')?.setValue('');
|
||||
this.loadProofTypes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,32 +639,38 @@ 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({
|
||||
proofId: matchedProof.id,
|
||||
documentKey: identifier.documentKey || ''
|
||||
});
|
||||
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
|
||||
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
|
||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
||||
// shows what was previously entered.
|
||||
const desc: string = identifier.description || '';
|
||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||
if (issueMatch) {
|
||||
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
|
||||
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,33 +772,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved address proof in a new browser tab.
|
||||
*
|
||||
* For images we already hold a base64 data URL in `previewUrl` from
|
||||
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
|
||||
* For PDFs / other types we need to fetch the binary blob and create a
|
||||
* temporary object URL so the browser's PDF viewer can render it.
|
||||
*/
|
||||
/** Open the saved address proof in an in-app preview dialog. */
|
||||
viewExistingAddressProof(): void {
|
||||
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingAddressProofDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
// Revoke after the tab has had time to load — browsers vary, so
|
||||
// err on the side of a generous wait.
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
next: (blob: Blob) =>
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||
}),
|
||||
error: () => {
|
||||
// Silent fail — UI shows the saved name so user knows it exists.
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -266,6 +280,23 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.change-number-btn {
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 14px !important;
|
||||
height: 36px !important;
|
||||
border-radius: 6px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.verify-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -287,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 {
|
||||
@@ -297,8 +556,24 @@
|
||||
.toggle-vis-btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 13px !important;
|
||||
color: #64748b;
|
||||
|
||||
/* Material reserves a 48x48 invisible touch-target + ripple around every
|
||||
mat-icon-button by default. Shrinking the button to 28px above doesn't
|
||||
shrink that touch-target/ripple with it, so — since the suffix
|
||||
container doesn't clip overflow — it visually spills out past the
|
||||
field's own border, making the icon look misplaced/half-outside the box. */
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-button-persistent-ripple,
|
||||
::ng-deep .mat-ripple {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Verification Info Banner */
|
||||
@@ -992,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 {
|
||||
@@ -1057,9 +1338,17 @@
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&__btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d5dd;
|
||||
@@ -1074,6 +1363,10 @@
|
||||
border-color: #1353a0;
|
||||
color: #1353a0;
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
@@ -2228,3 +2521,516 @@
|
||||
width: 95px;
|
||||
}
|
||||
}
|
||||
|
||||
.override-checkbox-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.risk-auto-calc-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-top: -8px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.info-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-text strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-color: #a7f3d0;
|
||||
|
||||
.info-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border-color: #fde68a;
|
||||
|
||||
.info-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-color: #fecaca;
|
||||
|
||||
.info-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.risk-rules-toggle-wrapper {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.rules-toggle-btn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e6bb8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
background: #f1f5f9;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.risk-rules-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.risk-rules-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
transition: background 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
|
||||
&--high {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #ecfdf5;
|
||||
color: #16a34a;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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
+7
-7
@@ -195,7 +195,7 @@
|
||||
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
||||
"
|
||||
>
|
||||
{{ client.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
||||
{{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AddFamilyMemberComponent } from './clients-view/family-members-tab/add-
|
||||
import { EditFamilyMemberComponent } from './clients-view/family-members-tab/edit-family-member/edit-family-member.component';
|
||||
import { IdentitiesTabComponent } from './clients-view/identities-tab/identities-tab.component';
|
||||
import { NotesTabComponent } from './clients-view/notes-tab/notes-tab.component';
|
||||
import { HistoryTabComponent } from './clients-view/history-tab/history-tab.component';
|
||||
import { DocumentsTabComponent } from './clients-view/documents-tab/documents-tab.component';
|
||||
import { DatatableTabComponent } from './clients-view/datatable-tab/datatable-tab.component';
|
||||
import { AddressTabComponent } from './clients-view/address-tab/address-tab.component';
|
||||
@@ -59,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 = [
|
||||
@@ -190,7 +193,21 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'form60',
|
||||
component: Form60TabComponent,
|
||||
data: { title: 'Form 60', breadcrumb: 'Form 60', routeParamBreadcrumb: false }
|
||||
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',
|
||||
@@ -208,6 +225,11 @@ const routes: Routes = [
|
||||
clientNotes: ClientNotesResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
component: HistoryTabComponent,
|
||||
data: { title: 'History', breadcrumb: 'History', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'datatables',
|
||||
children: [
|
||||
|
||||
@@ -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: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -234,8 +234,12 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Signature — client document with name='clientSignature'
|
||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
||||
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
|
||||
// for clients who re-cropped/replaced their signature before uploads became single-slot,
|
||||
// so pick the highest id (latest) rather than the first unordered match.
|
||||
const signatureDoc = (documents as any[])
|
||||
?.filter((d: any) => d?.name === 'clientSignature')
|
||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||
if (signatureDoc) {
|
||||
this.existing.CLIENT_SIGNATURE = {
|
||||
present: true,
|
||||
|
||||
@@ -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,6 +430,11 @@
|
||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||
{{ 'labels.buttons.Add Charge' | translate }}
|
||||
</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>
|
||||
@@ -426,6 +451,9 @@
|
||||
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
|
||||
{{ 'labels.buttons.Upload Signature' | translate }}
|
||||
</button>
|
||||
<button mat-menu-item (click)="doAction('Capture Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
|
||||
Capture Signature (Webcam)
|
||||
</button>
|
||||
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
|
||||
{{ 'labels.buttons.Delete Signature' | translate }}
|
||||
</button>
|
||||
@@ -539,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']"
|
||||
@@ -559,6 +612,15 @@
|
||||
>
|
||||
<i class="fa fa-sticky-note cv-tab-icon"></i> {{ 'labels.inputs.Notes' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./history']"
|
||||
routerLinkActive
|
||||
#history="routerLinkActive"
|
||||
[active]="history.isActive"
|
||||
>
|
||||
<i class="fa fa-history cv-tab-icon"></i> History
|
||||
</a>
|
||||
@for (clientDatatable of clientDatatables; track clientDatatable) {
|
||||
<a
|
||||
mat-tab-link
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
||||
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||
import { WelcomeKitDialogComponent } from './custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients.service';
|
||||
@@ -84,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 *
|
||||
*/
|
||||
@@ -240,6 +289,9 @@ export class ClientsViewComponent implements OnInit {
|
||||
case 'Upload Signature':
|
||||
this.uploadSignature();
|
||||
break;
|
||||
case 'Capture Signature':
|
||||
this.captureSignatureImage();
|
||||
break;
|
||||
case 'Delete Signature':
|
||||
this.deleteSignature();
|
||||
break;
|
||||
@@ -252,6 +304,9 @@ export class ClientsViewComponent implements OnInit {
|
||||
case 'Delete Image':
|
||||
this.deleteProfileImage();
|
||||
break;
|
||||
case 'Welcome Kit':
|
||||
this.dispatchWelcomeKit();
|
||||
break;
|
||||
case 'Create Standing Instructions':
|
||||
const createStandingInstructionsQueryParams: any = {
|
||||
officeId: this.clientViewData.officeId,
|
||||
@@ -332,6 +387,8 @@ export class ClientsViewComponent implements OnInit {
|
||||
this.uploadSignature();
|
||||
} else if (response.draw) {
|
||||
this.drawSignature();
|
||||
} else if (response.capture) {
|
||||
this.captureSignatureImage();
|
||||
} else if (response.delete) {
|
||||
this.deleteSignature();
|
||||
}
|
||||
@@ -391,7 +448,9 @@ export class ClientsViewComponent implements OnInit {
|
||||
* Captures clients profile image.
|
||||
*/
|
||||
private captureProfileImage() {
|
||||
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent);
|
||||
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||
data: { captureTarget: 'profile' }
|
||||
});
|
||||
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
||||
if (imageURL) {
|
||||
this.clientsService.uploadCapturedClientProfileImage(this.clientViewData.id, imageURL).subscribe(() => {
|
||||
@@ -401,6 +460,24 @@ export class ClientsViewComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures client signature via webcam (photograph of a paper signature).
|
||||
* Mirrors `captureProfileImage()` — same dialog, parametrized for signature capture,
|
||||
* uploaded via `uploadCapturedClientSignature()` instead of the profile-image upload.
|
||||
*/
|
||||
private captureSignatureImage() {
|
||||
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||
data: { captureTarget: 'signature' }
|
||||
});
|
||||
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
||||
if (imageURL) {
|
||||
this.clientsService.uploadCapturedClientSignature(this.clientViewData.id, imageURL).subscribe(() => {
|
||||
this.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the clients image.
|
||||
*/
|
||||
@@ -430,4 +507,32 @@ export class ClientsViewComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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) {
|
||||
// the form was successfully submitted
|
||||
this.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+22
-8
@@ -6,10 +6,24 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="layout-column gap-2px">
|
||||
<h1 mat-dialog-title align="center">{{ 'labels.heading.Capture Client Image' | translate }}</h1>
|
||||
<div class="layout-column gap-2px capture-dialog">
|
||||
<h1 mat-dialog-title align="center" class="capture-title">
|
||||
<fa-icon [icon]="isSignatureCapture ? 'signature' : 'camera'"></fa-icon>
|
||||
<span>{{ dialogTitle }}</span>
|
||||
</h1>
|
||||
<p class="capture-instructions" align="center">{{ dialogInstructions }}</p>
|
||||
|
||||
<video #video class="capture-video" autoplay></video>
|
||||
<div class="capture-frame" [class.capture-frame--signature]="isSignatureCapture">
|
||||
<video
|
||||
#video
|
||||
class="capture-video"
|
||||
autoplay
|
||||
playsinline
|
||||
disablepictureinpicture
|
||||
disableremoteplayback
|
||||
controlslist="nodownload noremoteplayback nofullscreen"
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- Using a class will break renderer changes -->
|
||||
<canvas #canvas [ngStyle]="{ display: 'none' }"></canvas>
|
||||
@@ -17,20 +31,20 @@
|
||||
<!-- Using a class will break renderer changes -->
|
||||
<p #fallback [ngStyle]="{ display: 'none' }" align="center"></p>
|
||||
|
||||
<div class="flex-20" align="center">
|
||||
<div class="capture-actions-row">
|
||||
@if (!isCaptured) {
|
||||
<button #captureButton mat-stroked-button color="primary" (click)="capture()">
|
||||
{{ 'labels.buttons.Capture' | translate }}
|
||||
<button #captureButton mat-stroked-button color="primary" class="capture-btn" (click)="capture()">
|
||||
<fa-icon icon="camera" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Capture' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (isCaptured) {
|
||||
<button mat-stroked-button color="primary" (click)="recapture()">
|
||||
<button mat-stroked-button color="primary" class="capture-btn" (click)="recapture()">
|
||||
<fa-icon icon="undo" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Recapture' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<mat-dialog-actions align="end" class="capture-dialog-footer">
|
||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||
<button [disabled]="!isCaptured" mat-raised-button color="primary" [mat-dialog-close]="clientImageDataURL">
|
||||
{{ 'labels.buttons.Upload' | translate }}
|
||||
|
||||
+96
-6
@@ -6,13 +6,103 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../../../../../assets/styles/colours' as colors;
|
||||
/* No explicit width/maxHeight is passed at every `dialog.open(CaptureImageDialogComponent)`
|
||||
call site (there are several, across clients-view/create/edit flows) — capping height and
|
||||
allowing internal scroll here, once, keeps the Cancel/Upload footer reachable on short
|
||||
viewports instead of relying on every caller to remember a maxHeight config. */
|
||||
:host {
|
||||
display: block;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.capture-dialog {
|
||||
padding: 4px 4px 8px;
|
||||
}
|
||||
|
||||
.capture-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
|
||||
fa-icon {
|
||||
color: #1074b9;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.capture-instructions {
|
||||
margin: 4px 0 14px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.capture-frame {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
|
||||
/* Capped in vh, not just px, so the frame shrinks on short viewports (laptop screens with
|
||||
browser chrome eating into the visible height) instead of pushing the Capture/Cancel/
|
||||
Upload buttons below the fold — `object-fit: cover` on the video means shrinking this box
|
||||
never reintroduces pillarboxing, it just changes how much of the live frame is visible. */
|
||||
max-height: 42vh;
|
||||
margin: 0 auto;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
box-shadow: 0 8px 24px -8px rgb(15 23 42 / 25%);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
|
||||
/* Default (profile) frame follows the requested 4:3 capture stream. */
|
||||
aspect-ratio: 4 / 3;
|
||||
|
||||
/* Signature capture uses a shorter, wider (16:9) frame — paper signatures are landscape-ish
|
||||
and this matches the wider resolution requested from getUserMedia, so the live preview
|
||||
fills the frame edge-to-edge instead of pillarboxing (black bars) inside a mismatched box. */
|
||||
&--signature {
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 36vh;
|
||||
}
|
||||
}
|
||||
|
||||
.capture-video {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
background-color: colors.$black;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
|
||||
/* Chromium auto-injects a Picture-in-Picture toggle (and sometimes a cast button) as small
|
||||
square overlay icons in a bare <video>'s corners on hover — the `disablepictureinpicture`/
|
||||
`disableremoteplayback` attributes stop the *feature*, but these pseudo-elements are the
|
||||
belt-and-braces way to make sure the icons themselves never render. */
|
||||
&::-webkit-media-controls,
|
||||
&::-webkit-media-controls-panel,
|
||||
&::-internal-media-controls-overlay-cast-button {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.capture-actions-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
|
||||
.capture-btn {
|
||||
border-radius: 999px !important;
|
||||
padding: 0 20px !important;
|
||||
height: 42px;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.capture-dialog-footer {
|
||||
padding: 8px 4px 0 !important;
|
||||
|
||||
.mat-mdc-raised-button {
|
||||
border-radius: 999px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
|
||||
+37
-6
@@ -8,11 +8,23 @@
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, ViewChild, ElementRef, AfterViewInit, Renderer2, OnDestroy, inject } from '@angular/core';
|
||||
import { MatDialogRef, MatDialogTitle, MatDialogActions, MatDialogClose } from '@angular/material/dialog';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { NgStyle } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** What the captured image is for — drives the dialog's title/instructions and lets the
|
||||
* caller decide (based on how it opened the dialog) which upload method to invoke on the
|
||||
* resulting data URL. Defaults to 'profile' so existing callers that don't pass `data`
|
||||
* keep working unchanged. */
|
||||
export type CaptureImageTarget = 'profile' | 'signature';
|
||||
|
||||
/**
|
||||
* Capture image dialog component
|
||||
*/
|
||||
@@ -32,6 +44,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
|
||||
dialogRef = inject<MatDialogRef<CaptureImageDialogComponent>>(MatDialogRef);
|
||||
private renderer = inject(Renderer2);
|
||||
private data = inject<{ captureTarget?: CaptureImageTarget } | null>(MAT_DIALOG_DATA, { optional: true });
|
||||
|
||||
/** 'profile' (default, unchanged behaviour) or 'signature' — photographing a paper signature. */
|
||||
captureTarget: CaptureImageTarget = this.data?.captureTarget || 'profile';
|
||||
|
||||
get isSignatureCapture(): boolean {
|
||||
return this.captureTarget === 'signature';
|
||||
}
|
||||
|
||||
get dialogTitle(): string {
|
||||
return this.isSignatureCapture ? 'Capture Client Signature' : 'Capture Client Image';
|
||||
}
|
||||
|
||||
get dialogInstructions(): string {
|
||||
return this.isSignatureCapture
|
||||
? 'Photograph the customer’s signed paper — line up the signature within the frame before capturing.'
|
||||
: 'Line up the customer’s face within the frame before capturing.';
|
||||
}
|
||||
|
||||
/** Video element reference */
|
||||
@ViewChild('video', { static: true }) video: ElementRef;
|
||||
@@ -62,11 +92,12 @@ export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
|
||||
*/
|
||||
startCamera() {
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
const videoConstraints: MediaTrackConstraints = {
|
||||
width: { ideal: 640 },
|
||||
height: { ideal: 480 },
|
||||
facingMode: 'user'
|
||||
};
|
||||
// Signature capture asks for a wide/short (16:9) stream to match the frame it's shown
|
||||
// in — requesting an aspect ratio close to what the CSS box actually renders avoids the
|
||||
// camera negotiating a 4:3 stream that then gets pillarboxed (black bars) inside a 16:9 box.
|
||||
const videoConstraints: MediaTrackConstraints = this.isSignatureCapture
|
||||
? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }
|
||||
: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' };
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: videoConstraints })
|
||||
.then((stream: MediaStream) => {
|
||||
|
||||
+7
-1
@@ -47,7 +47,13 @@ export class DeleteSignatureDialogComponent {
|
||||
* @param {any} data Documents data
|
||||
*/
|
||||
constructor() {
|
||||
const signature = this.data.find((document: any) => document.name === 'clientSignature') || {};
|
||||
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||
// the first unordered match.
|
||||
const signature =
|
||||
this.data
|
||||
.filter((document: any) => document.name === 'clientSignature')
|
||||
.sort((a: any, b: any) => b.id - a.id)[0] || {};
|
||||
this.signatureId = signature.id;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-3
@@ -347,9 +347,18 @@
|
||||
<i class="fa fa-calendar"></i> Issue / statement date
|
||||
<span class="kyc-opt">(not older than 3 months)</span>
|
||||
</label>
|
||||
<div class="kyc-input-wrap kyc-input-wrap--date">
|
||||
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
|
||||
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
|
||||
<div class="kyc-input-wrap kyc-input-wrap--date" (click)="uploadIssueDatePicker.open()">
|
||||
<input
|
||||
class="kyc-input kyc-input--date"
|
||||
[matDatepicker]="uploadIssueDatePicker"
|
||||
formControlName="issueDate"
|
||||
readonly
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
class="kyc-input-wrap__calendar-toggle"
|
||||
[for]="uploadIssueDatePicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #uploadIssueDatePicker></mat-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -438,6 +447,15 @@
|
||||
<div class="fd-field fd-field--file">
|
||||
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
|
||||
</div>
|
||||
<div class="fd-webcam-row">
|
||||
<span class="fd-webcam-divider">or</span>
|
||||
<button type="button" mat-stroked-button color="primary" class="fd-webcam-btn" (click)="captureFromWebcam()">
|
||||
<i class="fa fa-camera"></i> Capture from Webcam
|
||||
</button>
|
||||
@if (capturedFileName) {
|
||||
<span class="fd-webcam-confirm"><i class="fa fa-check-circle"></i> {{ capturedFileName }}</span>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
+53
-28
@@ -130,6 +130,41 @@ $white: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-webcam-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.fd-webcam-divider {
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.fd-webcam-btn {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
|
||||
i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-webcam-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #1e8e3e;
|
||||
|
||||
i {
|
||||
color: #1e8e3e;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-section-divider {
|
||||
margin: 8px 0 12px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
@@ -353,42 +388,32 @@ $red-900: #993c1d;
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
/* Date input — match styling of other kyc inputs */
|
||||
/* Date input — opens the Material calendar picker; styled to match other kyc inputs */
|
||||
.kyc-input--date {
|
||||
font-family: inherit;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
padding-right: 14px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 40px !important;
|
||||
}
|
||||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
filter: grayscale(1);
|
||||
transition: opacity 0.15s;
|
||||
.kyc-input-wrap--date .kyc-input-wrap__calendar-toggle {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $gray-500;
|
||||
|
||||
::ng-deep .mat-mdc-icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&:hover::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit {
|
||||
color: $gray-900;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-fields-wrapper {
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-text {
|
||||
color: $gray-500;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
&:invalid::-webkit-datetime-edit,
|
||||
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
|
||||
color: $gray-500;
|
||||
::ng-deep .mat-mdc-icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
@@ -16,12 +16,14 @@ import {
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { ImageCropperDialogComponent } from '../image-cropper-dialog/image-cropper-dialog.component';
|
||||
import { CaptureImageDialogComponent } from '../capture-image-dialog/capture-image-dialog.component';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ClientsService } from '../../../clients.service';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types';
|
||||
import { dataUrlToFile } from '../../../../shared/utils/data-url.util';
|
||||
|
||||
type SectionKey = 'id' | 'address';
|
||||
|
||||
@@ -72,6 +74,12 @@ export class UploadDocumentDialogComponent implements OnInit {
|
||||
addrMergedFile: File | null = null;
|
||||
addrMergedPreview: string | null = null;
|
||||
|
||||
/** Non-identifier (generic) mode only — set after a webcam capture, since
|
||||
* `FileUploadComponent` only reflects files picked through its own native input and has
|
||||
* no input for one supplied programmatically. Cleared if the user then picks a file via
|
||||
* Browse instead, so only one confirmation shows at a time. */
|
||||
capturedFileName: string | null = null;
|
||||
|
||||
constructor() {
|
||||
const data = this.data;
|
||||
this.documentIdentifier = !!data?.documentIdentifier;
|
||||
@@ -390,6 +398,24 @@ export class UploadDocumentDialogComponent implements OnInit {
|
||||
if ($event.target.files.length > 0) {
|
||||
const file = $event.target.files[0];
|
||||
this.uploadDocumentForm.get('file').setValue(file);
|
||||
this.capturedFileName = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-identifier (generic Documents tab) mode only — lets staff photograph a document
|
||||
* with the webcam instead of only being able to browse for an existing file. Reuses the
|
||||
* same capture dialog as the profile-photo/signature flows. */
|
||||
captureFromWebcam(): void {
|
||||
const ref = this.dialog.open(CaptureImageDialogComponent, {
|
||||
width: '720px',
|
||||
maxWidth: '95vw',
|
||||
data: { captureTarget: 'profile' }
|
||||
});
|
||||
ref.afterClosed().subscribe((dataUrl: string | null) => {
|
||||
if (!dataUrl) return;
|
||||
const file = dataUrlToFile(dataUrl, `webcam-capture-${Date.now()}.jpg`);
|
||||
this.uploadDocumentForm.get('file').setValue(file);
|
||||
this.capturedFileName = file.name;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -34,5 +34,8 @@
|
||||
<button mat-raised-button color="primary" [mat-dialog-close]="{ draw: true }">
|
||||
{{ 'labels.buttons.Draw' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [mat-dialog-close]="{ capture: true }">
|
||||
<fa-icon icon="camera"></fa-icon> Webcam
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
|
||||
+10
-2
@@ -24,6 +24,7 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
/** Node Types */
|
||||
import { Buffer } from 'buffer';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogClose,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ViewSignatureDialogComponent implements OnInit {
|
||||
@@ -63,7 +65,13 @@ export class ViewSignatureDialogComponent implements OnInit {
|
||||
* @param {any} data Documents data
|
||||
*/
|
||||
constructor() {
|
||||
const signature = this.data.documents.find((document: any) => document.name === 'clientSignature') || {};
|
||||
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||
// the first unordered match.
|
||||
const signature =
|
||||
this.data.documents
|
||||
.filter((document: any) => document.name === 'clientSignature')
|
||||
.sort((a: any, b: any) => b.id - a.id)[0] || {};
|
||||
this.signatureId = signature.id;
|
||||
this.clientId = this.data.id;
|
||||
}
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
<!--
|
||||
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="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 wk-content">
|
||||
<div class="wk-loading" *ngIf="isLoading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isLoading">
|
||||
<!-- 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>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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-select>
|
||||
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
||||
Delivery Mode is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<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>
|
||||
|
||||
<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 type="button" (click)="close()">Close</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading && !outcomeFor"
|
||||
(click)="submit()"
|
||||
[disabled]="dispatchForm.invalid || isLoading"
|
||||
>
|
||||
<mat-icon>send</mat-icon>
|
||||
Dispatch
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$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);
|
||||
|
||||
&__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;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 0.8rem;
|
||||
color: $wk-muted;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- dispatch panel ---------- */
|
||||
.wk-dispatch {
|
||||
border: 1px solid $wk-border;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: $wk-surface;
|
||||
|
||||
&--past {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 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;
|
||||
}
|
||||
}
|
||||
+737
@@ -0,0 +1,737 @@
|
||||
/**
|
||||
* 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 { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
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';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-welcome-kit-dialog',
|
||||
templateUrl: './welcome-kit-dialog.component.html',
|
||||
styleUrls: ['./welcome-kit-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatProgressSpinnerModule,
|
||||
MatIcon
|
||||
],
|
||||
providers: [DatePipe]
|
||||
})
|
||||
export class WelcomeKitDialogComponent implements OnInit {
|
||||
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
|
||||
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({
|
||||
// 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: [''],
|
||||
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',
|
||||
'OTHER'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.dispatchForm.get('deliveryMode')?.valueChanges.subscribe((mode) => {
|
||||
const otherControl = this.dispatchForm.get('deliveryModeOtherText');
|
||||
if (mode === 'OTHER') {
|
||||
otherControl?.setValidators([Validators.required]);
|
||||
} else {
|
||||
otherControl?.clearValidators();
|
||||
}
|
||||
otherControl?.updateValueAndValidity();
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
fetchData() {
|
||||
this.isLoading = true;
|
||||
this.clientsService.getWelcomeKits(this.data.clientId).subscribe({
|
||||
next: (response: any) => {
|
||||
this.welcomeKitsData = response;
|
||||
if (this.welcomeKitsData && this.welcomeKitsData.canDispatchNew) {
|
||||
this.fetchPrimaryAddress();
|
||||
} else {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
this.snackBar.open('Failed to load welcome kits data', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
this.addressesLoaded = true;
|
||||
|
||||
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;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.dispatchForm.invalid) {
|
||||
this.dispatchForm.markAllAsTouched();
|
||||
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 dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
|
||||
const payload = {
|
||||
// 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,
|
||||
trackingId: (formValue.trackingId || '').trim(),
|
||||
dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat),
|
||||
locale,
|
||||
dateFormat
|
||||
};
|
||||
|
||||
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
||||
next: () => {
|
||||
this.hasChanges = true;
|
||||
this.snackBar.open('Welcome Kit dispatched successfully', 'Close', { duration: 3000 });
|
||||
this.fetchData();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.isLoading = false;
|
||||
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.clientName;
|
||||
if (name && !address.startsWith(name)) {
|
||||
return `${name} — ${address}`;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
downloadPdf(dispatch: any) {
|
||||
const doc = new jsPDF();
|
||||
const clientName = this.welcomeKitsData?.clientName || dispatch.clientName || 'N/A';
|
||||
const address = this.getDisplayAddress(dispatch);
|
||||
const mode =
|
||||
dispatch.deliveryMode === 'OTHER'
|
||||
? `${dispatch.deliveryMode} (${dispatch.deliveryModeOtherText || ''})`
|
||||
: dispatch.deliveryMode;
|
||||
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,
|
||||
73,
|
||||
125
|
||||
]; // Navy Blue
|
||||
const grayColor: [number, number, number] = [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
];
|
||||
const lightGray: [number, number, number] = [
|
||||
230,
|
||||
230,
|
||||
230
|
||||
];
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
|
||||
// --- 1. Header Banner ---
|
||||
doc.setFillColor(...primaryColor);
|
||||
doc.rect(0, 0, pageWidth, 40, 'F');
|
||||
|
||||
// Decorative circles
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.circle(pageWidth - 20, 15, 3, 'F');
|
||||
doc.circle(pageWidth - 30, 25, 2, 'F');
|
||||
doc.circle(pageWidth - 10, 30, 1.5, 'F');
|
||||
|
||||
doc.setFontSize(28);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('CBSNexora', 20, 22);
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Core Banking Solution', 22, 30);
|
||||
|
||||
// --- 2. Title ---
|
||||
let currentY = 55;
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...primaryColor);
|
||||
doc.text('Welcome Kit Dispatch Confirmation', pageWidth / 2, currentY, { align: 'center' });
|
||||
currentY += 12;
|
||||
|
||||
// --- 3. Date Line ---
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
const today = this.datePipe.transform(new Date(), 'longDate');
|
||||
doc.text(`Date: ${today}`, pageWidth - 20, currentY, { align: 'right' });
|
||||
currentY += 15;
|
||||
|
||||
// --- 4. Greeting ---
|
||||
doc.text(`Dear ${clientName},`, 20, currentY);
|
||||
currentY += 10;
|
||||
|
||||
// --- 5. Opening welcome paragraph ---
|
||||
const p1 =
|
||||
'Welcome to the CBSNexora family! We are delighted to have you on board as a valued client. As part of our onboarding process, we have dispatched your Welcome Kit to your registered address, which includes your account documentation and essential banking materials to help you get started with us.';
|
||||
const splitP1 = doc.splitTextToSize(p1, pageWidth - 40);
|
||||
doc.text(splitP1, 20, currentY);
|
||||
currentY += splitP1.length * 6 + 8;
|
||||
|
||||
// --- 6. Short paragraph about the company ---
|
||||
const p2 =
|
||||
'CBSNexora is committed to providing secure, reliable, and customer-first banking services. Our team is always available to assist you with any banking needs, from account management to digital services, ensuring a seamless banking experience.';
|
||||
const splitP2 = doc.splitTextToSize(p2, pageWidth - 40);
|
||||
doc.text(splitP2, 20, currentY);
|
||||
currentY += splitP2.length * 6 + 8;
|
||||
|
||||
// --- 7. Transition line before details ---
|
||||
doc.text('Please find your Welcome Kit dispatch details below for your reference:', 20, currentY);
|
||||
currentY += 8;
|
||||
|
||||
// --- 8. Details Section ---
|
||||
autoTable(doc, {
|
||||
startY: currentY,
|
||||
theme: 'plain',
|
||||
margin: { left: 20, right: 20 },
|
||||
styles: { fontSize: 11, cellPadding: 6, textColor: [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
] },
|
||||
columnStyles: {
|
||||
0: { fontStyle: 'bold', cellWidth: 50 },
|
||||
1: { cellWidth: 'auto' }
|
||||
},
|
||||
didDrawCell: (data) => {
|
||||
if (data.row.index >= 0) {
|
||||
doc.setDrawColor(...lightGray);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(
|
||||
data.cell.x,
|
||||
data.cell.y + data.cell.height,
|
||||
data.cell.x + data.cell.width,
|
||||
data.cell.y + data.cell.height
|
||||
);
|
||||
}
|
||||
},
|
||||
body: [
|
||||
[
|
||||
'Delivery Address',
|
||||
address
|
||||
],
|
||||
[
|
||||
'Delivery Mode',
|
||||
mode
|
||||
],
|
||||
[
|
||||
'Tracking ID',
|
||||
trackingId
|
||||
],
|
||||
[
|
||||
'Status',
|
||||
status
|
||||
],
|
||||
[
|
||||
'Dispatched On',
|
||||
dispatchedOn
|
||||
],
|
||||
[
|
||||
'Address Verification',
|
||||
verification
|
||||
],
|
||||
[
|
||||
'Outcome Recorded On',
|
||||
outcomeOn
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
// --- 9. Closing paragraph ---
|
||||
currentY = (doc as any).lastAutoTable.finalY + 12;
|
||||
const p3 =
|
||||
'Should you have any questions about your Welcome Kit or need any assistance, please do not hesitate to reach out to our support team. We look forward to serving you and building a long-term relationship with you.';
|
||||
const splitP3 = doc.splitTextToSize(p3, pageWidth - 40);
|
||||
doc.text(splitP3, 20, currentY);
|
||||
currentY += splitP3.length * 6 + 12;
|
||||
|
||||
// --- 10. Sign-off ---
|
||||
doc.text('Warm regards,', 20, currentY);
|
||||
currentY += 7;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('CBSNexora Operations Team', 20, currentY);
|
||||
|
||||
// --- 11. Footer ---
|
||||
// Ensure footer doesn't float too high on short pages, but pushes down if content goes long
|
||||
let footerY = currentY + 30;
|
||||
if (footerY < pageHeight - 30) {
|
||||
footerY = pageHeight - 30;
|
||||
}
|
||||
|
||||
doc.setDrawColor(...primaryColor);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(20, footerY - 5, pageWidth - 20, footerY - 5);
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'italic');
|
||||
doc.setTextColor(...grayColor);
|
||||
const timestamp = this.datePipe.transform(new Date(), 'medium');
|
||||
doc.text(`This is a system-generated document. Generated on: ${timestamp}`, 20, footerY + 2);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,14 @@
|
||||
<div class="f60-pan-ok__icon-wrap">
|
||||
<mat-icon class="f60-pan-ok__icon">verified</mat-icon>
|
||||
</div>
|
||||
<h3 class="f60-pan-ok__title">Form 60 Not Required</h3>
|
||||
<h3 class="f60-pan-ok__title">Form 97 Not Required</h3>
|
||||
<p class="f60-pan-ok__desc">
|
||||
PAN <strong>{{ panNumber }}</strong> is attached to this client.
|
||||
@if (panVerified) {
|
||||
<mat-icon class="f60-pan-ok__tick">check_circle</mat-icon> <span class="f60-pan-ok__verified">Verified</span>
|
||||
}
|
||||
</p>
|
||||
<p class="f60-pan-ok__rule">As per Income Tax Rule 114B, Form 60 is only required when PAN is not available.</p>
|
||||
<p class="f60-pan-ok__rule">As per Income Tax Rule 114B, Form 97 is only required when PAN is not available.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="f60-warning">
|
||||
<mat-icon class="f60-warning__icon">warning_amber</mat-icon>
|
||||
<div class="f60-warning__text">
|
||||
<strong>PAN not provided</strong> — Form 60 declaration is mandatory as per Income Tax Rule 114B.
|
||||
<strong>PAN not provided</strong> — Form 97 declaration is mandatory as per Income Tax Rule 114B.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="f60-card">
|
||||
<div class="f60-card__header">
|
||||
<mat-icon class="f60-card__header-icon">description</mat-icon>
|
||||
<h3>Form 60 Declaration</h3>
|
||||
<h3>Form 97 Declaration</h3>
|
||||
<span class="f60-card__status">Submitted</span>
|
||||
<button mat-stroked-button color="primary" (click)="toggleEditMode()" class="f60-card__edit">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
@@ -123,7 +123,7 @@
|
||||
<div class="f60-card">
|
||||
<div class="f60-card__header">
|
||||
<mat-icon class="f60-card__header-icon">edit_note</mat-icon>
|
||||
<h3>Form 60 Declaration</h3>
|
||||
<h3>Form 97 Declaration</h3>
|
||||
</div>
|
||||
<div class="f60-card__body">
|
||||
<form [formGroup]="form60Form" (ngSubmit)="submit()">
|
||||
@@ -204,7 +204,7 @@
|
||||
<button type="button" mat-stroked-button (click)="toggleEditMode()">Cancel</button>
|
||||
}
|
||||
<button type="submit" mat-flat-button color="primary" [disabled]="!form60Form.valid">
|
||||
<mat-icon>check</mat-icon> Submit Form 60
|
||||
<mat-icon>check</mat-icon> Submit Form 97
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -224,14 +224,14 @@ export class Form60TabComponent implements OnInit, OnDestroy {
|
||||
this.isEditMode = false;
|
||||
this.alertService.alert({
|
||||
type: 'success',
|
||||
message: 'Form 60 submitted successfully.'
|
||||
message: 'Form 97 submitted successfully.'
|
||||
});
|
||||
this.loadForm60Data();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.alertService.alert({
|
||||
type: 'error',
|
||||
message: err?.error?.developerMessage || err?.error?.defaultUserMessage || 'Failed to submit Form 60.'
|
||||
message: err?.error?.developerMessage || err?.error?.defaultUserMessage || 'Failed to submit Form 97.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<!--
|
||||
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="history-tab">
|
||||
<div class="history-tab__header">
|
||||
<h3>Change History</h3>
|
||||
<p>Past changes to this client's details and address, pulled from the system Audit Trail.</p>
|
||||
</div>
|
||||
|
||||
<div class="history-tab__table-container">
|
||||
@if (isLoading) {
|
||||
<mat-progress-bar mode="indeterminate" class="history-tab__loader"></mat-progress-bar>
|
||||
}
|
||||
|
||||
@if (!isLoading && dataSource.data.length === 0) {
|
||||
<div class="history-tab__empty">No recorded changes for this client yet.</div>
|
||||
}
|
||||
|
||||
@if (!isLoading && dataSource.data.length > 0) {
|
||||
<table mat-table [dataSource]="dataSource" class="history-tab__table">
|
||||
<ng-container matColumnDef="entityName">
|
||||
<th mat-header-cell *matHeaderCellDef>Entity</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.entityName | translateKey: 'auditTrail.entities' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actionName">
|
||||
<th mat-header-cell *matHeaderCellDef>Action</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="history-tab__action-badge">{{ row.actionName | translateKey: 'auditTrail.actions' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fieldsChanged">
|
||||
<th mat-header-cell *matHeaderCellDef>Fields Changed</th>
|
||||
<td mat-cell *matCellDef="let row" class="history-tab__fields">{{ fieldsChanged(row) || '—' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="maker">
|
||||
<th mat-header-cell *matHeaderCellDef>Made By</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.maker }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="madeOnDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Made On</th>
|
||||
<td mat-cell *matCellDef="let row" class="history-tab__mono">{{ row.madeOnDate | datetimeFormat }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="processingResult">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span
|
||||
class="history-tab__status"
|
||||
[class.history-tab__status--success]="row.processingResult === 'SUCCESS'"
|
||||
[class.history-tab__status--failure]="row.processingResult === 'FAILURE'"
|
||||
[class.history-tab__status--awaiting]="row.processingResult === 'AWAITING_APPROVAL'"
|
||||
>
|
||||
{{ row.processingResult }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="history-tab__row"
|
||||
[routerLink]="['/system', 'audit-trails', row.id]"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSize]="10" [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.history-tab {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.history-tab__header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.history-tab__table-container {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.history-tab__loader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.history-tab__empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.history-tab__table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-tab__row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.history-tab__fields {
|
||||
max-width: 320px;
|
||||
white-space: normal;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.history-tab__mono {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.history-tab__action-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1074b9;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-tab__status {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--success {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--failure {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&--awaiting {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { SystemService } from 'app/system/system.service';
|
||||
import { DatetimeFormatPipe } from '../../../pipes/datetime-format.pipe';
|
||||
import { TranslatePipe } from '../../../pipes/translate.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Client "History" tab — a read-only, client-scoped view over the EXISTING system-wide
|
||||
* Audit Trail data (no new backend endpoint). Reuses `SystemService.getAuditTrails()`
|
||||
* exactly as `AuditTrailsComponent` does, pre-filtered to `entityName IN (CLIENT, ADDRESS)`
|
||||
* + `resourceId = clientId` — two calls (the underlying `filterBy` shape only carries one
|
||||
* `entityName` value at a time) merged and sorted client-side.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-clients-history-tab',
|
||||
templateUrl: './history-tab.component.html',
|
||||
styleUrls: ['./history-tab.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatProgressBar,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator,
|
||||
DatetimeFormatPipe,
|
||||
TranslatePipe
|
||||
]
|
||||
})
|
||||
export class HistoryTabComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private systemService = inject(SystemService);
|
||||
|
||||
/** Client ID (from the parent `:clientId` route param, same pattern as notes-tab). */
|
||||
clientId: string;
|
||||
|
||||
isLoading = false;
|
||||
displayedColumns: string[] = [
|
||||
'entityName',
|
||||
'actionName',
|
||||
'fieldsChanged',
|
||||
'maker',
|
||||
'madeOnDate',
|
||||
'processingResult'
|
||||
];
|
||||
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
|
||||
constructor() {
|
||||
this.clientId = this.route.parent.snapshot.params['clientId'];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
private auditFilterFor(entityName: string): { type: string; value: string }[] {
|
||||
return [
|
||||
{ type: 'entityName', value: entityName },
|
||||
{ type: 'resourceId', value: this.clientId }
|
||||
];
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
this.isLoading = true;
|
||||
forkJoin({
|
||||
client: this.systemService.getAuditTrails(this.auditFilterFor('CLIENT'), '', '', 0, 200),
|
||||
address: this.systemService.getAuditTrails(this.auditFilterFor('ADDRESS'), '', '', 0, 200)
|
||||
}).subscribe({
|
||||
next: ({ client, address }: { client: any; address: any }) => {
|
||||
const merged = [
|
||||
...(client?.pageItems || []),
|
||||
...(address?.pageItems || [])
|
||||
];
|
||||
// Most recent first — audit trail ids are monotonically increasing.
|
||||
merged.sort((a, b) => (b?.id ?? 0) - (a?.id ?? 0));
|
||||
this.dataSource = new MatTableDataSource(merged);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarises which fields a row changed, parsed from `commandAsJson` — same source
|
||||
* `ViewAuditComponent` parses for its command/value table, just condensed to a
|
||||
* comma-separated field list for this compact history view. Boilerplate command
|
||||
* params (locale/dateFormat) are excluded as noise.
|
||||
*/
|
||||
fieldsChanged(row: any): string {
|
||||
if (!row?.commandAsJson) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(row.commandAsJson);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const ignore = new Set([
|
||||
'locale',
|
||||
'dateFormat'
|
||||
]);
|
||||
return Object.keys(parsed)
|
||||
.filter((key) => !ignore.has(key))
|
||||
.join(', ');
|
||||
}
|
||||
} catch {
|
||||
// Malformed/absent commandAsJson — leave blank rather than throw.
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
|
||||
[class.pd-verify-badge--pending]="clientViewData.isMinor"
|
||||
>
|
||||
{{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
||||
{{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,14 +98,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Account no Column -->
|
||||
<ng-container matColumnDef="accountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Account No' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">
|
||||
<mifosx-account-number display="left" accountNo="{{ row.accountNumber }}"></mifosx-account-number>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- CIF Number Column -->
|
||||
<ng-container matColumnDef="cifNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.CIF Number' | translate }}</th>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user