Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a61b3057 | ||
|
|
7ef75c902d | ||
|
|
c18219fce5 | ||
|
|
2d93f80d50 | ||
|
|
1d1e76497c | ||
|
|
352a65cb5d | ||
|
|
8d67a22f18 | ||
|
|
997e8920e4 | ||
|
|
5581d4180d | ||
|
|
75df8865dd | ||
|
|
5a6fb1f9b5 | ||
|
|
f379b2415c | ||
|
|
c34f6a0131 |
Generated
+289
@@ -44,6 +44,7 @@
|
|||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"ngx-image-cropper": "^9.1.6",
|
"ngx-image-cropper": "^9.1.6",
|
||||||
"ngx-mat-select-search": "^8.0.2",
|
"ngx-mat-select-search": "^8.0.2",
|
||||||
|
"pdfjs-dist": "^5.7.284",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"signature_pad": "^5.1.3",
|
"signature_pad": "^5.1.3",
|
||||||
"tinymce": "^8.2.2",
|
"tinymce": "^8.2.2",
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||||
"@typescript-eslint/parser": "^8.48.1",
|
"@typescript-eslint/parser": "^8.48.1",
|
||||||
"@vendure/ngx-translate-extract": "^9.4.0",
|
"@vendure/ngx-translate-extract": "^9.4.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"cypress": "^13.17.0",
|
"cypress": "^13.17.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"git-describe": "^4.1.1",
|
"git-describe": "^4.1.1",
|
||||||
@@ -3559,6 +3561,13 @@
|
|||||||
"url": "https://github.com/sponsors/JounQin"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.9",
|
"version": "0.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||||
@@ -6126,6 +6135,256 @@
|
|||||||
"linux"
|
"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": {
|
"node_modules/@napi-rs/nice": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz",
|
||||||
@@ -11451,6 +11710,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -19922,6 +20199,18 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
|||||||
+6
-4
@@ -15,10 +15,10 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
|
"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/",
|
"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": "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 && ng serve --host 0.0.0.0 --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 && ng serve --proxy-config proxy.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 && ng serve --source-map --proxy-config proxy.localhost.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",
|
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
|
||||||
"lint": "eslint . && stylelint \"src/**/*.scss\" && prettier . --check && htmlhint \"src\" --config .htmlhintrc",
|
"lint": "eslint . && stylelint \"src/**/*.scss\" && prettier . --check && htmlhint \"src\" --config .htmlhintrc",
|
||||||
"test": "jest --config jest.config.ts",
|
"test": "jest --config jest.config.ts",
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"ngx-image-cropper": "^9.1.6",
|
"ngx-image-cropper": "^9.1.6",
|
||||||
"ngx-mat-select-search": "^8.0.2",
|
"ngx-mat-select-search": "^8.0.2",
|
||||||
|
"pdfjs-dist": "^5.7.284",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"signature_pad": "^5.1.3",
|
"signature_pad": "^5.1.3",
|
||||||
"tinymce": "^8.2.2",
|
"tinymce": "^8.2.2",
|
||||||
@@ -106,6 +107,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||||
"@typescript-eslint/parser": "^8.48.1",
|
"@typescript-eslint/parser": "^8.48.1",
|
||||||
"@vendure/ngx-translate-extract": "^9.4.0",
|
"@vendure/ngx-translate-extract": "^9.4.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"cypress": "^13.17.0",
|
"cypress": "^13.17.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"git-describe": "^4.1.1",
|
"git-describe": "^4.1.1",
|
||||||
|
|||||||
@@ -32,6 +32,28 @@ export class AccountingService {
|
|||||||
return this.http.get('/offices', { params: httpParams });
|
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.
|
* @param {boolean} useHttpParams True if http params should be used.
|
||||||
* @returns {Observable<any>} Accounting rules data.
|
* @returns {Observable<any>} Accounting rules data.
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { Routes, RouterModule } from '@angular/router';
|
|||||||
|
|
||||||
import { Route } from '../core/route/route.service';
|
import { Route } from '../core/route/route.service';
|
||||||
import { AccountsListComponent } from './accounts-list/accounts-list.component';
|
import { AccountsListComponent } from './accounts-list/accounts-list.component';
|
||||||
|
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
|
||||||
|
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
|
||||||
|
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
|
||||||
|
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -41,6 +45,28 @@ const routes: Routes = [
|
|||||||
title: 'Recurring Deposit Accounts',
|
title: 'Recurring Deposit Accounts',
|
||||||
breadcrumb: 'Recurring Deposit Accounts'
|
breadcrumb: 'Recurring Deposit Accounts'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dds',
|
||||||
|
component: PartyMasterComponent,
|
||||||
|
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dds-collection',
|
||||||
|
component: DailyTransactionEntryComponent,
|
||||||
|
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dds-reports',
|
||||||
|
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
|
||||||
|
children: [
|
||||||
|
{ path: '', component: DdsReportsComponent },
|
||||||
|
{
|
||||||
|
path: 'view/:reportName',
|
||||||
|
component: ReportViewerComponent,
|
||||||
|
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
|
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
|
||||||
data: { title: "Today's Activity", breadcrumb: "Today's Activity" }
|
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' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
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 { Observable } from 'rxjs';
|
||||||
|
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||||
|
|
||||||
/** HTTP service for Clerk Withdrawal Request maker-checker workflow. */
|
/** HTTP service for Clerk Withdrawal Request maker-checker workflow. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -50,8 +51,12 @@ export class ClerkWithdrawalService {
|
|||||||
return this.http.post(`/clerk/withdrawal-requests/${id}/reject`, { reason });
|
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> {
|
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> {
|
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
|
||||||
|
|||||||
@@ -925,42 +925,50 @@
|
|||||||
<div class="te-acs-meta">
|
<div class="te-acs-meta">
|
||||||
<span
|
<span
|
||||||
class="te-acs-tag"
|
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--blocked]="accountDetails?.status?.value === 'Blocked'"
|
||||||
[class.te-acs-tag--dormant]="accountDetails?.subStatus?.value === 'Dormant'"
|
[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
|
||||||
>
|
>
|
||||||
<span class="te-acs-kv"
|
<span class="te-acs-kv"
|
||||||
><b>A/c</b>
|
><b>A/c</b>
|
||||||
{{
|
{{
|
||||||
accountDetails?.depositType?.value ||
|
accountDetails?.depositType?.value ||
|
||||||
(selectedRow?.acTyp === 'CA'
|
(activeDividendClaimRequest
|
||||||
? 'Current'
|
? 'Dividend Claim'
|
||||||
: selectedRow?.acTyp === 'SD'
|
: selectedRow?.acTyp === 'CA'
|
||||||
? 'Savings'
|
? 'Current'
|
||||||
: selectedRow?.acTyp === 'FD'
|
: selectedRow?.acTyp === 'SD'
|
||||||
? 'Fixed Dep.'
|
? 'Savings'
|
||||||
: selectedRow?.acTyp === 'RD'
|
: selectedRow?.acTyp === 'FD'
|
||||||
? 'Recurring'
|
? 'Fixed Dep.'
|
||||||
: selectedRow?.acTyp === 'GL'
|
: selectedRow?.acTyp === 'RD'
|
||||||
? (accountDetails?.type?.value ?? 'GL Account')
|
? 'Recurring'
|
||||||
: selectedRow?.acTyp === 'SHR'
|
: selectedRow?.acTyp === 'GL'
|
||||||
? 'Share'
|
? (accountDetails?.type?.value ?? 'GL Account')
|
||||||
: selectedRow?.acTyp === 'LA'
|
: selectedRow?.acTyp === 'SHR'
|
||||||
? 'Loan'
|
? 'Share'
|
||||||
: '—')
|
: selectedRow?.acTyp === 'LA'
|
||||||
|
? 'Loan'
|
||||||
|
: '—')
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
||||||
<span class="te-acs-kv"
|
<span class="te-acs-kv"
|
||||||
><b>Product</b>
|
><b>Product</b>
|
||||||
{{
|
{{
|
||||||
accountDetails?.savingsProductName ||
|
activeDividendClaimRequest
|
||||||
accountDetails?.depositProductName ||
|
? 'Dividend Cash Payout'
|
||||||
accountDetails?.productName ||
|
: accountDetails?.savingsProductName ||
|
||||||
'—'
|
accountDetails?.depositProductName ||
|
||||||
|
accountDetails?.productName ||
|
||||||
|
'—'
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
<span class="te-acs-kv"
|
<span class="te-acs-kv"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
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 { forkJoin, of, Subject } from 'rxjs';
|
||||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@@ -20,6 +20,7 @@ import { CashierService } from '../cashier.service';
|
|||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||||
|
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
|
||||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||||
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||||||
import { CashierDepositService } from '../cashier-deposit.service';
|
import { CashierDepositService } from '../cashier-deposit.service';
|
||||||
@@ -749,6 +750,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Active approved withdrawal (cashier disburse flow)
|
// Active approved withdrawal (cashier disburse flow)
|
||||||
activeWithdrawalRequest: any = null;
|
activeWithdrawalRequest: any = null;
|
||||||
|
activeDividendClaimRequest: any = null;
|
||||||
loadingWithdrawal = false;
|
loadingWithdrawal = false;
|
||||||
|
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
@@ -757,6 +759,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||||
|
private dividendClaimRequestService = inject(DividendClaimRequestService);
|
||||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||||
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||||||
private cashierDepositService = inject(CashierDepositService);
|
private cashierDepositService = inject(CashierDepositService);
|
||||||
@@ -1731,10 +1734,28 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
const token = row.tokNo.trim();
|
const token = row.tokNo.trim();
|
||||||
this.loadingWithdrawal = true;
|
this.loadingWithdrawal = true;
|
||||||
this.activeWithdrawalRequest = null;
|
this.activeWithdrawalRequest = null;
|
||||||
|
this.activeDividendClaimRequest = null;
|
||||||
this.clerkWithdrawalService
|
this.clerkWithdrawalService
|
||||||
.getByToken(token)
|
.getByToken(token)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError((err: any) => {
|
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;
|
this.loadingWithdrawal = false;
|
||||||
const msg: string =
|
const msg: string =
|
||||||
err?.error?.message ??
|
err?.error?.message ??
|
||||||
@@ -1747,6 +1768,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe((req: any) => {
|
.subscribe((req: any) => {
|
||||||
this.loadingWithdrawal = false;
|
this.loadingWithdrawal = false;
|
||||||
if (!req) return;
|
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;
|
this.activeWithdrawalRequest = req;
|
||||||
row.acNo = req.account_no ?? '';
|
row.acNo = req.account_no ?? '';
|
||||||
row.acTyp = 'SD';
|
row.acTyp = 'SD';
|
||||||
@@ -3012,7 +3051,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cashier direct withdrawal BLOCKED — must use approved maker-checker token
|
// 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', [
|
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||||||
'Withdrawals cannot be processed directly by the cashier.',
|
'Withdrawals cannot be processed directly by the cashier.',
|
||||||
'All withdrawals must go through the Clerk → Manager approval process.',
|
'All withdrawals must go through the Clerk → Manager approval process.',
|
||||||
@@ -3021,9 +3066,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approved withdrawal: amount/denomination must exactly match
|
// Approved withdrawal / dividend cash claim: amount/denomination must exactly match
|
||||||
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
|
if (!this.isClerk && (this.activeWithdrawalRequest || this.activeDividendClaimRequest) && !isDeposit) {
|
||||||
const approvedAmt = Number(this.activeWithdrawalRequest.amount);
|
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 (Math.abs(amount - approvedAmt) > 0.005) {
|
||||||
if (isCash) {
|
if (isCash) {
|
||||||
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
|
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
|
||||||
@@ -3116,6 +3167,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
const isCash = !!selectedPtEarly?.isCashPayment;
|
const isCash = !!selectedPtEarly?.isCashPayment;
|
||||||
const isGlType = row.acTyp === 'GL';
|
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
|
// GL transactions post as journal entries — skip account-level facility/balance checks
|
||||||
if (isGlType) {
|
if (isGlType) {
|
||||||
if (!this.cashVaultGlId) {
|
if (!this.cashVaultGlId) {
|
||||||
@@ -3261,7 +3336,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
||||||
// Token-based cashier disbursement: balance was already deducted at manager approval
|
// 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
|
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
|
||||||
const isDepositRequest =
|
const isDepositRequest =
|
||||||
isDeposit &&
|
isDeposit &&
|
||||||
@@ -3292,7 +3368,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
paymentType: selectedPt?.name ?? 'Cash',
|
paymentType: selectedPt?.name ?? 'Cash',
|
||||||
chequeNo: row.chequeNo || null,
|
chequeNo: row.chequeNo || null,
|
||||||
symbol: this.currencySymbol,
|
symbol: this.currencySymbol,
|
||||||
tokNo: this.activeWithdrawalRequest?.tok_no ?? null,
|
tokNo: this.activeWithdrawalRequest?.tok_no ?? this.activeDividendClaimRequest?.tok_no ?? null,
|
||||||
isDdCashGl: this.isDdCashGlRow(row),
|
isDdCashGl: this.isDdCashGlRow(row),
|
||||||
ddFaceValue: row.amount ?? 0,
|
ddFaceValue: row.amount ?? 0,
|
||||||
ddCharges: row.ddCharges ?? 0,
|
ddCharges: row.ddCharges ?? 0,
|
||||||
@@ -3354,9 +3430,52 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
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()
|
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
|
||||||
// Extra safety net in case confirmSubmit() is ever called directly
|
// 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', [
|
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||||||
'Withdrawals must go through the maker-checker approval process.',
|
'Withdrawals must go through the maker-checker approval process.',
|
||||||
'Enter the approved token number to disburse.'
|
'Enter the approved token number to disburse.'
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
-20
@@ -136,7 +136,14 @@
|
|||||||
| translate
|
| translate
|
||||||
}}
|
}}
|
||||||
</mat-label>
|
</mat-label>
|
||||||
<input matInput required formControlName="fullname" autocomplete="off" />
|
<input
|
||||||
|
matInput
|
||||||
|
required
|
||||||
|
formControlName="fullname"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('fullname')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
@if (createClientForm.controls.fullname.hasError('required')) {
|
@if (createClientForm.controls.fullname.hasError('required')) {
|
||||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||||
@@ -151,10 +158,12 @@
|
|||||||
<div class="full-name-row full-width">
|
<div class="full-name-row full-width">
|
||||||
<div class="full-name-preview">
|
<div class="full-name-preview">
|
||||||
<span class="full-name-label">Entity Name:</span>
|
<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>
|
||||||
<div class="verify-inline">
|
<div class="verify-inline">
|
||||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -164,27 +173,27 @@
|
|||||||
(click)="verifySanctions()"
|
(click)="verifySanctions()"
|
||||||
>
|
>
|
||||||
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
||||||
Verify Entity
|
{{ sanctionsNeedsRescreen ? 'Re-verify Entity' : 'Verify Entity' }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (sanctionsVerified) {
|
@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>
|
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="sanctions-info-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"
|
matTooltipClass="sanctions-tooltip"
|
||||||
>info</mat-icon
|
>info</mat-icon
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
@if (sanctionsBlocked) {
|
@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">
|
<span class="verify-status blocked">
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
|
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
|
||||||
</span>
|
</span>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="sanctions-info-icon blocked-info"
|
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"
|
matTooltipClass="sanctions-tooltip"
|
||||||
>info</mat-icon
|
>info</mat-icon
|
||||||
>
|
>
|
||||||
@@ -214,7 +223,14 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field first-name">
|
<mat-form-field appearance="outline" class="name-field first-name">
|
||||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
||||||
<input matInput required formControlName="firstname" autocomplete="off" />
|
<input
|
||||||
|
matInput
|
||||||
|
required
|
||||||
|
formControlName="firstname"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('firstname')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
@if (createClientForm.controls.firstname.hasError('required')) {
|
@if (createClientForm.controls.firstname.hasError('required')) {
|
||||||
<mat-error>First name is <strong>required</strong></mat-error>
|
<mat-error>First name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||||
@@ -228,7 +244,13 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
||||||
<input matInput formControlName="middlename" autocomplete="off" />
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="middlename"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('middlename')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
@if (
|
@if (
|
||||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||||
createClientForm.controls.middlename?.touched
|
createClientForm.controls.middlename?.touched
|
||||||
@@ -241,7 +263,18 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field last-name">
|
<mat-form-field appearance="outline" class="name-field last-name">
|
||||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
||||||
<input matInput required formControlName="lastname" autocomplete="off" />
|
<<<<<<< Updated upstream
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
required
|
||||||
|
formControlName="lastname"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('lastname')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
|
=======
|
||||||
|
<input matInput required formControlName="lastname" autocomplete="off" (input)="formatName('lastname')" />
|
||||||
|
>>>>>>> Stashed changes
|
||||||
@if (createClientForm.controls.lastname.hasError('required')) {
|
@if (createClientForm.controls.lastname.hasError('required')) {
|
||||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||||
@@ -261,7 +294,7 @@
|
|||||||
<span class="full-name-value">{{ fullNamePreview }}</span>
|
<span class="full-name-value">{{ fullNamePreview }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="verify-inline">
|
<div class="verify-inline">
|
||||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -271,27 +304,27 @@
|
|||||||
(click)="verifySanctions()"
|
(click)="verifySanctions()"
|
||||||
>
|
>
|
||||||
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
||||||
Verify Client
|
{{ sanctionsNeedsRescreen ? 'Re-verify Client' : 'Verify Client' }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (sanctionsVerified) {
|
@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>
|
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="sanctions-info-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"
|
matTooltipClass="sanctions-tooltip"
|
||||||
>info</mat-icon
|
>info</mat-icon
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
@if (sanctionsBlocked) {
|
@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">
|
<span class="verify-status blocked">
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
|
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
|
||||||
</span>
|
</span>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="sanctions-info-icon blocked-info"
|
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"
|
matTooltipClass="sanctions-tooltip"
|
||||||
>info</mat-icon
|
>info</mat-icon
|
||||||
>
|
>
|
||||||
@@ -694,6 +727,9 @@
|
|||||||
}
|
}
|
||||||
</mat-form-field>
|
</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) -->
|
<!-- Authorized Signatories (multiple, registered clients) -->
|
||||||
<div class="form-field full-width signatory-section">
|
<div class="form-field full-width signatory-section">
|
||||||
<h4 class="section-title signatory-title">
|
<h4 class="section-title signatory-title">
|
||||||
@@ -822,7 +858,8 @@
|
|||||||
<div class="guardian-result-item__info">
|
<div class="guardian-result-item__info">
|
||||||
<span class="guardian-result-item__name">{{ result.displayName }}</span>
|
<span class="guardian-result-item__name">{{ result.displayName }}</span>
|
||||||
<span class="guardian-result-item__meta"
|
<span class="guardian-result-item__meta"
|
||||||
>ID: {{ result.id }} · A/C: {{ result.accountNumber }} ·
|
>ID: {{ result.id }} · A/C: {{ result.accountNumber }}
|
||||||
|
·
|
||||||
{{ result.officeName }}
|
{{ result.officeName }}
|
||||||
@if (result.dateOfBirth) {
|
@if (result.dateOfBirth) {
|
||||||
· DOB: {{ result.dateOfBirth | dateFormat }}
|
· DOB: {{ result.dateOfBirth | dateFormat }}
|
||||||
@@ -1148,6 +1185,79 @@
|
|||||||
</div>
|
</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) -->
|
<!-- Section: Financial & Classification (Individual only) -->
|
||||||
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
|
|||||||
@@ -56,6 +56,103 @@ form {
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.senior-citizen-auto-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+288
-34
@@ -32,6 +32,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
|
|||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||||
|
import { toProperNameCase } from 'app/clients/utils/name-case.util';
|
||||||
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -333,6 +334,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
alternateDipLookupLogId: number | null = null;
|
alternateDipLookupLogId: number | null = null;
|
||||||
/** Alternate DIP lookup timestamp from API */
|
/** Alternate DIP lookup timestamp from API */
|
||||||
alternateDipTimestamp: string | null = null;
|
alternateDipTimestamp: string | null = null;
|
||||||
|
/**
|
||||||
|
* DIP/MNRL status the number actually returned (CLEAN / SUSPECTED / SERVICE_UNAVAILABLE).
|
||||||
|
* Kept separately from `mobileVerified` so an unverifiable number is not indistinguishable
|
||||||
|
* from one that genuinely passed.
|
||||||
|
*/
|
||||||
|
dipStatus: string | null = null;
|
||||||
|
alternateDipStatus: string | null = null;
|
||||||
|
/** Officer rejected the number outright — it must be replaced before saving. */
|
||||||
|
dipRejected = false;
|
||||||
|
alternateDipRejected = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
|
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
|
||||||
@@ -529,8 +540,37 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
sanctionsVerified = false;
|
sanctionsVerified = false;
|
||||||
/** Sanctions match was found — blocks account opening */
|
/** Sanctions match was found — blocks account opening */
|
||||||
sanctionsBlocked = false;
|
sanctionsBlocked = false;
|
||||||
|
/**
|
||||||
|
* Outcome recorded against a match: 'FALSE_POSITIVE' (awaiting Principal Officer approval)
|
||||||
|
* or 'CONFIRMED_MATCH'. Null when the hit has not been reviewed yet. Both keep the client
|
||||||
|
* blocked — only the Principal Officer's approval, applied server-side, releases a name.
|
||||||
|
*/
|
||||||
|
sanctionsDisposition: string | null = null;
|
||||||
|
/**
|
||||||
|
* The name was edited after a block, so the applicant may no longer be the flagged one.
|
||||||
|
* Re-exposes the verify button; the block itself is only lifted by a CLEAR from the API.
|
||||||
|
*/
|
||||||
|
sanctionsNeedsRescreen = false;
|
||||||
/** Screening log ID from sanctions API */
|
/** Screening log ID from sanctions API */
|
||||||
screeningLogId: number | null = null;
|
screeningLogId: number | null = null;
|
||||||
|
|
||||||
|
/** Cites the direction actually in force — kept in step with the verification dialog. */
|
||||||
|
readonly sanctionsCitation = 'UAPA 1967 s.51-A & RBI KYC Directions, 2025 — Chapter IX';
|
||||||
|
readonly sanctionsTooltip =
|
||||||
|
'All customer names must be verified against the UN Security Council Consolidated List before account ' +
|
||||||
|
'opening and periodically thereafter. A match is not a rejection: compare the identifiers, record a ' +
|
||||||
|
'false positive with reasons, or confirm the match and escalate to the Principal Officer immediately.';
|
||||||
|
|
||||||
|
/** Badge text reflecting how far the flagged alert has progressed. */
|
||||||
|
get sanctionsStatusLabel(): string {
|
||||||
|
if (this.sanctionsDisposition === 'FALSE_POSITIVE') {
|
||||||
|
return 'Awaiting PO Approval';
|
||||||
|
}
|
||||||
|
if (this.sanctionsDisposition === 'CONFIRMED_MATCH') {
|
||||||
|
return 'Confirmed Match';
|
||||||
|
}
|
||||||
|
return 'Sanctions Match';
|
||||||
|
}
|
||||||
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
||||||
private verifiedName: string = '';
|
private verifiedName: string = '';
|
||||||
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
|
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
|
||||||
@@ -654,18 +694,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
if (this.createClientForm.get('guardianDetails')) {
|
if (this.createClientForm.get('guardianDetails')) {
|
||||||
this.createClientForm.removeControl('guardianDetails');
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
}
|
}
|
||||||
|
// Same reasoning as the guardian above: the reused component instance would otherwise carry the
|
||||||
|
// previous entity's authorized signatories into a brand new client.
|
||||||
|
this.signatories = [];
|
||||||
|
this.signatorySearchText = '';
|
||||||
|
this.signatorySearchResults = [];
|
||||||
// Verification state must not carry over — a previous client's cleared sanctions
|
// Verification state must not carry over — a previous client's cleared sanctions
|
||||||
// screening or verified OTP must not silently apply to a different new client.
|
// screening or verified OTP must not silently apply to a different new client.
|
||||||
this.mobileVerified = false;
|
this.mobileVerified = false;
|
||||||
this.dipLookupLogId = null;
|
this.dipLookupLogId = null;
|
||||||
this.dipTimestamp = null;
|
this.dipTimestamp = null;
|
||||||
|
this.dipStatus = null;
|
||||||
|
this.dipRejected = false;
|
||||||
this.alternateMobileVerified = false;
|
this.alternateMobileVerified = false;
|
||||||
this.alternateDipLookupLogId = null;
|
this.alternateDipLookupLogId = null;
|
||||||
this.alternateDipTimestamp = null;
|
this.alternateDipTimestamp = null;
|
||||||
|
this.alternateDipStatus = null;
|
||||||
|
this.alternateDipRejected = false;
|
||||||
this.mobileNewOtpId = null;
|
this.mobileNewOtpId = null;
|
||||||
this.emailNewOtpId = null;
|
this.emailNewOtpId = null;
|
||||||
this.sanctionsVerified = false;
|
this.sanctionsVerified = false;
|
||||||
this.sanctionsBlocked = false;
|
this.sanctionsBlocked = false;
|
||||||
|
this.sanctionsDisposition = null;
|
||||||
|
this.sanctionsNeedsRescreen = false;
|
||||||
this.screeningLogId = null;
|
this.screeningLogId = null;
|
||||||
this.verifiedName = '';
|
this.verifiedName = '';
|
||||||
}
|
}
|
||||||
@@ -777,8 +828,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
|
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
|
||||||
remarks: data.clientNonPersonDetails.remarks || '',
|
remarks: data.clientNonPersonDetails.remarks || '',
|
||||||
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
|
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
|
||||||
|
authorizedSignatoryClientIds: data.clientNonPersonDetails.authorizedSignatoryClientIds || '',
|
||||||
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
|
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
|
||||||
});
|
});
|
||||||
|
this.restoreSignatories(
|
||||||
|
data.clientNonPersonDetails.authorizedSignatoryClientIds,
|
||||||
|
data.clientNonPersonDetails.authorizedSignatory
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,7 +1027,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||||
* and annual income for annual/yearly turnover).
|
* and annual income for annual/yearly turnover), but only if the turnover fields
|
||||||
|
* have not been manually modified or cleared.
|
||||||
*/
|
*/
|
||||||
private linkIncomeToTurnover(): void {
|
private linkIncomeToTurnover(): void {
|
||||||
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||||
@@ -979,23 +1036,33 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const annualIncome = this.createClientForm.get('annualIncome');
|
const annualIncome = this.createClientForm.get('annualIncome');
|
||||||
const annualTurnover = this.createClientForm.get('annualTurnover');
|
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||||
|
|
||||||
if (monthlyIncome && monthlyTurnover) {
|
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
|
||||||
|
|
||||||
|
if (monthlyIncome && monthlyTurnover && annualTurnover) {
|
||||||
monthlyIncome.valueChanges.subscribe((value) => {
|
monthlyIncome.valueChanges.subscribe((value) => {
|
||||||
if (this.suppressMonthlyAnnualLink) {
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const num = parseFloat(value);
|
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
||||||
|
if (isPristine || isTurnoverEmpty) {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (annualIncome && annualTurnover) {
|
if (annualIncome && annualTurnover && monthlyTurnover) {
|
||||||
annualIncome.valueChanges.subscribe((value) => {
|
annualIncome.valueChanges.subscribe((value) => {
|
||||||
if (this.suppressMonthlyAnnualLink) {
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const num = parseFloat(value);
|
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
||||||
|
if (isPristine || isTurnoverEmpty) {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1189,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
mainBusinessLineId: [''],
|
mainBusinessLineId: [''],
|
||||||
incorpNumber: [''],
|
incorpNumber: [''],
|
||||||
authorizedSignatory: [''],
|
authorizedSignatory: [''],
|
||||||
|
// Client ids behind the names above, in the same order — what lets an edit rebuild the
|
||||||
|
// signatory rows with their client links and PAN/DIN state. No input is bound to it;
|
||||||
|
// syncSignatoryNames() keeps it in step with the chips.
|
||||||
|
authorizedSignatoryClientIds: [''],
|
||||||
additionalInfo: [''],
|
additionalInfo: [''],
|
||||||
remarks: ['']
|
remarks: ['']
|
||||||
})
|
})
|
||||||
@@ -1242,6 +1313,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.mobileVerified = false;
|
this.mobileVerified = false;
|
||||||
|
// The rejection applied to the previous number, not this one. Leaving it set would
|
||||||
|
// keep Save & Next dead with a message about a number no longer in the field.
|
||||||
|
this.dipRejected = false;
|
||||||
|
this.dipStatus = null;
|
||||||
|
this.dipLookupLogId = null;
|
||||||
// Also reset create-time contact-OTP verification — an OTP verified against a
|
// Also reset create-time contact-OTP verification — an OTP verified against a
|
||||||
// previous mobile number no longer applies once the value is edited. Belt-and-braces
|
// previous mobile number no longer applies once the value is edited. Belt-and-braces
|
||||||
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
|
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
|
||||||
@@ -1256,6 +1332,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.alternateMobileVerified = false;
|
this.alternateMobileVerified = false;
|
||||||
|
this.alternateDipRejected = false;
|
||||||
|
this.alternateDipStatus = null;
|
||||||
|
this.alternateDipLookupLogId = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset create-time contact-OTP verification when email address changes — see the
|
// Reset create-time contact-OTP verification when email address changes — see the
|
||||||
@@ -1296,9 +1375,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((result: any) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (result.verified) {
|
// A flagged number the officer chose to proceed with counts as verified — the decision and
|
||||||
this.mobileVerified = true;
|
// its reason are already stored against the lookup, which is what the save gate checks.
|
||||||
}
|
this.mobileVerified = result.verified === true || result.decision === 'PROCEEDED';
|
||||||
|
this.dipRejected = result.decision === 'REJECTED';
|
||||||
|
this.dipStatus = result.status || null;
|
||||||
this.dipLookupLogId = result.lookupLogId || null;
|
this.dipLookupLogId = result.lookupLogId || null;
|
||||||
this.dipTimestamp = result.timestamp || null;
|
this.dipTimestamp = result.timestamp || null;
|
||||||
});
|
});
|
||||||
@@ -1316,9 +1397,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((result: any) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (result.verified) {
|
this.alternateMobileVerified = result.verified === true || result.decision === 'PROCEEDED';
|
||||||
this.alternateMobileVerified = true;
|
this.alternateDipRejected = result.decision === 'REJECTED';
|
||||||
}
|
this.alternateDipStatus = result.status || null;
|
||||||
this.alternateDipLookupLogId = result.lookupLogId || null;
|
this.alternateDipLookupLogId = result.lookupLogId || null;
|
||||||
this.alternateDipTimestamp = result.timestamp || null;
|
this.alternateDipTimestamp = result.timestamp || null;
|
||||||
});
|
});
|
||||||
@@ -1343,6 +1424,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.removeGuardian();
|
this.removeGuardian();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
capitalizeControl(controlName: string) {
|
||||||
|
const control = this.createClientForm.get(controlName);
|
||||||
|
if (control && control.value) {
|
||||||
|
const originalValue = control.value;
|
||||||
|
const capitalized = toProperNameCase(originalValue);
|
||||||
|
if (originalValue !== capitalized) {
|
||||||
|
control.setValue(capitalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify client name against sanctions lists via dialog.
|
* Verify client name against sanctions lists via dialog.
|
||||||
* Passes the client name to the dialog for real API screening.
|
* Passes the client name to the dialog for real API screening.
|
||||||
@@ -1350,10 +1442,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
*/
|
*/
|
||||||
verifySanctions() {
|
verifySanctions() {
|
||||||
const name = this.currentNameSnapshot();
|
const name = this.currentNameSnapshot();
|
||||||
|
const dob = this.createClientForm.get('dateOfBirth')?.value;
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
|
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
|
||||||
width: '550px',
|
width: '620px',
|
||||||
data: { name }
|
data: {
|
||||||
|
name,
|
||||||
|
// The dialog compares these against the listed particulars — a name-only hit cannot be
|
||||||
|
// judged without the applicant's own identifiers next to it.
|
||||||
|
dateOfBirth: dob ? new Date(dob).toLocaleDateString('en-IN') : '',
|
||||||
|
nationality: this.nationalityLabel()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((result: any) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
@@ -1362,16 +1461,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
// CLEAR — no match found
|
// CLEAR — no match found
|
||||||
this.sanctionsVerified = true;
|
this.sanctionsVerified = true;
|
||||||
this.sanctionsBlocked = false;
|
this.sanctionsBlocked = false;
|
||||||
|
this.sanctionsDisposition = null;
|
||||||
|
this.sanctionsNeedsRescreen = false;
|
||||||
this.verifiedName = name;
|
this.verifiedName = name;
|
||||||
} else if (result.verified === false) {
|
} else if (result.verified === false) {
|
||||||
// FLAGGED — match found, block account opening
|
// FLAGGED — blocked either way. A FALSE_POSITIVE is a claim awaiting the Principal
|
||||||
|
// Officer, not a clearance; only their approval lets this name through.
|
||||||
this.sanctionsVerified = false;
|
this.sanctionsVerified = false;
|
||||||
this.sanctionsBlocked = true;
|
this.sanctionsBlocked = true;
|
||||||
|
this.sanctionsDisposition = result.disposition || null;
|
||||||
|
this.sanctionsNeedsRescreen = false;
|
||||||
this.verifiedName = '';
|
this.verifiedName = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human-readable nationality for the sanctions comparison panel. */
|
||||||
|
private nationalityLabel(): string {
|
||||||
|
return this.getSelectedNationality()?.label || '';
|
||||||
|
}
|
||||||
|
|
||||||
/** Concatenated current name from the form (handles individual + entity) */
|
/** Concatenated current name from the form (handles individual + entity) */
|
||||||
private currentNameSnapshot(): string {
|
private currentNameSnapshot(): string {
|
||||||
const firstname = this.createClientForm.get('firstname')?.value || '';
|
const firstname = this.createClientForm.get('firstname')?.value || '';
|
||||||
@@ -1411,9 +1520,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
// Only invalidate if a verification was previously recorded
|
// Only invalidate if a verification was previously recorded
|
||||||
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
|
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
|
||||||
if (this.currentNameSnapshot() !== this.verifiedName) {
|
if (this.currentNameSnapshot() !== this.verifiedName) {
|
||||||
|
// Editing the name forces a fresh screening, but must NOT silently clear an existing
|
||||||
|
// block: otherwise a flagged applicant is waved through by nudging one character. The
|
||||||
|
// block is only lifted by re-running the check and getting a CLEAR back from the API.
|
||||||
this.sanctionsVerified = false;
|
this.sanctionsVerified = false;
|
||||||
this.sanctionsBlocked = false;
|
|
||||||
this.screeningLogId = null;
|
this.screeningLogId = null;
|
||||||
|
if (this.sanctionsBlocked) {
|
||||||
|
this.sanctionsNeedsRescreen = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1467,6 +1581,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
target.mobileNo = data.mobileNo || null;
|
target.mobileNo = data.mobileNo || null;
|
||||||
target.emailAddress = data.emailAddress || null;
|
target.emailAddress = data.emailAddress || null;
|
||||||
target.officeName = data.officeName || null;
|
target.officeName = data.officeName || null;
|
||||||
|
// Filled in only when the caller has not supplied them. A signatory restored from stored
|
||||||
|
// ids starts as a bare { id } and needs both to render its row; one picked from the search
|
||||||
|
// dropdown already carries them, and a guardian must keep whatever it was given.
|
||||||
|
target.displayName = target.displayName || data.displayName;
|
||||||
|
target.status = target.status || data.status;
|
||||||
target._loading = false;
|
target._loading = false;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -1480,6 +1599,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
target.panVerified = kyc?.panVerified || false;
|
target.panVerified = kyc?.panVerified || false;
|
||||||
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
|
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
|
||||||
target.aadhaarVerified = kyc?.aadhaarVerified || false;
|
target.aadhaarVerified = kyc?.aadhaarVerified || false;
|
||||||
|
// Same reasoning as above: a DIN verified in an earlier session lives on the signatory's own
|
||||||
|
// KYC record, so re-reading it here is what stops the edit screen asking for it again.
|
||||||
|
target.dinNumber = target.dinNumber || kyc?.dinNumber || '';
|
||||||
|
target.dinVerified = target.dinVerified || kyc?.dinVerified || false;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
target.panNumber = '-';
|
target.panNumber = '-';
|
||||||
@@ -1678,9 +1801,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the authorized-signatory rows when an existing entity client is opened for edit.
|
||||||
|
*
|
||||||
|
* Driven by the stored client ids rather than the stored names: the ids are what make each row a
|
||||||
|
* real client again, so its CIF, PAN and DIN verification can be re-read instead of the officer
|
||||||
|
* having to re-add and re-verify signatories that were already recorded. The saved names are used
|
||||||
|
* only as the label until the lookup returns, and as the fallback for a client that can no longer
|
||||||
|
* be fetched, so a row is never silently dropped.
|
||||||
|
*
|
||||||
|
* Clients created before the ids were stored have names but no ids; those still list, as
|
||||||
|
* name-only rows, rather than showing nothing at all.
|
||||||
|
*/
|
||||||
|
private restoreSignatories(storedIds: string | null | undefined, storedNames: string | null | undefined): void {
|
||||||
|
const ids = (storedIds || '')
|
||||||
|
.split(',')
|
||||||
|
.map((id: string) => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const names = (storedNames || '')
|
||||||
|
.split(',')
|
||||||
|
.map((name: string) => name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!ids.length) {
|
||||||
|
this.signatories = names.map((displayName: string) => ({ displayName }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.signatories = ids.map((id: string, index: number) => ({
|
||||||
|
id: Number(id),
|
||||||
|
displayName: names[index] || '',
|
||||||
|
_loading: true
|
||||||
|
}));
|
||||||
|
this.signatories.forEach((sig: any) => this.fetchClientFullDetails(sig.id, sig));
|
||||||
|
}
|
||||||
|
|
||||||
private syncSignatoryNames(): void {
|
private syncSignatoryNames(): void {
|
||||||
const names = this.signatories.map((s) => s.displayName).join(', ');
|
const names = this.signatories.map((s) => s.displayName).join(', ');
|
||||||
this.createClientForm.get('clientNonPersonDetails')?.patchValue({ authorizedSignatory: names });
|
// Ids are written in the same order as the names, so the two columns line up on reload.
|
||||||
|
const clientIds = this.signatories.map((s) => s.id).join(',');
|
||||||
|
this.createClientForm.get('clientNonPersonDetails')?.patchValue({
|
||||||
|
authorizedSignatory: names,
|
||||||
|
authorizedSignatoryClientIds: clientIds
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Watch MSME turnover & investment for auto-classification */
|
/** Watch MSME turnover & investment for auto-classification */
|
||||||
@@ -2164,6 +2327,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
delete generalDetails[key];
|
delete generalDetails[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Entity name is normalised here as well as on blur: a pasted/auto-filled value, or one the
|
||||||
|
// user never blurred out of before submitting, would otherwise reach Fineract exactly as typed.
|
||||||
|
if (generalDetails.fullname) {
|
||||||
|
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
||||||
|
}
|
||||||
|
|
||||||
if (generalDetails.submittedOnDate instanceof Date) {
|
if (generalDetails.submittedOnDate instanceof Date) {
|
||||||
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
||||||
}
|
}
|
||||||
@@ -2233,15 +2402,86 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
];
|
];
|
||||||
unsupportedFields.forEach((f) => delete generalDetails[f]);
|
unsupportedFields.forEach((f) => delete generalDetails[f]);
|
||||||
|
|
||||||
// Format dateOfRegistration if present
|
return generalDetails;
|
||||||
if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) {
|
}
|
||||||
generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate(
|
|
||||||
generalDetails.clientNonPersonDetails.dateOfRegistration,
|
/**
|
||||||
dateFormat
|
* Turnover ladder that decides an entity's risk when no configured risk rule matches it.
|
||||||
);
|
*
|
||||||
|
* A company's size is the one thing about it the branch always knows at onboarding, and the
|
||||||
|
* bigger the book the more exposure it carries — so the band moves up with annual turnover
|
||||||
|
* instead of every entity landing on a flat Medium regardless of whether it turns over ten lakh
|
||||||
|
* or a hundred crore. Thresholds follow the MSME-style split the business already thinks in
|
||||||
|
* (crores), and are only a fallback: a rule configured in m_client_risk_rule with
|
||||||
|
* minAnnualTurnover / maxAnnualTurnover still wins, which is where a bank sets its own numbers.
|
||||||
|
*
|
||||||
|
* Amounts are in rupees. `upTo: null` is the open-ended top band.
|
||||||
|
*/
|
||||||
|
private static readonly ENTITY_TURNOVER_RISK_BANDS: { upTo: number | null; category: number; label: string }[] = [
|
||||||
|
{ upTo: 10000000, category: 1, label: 'up to ₹1 crore' },
|
||||||
|
{ upTo: 100000000, category: 2, label: '₹1 crore to ₹10 crore' },
|
||||||
|
{ upTo: null, category: 3, label: 'above ₹10 crore' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
|
||||||
|
riskCategoryLabel(category: number): string {
|
||||||
|
if (category === 3) {
|
||||||
|
return 'High Risk';
|
||||||
|
}
|
||||||
|
return category === 2 ? 'Medium Risk' : 'Low Risk';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity risk from annual turnover, for the fallback path only.
|
||||||
|
*
|
||||||
|
* AML is deliberately left CLEAR even in the top band. A configured High-risk rule defaults to
|
||||||
|
* FLAGGED because someone decided that case warrants it; turnover on its own is a size signal,
|
||||||
|
* not a suspicion, and auto-flagging every large company would put them all in the AML queue.
|
||||||
|
*/
|
||||||
|
private entityRiskFromTurnover(annualTurnover: number): {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus: string;
|
||||||
|
bannedEntityMatch: string;
|
||||||
|
willfulDefaulter: string;
|
||||||
|
ecgcSalFlag: string;
|
||||||
|
dormantFlag: string;
|
||||||
|
} {
|
||||||
|
const base = {
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nothing captured yet — the entity default stands until a turnover is entered.
|
||||||
|
if (!annualTurnover || annualTurnover <= 0) {
|
||||||
|
return {
|
||||||
|
category: 2,
|
||||||
|
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||||
|
...base
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return generalDetails;
|
const band =
|
||||||
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.find((b) => b.upTo == null || annualTurnover <= b.upTo) ??
|
||||||
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS[
|
||||||
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.length - 1
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: band.category,
|
||||||
|
reason: `Annual turnover ₹${annualTurnover.toLocaleString('en-IN')} — ${band.label} (${this.riskCategoryLabel(
|
||||||
|
band.category
|
||||||
|
)})`,
|
||||||
|
...base
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live risk band for the entity turnover card in the General step. */
|
||||||
|
get entityTurnoverRisk(): { category: number; reason: string } {
|
||||||
|
return this.calculatedRisk;
|
||||||
}
|
}
|
||||||
|
|
||||||
get calculatedRisk(): {
|
get calculatedRisk(): {
|
||||||
@@ -2338,15 +2578,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
// Default Fallbacks
|
// Default Fallbacks
|
||||||
if (legalFormId !== LegalFormId.PERSON) {
|
if (legalFormId !== LegalFormId.PERSON) {
|
||||||
return {
|
return this.entityRiskFromTurnover(annualTurnover);
|
||||||
category: 2,
|
|
||||||
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
|
||||||
amlCheckStatus: 'CLEAR',
|
|
||||||
bannedEntityMatch: 'NO',
|
|
||||||
willfulDefaulter: 'NO',
|
|
||||||
ecgcSalFlag: 'NO',
|
|
||||||
dormantFlag: 'ACTIVE'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
category: 1,
|
category: 1,
|
||||||
@@ -2358,6 +2590,28 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
dormantFlag: 'ACTIVE'
|
dormantFlag: 'ACTIVE'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically capitalizes the first letter of each word and lowercases the rest.
|
||||||
|
* e.g., "sri rAm" -> "Sri Ram"
|
||||||
|
*/
|
||||||
|
formatName(controlName: string): void {
|
||||||
|
const control = this.createClientForm.get(controlName);
|
||||||
|
if (!control || typeof control.value !== 'string') return;
|
||||||
|
|
||||||
|
const val = control.value;
|
||||||
|
const formatted = val
|
||||||
|
.split(' ')
|
||||||
|
.map((word: string) => {
|
||||||
|
if (!word) return '';
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (formatted !== val) {
|
||||||
|
control.setValue(formatted, { emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
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>
|
<span class="warning-text">This may take a few seconds. Do not close or refresh this window.</span>
|
||||||
</div>
|
</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) {
|
@if (!verifying && serviceUnavailable) {
|
||||||
<div class="verify-result">
|
<div class="verify-result unavailable">
|
||||||
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
|
<fa-icon icon="exclamation-triangle" class="result-icon unavailable-icon"></fa-icon>
|
||||||
<p class="result-msg">DIP & MNRL verification completed successfully.</p>
|
<p class="result-msg unavailable-msg">Could not verify — DIP/MNRL service unavailable</p>
|
||||||
<div class="success-detail">
|
<div class="unavailable-detail">
|
||||||
<fa-icon icon="shield-alt" class="success-detail-icon"></fa-icon>
|
This number has <strong>not</strong> been checked against the DoT Digital Intelligence Platform. Retry once
|
||||||
<span>Mobile number is <strong>CLEAN</strong> — not found in the revocation list.</span>
|
the service is reachable, or proceed with a recorded reason.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -66,11 +80,63 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<p class="result-msg error-msg">{{ error }}</p>
|
<p class="result-msg error-msg">{{ error }}</p>
|
||||||
</div>
|
</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-content>
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
@if (!verifying) {
|
@if (!verifying && !needsDecision) {
|
||||||
<button mat-raised-button color="primary" (click)="close()">OK</button>
|
<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>
|
</mat-dialog-actions>
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
@@ -228,13 +294,100 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
color: #d32f2f;
|
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 {
|
mat-dialog-actions {
|
||||||
padding: 8px 24px 16px;
|
padding: 8px 24px 16px;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
@@ -255,6 +408,25 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
suspectedStatus = '';
|
suspectedStatus = '';
|
||||||
suspectedReason = '';
|
suspectedReason = '';
|
||||||
suspectedReportedBy = '';
|
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 subscription: any;
|
||||||
private intervalId: any;
|
private intervalId: any;
|
||||||
private apiResponse: any = null;
|
private apiResponse: any = null;
|
||||||
@@ -305,6 +477,7 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = this.apiResponse;
|
const res = this.apiResponse;
|
||||||
|
this.priorRejection = res?.priorRejection || null;
|
||||||
if (res?.data?.status === 'SERVICE_UNAVAILABLE') {
|
if (res?.data?.status === 'SERVICE_UNAVAILABLE') {
|
||||||
this.serviceUnavailable = true;
|
this.serviceUnavailable = true;
|
||||||
this.resultMessage = res.message || 'DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.';
|
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() {
|
close() {
|
||||||
const verified = !this.suspected && !this.error;
|
const verified = !this.suspected && !this.error && !this.serviceUnavailable;
|
||||||
this.dialogRef.close({
|
this.dialogRef.close({
|
||||||
verified,
|
verified,
|
||||||
lookupLogId: this.apiResponse?.lookupLogId || null,
|
lookupLogId: this.apiResponse?.lookupLogId || null,
|
||||||
|
status: this.apiResponse?.data?.status || null,
|
||||||
timestamp: this.apiResponse?.timestamp || null
|
timestamp: this.apiResponse?.timestamp || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+248
-8
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
|
|
||||||
@@ -118,10 +119,78 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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">
|
<div class="escalate-banner">
|
||||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||||
</div>
|
</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>
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -141,9 +210,26 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!verifying && matchFound) {
|
@if (!verifying && matchFound) {
|
||||||
<button mat-raised-button color="warn" (click)="close(false)" class="reject-btn">
|
@if (!showReasonForm) {
|
||||||
<fa-icon icon="times"></fa-icon> Flag & Escalate
|
<button mat-stroked-button (click)="startDisposition('FALSE_POSITIVE')" class="approve-btn-secondary">
|
||||||
</button>
|
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) {
|
@if (!verifying && errorOccurred) {
|
||||||
<button mat-raised-button (click)="close(false)">Close</button>
|
<button mat-raised-button (click)="close(false)">Close</button>
|
||||||
@@ -174,8 +260,13 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
|
|
||||||
mat-dialog-content {
|
mat-dialog-content {
|
||||||
padding: 16px 24px;
|
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 {
|
.progress-section {
|
||||||
@@ -489,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
|
|
||||||
.match-list {
|
.match-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: unset;
|
|
||||||
overflow: visible;
|
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +620,84 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
margin-top: 4px;
|
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 {
|
.block-msg {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -545,11 +712,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
|
|
||||||
mat-dialog-actions {
|
mat-dialog-actions {
|
||||||
padding: 8px 24px 16px;
|
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: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
@@ -558,10 +729,13 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
})
|
})
|
||||||
export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||||
private dialogRef = inject(MatDialogRef<SanctionsVerifyDialogComponent>);
|
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);
|
private clientsService = inject(ClientsService);
|
||||||
|
|
||||||
clientName = '';
|
clientName = '';
|
||||||
|
/** Applicant's own identifiers, shown beside the listed particulars for comparison. */
|
||||||
|
applicantDob = '';
|
||||||
|
applicantNationality = '';
|
||||||
verifying = true;
|
verifying = true;
|
||||||
matchFound = false;
|
matchFound = false;
|
||||||
matchCount = 0;
|
matchCount = 0;
|
||||||
@@ -571,6 +745,19 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
screeningLogId: number | null = null;
|
screeningLogId: number | null = null;
|
||||||
listDataAsOf: string | 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
|
* 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
|
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
|
||||||
@@ -599,6 +786,8 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.clientName = this.data?.name || '';
|
this.clientName = this.data?.name || '';
|
||||||
|
this.applicantDob = this.data?.dateOfBirth || '';
|
||||||
|
this.applicantNationality = this.data?.nationality || '';
|
||||||
this.dialogRef.disableClose = true;
|
this.dialogRef.disableClose = true;
|
||||||
|
|
||||||
// Start API call immediately
|
// Start API call immediately
|
||||||
@@ -710,6 +899,57 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
clearInterval(this.progressTimer);
|
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) {
|
close(verified: boolean) {
|
||||||
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
|
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-9
@@ -122,8 +122,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="addr-proof-field">
|
<div class="addr-proof-field">
|
||||||
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
|
<div
|
||||||
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly">
|
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) {
|
@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-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
|
||||||
<div class="addr-chip-grid">
|
<div class="addr-chip-grid">
|
||||||
@@ -131,11 +164,11 @@
|
|||||||
<span
|
<span
|
||||||
class="addr-chip"
|
class="addr-chip"
|
||||||
[class.addr-chip--sel]="isAddrProofSelected(p)"
|
[class.addr-chip--sel]="isAddrProofSelected(p)"
|
||||||
[class.addr-chip--locked]="readOnly && !isAddrProofSelected(p)"
|
[class.addr-chip--locked]="(readOnly || isAddressProofLinkedToKyc) && !isAddrProofSelected(p)"
|
||||||
[attr.aria-disabled]="readOnly ? 'true' : null"
|
[attr.aria-disabled]="readOnly || isAddressProofLinkedToKyc ? 'true' : null"
|
||||||
(click)="readOnly ? null : selectAddrProof(p)"
|
(click)="readOnly || isAddressProofLinkedToKyc ? null : selectAddrProof(p)"
|
||||||
(keydown.enter)="readOnly ? null : selectAddrProof(p)"
|
(keydown.enter)="readOnly || isAddressProofLinkedToKyc ? null : selectAddrProof(p)"
|
||||||
[attr.tabindex]="readOnly ? -1 : 0"
|
[attr.tabindex]="readOnly || isAddressProofLinkedToKyc ? -1 : 0"
|
||||||
role="button"
|
role="button"
|
||||||
>
|
>
|
||||||
<mat-icon class="addr-chip__icon">
|
<mat-icon class="addr-chip__icon">
|
||||||
@@ -167,14 +200,24 @@
|
|||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
formControlName="documentKey"
|
formControlName="documentKey"
|
||||||
|
[readonly]="readOnly || isAddressProofLinkedToKyc"
|
||||||
[placeholder]="selectedAddrProof.documentKeyPlaceholder || ''"
|
[placeholder]="selectedAddrProof.documentKeyPlaceholder || ''"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@if (selectedAddrProof.requiresDate) {
|
@if (selectedAddrProof.requiresDate) {
|
||||||
<mat-form-field appearance="outline" class="addr-ff">
|
<mat-form-field appearance="outline" class="addr-ff">
|
||||||
<mat-label>Issue / bill date</mat-label>
|
<mat-label>Issue / bill date</mat-label>
|
||||||
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
|
<input
|
||||||
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
|
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-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-10
@@ -55,9 +55,22 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
|
* 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. */
|
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
|
||||||
@Input() isNonIndividual = false;
|
@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;
|
activeTab = 0;
|
||||||
sameAsPermanent = false;
|
sameAsPermanent = false;
|
||||||
|
isAddressProofLinkedToKyc = false;
|
||||||
|
|
||||||
permanentForm: UntypedFormGroup = this.fb.group({
|
permanentForm: UntypedFormGroup = this.fb.group({
|
||||||
addressLine1: [
|
addressLine1: [
|
||||||
@@ -240,6 +253,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
/** Address type name → tab mapping for loading existing data */
|
/** Address type name → tab mapping for loading existing data */
|
||||||
private typeToTab: Record<string, string> = {};
|
private typeToTab: Record<string, string> = {};
|
||||||
|
|
||||||
|
private existingAddressProofCheckComplete = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadProofTypes();
|
this.loadProofTypes();
|
||||||
if (this.clientId) {
|
if (this.clientId) {
|
||||||
@@ -247,6 +262,73 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findMatchingProof(targetName: string | undefined): any {
|
||||||
|
if (!targetName) return undefined;
|
||||||
|
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const n = cleanStr(targetName);
|
||||||
|
return this.addressProofs.find((p) => {
|
||||||
|
const pname = cleanStr(p.name);
|
||||||
|
return pname === n || n.includes(pname) || pname.includes(n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryLinkKycData(): void {
|
||||||
|
if (!this.kycData || !this.kycData.kycDocumentName) return;
|
||||||
|
|
||||||
|
if (this.clientId && !this.existingAddressProofCheckComplete) return;
|
||||||
|
|
||||||
|
if (this.isAddressProofLinkedToKyc || !this.permanentForm.get('documentKey')?.value) {
|
||||||
|
let matchedProof = undefined;
|
||||||
|
if (this.kycDocCode) {
|
||||||
|
matchedProof = this.addressProofs.find((p) => p.code === this.kycDocCode);
|
||||||
|
}
|
||||||
|
if (!matchedProof) {
|
||||||
|
matchedProof = this.findMatchingProof(this.kycData.kycDocumentName);
|
||||||
|
}
|
||||||
|
if (matchedProof) {
|
||||||
|
this.isAddressProofLinkedToKyc = true;
|
||||||
|
this.selectAddrProof(matchedProof);
|
||||||
|
this.permanentForm.patchValue({
|
||||||
|
documentKey: this.kycData.kycDocumentNumber,
|
||||||
|
issueDate: this.kycData.kycDocIssueDate
|
||||||
|
});
|
||||||
|
this.currentForm.patchValue({
|
||||||
|
documentKey: this.kycData.kycDocumentNumber,
|
||||||
|
issueDate: this.kycData.kycDocIssueDate
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync uploaded images if they exist in KYC step
|
||||||
|
if (this.kycDocFiles && (this.kycDocFiles.front || this.kycDocFiles.back)) {
|
||||||
|
this.addrFiles = { front: this.kycDocFiles.front || null, back: this.kycDocFiles.back || null };
|
||||||
|
if (this.kycDocMergedPreview) {
|
||||||
|
this.addrMergedPreview = this.kycDocMergedPreview;
|
||||||
|
}
|
||||||
|
} else if (this.existingKycDocSides && (this.existingKycDocSides.front || this.existingKycDocSides.back)) {
|
||||||
|
this.existingAddressProofDocuments = [];
|
||||||
|
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
|
||||||
|
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
|
||||||
|
if (this.existingKycDocSides.front?.previewUrl) {
|
||||||
|
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
|
||||||
|
} else if (this.existingKycDocSides.back?.previewUrl) {
|
||||||
|
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
|
||||||
|
}
|
||||||
|
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
|
||||||
|
}
|
||||||
|
} else if (this.isAddressProofLinkedToKyc) {
|
||||||
|
// If it was linked but the KYC document changed to something not allowed for address (e.g. PAN for non-individuals), unlink.
|
||||||
|
this.unlinkAddressProof();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkAddressProof(): void {
|
||||||
|
this.isAddressProofLinkedToKyc = false;
|
||||||
|
this.permanentForm.patchValue({ documentKey: '', issueDate: null });
|
||||||
|
this.currentForm.patchValue({ documentKey: '', issueDate: null });
|
||||||
|
this.addrFiles = { front: null, back: null };
|
||||||
|
this.addrMergedPreview = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets transient address-tab state for a brand-new client.
|
* Resets transient address-tab state for a brand-new client.
|
||||||
*
|
*
|
||||||
@@ -277,6 +359,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
this.existingAddressProofIdentifierId = null;
|
this.existingAddressProofIdentifierId = null;
|
||||||
this.existingAddressProofDocuments = [];
|
this.existingAddressProofDocuments = [];
|
||||||
this.addressLoaded = false;
|
this.addressLoaded = false;
|
||||||
|
this.existingAddressProofCheckComplete = false;
|
||||||
// Re-fetch rather than re-filter `addressProofs` in place — if the previous session's
|
// 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
|
// 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.
|
// that reduced list again can never recover the items it already removed.
|
||||||
@@ -293,6 +376,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
);
|
);
|
||||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||||
this.loadingProofs = false;
|
this.loadingProofs = false;
|
||||||
|
this.tryLinkKycData();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.loadingProofs = false;
|
this.loadingProofs = false;
|
||||||
@@ -426,6 +510,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
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']) {
|
if (changes['readOnly']) {
|
||||||
const forms = [
|
const forms = [
|
||||||
this.permanentForm,
|
this.permanentForm,
|
||||||
@@ -544,19 +639,25 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
||||||
next: (identifiers: any[]) => {
|
next: (identifiers: any[]) => {
|
||||||
if (!identifiers || identifiers.length === 0) return;
|
if (!identifiers || identifiers.length === 0) {
|
||||||
const proofNames = new Set(this.addressProofs.map((p) => p.name?.toLowerCase()).filter(Boolean));
|
this.existingAddressProofCheckComplete = true;
|
||||||
const identifier = identifiers.find((i: any) => {
|
this.tryLinkKycData();
|
||||||
const n = i?.documentType?.name?.toLowerCase();
|
return;
|
||||||
return n && proofNames.has(n);
|
}
|
||||||
});
|
const identifier = identifiers.find((i: any) => this.findMatchingProof(i?.documentType?.name));
|
||||||
if (!identifier?.id) return;
|
if (!identifier?.id) {
|
||||||
|
this.existingAddressProofCheckComplete = true;
|
||||||
|
this.tryLinkKycData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.existingAddressProofCheckComplete = true;
|
||||||
this.existingAddressProofIdentifierId = identifier.id.toString();
|
this.existingAddressProofIdentifierId = identifier.id.toString();
|
||||||
|
if (this.existingKycIdentifierId && this.existingKycIdentifierId === this.existingAddressProofIdentifierId) {
|
||||||
|
this.isAddressProofLinkedToKyc = true;
|
||||||
|
}
|
||||||
// Restore selected chip + form values
|
// Restore selected chip + form values
|
||||||
const matchedProof = this.addressProofs.find(
|
const matchedProof = this.findMatchingProof(identifier.documentType?.name);
|
||||||
(p) => p.name?.toLowerCase() === identifier.documentType?.name?.toLowerCase()
|
|
||||||
);
|
|
||||||
if (matchedProof) {
|
if (matchedProof) {
|
||||||
this.selectedAddrProof = matchedProof;
|
this.selectedAddrProof = matchedProof;
|
||||||
this.permanentForm.patchValue({
|
this.permanentForm.patchValue({
|
||||||
|
|||||||
@@ -588,95 +588,23 @@
|
|||||||
|
|
||||||
<!-- ══ NON-INDIVIDUAL KYC ══ -->
|
<!-- ══ NON-INDIVIDUAL KYC ══ -->
|
||||||
@if (isNonIndividual) {
|
@if (isNonIndividual) {
|
||||||
<!-- Incorporation Date -->
|
<!-- Date of Incorporation — captured before PAN because Enterprise PAN verification
|
||||||
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
|
matches the entity on name + date of incorporation as held in the PAN records. -->
|
||||||
<mat-label>Incorporation Date</mat-label>
|
<mat-form-field appearance="outline" class="form-field" (click)="incorporationDatePicker.open()">
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
<mat-label>Date of Incorporation</mat-label>
|
||||||
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
|
<span matPrefix class="field-prefix-icon"><mat-icon>event_available</mat-icon></span>
|
||||||
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
|
<input
|
||||||
<mat-datepicker #incorpDatePicker></mat-datepicker>
|
matInput
|
||||||
|
[max]="today"
|
||||||
|
[matDatepicker]="incorporationDatePicker"
|
||||||
|
formControlName="incorporationDate"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="incorporationDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #incorporationDatePicker></mat-datepicker>
|
||||||
|
<mat-hint>As per Certificate of Incorporation / Registration</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<!-- GSTIN (all non-individual) with Verify -->
|
|
||||||
<div class="form-field verify-field">
|
|
||||||
<mat-form-field appearance="outline" class="verify-input">
|
|
||||||
<mat-label>GSTIN Number</mat-label>
|
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="gstinNumber"
|
|
||||||
placeholder="15-character GSTIN"
|
|
||||||
maxlength="15"
|
|
||||||
style="text-transform: uppercase"
|
|
||||||
(input)="
|
|
||||||
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
|
||||||
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
|
||||||
}
|
|
||||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
|
||||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
|
||||||
}
|
|
||||||
@if (gstinVerifyStatus === 'FAILED') {
|
|
||||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
<div class="verify-action">
|
|
||||||
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
class="verify-btn-inline"
|
|
||||||
[disabled]="
|
|
||||||
readOnly ||
|
|
||||||
!kycForm.get('gstinNumber')?.value ||
|
|
||||||
kycForm.get('gstinNumber')?.value.length < 15 ||
|
|
||||||
gstinVerifying ||
|
|
||||||
!clientId
|
|
||||||
"
|
|
||||||
(click)="verifyGstin()"
|
|
||||||
>
|
|
||||||
@if (gstinVerifying) {
|
|
||||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
|
||||||
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
|
||||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
|
||||||
<div class="pan-result-card full-width">
|
|
||||||
<div class="pan-result-header">
|
|
||||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
|
||||||
<span>GSTIN Verified Successfully</span>
|
|
||||||
</div>
|
|
||||||
<div class="pan-result-body">
|
|
||||||
@if (gstinVerifyName) {
|
|
||||||
<div class="pan-result-row">
|
|
||||||
<span class="pan-result-label">Trade Name / Legal Name</span>
|
|
||||||
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (gstinVerifyState) {
|
|
||||||
<div class="pan-result-row">
|
|
||||||
<span class="pan-result-label">State</span>
|
|
||||||
<span class="pan-result-value">{{ gstinVerifyState }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="pan-result-row">
|
|
||||||
<span class="pan-result-label">Verified On</span>
|
|
||||||
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
|
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
|
||||||
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
||||||
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
|
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
|
||||||
@@ -826,8 +754,88 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- CIN (Companies & LLP only) -->
|
<!-- GSTIN (all non-individual) with Verify -->
|
||||||
@if (constitutionMeta?.showCin) {
|
<div class="form-field verify-field">
|
||||||
|
<mat-form-field appearance="outline" class="verify-input">
|
||||||
|
<mat-label>GSTIN Number</mat-label>
|
||||||
|
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="gstinNumber"
|
||||||
|
placeholder="15-character GSTIN"
|
||||||
|
maxlength="15"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
(input)="
|
||||||
|
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||||
|
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
||||||
|
}
|
||||||
|
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||||
|
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||||
|
}
|
||||||
|
@if (gstinVerifyStatus === 'FAILED') {
|
||||||
|
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="verify-action">
|
||||||
|
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
class="verify-btn-inline"
|
||||||
|
[disabled]="
|
||||||
|
readOnly ||
|
||||||
|
!kycForm.get('gstinNumber')?.value ||
|
||||||
|
kycForm.get('gstinNumber')?.value.length < 15 ||
|
||||||
|
gstinVerifying ||
|
||||||
|
!clientId
|
||||||
|
"
|
||||||
|
(click)="verifyGstin()"
|
||||||
|
>
|
||||||
|
@if (gstinVerifying) {
|
||||||
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||||
|
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||||
|
<div class="pan-result-card full-width">
|
||||||
|
<div class="pan-result-header">
|
||||||
|
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||||
|
<span>GSTIN Verified Successfully</span>
|
||||||
|
</div>
|
||||||
|
<div class="pan-result-body">
|
||||||
|
@if (gstinVerifyName) {
|
||||||
|
<div class="pan-result-row">
|
||||||
|
<span class="pan-result-label">Trade Name / Legal Name</span>
|
||||||
|
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (gstinVerifyState) {
|
||||||
|
<div class="pan-result-row">
|
||||||
|
<span class="pan-result-label">State</span>
|
||||||
|
<span class="pan-result-value">{{ gstinVerifyState }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="pan-result-row">
|
||||||
|
<span class="pan-result-label">Verified On</span>
|
||||||
|
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- CIN -->
|
||||||
|
@if (isNonIndividual) {
|
||||||
<div class="form-field verify-field">
|
<div class="form-field verify-field">
|
||||||
<mat-form-field appearance="outline" class="verify-input">
|
<mat-form-field appearance="outline" class="verify-input">
|
||||||
<mat-label>CIN (Corporate Identity Number)</mat-label>
|
<mat-label>CIN (Corporate Identity Number)</mat-label>
|
||||||
@@ -850,7 +858,7 @@
|
|||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<div class="verify-action">
|
<div class="verify-action">
|
||||||
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
|
@if (cinVerifyStatus !== 'VERIFIED') {
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -862,16 +870,31 @@
|
|||||||
@if (cinVerifying) {
|
@if (cinVerifying) {
|
||||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
} @else {
|
} @else {
|
||||||
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
{{ cinVerifyStatus === 'UNCONFIRMED' ? 'Retry' : 'Verify CIN' }}
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (cinVerifyStatus === 'VERIFIED') {
|
@if (cinVerifyStatus === 'VERIFIED') {
|
||||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||||
}
|
}
|
||||||
|
@if (cinVerifyStatus === 'UNCONFIRMED') {
|
||||||
|
<span class="verify-chip unconfirmed"
|
||||||
|
><fa-icon icon="info-circle"></fa-icon> Saved — not confirmed</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Accepted, but MCA returned no match. Stated plainly rather than as a failure: the
|
||||||
|
number is stored and the officer has nothing to correct. -->
|
||||||
|
@if (cinUnconfirmedMessage) {
|
||||||
|
<div class="cin-unconfirmed-note full-width">
|
||||||
|
<fa-icon icon="info-circle" class="cin-unconfirmed-note__icon"></fa-icon>
|
||||||
|
<span>{{ cinUnconfirmedMessage }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- CIN verification progress -->
|
<!-- CIN verification progress -->
|
||||||
@if (cinVerifying) {
|
@if (cinVerifying) {
|
||||||
<div class="cin-progress full-width">
|
<div class="cin-progress full-width">
|
||||||
@@ -1429,15 +1452,19 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Overriding the risk category is the one thing that makes remarks mandatory here. A verified
|
||||||
|
record still gets the box — the officer needs somewhere to record a re-edit reason, and the
|
||||||
|
server asks for one if the save actually replaces data already on the record — but it is
|
||||||
|
offered, not demanded, so a record that auto-verified mid-wizard does not open in error.
|
||||||
|
-->
|
||||||
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
|
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
|
||||||
<mat-form-field appearance="outline" class="form-field full-width">
|
<mat-form-field appearance="outline" class="form-field full-width">
|
||||||
<mat-label>
|
<mat-label>
|
||||||
{{
|
{{
|
||||||
isKycVerified
|
kycForm.get('allowRiskOverride')?.value
|
||||||
? 'Reason for Re-edit'
|
? 'Remarks (Required for Risk Override)'
|
||||||
: kycForm.get('allowRiskOverride')?.value
|
: 'Reason for Re-edit (optional)'
|
||||||
? 'Remarks (Required for Risk Override)'
|
|
||||||
: 'Remarks'
|
|
||||||
}}
|
}}
|
||||||
</mat-label>
|
</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1445,18 +1472,13 @@
|
|||||||
formControlName="remarks"
|
formControlName="remarks"
|
||||||
rows="2"
|
rows="2"
|
||||||
[placeholder]="
|
[placeholder]="
|
||||||
isKycVerified
|
kycForm.get('allowRiskOverride')?.value
|
||||||
? 'Mandatory — explain why KYC is being re-edited'
|
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
||||||
: kycForm.get('allowRiskOverride')?.value
|
: 'Optional — needed only if you change details already recorded on this verified KYC'
|
||||||
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
|
||||||
: ''
|
|
||||||
"
|
"
|
||||||
></textarea>
|
></textarea>
|
||||||
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
|
@if (kycForm.get('allowRiskOverride')?.value && !kycForm.get('remarks')?.value?.trim()) {
|
||||||
<mat-error>
|
<mat-error> Remarks are mandatory when overriding the risk category </mat-error>
|
||||||
Remarks are mandatory when
|
|
||||||
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
|
|
||||||
</mat-error>
|
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
@@ -1672,77 +1694,92 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
||||||
<div class="section-block">
|
@if (!isNonIndividual) {
|
||||||
<h4 class="section-title">
|
<div class="section-block">
|
||||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
<h4 class="section-title">
|
||||||
Form 97 / 98
|
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||||
|
Form 97 / 98
|
||||||
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
|
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||||
|
}
|
||||||
|
</h4>
|
||||||
@if (!kycForm.get('hasPan')?.value) {
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
<div class="kyc-info-hint warning-hint">
|
||||||
}
|
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||||
</h4>
|
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||||
@if (!kycForm.get('hasPan')?.value) {
|
</div>
|
||||||
<div class="kyc-info-hint warning-hint">
|
<div class="form-grid">
|
||||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
<mat-label>Form Type</mat-label>
|
||||||
</div>
|
<mat-select formControlName="form60Type">
|
||||||
<div class="form-grid">
|
<mat-option value="FORM_60">Form 97</mat-option>
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-option value="FORM_61">Form 98</mat-option>
|
||||||
<mat-label>Form Type</mat-label>
|
</mat-select>
|
||||||
<mat-select formControlName="form60Type">
|
</mat-form-field>
|
||||||
<mat-option value="FORM_60">Form 97</mat-option>
|
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||||
<mat-option value="FORM_61">Form 98</mat-option>
|
<mat-label>Declaration Date</mat-label>
|
||||||
</mat-select>
|
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
||||||
</mat-form-field>
|
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
||||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
<mat-datepicker #form60DatePicker></mat-datepicker>
|
||||||
<mat-label>Declaration Date</mat-label>
|
</mat-form-field>
|
||||||
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
</div>
|
||||||
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
} @else {
|
||||||
<mat-datepicker #form60DatePicker></mat-datepicker>
|
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 121 ═══ -->
|
|
||||||
<div class="section-block">
|
|
||||||
<h4 class="section-title">
|
|
||||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
|
||||||
Form 121
|
|
||||||
</h4>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-field checkbox-field">
|
|
||||||
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
|
||||||
</div>
|
|
||||||
@if (kycForm.get('form15Applicable')?.value) {
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
|
||||||
<mat-label>Form Type</mat-label>
|
|
||||||
<mat-select formControlName="form15Type">
|
|
||||||
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
|
||||||
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
|
||||||
<mat-label>Financial Year</mat-label>
|
|
||||||
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
<!-- ═══ SECTION: Form 121 ═══ -->
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<div class="section-block">
|
||||||
|
<h4 class="section-title">
|
||||||
|
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||||
|
Form 121
|
||||||
|
</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field checkbox-field">
|
||||||
|
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
@if (kycForm.get('form15Applicable')?.value) {
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Form Type</mat-label>
|
||||||
|
<mat-select formControlName="form15Type">
|
||||||
|
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
||||||
|
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Financial Year</mat-label>
|
||||||
|
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ═══ SECTION: Photograph & Signature ═══
|
||||||
|
|
||||||
|
An entity gets the photograph box too — a shopfront, signboard or logo is what staff
|
||||||
|
recognise the client by in the ledger, and the client view has always had a photo slot in
|
||||||
|
its header with no way to fill it from the stepper. The specimen signature stays individual-
|
||||||
|
only: an entity signs through its authorised signatories, whose signatures are captured with
|
||||||
|
their own KYC rather than as one image for the company. Optional here, so a company can
|
||||||
|
still be onboarded before anyone has a usable photograph of the premises. -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
||||||
Photograph & Signature
|
{{ isNonIndividual ? 'Entity Photograph' : 'Photograph & Signature' }}
|
||||||
<span class="section-required-tag">Required</span>
|
@if (isNonIndividual) {
|
||||||
|
<span class="section-optional-tag">Optional</span>
|
||||||
|
} @else {
|
||||||
|
<span class="section-required-tag">Required</span>
|
||||||
|
}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="photo-sig-grid">
|
<div class="photo-sig-grid" [class.photo-sig-grid--single]="isNonIndividual">
|
||||||
<div
|
<div
|
||||||
class="upload-box"
|
class="upload-box"
|
||||||
[class.upload-box--has-preview]="photographPreview"
|
[class.upload-box--has-preview]="photographPreview"
|
||||||
[class.upload-box--missing]="!photographPreview && photoSigTouched"
|
[class.upload-box--missing]="!photographPreview && photoSigTouched && !isNonIndividual"
|
||||||
>
|
>
|
||||||
@if (photographPreview) {
|
@if (photographPreview) {
|
||||||
<div class="upload-preview">
|
<div class="upload-preview">
|
||||||
@@ -1758,12 +1795,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
|
<div class="upload-box__icon"><fa-icon [icon]="isNonIndividual ? 'building' : 'user'"></fa-icon></div>
|
||||||
<span class="upload-box__label">
|
<span class="upload-box__label">
|
||||||
Client Photograph
|
{{ isNonIndividual ? 'Entity Photograph / Logo' : 'Client Photograph' }}
|
||||||
<span class="upload-box__req">*</span>
|
@if (!isNonIndividual) {
|
||||||
|
<span class="upload-box__req">*</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="upload-box__hint">
|
||||||
|
{{ isNonIndividual ? 'Office premises, signboard or logo — JPG / PNG, max 2MB' : 'JPG / PNG, max 2MB' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
|
||||||
<div class="upload-box__btn-row">
|
<div class="upload-box__btn-row">
|
||||||
<label class="upload-box__btn">
|
<label class="upload-box__btn">
|
||||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||||
@@ -1773,50 +1814,52 @@
|
|||||||
<fa-icon icon="video"></fa-icon> Use Webcam
|
<fa-icon icon="video"></fa-icon> Use Webcam
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (photoSigTouched) {
|
@if (photoSigTouched && !isNonIndividual) {
|
||||||
<span class="upload-box__error">Photograph is required</span>
|
<span class="upload-box__error">Photograph is required</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div
|
@if (!isNonIndividual) {
|
||||||
class="upload-box"
|
<div
|
||||||
[class.upload-box--has-preview]="signaturePreview"
|
class="upload-box"
|
||||||
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
[class.upload-box--has-preview]="signaturePreview"
|
||||||
>
|
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
||||||
@if (signaturePreview) {
|
>
|
||||||
<div class="upload-preview">
|
@if (signaturePreview) {
|
||||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
<div class="upload-preview">
|
||||||
<div class="upload-preview__actions">
|
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||||
<label class="upload-preview__btn recrop">
|
<div class="upload-preview__actions">
|
||||||
|
<label class="upload-preview__btn recrop">
|
||||||
|
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||||
|
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||||
|
</label>
|
||||||
|
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
||||||
|
<fa-icon icon="trash"></fa-icon> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
||||||
|
<span class="upload-box__label">
|
||||||
|
Specimen Signature
|
||||||
|
<span class="upload-box__req">*</span>
|
||||||
|
</span>
|
||||||
|
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||||
|
<div class="upload-box__btn-row">
|
||||||
|
<label class="upload-box__btn">
|
||||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||||
<fa-icon icon="trash"></fa-icon> Remove
|
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@if (photoSigTouched) {
|
||||||
} @else {
|
<span class="upload-box__error">Signature is required</span>
|
||||||
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
}
|
||||||
<span class="upload-box__label">
|
|
||||||
Specimen Signature
|
|
||||||
<span class="upload-box__req">*</span>
|
|
||||||
</span>
|
|
||||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
|
||||||
<div class="upload-box__btn-row">
|
|
||||||
<label class="upload-box__btn">
|
|
||||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
|
||||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
|
||||||
</label>
|
|
||||||
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
|
||||||
<fa-icon icon="pen"></fa-icon> Draw Signature
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@if (photoSigTouched) {
|
|
||||||
<span class="upload-box__error">Signature is required</span>
|
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,20 @@
|
|||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
text-transform: uppercase;
|
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 {
|
.kyc-badge {
|
||||||
@@ -312,6 +326,34 @@
|
|||||||
color: #b45309;
|
color: #b45309;
|
||||||
border: 1px solid #fcd34d;
|
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 declaration summary ────────────────────────────────────────────────
|
||||||
@@ -1225,6 +1267,12 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
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 {
|
.upload-box {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
|
|||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
|
import { ENTITY_KYC_DOCUMENT_TYPES, KycDocumentType, withEntityKycDocuments } from '../../shared/entity-kyc-documents';
|
||||||
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||||
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
@@ -110,6 +111,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
today = new Date();
|
today = new Date();
|
||||||
|
/**
|
||||||
|
* ClientKycType on the backend: 1 = INDIVIDUAL, 2 = NON_INDIVIDUAL. Every payload must carry the
|
||||||
|
* value matching the client's legal form — the backend rejects the entity-only fields
|
||||||
|
* (enterprisePan / gstinNumber / cinNumber / incorporationDate) on a KYC record typed INDIVIDUAL.
|
||||||
|
*/
|
||||||
|
get kycTypeValue(): number {
|
||||||
|
return this.isNonIndividual ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risk category as it will actually be saved.
|
||||||
|
*
|
||||||
|
* Read via getRawValue() because the riskCategory control is disabled whenever the override
|
||||||
|
* checkbox is off — which is the normal case — and `kycForm.value` omits disabled controls.
|
||||||
|
* Reading `.value` there yields undefined and quietly falls back to 1 (Low), so a verification
|
||||||
|
* would stamp Low onto the record and the next full save would change it to the auto-calculated
|
||||||
|
* category, showing up as an edit of a verified record that the officer never made.
|
||||||
|
*/
|
||||||
|
private get currentRiskCategory(): number {
|
||||||
|
return this.kycForm.getRawValue().riskCategory || 1;
|
||||||
|
}
|
||||||
kycId: string = '';
|
kycId: string = '';
|
||||||
/** Whether existing KYC data has been loaded (edit mode) */
|
/** Whether existing KYC data has been loaded (edit mode) */
|
||||||
kycLoaded = false;
|
kycLoaded = false;
|
||||||
@@ -121,12 +143,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** KYC Document Types — populated from API template, falls back to defaults */
|
/** KYC Document Types — populated from API template, falls back to defaults */
|
||||||
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
|
kycDocumentTypes: KycDocumentType[] = [];
|
||||||
|
|
||||||
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
|
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
|
||||||
kycDocProofs: ProofType[] = [];
|
kycDocProofs: ProofType[] = [];
|
||||||
kycDocGroups: { label: string; items: ProofType[] }[] = [];
|
kycDocGroups: { label: string; items: ProofType[] }[] = [];
|
||||||
selectedKycDoc: ProofType | null = null;
|
selectedKycDoc: ProofType | null = null;
|
||||||
|
/**
|
||||||
|
* Constitution whose document list is currently loaded. Distinguishes the first arrival of a
|
||||||
|
* constitution from the officer switching to a different one — see updateDocTypesForConstitution.
|
||||||
|
*/
|
||||||
|
private appliedConstitutionValue: string | null = null;
|
||||||
/** Front/back files for the chosen KYC document */
|
/** Front/back files for the chosen KYC document */
|
||||||
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
|
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
|
||||||
/**
|
/**
|
||||||
@@ -185,6 +212,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
/** Photo + signature are mandatory before leaving the KYC tab */
|
/** Photo + signature are mandatory before leaving the KYC tab */
|
||||||
get photoSigComplete(): boolean {
|
get photoSigComplete(): boolean {
|
||||||
|
if (this.isNonIndividual) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return this.hasPhoto && this.hasSignature;
|
return this.hasPhoto && this.hasSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +255,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
|
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
|
||||||
const panOk = this.entPanVerifyStatus === 'VERIFIED';
|
const panOk = this.entPanVerifyStatus === 'VERIFIED';
|
||||||
const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED';
|
const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED';
|
||||||
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED';
|
// A CIN the provider cannot confirm still counts as done. MCA lookups return no match for
|
||||||
|
// CINs that are perfectly valid, and PAN and GSTIN already establish the entity's identity —
|
||||||
|
// holding the whole KYC open on a check that cannot pass would strand the client instead.
|
||||||
|
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED' || this.cinVerifyStatus === 'UNCONFIRMED';
|
||||||
return panOk && gstinOk && cinOk;
|
return panOk && gstinOk && cinOk;
|
||||||
} else {
|
} else {
|
||||||
const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value;
|
const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value;
|
||||||
@@ -250,18 +283,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
||||||
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
||||||
];
|
];
|
||||||
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
|
/** Constitution-based KYC document types for non-individual clients. Shown in addition to
|
||||||
* Annexure I) — shown for non-individual clients before a specific constitution type is
|
* the RBI entity documents (ENTITY_KYC_DOCUMENT_TYPES), never instead of them — see
|
||||||
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
|
* withEntityKycDocuments. */
|
||||||
private nonIndividualDefaultDocTypes = [
|
|
||||||
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
|
||||||
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
|
||||||
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
|
||||||
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
|
||||||
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
|
||||||
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
|
||||||
];
|
|
||||||
/** Constitution-based KYC document types for non-individual clients */
|
|
||||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||||
HUF: [
|
HUF: [
|
||||||
{ name: 'PAN Card of HUF', mandatory: true },
|
{ name: 'PAN Card of HUF', mandatory: true },
|
||||||
@@ -513,7 +537,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
gstinVerifyState = '';
|
gstinVerifyState = '';
|
||||||
|
|
||||||
/** CIN verification (dummy) */
|
/** CIN verification (dummy) */
|
||||||
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
/**
|
||||||
|
* UNCONFIRMED means the CIN is saved and correctly formatted, but MCA (via LincPay) would not
|
||||||
|
* confirm it. Kept distinct from VERIFIED, which is only ever set by a positive answer from the
|
||||||
|
* provider, and from FAILED, which is reserved for the provider being unreachable.
|
||||||
|
*/
|
||||||
|
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'UNCONFIRMED' | 'FAILED' = 'NONE';
|
||||||
|
/** Explanation shown beside the CIN when the provider could not confirm it. */
|
||||||
|
cinUnconfirmedMessage = '';
|
||||||
cinVerifying = false;
|
cinVerifying = false;
|
||||||
cinVerifiedOn = '';
|
cinVerifiedOn = '';
|
||||||
cinCompanyName = '';
|
cinCompanyName = '';
|
||||||
@@ -648,9 +679,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
||||||
* ever applies to individuals. */
|
* ever applies to individuals. */
|
||||||
private getDefaultDocTypes(): { name: string; code: string }[] {
|
private getDefaultDocTypes(): { name: string; code: string }[] {
|
||||||
return this.isNonIndividual
|
return this.isNonIndividual ? ENTITY_KYC_DOCUMENT_TYPES : this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||||
? this.nonIndividualDefaultDocTypes
|
|
||||||
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -673,9 +702,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (this.calculatedRisk) {
|
if (this.calculatedRisk) {
|
||||||
riskControl?.setValue(this.calculatedRisk.category);
|
riskControl?.setValue(this.calculatedRisk.category);
|
||||||
}
|
}
|
||||||
if (!this.isKycVerified) {
|
// Overriding the risk category is the only thing this form demands a justification for.
|
||||||
this.kycForm.get('remarks')?.clearValidators();
|
// A verified record does not, by itself, need one — see loadExistingKycIfNeeded.
|
||||||
}
|
this.kycForm.get('remarks')?.clearValidators();
|
||||||
}
|
}
|
||||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||||
});
|
});
|
||||||
@@ -797,6 +826,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.gstinVerifiedOn = '';
|
this.gstinVerifiedOn = '';
|
||||||
this.gstinVerifyState = '';
|
this.gstinVerifyState = '';
|
||||||
this.cinVerifyStatus = 'NONE';
|
this.cinVerifyStatus = 'NONE';
|
||||||
|
this.cinUnconfirmedMessage = '';
|
||||||
this.cinVerifying = false;
|
this.cinVerifying = false;
|
||||||
this.cinVerifiedOn = '';
|
this.cinVerifiedOn = '';
|
||||||
this.cinCompanyName = '';
|
this.cinCompanyName = '';
|
||||||
@@ -898,14 +928,26 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
private updateDocTypesForConstitution(): void {
|
private updateDocTypesForConstitution(): void {
|
||||||
const constitutionValue = this.constitutionMeta?.value;
|
const constitutionValue = this.constitutionMeta?.value;
|
||||||
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
|
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
|
||||||
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
|
// The RBI entity documents stay at the head of the list — picking a constitution adds that
|
||||||
name: doc.name,
|
// constitution's own requirements, it does not swap out the set the officer was just shown.
|
||||||
code: doc.name,
|
this.kycDocumentTypes = withEntityKycDocuments(
|
||||||
mandatory: doc.mandatory
|
this.constitutionDocTypes[constitutionValue],
|
||||||
}));
|
this.constitutionMeta?.label || null
|
||||||
// Reset selected document type since options changed
|
);
|
||||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
|
||||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
// Clearing the chosen document type only makes sense when the officer switches constitution,
|
||||||
|
// which genuinely invalidates it. It must NOT happen the first time a constitution arrives:
|
||||||
|
// on edit that happens after the saved KYC has been restored, so wiping the value here would
|
||||||
|
// discard the restored document type — and with it the upload slots that display the file
|
||||||
|
// already held against the client, since they render only once a type is selected.
|
||||||
|
const constitutionChanged =
|
||||||
|
this.appliedConstitutionValue !== null && this.appliedConstitutionValue !== constitutionValue;
|
||||||
|
this.appliedConstitutionValue = constitutionValue;
|
||||||
|
if (constitutionChanged) {
|
||||||
|
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||||
|
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||||
|
}
|
||||||
|
// Re-resolves the selected chip against the new list from whatever name is on the form.
|
||||||
this.rebuildKycDocPicker();
|
this.rebuildKycDocPicker();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -913,18 +955,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Rebuild chip-group picker model from kycDocumentTypes */
|
/** Rebuild chip-group picker model from kycDocumentTypes */
|
||||||
private rebuildKycDocPicker(): void {
|
private rebuildKycDocPicker(): void {
|
||||||
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
|
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
|
||||||
normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
|
normalizeProofType({
|
||||||
|
id: d.code || d.name || i,
|
||||||
|
name: d.name,
|
||||||
|
code: d.code,
|
||||||
|
description: d.code || d.name,
|
||||||
|
// Constitution-specific documents carry no code PROOF_META knows, so their group label
|
||||||
|
// has to be handed over explicitly or they all collect under "Other".
|
||||||
|
category: d.category
|
||||||
|
})
|
||||||
);
|
);
|
||||||
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
|
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
|
||||||
// Sync selected proof with current form value (e.g. on edit-mode prefill)
|
// Sync selected proof with current form value (e.g. on edit-mode prefill)
|
||||||
const currentName = this.kycForm?.get('kycDocumentName')?.value;
|
const currentName = this.kycForm?.get('kycDocumentName')?.value;
|
||||||
if (currentName) {
|
this.selectedKycDoc = currentName ? this.findKycDocProofByName(currentName) : null;
|
||||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
}
|
||||||
const targetName = cleanStr(currentName);
|
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
|
/**
|
||||||
} else {
|
* Resolves a stored document-type name back to a chip in the picker.
|
||||||
this.selectedKycDoc = null;
|
*
|
||||||
|
* A tolerant match is required because the two names are not written by the same thing: an
|
||||||
|
* identifier row carries the CodeValue label the backend holds ("Certificate of Incorporation")
|
||||||
|
* while the picker shows the fuller, constitution-specific label ("Certificate of Incorporation /
|
||||||
|
* Registration Certificate"), so one is usually contained in the other rather than equal to it.
|
||||||
|
* An exact comparison leaves the chip unselected on edit, which in turn hides the file-upload
|
||||||
|
* slots and therefore the document the officer already uploaded.
|
||||||
|
*/
|
||||||
|
private findKycDocProofByName(name: string | null | undefined): ProofType | null {
|
||||||
|
const clean = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const target = clean(name || '');
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
const proofs = this.kycDocProofs || [];
|
||||||
|
const exact = proofs.find((p) => clean(p.name) === target);
|
||||||
|
if (exact) {
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
// Closest containment wins, so a short label cannot claim a chip that a nearer one also matches.
|
||||||
|
const contained = proofs
|
||||||
|
.filter((p) => {
|
||||||
|
const candidate = clean(p.name);
|
||||||
|
return candidate.includes(target) || target.includes(candidate);
|
||||||
|
})
|
||||||
|
.sort((a, b) => Math.abs(clean(a.name).length - target.length) - Math.abs(clean(b.name).length - target.length));
|
||||||
|
return contained[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectKycDoc(proof: ProofType): void {
|
selectKycDoc(proof: ProofType): void {
|
||||||
@@ -1102,19 +1177,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
isException: data.isException || false,
|
isException: data.isException || false,
|
||||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||||
});
|
});
|
||||||
// Set remarks as required if KYC is verified
|
// Remarks are NOT forced just because the record is verified. The server asks for them
|
||||||
if (this.isKycVerified) {
|
// only when a save actually overwrites data the verified record already held
|
||||||
this.kycForm.get('remarks')?.setValidators(Validators.required);
|
// (ClientKycWritePlatformServiceJpaRepositoryImpl.updateKyc), and it says so in the error
|
||||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
// it returns. Demanding them up front made every non-individual client open with a red
|
||||||
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
|
// "Reason for Re-edit" the moment PAN and GSTIN auto-verified — before the officer had
|
||||||
}
|
// changed anything, and while they still had the KYC document fields left to fill in.
|
||||||
this.persistedIsException = data.isException === true;
|
this.persistedIsException = data.isException === true;
|
||||||
this.persistedExceptionReason = data.exceptionReason || null;
|
this.persistedExceptionReason = data.exceptionReason || null;
|
||||||
// Sync chip-picker selection with the prefilled document name
|
// Sync chip-picker selection with the prefilled document name
|
||||||
if (data.kycDocumentName) {
|
if (data.kycDocumentName) {
|
||||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
|
||||||
const targetName = cleanStr(data.kycDocumentName);
|
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
|
|
||||||
}
|
}
|
||||||
// Set verification statuses from existing data
|
// Set verification statuses from existing data
|
||||||
if (data.panVerified) {
|
if (data.panVerified) {
|
||||||
@@ -1175,6 +1248,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
if (data.cinVerified) {
|
if (data.cinVerified) {
|
||||||
this.cinVerifyStatus = 'VERIFIED';
|
this.cinVerifyStatus = 'VERIFIED';
|
||||||
|
// The verified card shows this; without it a reopened client renders the panel blank.
|
||||||
|
this.cinCompanyName = data.cinCompanyName || 'Verified via MCA';
|
||||||
const rawCinDate = data.cinVerifiedOn;
|
const rawCinDate = data.cinVerifiedOn;
|
||||||
if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) {
|
if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) {
|
||||||
this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`;
|
this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`;
|
||||||
@@ -1243,11 +1318,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// Backfill document type + number on the form in case the KYC record didn't have
|
// Backfill document type + number on the form in case the KYC record didn't have
|
||||||
// them (older clients were created via the identifier flow only).
|
// them (older clients were created via the identifier flow only).
|
||||||
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
|
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
|
||||||
|
const proof = this.findKycDocProofByName(identifier.documentType.name);
|
||||||
|
this.selectedKycDoc = proof;
|
||||||
|
// Store the picker's own label rather than the identifier's when one matched, so the
|
||||||
|
// selected chip and the form value agree and a later save round-trips the same name.
|
||||||
this.kycForm.patchValue({
|
this.kycForm.patchValue({
|
||||||
kycDocumentName: identifier.documentType.name,
|
kycDocumentName: proof?.name || identifier.documentType.name,
|
||||||
kycDocumentNumber: identifier.documentKey || ''
|
kycDocumentNumber: identifier.documentKey || ''
|
||||||
});
|
});
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||||
@@ -1453,13 +1531,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycTemplate = template;
|
this.kycTemplate = template;
|
||||||
this.kycTemplateLoaded = true;
|
this.kycTemplateLoaded = true;
|
||||||
if (template?.requiredDocuments?.length > 0) {
|
if (template?.requiredDocuments?.length > 0) {
|
||||||
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
const templateDocs = template.requiredDocuments.map((doc: any) => ({
|
||||||
template.requiredDocuments.map((doc: any) => ({
|
name: doc.name,
|
||||||
name: doc.name,
|
code: doc.code || doc.name,
|
||||||
code: doc.code || doc.name,
|
mandatory: doc.mandatory ?? false
|
||||||
mandatory: doc.mandatory ?? false
|
}));
|
||||||
}))
|
// The backend template names the same entity documents differently ("Certificate of
|
||||||
);
|
// Incorporation" / "COI", "PAN Card of Company" / "PAN"), so for an entity it is merged
|
||||||
|
// onto the RBI list rather than replacing it — otherwise reopening a non-individual
|
||||||
|
// client for edit shows a different set of chips than the one used to create it.
|
||||||
|
this.kycDocumentTypes = this.isNonIndividual
|
||||||
|
? withEntityKycDocuments(templateDocs, template?.clientTypeName || this.constitutionMeta?.label || null)
|
||||||
|
: this.filterDocTypesForMinor(templateDocs);
|
||||||
// Update kycDocumentName validator based on mandatory docs
|
// Update kycDocumentName validator based on mandatory docs
|
||||||
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
||||||
if (hasMandatory) {
|
if (hasMandatory) {
|
||||||
@@ -1472,11 +1555,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
error: () => {
|
error: () => {
|
||||||
// Fallback to defaults if template API unavailable
|
// Fallback to defaults if template API unavailable
|
||||||
this.kycTemplateLoaded = true;
|
this.kycTemplateLoaded = true;
|
||||||
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
// An entity keeps whatever constitution-specific documents it already has alongside the
|
||||||
name: d.name,
|
// RBI list; a failed template call must not quietly shorten the picker.
|
||||||
code: d.code,
|
const constitutionDocs = this.isNonIndividual
|
||||||
mandatory: false
|
? this.constitutionDocTypes[this.constitutionMeta?.value] || []
|
||||||
}));
|
: [];
|
||||||
|
this.kycDocumentTypes = this.isNonIndividual
|
||||||
|
? withEntityKycDocuments(constitutionDocs, this.constitutionMeta?.label || null)
|
||||||
|
: this.getDefaultDocTypes().map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
code: d.code,
|
||||||
|
mandatory: false
|
||||||
|
}));
|
||||||
this.rebuildKycDocPicker();
|
this.rebuildKycDocPicker();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1485,17 +1575,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Save or update KYC, then execute callback */
|
/** Save or update KYC, then execute callback */
|
||||||
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
|
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
|
||||||
if (this.kycId) {
|
if (this.kycId) {
|
||||||
const { kycType, ...updatePayload } = payload;
|
// riskCategory is dropped alongside kycType on an update. The verify payloads only carry it to
|
||||||
|
// satisfy the initiate validator, which requires it when the record is first created; once the
|
||||||
|
// row exists, re-asserting it on every PAN/GSTIN/CIN check means a verification silently
|
||||||
|
// rewrites the risk category. On a record that has auto-verified that reads as an edit of
|
||||||
|
// verified data and is refused, blocking the verification itself. The officer's risk choice is
|
||||||
|
// persisted by the KYC step's own save, which does not come through here.
|
||||||
|
const { kycType, riskCategory, ...updatePayload } = payload;
|
||||||
|
|
||||||
// If KYC is VERIFIED, remarks are mandatory — use re-edit command
|
// If KYC is VERIFIED, changing data already on the record needs remarks — use re-edit command.
|
||||||
|
// Whether this particular save actually replaces stored data is something only the server can
|
||||||
|
// tell (it holds the current values), so it decides: a non-individual record auto-verifies as
|
||||||
|
// soon as PAN and GSTIN pass, and the officer must still be able to fill in the KYC document
|
||||||
|
// fields that are empty at that point. Remarks are forwarded when present and the server
|
||||||
|
// rejects the save if they were required.
|
||||||
if (this.isKycVerified) {
|
if (this.isKycVerified) {
|
||||||
const remarks = this.kycForm.get('remarks')?.value?.trim();
|
const remarks = this.kycForm.get('remarks')?.value?.trim();
|
||||||
if (!remarks) {
|
if (remarks) {
|
||||||
onError('Remarks are mandatory when updating a verified KYC record.');
|
updatePayload.remarks = remarks;
|
||||||
this.kycForm.get('remarks')?.markAsTouched();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
updatePayload.remarks = remarks;
|
|
||||||
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
|
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
// Status transitions to IN_PROGRESS, PAN verification is invalidated
|
// Status transitions to IN_PROGRESS, PAN verification is invalidated
|
||||||
@@ -1571,12 +1669,30 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||||
const panPayload: any = {
|
const panPayload: any = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.currentRiskCategory,
|
||||||
panNumber: pan,
|
panNumber: pan,
|
||||||
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
|
||||||
|
// still legalForm=ENTITY to the backend, which matches an entity on name + date of
|
||||||
|
// incorporation. Persist that date with this save so verifyPAN() can read it off the record.
|
||||||
|
// Never sent for individuals — the backend rejects entity-only fields on an individual KYC.
|
||||||
|
if (this.isNonIndividual) {
|
||||||
|
const incorpDate = this.kycForm.value.incorporationDate;
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
panPayload.dateFormat = dateFormat;
|
||||||
|
panPayload.incorporationDate =
|
||||||
|
incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate || null;
|
||||||
|
if (!panPayload.incorporationDate) {
|
||||||
|
this.panVerifying = false;
|
||||||
|
this.panVerifyError = 'Enter the Date of Incorporation before verifying the PAN.';
|
||||||
|
this.kycForm.get('incorporationDate')?.markAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.saveKycAndDo(
|
this.saveKycAndDo(
|
||||||
panPayload,
|
panPayload,
|
||||||
() => {
|
() => {
|
||||||
@@ -1834,16 +1950,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
const incorpDate = this.kycForm.value.incorporationDate;
|
const incorpDate = this.kycForm.value.incorporationDate;
|
||||||
|
|
||||||
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
|
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification.
|
||||||
|
// For an entity the provider's "DOB" is the date of incorporation, so it must be persisted on the
|
||||||
|
// KYC record by this same save — verifyPAN() below reads it back off the record, not off the form.
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.currentRiskCategory,
|
||||||
panNumber: pan, // Company PAN → panNumber
|
panNumber: pan, // Company PAN → panNumber
|
||||||
|
enterprisePan: pan,
|
||||||
|
incorporationDate: incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
|
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!payload.incorporationDate) {
|
||||||
|
this.entPanVerifying = false;
|
||||||
|
this.showPanErrorAlert('Enter the Date of Incorporation before verifying the Enterprise PAN.');
|
||||||
|
this.kycForm.get('incorporationDate')?.markAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.saveKycAndDo(
|
this.saveKycAndDo(
|
||||||
payload,
|
payload,
|
||||||
() => {
|
() => {
|
||||||
@@ -1862,6 +1989,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
rawDate ||
|
rawDate ||
|
||||||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
// Seeding status — mirrors verifyPan(), so the value the provider returned is held
|
||||||
|
// here too and the next save round-trips it instead of leaving this screen blank.
|
||||||
|
if (res.changes?.panAadhaarSeedingStatus) {
|
||||||
|
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.entPanVerifyStatus = 'FAILED';
|
this.entPanVerifyStatus = 'FAILED';
|
||||||
this.showPanErrorAlert(
|
this.showPanErrorAlert(
|
||||||
@@ -1907,8 +2039,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.currentRiskCategory,
|
||||||
gstinNumber: gstin,
|
gstinNumber: gstin,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
||||||
@@ -1976,7 +2108,105 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.clientId) {
|
||||||
|
this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.cinVerifying = true;
|
this.cinVerifying = true;
|
||||||
|
this.startCinProgress();
|
||||||
|
|
||||||
|
// The CIN has to be on the record before the command runs: the backend reads it off the KYC row
|
||||||
|
// (and rejects with "CIN number is not set on this KYC record" otherwise) rather than taking it
|
||||||
|
// from the request. Same save-then-verify order as PAN and GSTIN.
|
||||||
|
const payload: any = {
|
||||||
|
locale: 'en',
|
||||||
|
kycType: this.kycTypeValue,
|
||||||
|
riskCategory: this.currentRiskCategory,
|
||||||
|
cinNumber: cin,
|
||||||
|
dateFormat: this.settingsService.dateFormat,
|
||||||
|
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
||||||
|
aadhaarNumberMasked: 'XXXXXXXX0000'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.saveKycAndDo(
|
||||||
|
payload,
|
||||||
|
() => {
|
||||||
|
this.clientsService.verifyCIN(this.clientId, this.kycId).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.stopCinProgress();
|
||||||
|
const changes = res?.changes || res;
|
||||||
|
if (changes?.cinVerified === true) {
|
||||||
|
this.cinVerifyStatus = 'VERIFIED';
|
||||||
|
// The label distinguishes a CIN the registry actually matched from one accepted on
|
||||||
|
// its structure alone, so the card never claims an MCA confirmation that never came.
|
||||||
|
this.cinCompanyName =
|
||||||
|
changes?.cinCompanyName ||
|
||||||
|
(changes?.cinConfirmedByRegistry === false ? 'Format validated' : 'Verified via MCA');
|
||||||
|
this.cinUnconfirmedMessage =
|
||||||
|
changes?.cinConfirmedByRegistry === false ? changes?.cinVerificationMessage || '' : '';
|
||||||
|
const rawDate = changes?.cinVerifiedOn;
|
||||||
|
if (Array.isArray(rawDate)) {
|
||||||
|
this.cinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
this.cinVerifiedOn =
|
||||||
|
rawDate ||
|
||||||
|
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.markCinUnconfirmed(changes?.cinVerificationMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.stopCinProgress();
|
||||||
|
const message =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
'CIN verification failed.';
|
||||||
|
// A 503 means the provider could not be reached, which is worth interrupting the officer
|
||||||
|
// for — it is likely to succeed on a retry. Anything else is the provider having
|
||||||
|
// answered without confirming the company, which is not something a retry fixes and not
|
||||||
|
// something the officer can correct, so it is reported in place instead of as an alert.
|
||||||
|
if (err?.status === 503) {
|
||||||
|
this.cinVerifyStatus = 'FAILED';
|
||||||
|
this.showPanErrorAlert(message);
|
||||||
|
} else {
|
||||||
|
this.markCinUnconfirmed(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(msg) => {
|
||||||
|
this.stopCinProgress();
|
||||||
|
this.showPanErrorAlert(msg);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The CIN is stored and well-formed, but MCA did not confirm it.
|
||||||
|
*
|
||||||
|
* Reported in place rather than as an error dialog because there is nothing for the officer to
|
||||||
|
* correct: the number is already saved and the provider simply has no record to match it against.
|
||||||
|
* Deliberately NOT recorded as verified — `cinVerified` stays false on the KYC record, so the
|
||||||
|
* distinction between a confirmed and an unconfirmed CIN survives into the client's history.
|
||||||
|
*/
|
||||||
|
private markCinUnconfirmed(message?: string): void {
|
||||||
|
this.cinVerifyStatus = 'UNCONFIRMED';
|
||||||
|
this.cinUnconfirmedMessage =
|
||||||
|
message || 'MCA could not confirm this CIN. The number has been saved against the client.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the stepped MCA progress display while the verification is in flight. Deliberately stops
|
||||||
|
* short of the last step and of 100%: the real outcome comes from the server, and letting the bar
|
||||||
|
* finish on its own is what used to make an unverified CIN look verified.
|
||||||
|
*/
|
||||||
|
private startCinProgress(): void {
|
||||||
|
if (this.cinProgressTimer) {
|
||||||
|
clearInterval(this.cinProgressTimer);
|
||||||
|
}
|
||||||
this.cinProgressValue = 0;
|
this.cinProgressValue = 0;
|
||||||
this.cinCurrentStep = 0;
|
this.cinCurrentStep = 0;
|
||||||
|
|
||||||
@@ -2000,22 +2230,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
|
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 90);
|
||||||
|
|
||||||
if (elapsed >= totalDuration) {
|
if (elapsed >= totalDuration) {
|
||||||
clearInterval(this.cinProgressTimer);
|
clearInterval(this.cinProgressTimer);
|
||||||
this.cinCurrentStep = stepDurations.length;
|
this.cinProgressTimer = null;
|
||||||
setTimeout(() => {
|
|
||||||
this.cinVerifying = false;
|
|
||||||
this.cinVerifyStatus = 'VERIFIED';
|
|
||||||
this.cinCompanyName = 'Verified via MCA';
|
|
||||||
const now = new Date();
|
|
||||||
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
}, interval);
|
}, interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stopCinProgress(): void {
|
||||||
|
if (this.cinProgressTimer) {
|
||||||
|
clearInterval(this.cinProgressTimer);
|
||||||
|
this.cinProgressTimer = null;
|
||||||
|
}
|
||||||
|
this.cinProgressValue = 100;
|
||||||
|
this.cinVerifying = false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
|
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
|
||||||
sendAadhaarOtp() {
|
sendAadhaarOtp() {
|
||||||
if (!this.clientId) {
|
if (!this.clientId) {
|
||||||
@@ -2041,8 +2272,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
|
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
|
||||||
const aadhaarPayload: any = {
|
const aadhaarPayload: any = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.currentRiskCategory,
|
||||||
aadhaarNumberMasked: aadhaarMasked
|
aadhaarNumberMasked: aadhaarMasked
|
||||||
};
|
};
|
||||||
const panVal = this.kycForm.value.panNumber;
|
const panVal = this.kycForm.value.panNumber;
|
||||||
@@ -2203,7 +2434,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getRenewalYears(): number {
|
getRenewalYears(): number {
|
||||||
switch (this.kycForm.value.riskCategory) {
|
switch (this.currentRiskCategory) {
|
||||||
case 1:
|
case 1:
|
||||||
return 10;
|
return 10;
|
||||||
case 2:
|
case 2:
|
||||||
@@ -2387,12 +2618,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
dateFormat,
|
dateFormat,
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: v.riskCategory || 1,
|
riskCategory: v.riskCategory || 1,
|
||||||
// For non-individual: Company PAN is used as panNumber for verification
|
// For non-individual: Company PAN is used as panNumber for verification
|
||||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||||
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
|
// Only ever sent when this screen actually holds a value. The seeding status is derived by
|
||||||
|
// the provider during PAN verification and written straight to the record, so sending an
|
||||||
|
// empty one back would blank a field the server filled in — which reads as an officer
|
||||||
|
// editing a verified record rather than the no-op it is.
|
||||||
|
...(this.panAadhaarSeedingStatus ? { panAadhaarSeedingStatus: this.panAadhaarSeedingStatus } : {}),
|
||||||
// PAN declaration — stored so the waived exception can be justified later, and so a reload
|
// PAN declaration — stored so the waived exception can be justified later, and so a reload
|
||||||
// does not silently drop the paperwork that is holding the exception off.
|
// does not silently drop the paperwork that is holding the exception off.
|
||||||
panDeclarationTaken: this.panDeclarationTaken,
|
panDeclarationTaken: this.panDeclarationTaken,
|
||||||
@@ -2470,6 +2705,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
v.gstinNumber ||
|
v.gstinNumber ||
|
||||||
v.enterprisePan ||
|
v.enterprisePan ||
|
||||||
v.cinNumber ||
|
v.cinNumber ||
|
||||||
|
v.incorporationDate ||
|
||||||
v.kycDocumentName ||
|
v.kycDocumentName ||
|
||||||
v.form15Applicable ||
|
v.form15Applicable ||
|
||||||
v.isException
|
v.isException
|
||||||
|
|||||||
+6
-6
@@ -442,18 +442,18 @@
|
|||||||
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
|
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
|
||||||
</div>
|
</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) {
|
@if (kycData.cinNumber) {
|
||||||
<div class="preview-field">
|
<div class="preview-field">
|
||||||
<span class="preview-label">CIN (Corporate Identity Number)</span>
|
<span class="preview-label">CIN (Corporate Identity Number)</span>
|
||||||
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
|
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
|
||||||
</div>
|
</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) {
|
@if (kycData.gstinNumber) {
|
||||||
<div class="preview-field">
|
<div class="preview-field">
|
||||||
<span class="preview-label">GSTIN Number</span>
|
<span class="preview-label">GSTIN Number</span>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
|||||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||||
import { Form121TabComponent } from './clients-view/form121-tab/form121-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 { 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';
|
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
@@ -199,6 +200,15 @@ const routes: Routes = [
|
|||||||
component: Form121TabComponent,
|
component: Form121TabComponent,
|
||||||
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
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',
|
path: 'documents',
|
||||||
component: DocumentsTabComponent,
|
component: DocumentsTabComponent,
|
||||||
|
|||||||
@@ -41,6 +41,26 @@
|
|||||||
>
|
>
|
||||||
{{ address.isActive ? 'Active' : 'Inactive' }}
|
{{ address.isActive ? 'Active' : 'Inactive' }}
|
||||||
</span>
|
</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-title>
|
||||||
<mat-panel-description class="addr-panel__desc">
|
<mat-panel-description class="addr-panel__desc">
|
||||||
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : ''
|
{{ 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 {
|
&__desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $gray-500 !important;
|
color: $gray-500 !important;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from '@angular/material/expansion';
|
} from '@angular/material/expansion';
|
||||||
import { MatDivider } from '@angular/material/divider';
|
import { MatDivider } from '@angular/material/divider';
|
||||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatExpansionPanelTitle,
|
MatExpansionPanelTitle,
|
||||||
MatExpansionPanelDescription,
|
MatExpansionPanelDescription,
|
||||||
MatDivider,
|
MatDivider,
|
||||||
MatSlideToggle
|
MatSlideToggle,
|
||||||
|
MatTooltip
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AddressTabComponent {
|
export class AddressTabComponent {
|
||||||
@@ -68,6 +70,11 @@ export class AddressTabComponent {
|
|||||||
clientAddressTemplate: any;
|
clientAddressTemplate: any;
|
||||||
/** Client Id */
|
/** Client Id */
|
||||||
clientId: string;
|
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
|
* @param {ActivatedRoute} route Activated Route
|
||||||
@@ -82,10 +89,48 @@ export class AddressTabComponent {
|
|||||||
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
||||||
this.clientAddressTemplate = data.clientAddressTemplateData;
|
this.clientAddressTemplate = data.clientAddressTemplateData;
|
||||||
this.clientId = this.route.parent.snapshot.paramMap.get('clientId');
|
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.
|
* Adds a client address.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
<div class="as-view">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="as-loading">
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
|
||||||
|
<p>Loading authorized signatories...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading) {
|
||||||
|
<div class="as-header">
|
||||||
|
<h3><fa-icon icon="user-shield"></fa-icon> Authorized Signatories</h3>
|
||||||
|
@if (signatories.length) {
|
||||||
|
<span class="as-count">{{ signatories.length }} on record</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The tab link is entity-only, but the route can be reached directly by URL. -->
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<div class="as-empty">
|
||||||
|
<mat-icon>person</mat-icon>
|
||||||
|
<span>Authorized signatories apply to non-individual clients only.</span>
|
||||||
|
</div>
|
||||||
|
} @else if (!signatories.length) {
|
||||||
|
<div class="as-empty">
|
||||||
|
<mat-icon>group_off</mat-icon>
|
||||||
|
<span>No authorized signatories recorded for this entity.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="as-note">
|
||||||
|
The persons authorised to transact on this entity's behalf, as named in its Board Resolution / Authorization
|
||||||
|
Letter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@for (signatory of signatories; track $index) {
|
||||||
|
<div class="as-card">
|
||||||
|
<div class="as-card__main">
|
||||||
|
<div class="as-card__avatar"><mat-icon>person</mat-icon></div>
|
||||||
|
<div class="as-card__identity">
|
||||||
|
<span class="as-card__name">{{ signatory.name || 'Unnamed signatory' }}</span>
|
||||||
|
<span class="as-card__meta">
|
||||||
|
@if (signatory.loading) {
|
||||||
|
Loading details…
|
||||||
|
} @else {
|
||||||
|
@if (signatory.cifNumber) {
|
||||||
|
<span class="as-mono">CIF {{ signatory.cifNumber }}</span>
|
||||||
|
}
|
||||||
|
@if (signatory.officeName) {
|
||||||
|
<span class="as-card__dot">•</span>{{ signatory.officeName }}
|
||||||
|
}
|
||||||
|
@if (signatory.status) {
|
||||||
|
<span class="as-card__dot">•</span>{{ signatory.status }}
|
||||||
|
}
|
||||||
|
<!-- A signatory recorded as a name only — nothing to link to, and saying so is
|
||||||
|
better than an empty row the officer cannot explain. -->
|
||||||
|
@if (!signatory.clientId) {
|
||||||
|
<span class="as-card__unlinked">Name on record only — no linked client</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="as-card__side">
|
||||||
|
<div class="as-din">
|
||||||
|
<span class="as-din__label">DIN</span>
|
||||||
|
@if (signatory.dinNumber) {
|
||||||
|
<span class="as-din__value as-mono">{{ signatory.dinNumber }}</span>
|
||||||
|
@if (signatory.dinVerified) {
|
||||||
|
<span class="as-chip as-chip--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||||
|
} @else {
|
||||||
|
<span class="as-chip as-chip--pending"><mat-icon>radio_button_unchecked</mat-icon> Unverified</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span class="as-din__value as-din__value--none">—</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (signatory.clientId) {
|
||||||
|
<a mat-stroked-button class="as-link-btn" [routerLink]="['/clients', signatory.clientId, 'general']">
|
||||||
|
<mat-icon>open_in_new</mat-icon> View client
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.as-view {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #059669;
|
||||||
|
background: #ecfdf5;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-note {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 2px dashed #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eaf2fb;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__unlinked {
|
||||||
|
font-style: italic;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-din {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e293b;
|
||||||
|
|
||||||
|
&--none {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-mono {
|
||||||
|
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-link-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
|
// MatAnchor, not MatButton: the link to the signatory's client record is an <a>, and the shared
|
||||||
|
// imports only carry the <button> directive.
|
||||||
|
import { MatAnchor } from '@angular/material/button';
|
||||||
|
import { ClientsService } from '../../clients.service';
|
||||||
|
import { LegalFormId } from '../../models/legal-form.enum';
|
||||||
|
|
||||||
|
/** One row of the tab — a name from the entity's record, enriched from the linked client. */
|
||||||
|
interface AuthorizedSignatory {
|
||||||
|
/** Client id of the individual, when the signatory was linked to a real client record. */
|
||||||
|
clientId: number | null;
|
||||||
|
name: string;
|
||||||
|
cifNumber: string;
|
||||||
|
officeName: string;
|
||||||
|
status: string;
|
||||||
|
dinNumber: string;
|
||||||
|
dinVerified: boolean;
|
||||||
|
/** Details are still being fetched for this row. */
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-authorized-signatories-tab',
|
||||||
|
templateUrl: './authorized-signatories-tab.component.html',
|
||||||
|
styleUrls: ['./authorized-signatories-tab.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FaIconComponent,
|
||||||
|
MatIcon,
|
||||||
|
MatProgressSpinner,
|
||||||
|
MatAnchor
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AuthorizedSignatoriesTabComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
|
||||||
|
clientId = '';
|
||||||
|
loading = true;
|
||||||
|
isNonIndividual = false;
|
||||||
|
signatories: AuthorizedSignatory[] = [];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.clientId = this.route.parent?.snapshot.paramMap.get('clientId') || '';
|
||||||
|
// Read from the parent route's resolved data — the entity's own record already carries the
|
||||||
|
// signatory names and ids, so the tab needs no request of its own to render its rows. Same
|
||||||
|
// subscription style as the other client tabs, which keeps this correct when the view is
|
||||||
|
// re-entered for a different client on a reused component instance.
|
||||||
|
this.route.parent?.data.subscribe((data: any) => {
|
||||||
|
const clientViewData = data?.clientViewData;
|
||||||
|
this.isNonIndividual = clientViewData?.legalForm?.id === LegalFormId.ENTITY;
|
||||||
|
|
||||||
|
const nonPerson = clientViewData?.clientNonPersonDetails;
|
||||||
|
this.signatories = this.buildRows(nonPerson?.authorizedSignatory, nonPerson?.authorizedSignatoryClientIds);
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
// DIN lives on the signatory's own KYC record, not on the entity's — the same place the
|
||||||
|
// General step reads it back from — so each linked row is filled in from there.
|
||||||
|
this.signatories.filter((s) => s.clientId != null).forEach((s) => this.loadSignatoryDetails(s));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names and client ids are stored as two comma-separated columns written in the same order
|
||||||
|
* (see ClientGeneralStepComponent.syncSignatoryNames), so they are zipped back by position.
|
||||||
|
* A name with no id behind it is still shown: signatories recorded before linking existed, or
|
||||||
|
* typed in without a client record, are part of the entity's mandate and must not disappear.
|
||||||
|
*/
|
||||||
|
private buildRows(names: string | null | undefined, clientIds: string | null | undefined): AuthorizedSignatory[] {
|
||||||
|
const splitCsv = (value: string | null | undefined): string[] =>
|
||||||
|
(value || '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const nameList = splitCsv(names);
|
||||||
|
const idList = splitCsv(clientIds);
|
||||||
|
// An id column longer than the name column would otherwise drop the extra links silently.
|
||||||
|
const rowCount = Math.max(nameList.length, idList.length);
|
||||||
|
|
||||||
|
return Array.from({ length: rowCount }, (_unused, index) => {
|
||||||
|
const rawId = idList[index];
|
||||||
|
const parsedId = rawId ? Number(rawId) : NaN;
|
||||||
|
const clientId = Number.isFinite(parsedId) ? parsedId : null;
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
name: nameList[index] || '',
|
||||||
|
cifNumber: '',
|
||||||
|
officeName: '',
|
||||||
|
status: '',
|
||||||
|
dinNumber: '',
|
||||||
|
dinVerified: false,
|
||||||
|
loading: clientId != null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSignatoryDetails(signatory: AuthorizedSignatory): void {
|
||||||
|
const id = signatory.clientId!.toString();
|
||||||
|
|
||||||
|
this.clientsService.getClientData(id).subscribe({
|
||||||
|
next: (data: any) => {
|
||||||
|
signatory.name = signatory.name || data?.displayName || '';
|
||||||
|
signatory.cifNumber = data?.cifNumber || data?.accountNo || '';
|
||||||
|
signatory.officeName = data?.officeName || '';
|
||||||
|
signatory.status = data?.status?.value || '';
|
||||||
|
signatory.loading = false;
|
||||||
|
},
|
||||||
|
// A signatory whose client record cannot be read still shows as a row with its stored name,
|
||||||
|
// rather than spinning forever.
|
||||||
|
error: () => {
|
||||||
|
signatory.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clientsService.getClientKyc(id).subscribe({
|
||||||
|
next: (kyc: any) => {
|
||||||
|
signatory.dinNumber = kyc?.dinNumber || '';
|
||||||
|
signatory.dinVerified = kyc?.dinVerified === true;
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,58 +168,78 @@
|
|||||||
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
||||||
</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">
|
||||||
<div class="cv-cell__label">DIP Status</div>
|
<div class="cv-cell__label">DIP Status</div>
|
||||||
<div class="cv-cell__value">
|
<div class="cv-cell__value">
|
||||||
<span
|
<span
|
||||||
class="cv-badge"
|
class="cv-badge"
|
||||||
[class.cv-badge--green]="
|
[class.cv-badge--red]="isDipFlagged(clientViewData.dipLiveStatus)"
|
||||||
clientViewData.dipVerified === true ||
|
[class.cv-badge--green]="!isDipFlagged(clientViewData.dipLiveStatus) && isDipClear(clientViewData)"
|
||||||
clientViewData.dipVerified === 'true' ||
|
[class.cv-badge--yellow]="!isDipFlagged(clientViewData.dipLiveStatus) && !isDipClear(clientViewData)"
|
||||||
clientViewData.dipVerified === 1
|
|
||||||
"
|
|
||||||
[class.cv-badge--yellow]="
|
|
||||||
clientViewData.dipVerified !== true &&
|
|
||||||
clientViewData.dipVerified !== 'true' &&
|
|
||||||
clientViewData.dipVerified !== 1
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{
|
{{ dipStatusLabel(clientViewData) }}
|
||||||
clientViewData.dipVerified === true ||
|
|
||||||
clientViewData.dipVerified === 'true' ||
|
|
||||||
clientViewData.dipVerified === 1
|
|
||||||
? 'Verified'
|
|
||||||
: 'Pending'
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="cv-cell__label">Sanctions Screening</div>
|
<div class="cv-cell__label">Sanctions Screening</div>
|
||||||
<div class="cv-cell__value">
|
<div class="cv-cell__value">
|
||||||
<span
|
<span
|
||||||
class="cv-badge"
|
class="cv-badge"
|
||||||
[class.cv-badge--green]="
|
[class.cv-badge--green]="!clientViewData.sanctionsAlertStatus && isNameCleared(clientViewData)"
|
||||||
clientViewData.nameVerified === true ||
|
[class.cv-badge--yellow]="
|
||||||
clientViewData.nameVerified === 'true' ||
|
clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW' ||
|
||||||
clientViewData.nameVerified === 1
|
(!clientViewData.sanctionsAlertStatus && !isNameCleared(clientViewData))
|
||||||
"
|
|
||||||
[class.cv-badge--red]="
|
|
||||||
clientViewData.nameVerified === false ||
|
|
||||||
clientViewData.nameVerified === 'false' ||
|
|
||||||
clientViewData.nameVerified === 0
|
|
||||||
"
|
"
|
||||||
|
[class.cv-badge--red]="clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH'"
|
||||||
>
|
>
|
||||||
{{
|
{{ sanctionsHeaderLabel(clientViewData) }}
|
||||||
clientViewData.nameVerified === true ||
|
|
||||||
clientViewData.nameVerified === 'true' ||
|
|
||||||
clientViewData.nameVerified === 1
|
|
||||||
? 'Clear'
|
|
||||||
: 'Pending'
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -410,9 +430,11 @@
|
|||||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||||
{{ 'labels.buttons.Add Charge' | translate }}
|
{{ 'labels.buttons.Add Charge' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
@if (isActive()) {
|
||||||
Welcome Kit
|
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||||
</button>
|
Welcome Kit
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button mat-menu-item (click)="doAction('Create Collateral')">
|
<button mat-menu-item (click)="doAction('Create Collateral')">
|
||||||
{{ 'labels.buttons.Create Collateral' | translate }}
|
{{ 'labels.buttons.Create Collateral' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -554,6 +576,22 @@
|
|||||||
>
|
>
|
||||||
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
||||||
</a>
|
</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
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
[routerLink]="['./documents']"
|
[routerLink]="['./documents']"
|
||||||
|
|||||||
@@ -286,6 +286,39 @@ $accent: #0ea5e9;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A DIP hit on an existing customer is not an account restriction, so it is shown as a standing
|
||||||
|
// advisory on the client rather than as a blocking state.
|
||||||
|
.cv-cell--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dip-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgb(239 68 68 / 15%);
|
||||||
|
border: 1px solid rgb(239 68 68 / 40%);
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fecaca;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__detail {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.actions-menu {
|
.actions-menu {
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,54 @@ import { formatTabLabel } from 'app/shared/utils/format-tab-label.util';
|
|||||||
})
|
})
|
||||||
export class ClientsViewComponent implements OnInit {
|
export class ClientsViewComponent implements OnInit {
|
||||||
complianceHideClientData = environment.complianceHideClientData;
|
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 *
|
* Mask a string, keeping first and last letter, masking the rest with *
|
||||||
*/
|
*/
|
||||||
@@ -461,12 +509,24 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the Welcome Kit Dispatch dialog.
|
* Opens the Welcome Kit Dispatch dialog. The kit is only posted once the client is active, so the dialog is
|
||||||
|
* unreachable before then — the menu entry is hidden and this guard covers any other route into the action.
|
||||||
*/
|
*/
|
||||||
private dispatchWelcomeKit() {
|
private dispatchWelcomeKit() {
|
||||||
|
if (!this.isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
||||||
data: { clientId: this.clientViewData.id },
|
data: {
|
||||||
width: '600px'
|
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) => {
|
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
+13
-1
@@ -11,9 +11,21 @@
|
|||||||
<div>
|
<div>
|
||||||
<mifosx-file-upload flex="60%" (change)="onFileSelect($event)" acceptFilter=".png,.jpeg,.jpg"></mifosx-file-upload>
|
<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">
|
<mat-dialog-actions align="end">
|
||||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
<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 }}
|
{{ 'labels.buttons.Confirm' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
+42
@@ -5,3 +5,45 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* 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 */
|
/** Client Image */
|
||||||
image: File;
|
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.
|
* Sets file form control value.
|
||||||
* @param {any} $event file change event.
|
* @param {any} $event file change event.
|
||||||
@@ -40,6 +50,29 @@ export class UploadImageDialogComponent {
|
|||||||
onFileSelect($event: any) {
|
onFileSelect($event: any) {
|
||||||
if ($event.target.files.length > 0) {
|
if ($event.target.files.length > 0) {
|
||||||
this.image = $event.target.files[0];
|
this.image = $event.target.files[0];
|
||||||
|
this.buildPreview(this.image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reads the picked file into a data URL for the preview. */
|
||||||
|
private buildPreview(file: File) {
|
||||||
|
this.imagePreview = null;
|
||||||
|
this.previewError = '';
|
||||||
|
|
||||||
|
// The picker's accept filter is a hint the OS dialog can be talked out of, so re-check here
|
||||||
|
// rather than handing a non-image to FileReader and rendering a broken <img>.
|
||||||
|
if (!file.type?.startsWith('image/')) {
|
||||||
|
this.previewError = 'Selected file is not an image. Choose a PNG or JPEG file.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
this.imagePreview = reader.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
this.previewError = 'Could not read the selected file.';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+240
-53
@@ -5,90 +5,277 @@
|
|||||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
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/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
|
<div class="wk-header" mat-dialog-title>
|
||||||
|
<div class="wk-header__icon">
|
||||||
|
<mat-icon>card_giftcard</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="wk-header__text">
|
||||||
|
<h2 class="wk-header__title">Welcome Kit Dispatch</h2>
|
||||||
|
<p class="wk-header__subtitle">Posting the kit doubles as a physical address check for this client.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-dialog-content class="mat-typography">
|
<mat-dialog-content class="mat-typography wk-content">
|
||||||
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
|
<div class="wk-loading" *ngIf="isLoading">
|
||||||
<mat-spinner diameter="40"></mat-spinner>
|
<mat-spinner diameter="40"></mat-spinner>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="!isLoading">
|
<ng-container *ngIf="!isLoading">
|
||||||
<!-- Tracking view if dispatches already exist -->
|
<!-- Who the kit is going to -->
|
||||||
<div
|
<section class="wk-client">
|
||||||
class="dispatch-tracking-panel"
|
<div class="wk-client__avatar">{{ (clientName || '?').charAt(0) }}</div>
|
||||||
*ngFor="let dispatch of activeDispatches; let i = index"
|
<div class="wk-client__details">
|
||||||
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
|
<div class="wk-client__name">{{ clientName || 'Client' }}</div>
|
||||||
>
|
<div class="wk-client__meta" *ngIf="data.accountNo">Account No. {{ data.accountNo }}</div>
|
||||||
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
|
|
||||||
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
|
|
||||||
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
|
|
||||||
Download Details
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
|
</section>
|
||||||
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
|
|
||||||
<p style="margin: 4px 0">
|
|
||||||
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
|
|
||||||
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
|
|
||||||
</p>
|
|
||||||
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
|
|
||||||
<p style="margin: 4px 0">
|
|
||||||
<strong>Status:</strong>
|
|
||||||
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
<!-- Existing dispatches, newest first -->
|
||||||
|
<section
|
||||||
|
class="wk-dispatch"
|
||||||
|
*ngFor="let dispatch of activeDispatches; let i = index"
|
||||||
|
[class.wk-dispatch--past]="i > 0"
|
||||||
|
>
|
||||||
|
<header class="wk-dispatch__head">
|
||||||
|
<span class="wk-dispatch__label">{{ i === 0 ? 'Current Dispatch' : 'Previous Dispatch' }}</span>
|
||||||
|
<span class="wk-chip" [ngClass]="'wk-chip--' + (dispatch.status | lowercase)">
|
||||||
|
<mat-icon class="wk-chip__icon">
|
||||||
|
{{
|
||||||
|
dispatch.status === 'DELIVERED'
|
||||||
|
? 'verified'
|
||||||
|
: dispatch.status === 'RETURNED'
|
||||||
|
? 'gpp_bad'
|
||||||
|
: 'local_shipping'
|
||||||
|
}}
|
||||||
|
</mat-icon>
|
||||||
|
{{ verificationLabel(dispatch.status) }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Create form if they can dispatch a new one -->
|
<p class="wk-dispatch__hint">{{ verificationHint(dispatch) }}</p>
|
||||||
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
|
|
||||||
<h3>Create New Dispatch</h3>
|
<dl class="wk-facts">
|
||||||
<div fxLayout="column" fxLayoutGap="16px">
|
<div class="wk-facts__row wk-facts__row--wide">
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
<dt>Delivery Address</dt>
|
||||||
<mat-label>Delivery Address</mat-label>
|
<dd>{{ getDisplayAddress(dispatch) }}</dd>
|
||||||
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
|
</div>
|
||||||
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
|
<div class="wk-facts__row">
|
||||||
Delivery Address is required
|
<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-error>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
<div class="wk-address" *ngIf="selectedAddress as address">
|
||||||
|
<mat-icon class="wk-address__icon">place</mat-icon>
|
||||||
|
<div class="wk-address__body">
|
||||||
|
<span class="wk-address__type">{{ address.type }}</span>
|
||||||
|
<span class="wk-address__name">{{ clientName }}</span>
|
||||||
|
<span class="wk-address__line" *ngFor="let line of address.lines">{{ line }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<div class="wk-noaddress" *ngIf="addressesLoaded && addressChoices.length === 0">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
<span>
|
||||||
|
No address is recorded for this client. Add one on the Address tab before dispatching a welcome kit.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wk-form__row">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>Delivery Mode</mat-label>
|
<mat-label>Delivery Mode</mat-label>
|
||||||
<mat-select formControlName="deliveryMode" required>
|
<mat-select formControlName="deliveryMode" required>
|
||||||
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
|
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">{{ mode }}</mat-option>
|
||||||
{{ mode }}
|
|
||||||
</mat-option>
|
|
||||||
</mat-select>
|
</mat-select>
|
||||||
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
||||||
Delivery Mode is required
|
Delivery Mode is required
|
||||||
</mat-error>
|
</mat-error>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field
|
<mat-form-field appearance="outline">
|
||||||
appearance="outline"
|
<mat-label>Dispatched On</mat-label>
|
||||||
class="full-width"
|
<input
|
||||||
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
|
matInput
|
||||||
>
|
[matDatepicker]="dispatchPicker"
|
||||||
<mat-label>Specify Other Mode</mat-label>
|
[max]="maxDate"
|
||||||
<input matInput formControlName="deliveryModeOtherText" required />
|
formControlName="dispatchedOnDate"
|
||||||
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
required
|
||||||
Please specify the delivery mode
|
/>
|
||||||
|
<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-error>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<mat-form-field appearance="outline" class="wk-full" *ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'">
|
||||||
|
<mat-label>Specify Other Mode</mat-label>
|
||||||
|
<input matInput formControlName="deliveryModeOtherText" required />
|
||||||
|
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
||||||
|
Please specify the delivery mode
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Printed before the kit is posted, so the tracking ID below is filled in afterwards. -->
|
||||||
|
<div class="wk-label-cta">
|
||||||
|
<div class="wk-label-cta__text">
|
||||||
|
<span class="wk-label-cta__title">Envelope address label</span>
|
||||||
|
<span class="wk-label-cta__hint">
|
||||||
|
Print this first — name, address, contact details and the welcome note, ready to fold into a window
|
||||||
|
envelope.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!selectedAddress"
|
||||||
|
(click)="downloadAddressLabel()"
|
||||||
|
>
|
||||||
|
<mat-icon>print</mat-icon>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="wk-full">
|
||||||
|
<mat-label>Tracking ID</mat-label>
|
||||||
|
<input matInput formControlName="trackingId" maxlength="100" required placeholder="e.g. EX123456789IN" />
|
||||||
|
<mat-icon matSuffix>confirmation_number</mat-icon>
|
||||||
|
<mat-hint>Enter the consignment number printed on the delivery partner's receipt.</mat-hint>
|
||||||
|
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('required')"> Tracking ID is required </mat-error>
|
||||||
|
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('maxlength')">
|
||||||
|
Tracking ID cannot exceed 100 characters
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Nothing to do: a kit is already out and not yet resolved -->
|
||||||
|
<p class="wk-empty" *ngIf="!welcomeKitsData?.canDispatchNew && activeDispatches.length > 0">
|
||||||
|
A welcome kit is already recorded for this client. Record its outcome above before dispatching another.
|
||||||
|
</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
<button mat-button mat-dialog-close>Close</button>
|
<button mat-button type="button" (click)="close()">Close</button>
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-flat-button
|
||||||
color="primary"
|
color="primary"
|
||||||
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
|
type="button"
|
||||||
|
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading && !outcomeFor"
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[disabled]="dispatchForm.invalid || isLoading"
|
[disabled]="dispatchForm.invalid || isLoading"
|
||||||
>
|
>
|
||||||
|
<mat-icon>send</mat-icon>
|
||||||
Dispatch
|
Dispatch
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
+353
-33
@@ -5,55 +5,375 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
.dispatch-tracking-panel {
|
|
||||||
background-color: #f5f5f5;
|
$wk-primary: #1f497d;
|
||||||
border-radius: 8px;
|
$wk-border: #e3e7ee;
|
||||||
padding: 16px;
|
$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;
|
margin-bottom: 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, rgba($wk-primary, 0.08), rgba($wk-primary, 0.02));
|
||||||
|
border: 1px solid rgba($wk-primary, 0.15);
|
||||||
|
|
||||||
h3 {
|
&__avatar {
|
||||||
margin-top: 0;
|
display: flex;
|
||||||
margin-bottom: 12px;
|
align-items: center;
|
||||||
font-size: 1.1rem;
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
flex: 0 0 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $wk-primary;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
&__name {
|
||||||
margin: 6px 0;
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
&__meta {
|
||||||
padding: 4px 8px;
|
font-size: 0.8rem;
|
||||||
border-radius: 4px;
|
color: $wk-muted;
|
||||||
font-size: 0.85rem;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 500;
|
}
|
||||||
color: white;
|
}
|
||||||
|
|
||||||
&.status-dispatched {
|
/* ---------- dispatch panel ---------- */
|
||||||
background-color: #1976d2;
|
.wk-dispatch {
|
||||||
}
|
border: 1px solid $wk-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: $wk-surface;
|
||||||
|
|
||||||
&.status-in_transit {
|
&--past {
|
||||||
background-color: #f57c00;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.status-delivered {
|
&__head {
|
||||||
background-color: #388e3c;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
&.status-returned {
|
&__label {
|
||||||
background-color: #d32f2f;
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $wk-muted;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
|
||||||
|
button mat-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-create-form {
|
/* ---------- status chip ---------- */
|
||||||
h3 {
|
.wk-chip {
|
||||||
margin-top: 0;
|
display: inline-flex;
|
||||||
margin-bottom: 12px;
|
align-items: center;
|
||||||
font-size: 1.1rem;
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--dispatched {
|
||||||
|
background: rgb(25 118 210 / 12%);
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--delivered {
|
||||||
|
background: rgb(56 142 60 / 14%);
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--returned {
|
||||||
|
background: rgb(211 47 47 / 12%);
|
||||||
|
color: #c62828;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-width {
|
/* ---------- fact grid ---------- */
|
||||||
|
.wk-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px 18px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: $wk-muted;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__mono {
|
||||||
|
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- outcome form ---------- */
|
||||||
|
.wk-outcome {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed rgba($wk-primary, 0.4);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- create form ---------- */
|
||||||
|
.wk-form {
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 0 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wk-address {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $wk-surface;
|
||||||
|
border: 1px solid $wk-border;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: $wk-primary;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: $wk-primary;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__line {
|
||||||
|
color: $wk-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-noaddress {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(211 47 47 / 6%);
|
||||||
|
border: 1px solid rgb(211 47 47 / 25%);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #b3261e;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 19px;
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
flex: 0 0 19px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- envelope label call-to-action ---------- */
|
||||||
|
.wk-label-cta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba($wk-primary, 0.04);
|
||||||
|
border: 1px solid rgba($wk-primary, 0.18);
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: $wk-muted;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
button mat-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-empty {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $wk-surface;
|
||||||
|
border: 1px solid $wk-border;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $wk-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-dialog-actions {
|
||||||
|
padding: 12px 24px 20px;
|
||||||
|
|
||||||
|
button mat-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+422
-32
@@ -12,12 +12,14 @@ import {
|
|||||||
MatDialogRef,
|
MatDialogRef,
|
||||||
MatDialogTitle,
|
MatDialogTitle,
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
MatDialogActions,
|
MatDialogActions
|
||||||
MatDialogClose
|
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { ClientsService } from '../../../clients.service';
|
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { DatePipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
@@ -32,32 +34,79 @@ import autoTable from 'jspdf-autotable';
|
|||||||
MatDialogTitle,
|
MatDialogTitle,
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
MatDialogActions,
|
MatDialogActions,
|
||||||
MatDialogClose,
|
MatProgressSpinnerModule,
|
||||||
MatProgressSpinnerModule
|
MatIcon
|
||||||
],
|
],
|
||||||
providers: [DatePipe]
|
providers: [DatePipe]
|
||||||
})
|
})
|
||||||
export class WelcomeKitDialogComponent implements OnInit {
|
export class WelcomeKitDialogComponent implements OnInit {
|
||||||
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
|
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
|
||||||
data: { clientId: string } = inject(MAT_DIALOG_DATA);
|
data: {
|
||||||
|
clientId: string;
|
||||||
|
clientName?: string;
|
||||||
|
accountNo?: string;
|
||||||
|
cifNumber?: string;
|
||||||
|
mobileNo?: string;
|
||||||
|
emailAddress?: string;
|
||||||
|
officeName?: string;
|
||||||
|
} = inject(MAT_DIALOG_DATA);
|
||||||
private clientsService = inject(ClientsService);
|
private clientsService = inject(ClientsService);
|
||||||
private formBuilder = inject(FormBuilder);
|
private formBuilder = inject(FormBuilder);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
private datePipe = inject(DatePipe);
|
private datePipe = inject(DatePipe);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
|
||||||
isLoading = true;
|
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;
|
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({
|
dispatchForm: FormGroup = this.formBuilder.group({
|
||||||
deliveryAddress: [
|
// The kit goes to one of the addresses captured at client creation, never to a hand-typed one, so the
|
||||||
'',
|
// dispatch record always traces back to an address the client actually gave.
|
||||||
|
addressId: [
|
||||||
|
null,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
deliveryMode: [
|
deliveryMode: [
|
||||||
'POSTMAN',
|
'POSTMAN',
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
deliveryModeOtherText: ['']
|
deliveryModeOtherText: [''],
|
||||||
|
trackingId: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.maxLength(100)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
dispatchedOnDate: [
|
||||||
|
new Date(),
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
outcomeForm: FormGroup = this.formBuilder.group({
|
||||||
|
outcomeDate: [
|
||||||
|
new Date(),
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
remarks: [
|
||||||
|
'',
|
||||||
|
Validators.maxLength(500)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
deliveryModes = [
|
deliveryModes = [
|
||||||
'POSTMAN',
|
'POSTMAN',
|
||||||
'COURIER',
|
'COURIER',
|
||||||
@@ -76,7 +125,37 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get clientName(): string {
|
||||||
|
return this.welcomeKitsData?.clientName || this.data.clientName || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The kit is a physical address check, so the outcome is phrased as verification rather than delivery. */
|
||||||
|
verificationLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'DELIVERED':
|
||||||
|
return 'Verified';
|
||||||
|
case 'RETURNED':
|
||||||
|
return 'Not Verified';
|
||||||
|
default:
|
||||||
|
return 'Awaiting Verification';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verificationHint(dispatch: any): string {
|
||||||
|
switch (dispatch?.status) {
|
||||||
|
case 'DELIVERED':
|
||||||
|
return 'Kit delivered, so the address stands verified.';
|
||||||
|
case 'RETURNED':
|
||||||
|
return 'Kit came back undelivered, so the address could not be verified.';
|
||||||
|
default:
|
||||||
|
return 'Check this tracking ID on the delivery partner’s website or app, then record the outcome below.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.maxDate = this.settingsService.businessDate;
|
||||||
|
this.dispatchForm.patchValue({ dispatchedOnDate: this.maxDate });
|
||||||
|
this.outcomeForm.patchValue({ outcomeDate: this.maxDate });
|
||||||
this.fetchData();
|
this.fetchData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,33 +177,77 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Order the address types are offered in: a kit is posted to the correspondence address when one exists. */
|
||||||
|
private static readonly ADDRESS_TYPE_ORDER = [
|
||||||
|
'Correspondence',
|
||||||
|
'Current / Residential',
|
||||||
|
'Permanent Address',
|
||||||
|
'Office / Work'
|
||||||
|
];
|
||||||
|
|
||||||
fetchPrimaryAddress() {
|
fetchPrimaryAddress() {
|
||||||
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
|
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
|
||||||
next: (addresses: any) => {
|
next: (addresses: any) => {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
if (addresses && addresses.length > 0) {
|
this.addressesLoaded = true;
|
||||||
// Attempt to build a readable address from the first address returned
|
|
||||||
const primary = addresses[0];
|
|
||||||
const parts = [];
|
|
||||||
if (primary.addressLine1) parts.push(primary.addressLine1);
|
|
||||||
if (primary.addressLine2) parts.push(primary.addressLine2);
|
|
||||||
if (primary.city) parts.push(primary.city);
|
|
||||||
if (primary.stateName) parts.push(primary.stateName);
|
|
||||||
if (primary.postalCode) parts.push(primary.postalCode);
|
|
||||||
if (primary.countryName) parts.push(primary.countryName);
|
|
||||||
|
|
||||||
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
|
const usable = (addresses || []).filter((a: any) => a && (a.addressLine1 || a.postalCode || a.city));
|
||||||
this.dispatchForm.patchValue({
|
this.addressChoices = usable
|
||||||
deliveryAddress: clientNameStr + parts.join(', ')
|
.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: () => {
|
error: () => {
|
||||||
this.isLoading = false;
|
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() {
|
get activeDispatches() {
|
||||||
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
|
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
|
||||||
return this.welcomeKitsData.dispatches;
|
return this.welcomeKitsData.dispatches;
|
||||||
@@ -138,36 +261,104 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
return;
|
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;
|
this.isLoading = true;
|
||||||
const formValue = this.dispatchForm.value;
|
const formValue = this.dispatchForm.value;
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
const today = new Date();
|
const locale = this.settingsService.language.code;
|
||||||
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
deliveryAddress: formValue.deliveryAddress,
|
// Stored as text so the dispatch keeps the address exactly as it read on the day it went out, even if
|
||||||
|
// the client's address record is edited later.
|
||||||
|
deliveryAddress: `${chosen.type}: ${chosen.oneLine}`,
|
||||||
|
addressId: chosen.id,
|
||||||
deliveryMode: formValue.deliveryMode,
|
deliveryMode: formValue.deliveryMode,
|
||||||
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
|
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
|
||||||
dispatchedOnDate: formattedDate,
|
trackingId: (formValue.trackingId || '').trim(),
|
||||||
locale: 'en',
|
dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat),
|
||||||
dateFormat: 'dd MMMM yyyy'
|
locale,
|
||||||
|
dateFormat
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
|
this.hasChanges = true;
|
||||||
|
this.snackBar.open('Welcome Kit dispatched successfully', 'Close', { duration: 3000 });
|
||||||
this.fetchData();
|
this.fetchData();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (err: any) => {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
|
this.snackBar.open(this.errorMessage(err, 'Failed to dispatch welcome kit'), 'Close', { duration: 5000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the outcome form for a dispatch, pre-set to the outcome the operator clicked. */
|
||||||
|
startOutcome(dispatch: any, status: 'DELIVERED' | 'RETURNED') {
|
||||||
|
this.outcomeFor = dispatch;
|
||||||
|
this.outcomeStatus = status;
|
||||||
|
this.outcomeForm.reset({ outcomeDate: this.maxDate, remarks: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelOutcome() {
|
||||||
|
this.outcomeFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records what the operator found on the delivery partner's site: delivered verifies the address, returned does not. */
|
||||||
|
submitOutcome() {
|
||||||
|
if (this.outcomeForm.invalid || !this.outcomeFor) {
|
||||||
|
this.outcomeForm.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
const formValue = this.outcomeForm.value;
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
const locale = this.settingsService.language.code;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
status: this.outcomeStatus,
|
||||||
|
outcomeDate: this.dateUtils.formatDate(formValue.outcomeDate, dateFormat),
|
||||||
|
remarks: (formValue.remarks || '').trim() || null,
|
||||||
|
locale,
|
||||||
|
dateFormat
|
||||||
|
};
|
||||||
|
|
||||||
|
this.clientsService.updateWelcomeKitStatus(this.outcomeFor.id, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.hasChanges = true;
|
||||||
|
this.outcomeFor = null;
|
||||||
|
this.snackBar.open(
|
||||||
|
this.outcomeStatus === 'DELIVERED' ? 'Address marked as verified' : 'Address marked as not verified',
|
||||||
|
'Close',
|
||||||
|
{ duration: 3000 }
|
||||||
|
);
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackBar.open(this.errorMessage(err, 'Failed to record the outcome'), 'Close', { duration: 5000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.dialogRef.close(this.hasChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Surfaces the backend's own message (duplicate tracking ID, inactive client) instead of a generic failure. */
|
||||||
|
private errorMessage(err: any, fallback: string): string {
|
||||||
|
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
getDisplayAddress(dispatch: any): string {
|
getDisplayAddress(dispatch: any): string {
|
||||||
const address = dispatch.deliveryAddress || '';
|
const address = dispatch.deliveryAddress || '';
|
||||||
const name = this.welcomeKitsData?.clientName || '';
|
const name = this.clientName;
|
||||||
if (name && !address.startsWith(name)) {
|
if (name && !address.startsWith(name)) {
|
||||||
return `${name} — ${address}`;
|
return `${name} — ${address}`;
|
||||||
}
|
}
|
||||||
@@ -185,6 +376,9 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
const trackingId = dispatch.trackingId || 'N/A';
|
const trackingId = dispatch.trackingId || 'N/A';
|
||||||
const status = dispatch.status || 'N/A';
|
const status = dispatch.status || 'N/A';
|
||||||
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || '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] = [
|
const primaryColor: [number, number, number] = [
|
||||||
31,
|
31,
|
||||||
@@ -307,6 +501,14 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
[
|
[
|
||||||
'Dispatched On',
|
'Dispatched On',
|
||||||
dispatchedOn
|
dispatchedOn
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Address Verification',
|
||||||
|
verification
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Outcome Recorded On',
|
||||||
|
outcomeOn
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -344,4 +546,192 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
|
|
||||||
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
|
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Address label for the envelope. It is printed before the kit is posted — off the live form for a new
|
||||||
|
* dispatch, or off a saved record — so the operator can fold it into a window envelope and hand it over the
|
||||||
|
* counter. Layout is a single A4 sheet: the addressee block sits inside the window cut-out zone, and the
|
||||||
|
* welcome message sits below the fold where it stays hidden until the envelope is opened.
|
||||||
|
*/
|
||||||
|
downloadAddressLabel(dispatch?: any) {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
|
||||||
|
const primaryColor: [number, number, number] = [
|
||||||
|
31,
|
||||||
|
73,
|
||||||
|
125
|
||||||
|
];
|
||||||
|
const grayColor: [number, number, number] = [
|
||||||
|
110,
|
||||||
|
110,
|
||||||
|
110
|
||||||
|
];
|
||||||
|
const lineColor: [number, number, number] = [
|
||||||
|
205,
|
||||||
|
212,
|
||||||
|
222
|
||||||
|
];
|
||||||
|
|
||||||
|
const name = this.clientName || 'Client';
|
||||||
|
// A saved dispatch keeps the address it actually went out with; a new one uses the address currently
|
||||||
|
// selected in the form. The saved value carries a "Type: " prefix that belongs on the record, not the envelope.
|
||||||
|
const addressLines = dispatch
|
||||||
|
? String(dispatch.deliveryAddress || '')
|
||||||
|
.replace(/^[^:]+:\s*/, '')
|
||||||
|
.split(',')
|
||||||
|
.map((part: string) => part.trim())
|
||||||
|
.filter((part: string) => part.length > 0)
|
||||||
|
: this.addressLines;
|
||||||
|
|
||||||
|
// --- Sender block, top-left, as the return address ---
|
||||||
|
doc.setFillColor(...primaryColor);
|
||||||
|
doc.rect(0, 0, pageWidth, 26, 'F');
|
||||||
|
doc.setFontSize(17);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.text('CBSNexora', 18, 14);
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('Core Banking Solution', 18, 20);
|
||||||
|
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(...grayColor);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('FROM', 22, 37);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
doc.setTextColor(95, 100, 110);
|
||||||
|
let fromY = 42.5;
|
||||||
|
const branch = this.data.officeName ? `CBSNexora — ${this.data.officeName}` : 'CBSNexora';
|
||||||
|
[
|
||||||
|
branch,
|
||||||
|
'Customer Service Desk | support@cbsnexora.com | 1800-000-0000'
|
||||||
|
].forEach((line) => {
|
||||||
|
doc.text(line, 22, fromY);
|
||||||
|
fromY += 4.6;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Addressee card: name, address and the client's reference details in one block, sized so the whole
|
||||||
|
// card sits behind a standard window cut-out ---
|
||||||
|
const cardX = 22;
|
||||||
|
const cardY = 60;
|
||||||
|
const cardW = pageWidth - 44;
|
||||||
|
const padX = cardX + 9;
|
||||||
|
|
||||||
|
const refs: string[] = [];
|
||||||
|
if (this.data.accountNo) refs.push(`Account No. ${this.data.accountNo}`);
|
||||||
|
if (this.data.cifNumber) refs.push(`CIF ${this.data.cifNumber}`);
|
||||||
|
if (this.data.mobileNo) refs.push(`Mobile ${this.data.mobileNo}`);
|
||||||
|
if (this.data.emailAddress) refs.push(`Email ${this.data.emailAddress}`);
|
||||||
|
if (dispatch?.trackingId) refs.push(`Tracking ID ${dispatch.trackingId}`);
|
||||||
|
|
||||||
|
// Height is measured off the same offsets the text below uses, so the card closes a fixed margin under the
|
||||||
|
// last line instead of leaving a gap that grows with a short address.
|
||||||
|
const addrStartY = 27;
|
||||||
|
const addrEndY = addrStartY + (addressLines.length - 1) * 6.4;
|
||||||
|
const cardH = refs.length > 0 ? addrEndY + 5 + refs.length * 5 + 4 : addrEndY + 9;
|
||||||
|
|
||||||
|
doc.setFillColor(252, 253, 255);
|
||||||
|
doc.setDrawColor(...lineColor);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.roundedRect(cardX, cardY, cardW, cardH, 2, 2, 'FD');
|
||||||
|
|
||||||
|
// Accent spine marks the addressee edge without boxing the text in.
|
||||||
|
doc.setFillColor(...primaryColor);
|
||||||
|
doc.rect(cardX, cardY + 1, 1.6, cardH - 2, 'F');
|
||||||
|
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setTextColor(...grayColor);
|
||||||
|
doc.text('ALIGN THIS BLOCK WITH THE ENVELOPE WINDOW', cardX, cardY - 3.5);
|
||||||
|
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...primaryColor);
|
||||||
|
doc.text('TO', padX, cardY + 10);
|
||||||
|
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(20, 20, 20);
|
||||||
|
doc.text(name, padX, cardY + 19);
|
||||||
|
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(45, 45, 45);
|
||||||
|
let addrY = cardY + addrStartY;
|
||||||
|
addressLines.forEach((line: string) => {
|
||||||
|
doc.text(line, padX, addrY);
|
||||||
|
addrY += 6.4;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reference details close the same card, separated by a hairline rather than a second frame.
|
||||||
|
if (refs.length > 0) {
|
||||||
|
const dividerY = cardY + addrEndY + 5;
|
||||||
|
doc.setDrawColor(...lineColor);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.line(padX, dividerY, cardX + cardW - 9, dividerY);
|
||||||
|
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
doc.setTextColor(...grayColor);
|
||||||
|
let refY = dividerY + 5;
|
||||||
|
refs.forEach((line) => {
|
||||||
|
doc.text(line, padX, refY);
|
||||||
|
refY += 5;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fold line: hairline broken around a centred label, so it guides the fold without competing with
|
||||||
|
// the address block above it ---
|
||||||
|
const foldY = cardY + cardH + 22;
|
||||||
|
const foldGap = 12;
|
||||||
|
doc.setDrawColor(226, 230, 236);
|
||||||
|
doc.setLineWidth(0.15);
|
||||||
|
doc.setLineDashPattern(
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
2.5
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
doc.line(30, foldY, pageWidth / 2 - foldGap, foldY);
|
||||||
|
doc.line(pageWidth / 2 + foldGap, foldY, pageWidth - 30, foldY);
|
||||||
|
doc.setLineDashPattern([], 0);
|
||||||
|
doc.setFontSize(6.5);
|
||||||
|
doc.setTextColor(178, 184, 194);
|
||||||
|
doc.text('FOLD', pageWidth / 2, foldY + 1.6, { align: 'center' });
|
||||||
|
|
||||||
|
// --- Welcome message, below the fold ---
|
||||||
|
let msgY = foldY + 14;
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...primaryColor);
|
||||||
|
doc.text('Welcome to CBSNexora', 22, msgY);
|
||||||
|
msgY += 10;
|
||||||
|
|
||||||
|
doc.setFontSize(10.5);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(40, 40, 40);
|
||||||
|
doc.text(`Dear ${name},`, 22, msgY);
|
||||||
|
msgY += 8;
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
'Thank you for opening your account with us. Enclosed is your Welcome Kit containing your account documentation and the essential materials you need to start banking with us.',
|
||||||
|
'Please keep your account number and CIF safe, and do not share your banking credentials with anyone. Our staff will never ask you for your PIN, password or OTP.',
|
||||||
|
'If anything in this kit is missing or incorrect, contact your branch or call our customer service desk and we will put it right.'
|
||||||
|
];
|
||||||
|
body.forEach((paragraph) => {
|
||||||
|
const lines = doc.splitTextToSize(paragraph, pageWidth - 44);
|
||||||
|
doc.text(lines, 22, msgY);
|
||||||
|
msgY += lines.length * 5.6 + 5;
|
||||||
|
});
|
||||||
|
|
||||||
|
msgY += 4;
|
||||||
|
doc.text('Warm regards,', 22, msgY);
|
||||||
|
msgY += 6;
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('CBSNexora Operations Team', 22, msgY);
|
||||||
|
|
||||||
|
const stamp = this.datePipe.transform(new Date(), 'yyyyMMdd-HHmmss');
|
||||||
|
doc.save(`WelcomeKit_Label_${this.data.accountNo || this.data.clientId}_${stamp}.pdf`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,221 +104,230 @@
|
|||||||
<span class="kv-table__col kv-table__col--action">Action</span>
|
<span class="kv-table__col kv-table__col--action">Action</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PAN Row -->
|
<!--
|
||||||
<div class="kv-table__row">
|
PAN and Aadhaar below are the individual's own OVDs. An entity holds neither: its PAN is
|
||||||
<span class="kv-table__col kv-table__col--doc">
|
the Enterprise PAN reported further down (the same stored panNumber, under the name the
|
||||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
entity actually knows it by), and Aadhaar is issued to people, not companies — the masked
|
||||||
</span>
|
number on a non-individual record is a placeholder the KYC step writes to satisfy the
|
||||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
column, so showing it as "Pending" invited officers to chase a document that cannot exist.
|
||||||
<span class="kv-table__col kv-table__col--status">
|
-->
|
||||||
@if (kycData.panVerified) {
|
@if (!isNonIndividual) {
|
||||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
<!-- PAN Row -->
|
||||||
} @else if (panDeclarationTaken) {
|
<div class="kv-table__row">
|
||||||
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
<span class="kv-table__col kv-table__col--doc">
|
||||||
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
||||||
} @else {
|
</span>
|
||||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
||||||
}
|
<span class="kv-table__col kv-table__col--status">
|
||||||
</span>
|
@if (kycData.panVerified) {
|
||||||
<span class="kv-table__col kv-table__col--date">{{
|
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||||
kycData.panVerified
|
} @else if (panDeclarationTaken) {
|
||||||
? kycData.panVerifiedOn
|
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
||||||
? formatDate(kycData.panVerifiedOn)
|
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
||||||
: '—'
|
|
||||||
: panDeclarationTaken && kycData.panDeclarationTakenOn
|
|
||||||
? formatDate(kycData.panDeclarationTakenOn)
|
|
||||||
: '—'
|
|
||||||
}}</span>
|
|
||||||
<span class="kv-table__col kv-table__col--action">
|
|
||||||
<button
|
|
||||||
mat-stroked-button
|
|
||||||
class="kv-action-btn"
|
|
||||||
type="button"
|
|
||||||
[disabled]="verifying.pan || !kycData.panNumber"
|
|
||||||
(click)="verifyPAN()"
|
|
||||||
>
|
|
||||||
@if (verifying.pan) {
|
|
||||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
} @else {
|
||||||
Re-Verify
|
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||||
}
|
}
|
||||||
</button>
|
</span>
|
||||||
<!--
|
<span class="kv-table__col kv-table__col--date">{{
|
||||||
|
kycData.panVerified
|
||||||
|
? kycData.panVerifiedOn
|
||||||
|
? formatDate(kycData.panVerifiedOn)
|
||||||
|
: '—'
|
||||||
|
: panDeclarationTaken && kycData.panDeclarationTakenOn
|
||||||
|
? formatDate(kycData.panDeclarationTakenOn)
|
||||||
|
: '—'
|
||||||
|
}}</span>
|
||||||
|
<span class="kv-table__col kv-table__col--action">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="kv-action-btn"
|
||||||
|
type="button"
|
||||||
|
[disabled]="verifying.pan || !kycData.panNumber"
|
||||||
|
(click)="verifyPAN()"
|
||||||
|
>
|
||||||
|
@if (verifying.pan) {
|
||||||
|
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
} @else {
|
||||||
|
Re-Verify
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<!--
|
||||||
A PAN that has never passed is not always fixable by another attempt. Offer the
|
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
|
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.
|
cleared without the officer having to run a verification they know will fail.
|
||||||
-->
|
-->
|
||||||
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
class="kv-action-btn"
|
class="kv-action-btn"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!kycId || saving"
|
[disabled]="!kycId || saving"
|
||||||
(click)="takePanDeclaration()"
|
(click)="takePanDeclaration()"
|
||||||
>
|
>
|
||||||
Declaration
|
Declaration
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
PAN is unverified but a declaration is on file. Shown in full because it is the only
|
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.
|
reason PAN carries no KYC exception — a reviewer has to see what was declared.
|
||||||
-->
|
-->
|
||||||
@if (!kycData.panVerified && panDeclarationTaken) {
|
@if (!kycData.panVerified && panDeclarationTaken) {
|
||||||
<div class="kv-decl">
|
<div class="kv-decl">
|
||||||
<div class="kv-decl__head">
|
<div class="kv-decl__head">
|
||||||
<mat-icon>assignment_turned_in</mat-icon>
|
<mat-icon>assignment_turned_in</mat-icon>
|
||||||
<span class="kv-decl__title">PAN Declaration</span>
|
<span class="kv-decl__title">PAN Declaration</span>
|
||||||
@if (!saving) {
|
@if (!saving) {
|
||||||
<span class="kv-decl__actions">
|
<span class="kv-decl__actions">
|
||||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
class="kv-action-btn danger"
|
class="kv-action-btn danger"
|
||||||
(click)="removePanDeclaration()"
|
(click)="removePanDeclaration()"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
@if (kycData.panDeclarationNameOnPan) {
|
<div class="kv-decl__rows">
|
||||||
<div class="kv-decl__row">
|
<div class="kv-decl__row">
|
||||||
<span class="k">Name on PAN</span>
|
<span class="k">Does not match</span>
|
||||||
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
@if (kycData.panDeclarationNameOnPan) {
|
||||||
@if (kycData.panDeclarationDobOnPan) {
|
<div class="kv-decl__row">
|
||||||
<div class="kv-decl__row">
|
<span class="k">Name on PAN</span>
|
||||||
<span class="k">DOB on PAN</span>
|
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
||||||
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
@if (kycData.panDeclarationDobOnPan) {
|
||||||
@if (kycData.panDeclarationRemarks) {
|
<div class="kv-decl__row">
|
||||||
<div class="kv-decl__row">
|
<span class="k">DOB on PAN</span>
|
||||||
<span class="k">Declaration file</span>
|
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
||||||
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
@if (kycData.panDeclarationRemarks) {
|
||||||
|
<div class="kv-decl__row">
|
||||||
|
<span class="k">Declaration file</span>
|
||||||
|
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="kv-decl__note">
|
||||||
|
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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 -->
|
<!-- Aadhaar Row -->
|
||||||
<div class="kv-table__row">
|
<div class="kv-table__row">
|
||||||
<span class="kv-table__col kv-table__col--doc">
|
<span class="kv-table__col kv-table__col--doc">
|
||||||
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
||||||
</span>
|
</span>
|
||||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
||||||
<span class="kv-table__col kv-table__col--status">
|
<span class="kv-table__col kv-table__col--status">
|
||||||
@if (kycData.aadhaarVerified) {
|
@if (kycData.aadhaarVerified) {
|
||||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||||
} @else if (aadhaarReceiptTaken) {
|
} @else if (aadhaarReceiptTaken) {
|
||||||
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
|
<!-- 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>
|
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span class="kv-table__col kv-table__col--date">{{
|
<span class="kv-table__col kv-table__col--date">{{
|
||||||
kycData.aadhaarVerified
|
kycData.aadhaarVerified
|
||||||
? kycData.aadhaarVerifiedOn
|
? kycData.aadhaarVerifiedOn
|
||||||
? formatDate(kycData.aadhaarVerifiedOn)
|
? formatDate(kycData.aadhaarVerifiedOn)
|
||||||
: '—'
|
: '—'
|
||||||
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
||||||
? formatDate(kycData.aadhaarReceiptTakenOn)
|
? formatDate(kycData.aadhaarReceiptTakenOn)
|
||||||
: '—'
|
: '—'
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="kv-table__col kv-table__col--action">
|
<span class="kv-table__col kv-table__col--action">
|
||||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
class="kv-action-btn"
|
class="kv-action-btn"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
||||||
(click)="startAadhaarReVerify()"
|
(click)="startAadhaarReVerify()"
|
||||||
>
|
>
|
||||||
Re-Verify
|
Re-Verify
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<!--
|
<!--
|
||||||
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
|
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.
|
slip here so an existing client sitting in the exception list can be cleared.
|
||||||
-->
|
-->
|
||||||
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
class="kv-action-btn"
|
class="kv-action-btn"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!kycId || saving"
|
[disabled]="!kycId || saving"
|
||||||
(click)="takeAadhaarReceipt()"
|
(click)="takeAadhaarReceipt()"
|
||||||
>
|
>
|
||||||
Receipt
|
Receipt
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||||
only reason Aadhaar carries no KYC exception.
|
only reason Aadhaar carries no KYC exception.
|
||||||
-->
|
-->
|
||||||
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
||||||
<div class="kv-rcpt">
|
<div class="kv-rcpt">
|
||||||
<div class="kv-rcpt__head">
|
<div class="kv-rcpt__head">
|
||||||
<mat-icon>receipt_long</mat-icon>
|
<mat-icon>receipt_long</mat-icon>
|
||||||
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
||||||
@if (!saving) {
|
@if (!saving) {
|
||||||
<span class="kv-rcpt__actions">
|
<span class="kv-rcpt__actions">
|
||||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
class="kv-action-btn danger"
|
class="kv-action-btn danger"
|
||||||
(click)="removeAadhaarReceipt()"
|
(click)="removeAadhaarReceipt()"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
@if (kycData.aadhaarEnrolmentDate) {
|
<div class="kv-rcpt__rows">
|
||||||
<div class="kv-rcpt__row">
|
<div class="kv-rcpt__row">
|
||||||
<span class="k">Enrolment date</span>
|
<span class="k">Enrolment ID</span>
|
||||||
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
@if (kycData.aadhaarEnrolmentDate) {
|
||||||
@if (kycData.aadhaarReceiptRemarks) {
|
<div class="kv-rcpt__row">
|
||||||
<div class="kv-rcpt__row">
|
<span class="k">Enrolment date</span>
|
||||||
<span class="k">Remarks</span>
|
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
||||||
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
@if (kycData.aadhaarReceiptRemarks) {
|
||||||
|
<div class="kv-rcpt__row">
|
||||||
|
<span class="k">Remarks</span>
|
||||||
|
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="kv-rcpt__note">
|
||||||
|
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kv-rcpt__note">
|
}
|
||||||
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -425,23 +434,59 @@
|
|||||||
</div>
|
</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">
|
<div class="kv-table__row">
|
||||||
<span class="kv-table__col kv-table__col--doc">
|
<span class="kv-table__col kv-table__col--doc">
|
||||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
|
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
|
||||||
</span>
|
</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">
|
<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>
|
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span class="kv-table__col kv-table__col--date">{{
|
<span class="kv-table__col kv-table__col--date">{{
|
||||||
kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—'
|
kycData.panVerified && kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
|
||||||
}}</span>
|
}}</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>
|
<span class="kv-table__col kv-table__col--action"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -464,68 +509,61 @@
|
|||||||
<span class="kv-table__col kv-table__col--action"></span>
|
<span class="kv-table__col kv-table__col--action"></span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
|
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
|
||||||
table row). Dark gradient base with state-driven accent so the
|
table row). Dark gradient base with state-driven accent so the
|
||||||
linking state is the first thing a reviewer sees. -->
|
linking state is the first thing a reviewer sees.
|
||||||
<div
|
|
||||||
class="pal-card"
|
Section 139AA seeding applies to a person's PAN and Aadhaar. An entity has no Aadhaar
|
||||||
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
to seed, so for a non-individual the card could only ever read "Pending" against a
|
||||||
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
requirement that does not apply to it. -->
|
||||||
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
@if (!isNonIndividual) {
|
||||||
[class.pal-card--pending]="
|
<div
|
||||||
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
class="pal-card"
|
||||||
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
||||||
kycData?.panAadhaarSeedingStatus !== 'E'
|
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
||||||
"
|
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
||||||
>
|
[class.pal-card--pending]="
|
||||||
<div class="pal-card__left">
|
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
||||||
<div class="pal-card__icon">
|
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
||||||
<mat-icon>credit_card</mat-icon>
|
kycData?.panAadhaarSeedingStatus !== 'E'
|
||||||
</div>
|
"
|
||||||
<div class="pal-card__txt">
|
>
|
||||||
<div class="pal-card__small">Verification Status</div>
|
<div class="pal-card__left">
|
||||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
<div class="pal-card__icon">
|
||||||
<div class="pal-card__status">
|
<mat-icon>credit_card</mat-icon>
|
||||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
</div>
|
||||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
<div class="pal-card__txt">
|
||||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
<div class="pal-card__small">Verification Status</div>
|
||||||
<mat-icon>cancel</mat-icon> Not Linked
|
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
<div class="pal-card__status">
|
||||||
<mat-icon>info</mat-icon> Exempt
|
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||||
} @else {
|
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||||
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
} @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>
|
</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>
|
||||||
<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) -->
|
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
|
||||||
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
||||||
@@ -795,15 +833,19 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
|
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
|
||||||
<div class="kyc-edit-grid">
|
<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-edit-field">
|
||||||
<div class="kyc-field-label-row">
|
<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>
|
<span class="kyc-tag">KYC</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kyc-input-row">
|
<div class="kyc-input-row">
|
||||||
<mat-form-field appearance="outline" class="kyc-input">
|
<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
|
<input
|
||||||
matInput
|
matInput
|
||||||
formControlName="panNumber"
|
formControlName="panNumber"
|
||||||
@@ -852,147 +894,154 @@
|
|||||||
The check can fail for reasons no retry will fix — a PAN card that spells the
|
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
|
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.
|
out, so it is offered here rather than leaving the officer at a dead end.
|
||||||
|
|
||||||
|
It records a mismatch against the customer's own name and date of birth, so it
|
||||||
|
is offered to individuals only — an entity PAN is checked against neither.
|
||||||
-->
|
-->
|
||||||
<div class="kyc-decl-offer">
|
@if (!isNonIndividual) {
|
||||||
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
<div class="kyc-decl-offer">
|
||||||
<button
|
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
||||||
mat-stroked-button
|
<button
|
||||||
type="button"
|
mat-stroked-button
|
||||||
[disabled]="!kycId || saving"
|
type="button"
|
||||||
(click)="takePanDeclaration()"
|
[disabled]="!kycId || saving"
|
||||||
>
|
(click)="takePanDeclaration()"
|
||||||
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
>
|
||||||
</button>
|
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
||||||
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
|
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aadhaar -->
|
<!-- Aadhaar — individuals only; an entity has no Aadhaar to capture or verify. -->
|
||||||
<div class="kyc-edit-field">
|
@if (!isNonIndividual) {
|
||||||
<div class="kyc-field-label-row">
|
<div class="kyc-edit-field">
|
||||||
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
<div class="kyc-field-label-row">
|
||||||
<span class="kyc-tag">KYC</span>
|
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
||||||
</div>
|
<span class="kyc-tag">KYC</span>
|
||||||
<div class="kyc-input-row">
|
</div>
|
||||||
<mat-form-field appearance="outline" class="kyc-input">
|
<div class="kyc-input-row">
|
||||||
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
<mat-form-field appearance="outline" class="kyc-input">
|
||||||
<input
|
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
||||||
matInput
|
<input
|
||||||
formControlName="aadhaarNumber"
|
matInput
|
||||||
maxlength="12"
|
formControlName="aadhaarNumber"
|
||||||
inputmode="numeric"
|
maxlength="12"
|
||||||
autocomplete="off"
|
inputmode="numeric"
|
||||||
placeholder="Enter 12-digit Aadhaar"
|
autocomplete="off"
|
||||||
[readonly]="aadhaarOtpSent"
|
placeholder="Enter 12-digit Aadhaar"
|
||||||
/>
|
[readonly]="aadhaarOtpSent"
|
||||||
@if (kycData?.aadhaarNumberMasked) {
|
/>
|
||||||
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
|
@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
|
|
||||||
}
|
}
|
||||||
</button>
|
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||||
}
|
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
|
||||||
</div>
|
}
|
||||||
|
@if (verificationStatus.aadhaar === 'VERIFIED') {
|
||||||
<!-- OTP Section in Edit Mode -->
|
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
|
||||||
@if (aadhaarOtpSent) {
|
}
|
||||||
<div class="otp-section">
|
@if (verificationStatus.aadhaar === 'FAILED') {
|
||||||
<p class="otp-info">
|
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
|
||||||
<mat-icon class="otp-info-icon">sms</mat-icon>
|
}
|
||||||
OTP sent to Aadhaar-linked mobile
|
</mat-form-field>
|
||||||
</p>
|
@if (!aadhaarOtpSent) {
|
||||||
<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
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
color="accent"
|
color="accent"
|
||||||
class="otp-verify-btn"
|
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
class="verify-btn"
|
||||||
(click)="verifyAadhaarOtp()"
|
[disabled]="
|
||||||
|
!kycId ||
|
||||||
|
!kycForm.get('aadhaarNumber')?.value ||
|
||||||
|
kycForm.get('aadhaarNumber')?.invalid ||
|
||||||
|
verifying.aadhaar
|
||||||
|
"
|
||||||
|
(click)="sendAadhaarOtp()"
|
||||||
|
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
|
||||||
>
|
>
|
||||||
@if (aadhaarOtpVerifying) {
|
@if (verifying.aadhaar) {
|
||||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
} @else {
|
} @else {
|
||||||
Verify OTP
|
Send OTP
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<button
|
}
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
class="otp-resend-btn"
|
|
||||||
[disabled]="verifying.aadhaar"
|
|
||||||
(click)="sendAadhaarOtp()"
|
|
||||||
>
|
|
||||||
Resend
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (verificationError.aadhaar) {
|
<!-- OTP Section in Edit Mode -->
|
||||||
<div class="kyc-error-msg">
|
@if (aadhaarOtpSent) {
|
||||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
<div class="otp-section">
|
||||||
<span>{{ verificationError.aadhaar }}</span>
|
<p class="otp-info">
|
||||||
</div>
|
<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
|
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
|
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.
|
out, so it is offered here rather than leaving the officer at a dead end.
|
||||||
-->
|
-->
|
||||||
<div class="kyc-decl-offer">
|
<div class="kyc-decl-offer">
|
||||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!kycId || saving"
|
[disabled]="!kycId || saving"
|
||||||
(click)="takeAadhaarReceipt()"
|
(click)="takeAadhaarReceipt()"
|
||||||
>
|
>
|
||||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- GSTIN (Non-Individual only) -->
|
<!-- GSTIN (Non-Individual only) -->
|
||||||
@if (isNonIndividual) {
|
@if (isNonIndividual) {
|
||||||
|
|||||||
@@ -221,6 +221,17 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
return this.kycData?.panDeclarationTaken === true;
|
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. */
|
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
|
||||||
get panDeclarationMismatchLabel(): string {
|
get panDeclarationMismatchLabel(): string {
|
||||||
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
||||||
|
|||||||
@@ -222,6 +222,14 @@
|
|||||||
<div class="pd-field__value monospace">{{ clientViewData.clientNonPersonDetails.incorpNumber }}</div>
|
<div class="pd-field__value monospace">{{ clientViewData.clientNonPersonDetails.incorpNumber }}</div>
|
||||||
</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) {
|
@if (clientViewData.clientNonPersonDetails.remarks) {
|
||||||
<div class="pd-field full-width">
|
<div class="pd-field full-width">
|
||||||
<div class="pd-field__label">{{ 'labels.inputs.Remarks' | translate }}</div>
|
<div class="pd-field__label">{{ 'labels.inputs.Remarks' | translate }}</div>
|
||||||
@@ -275,26 +283,61 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pd-section__body">
|
<div class="pd-section__body">
|
||||||
<div class="pd-grid">
|
<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">
|
||||||
<div class="pd-field__label">Mobile DIP Status</div>
|
<div class="pd-field__label">Mobile DIP Status</div>
|
||||||
<div class="pd-field__value">
|
<div class="pd-field__value">
|
||||||
@if (
|
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
|
||||||
clientViewData.mobileVerified === true ||
|
<span class="pd-verify-badge pd-verify-badge--flagged">
|
||||||
clientViewData.mobileVerified === 'true' ||
|
<i class="fa fa-exclamation-triangle"></i> {{ dipStatusLabel(clientViewData) }}
|
||||||
clientViewData.mobileVerified === 1 ||
|
</span>
|
||||||
clientViewData.dipVerified === true ||
|
} @else if (isDipClear(clientViewData)) {
|
||||||
clientViewData.dipVerified === 'true' ||
|
|
||||||
clientViewData.dipVerified === 1
|
|
||||||
) {
|
|
||||||
<span class="pd-verify-badge pd-verify-badge--verified">
|
<span class="pd-verify-badge pd-verify-badge--verified">
|
||||||
<i class="fa fa-check-circle"></i> Verified
|
<i class="fa fa-check-circle"></i> Verified
|
||||||
</span>
|
</span>
|
||||||
} @else {
|
} @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>
|
||||||
</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) {
|
@if (clientViewData.dipTimestamp) {
|
||||||
<div class="pd-field">
|
<div class="pd-field">
|
||||||
<div class="pd-field__label">DIP Verified On</div>
|
<div class="pd-field__label">DIP Verified On</div>
|
||||||
@@ -306,7 +349,12 @@
|
|||||||
<div class="pd-field">
|
<div class="pd-field">
|
||||||
<div class="pd-field__label">Alternate Mobile DIP Status</div>
|
<div class="pd-field__label">Alternate Mobile DIP Status</div>
|
||||||
<div class="pd-field__value">
|
<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 === 'true' ||
|
clientViewData.alternateDipVerified === 'true' ||
|
||||||
clientViewData.alternateDipVerified === 1
|
clientViewData.alternateDipVerified === 1
|
||||||
|
|||||||
@@ -248,9 +248,59 @@ $teal: #00897b;
|
|||||||
border: 1px solid #fde68a;
|
border: 1px solid #fde68a;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--danger {
|
&--danger,
|
||||||
|
&--flagged {
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
border: 1px solid #fecaca;
|
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;
|
displayName?: string;
|
||||||
mobileNo?: string;
|
mobileNo?: string;
|
||||||
emailAddress?: 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[];
|
dateOfBirth?: number[];
|
||||||
gender?: { id: number; name: string; active?: boolean };
|
gender?: { id: number; name: string; active?: boolean };
|
||||||
clientType?: { id: number; name: string; active?: boolean };
|
clientType?: { id: number; name: string; active?: boolean };
|
||||||
@@ -67,6 +75,91 @@ export class PersonalDataTabComponent {
|
|||||||
/** Client View Data */
|
/** Client View Data */
|
||||||
clientViewData!: ClientViewData;
|
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() {
|
constructor() {
|
||||||
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
|
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
|
||||||
this.clientViewData = data.clientViewData;
|
this.clientViewData = data.clientViewData;
|
||||||
|
|||||||
@@ -855,6 +855,30 @@ export class ClientsService {
|
|||||||
return this.http.post('/sanctions/screen', { name });
|
return this.http.post('/sanctions/screen', { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the reviewing officer's assessment of a flagged screening.
|
||||||
|
*
|
||||||
|
* A FALSE_POSITIVE does not unblock onboarding on its own — it only becomes effective once
|
||||||
|
* the Principal Officer approves it from the Sanctions Logs screen.
|
||||||
|
*
|
||||||
|
* @param logId screening log id returned by screenSanctions()
|
||||||
|
* @param disposition 'FALSE_POSITIVE' | 'CONFIRMED_MATCH'
|
||||||
|
* @param reason which identifiers were compared and why they do/don't match
|
||||||
|
*/
|
||||||
|
recordSanctionsDisposition(logId: number, disposition: string, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/sanctions/alerts/${logId}/disposition`, { disposition, reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the officer's decision on a DIP/MNRL lookup that came back flagged or unverifiable.
|
||||||
|
* Until PROCEEDED is stored, the client-create gate refuses to save the number.
|
||||||
|
*
|
||||||
|
* @param decision 'PROCEEDED' | 'REJECTED'
|
||||||
|
*/
|
||||||
|
recordDipDecision(logId: number, decision: string, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates existing KYC details for a client.
|
* Updates existing KYC details for a client.
|
||||||
* @param clientId Client ID
|
* @param clientId Client ID
|
||||||
@@ -915,6 +939,15 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
|
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies CIN against MCA (reads the CIN from the stored KYC record, so it must be saved first).
|
||||||
|
* @param clientId Client ID
|
||||||
|
* @param kycId KYC record ID
|
||||||
|
*/
|
||||||
|
verifyCIN(clientId: string, kycId: string): Observable<any> {
|
||||||
|
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-cin`, {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies DIN (Director Identification Number) for an authorized signatory.
|
* Verifies DIN (Director Identification Number) for an authorized signatory.
|
||||||
* Sends signatory clientId, DIN number and the non-individual entity name.
|
* Sends signatory clientId, DIN number and the non-individual entity name.
|
||||||
@@ -982,8 +1015,8 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
|
updateWelcomeKitStatus(welcomeKitId: number, data: any): Observable<any> {
|
||||||
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
|
return this.http.put(`/welcomekit/${welcomeKitId}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
getKycExceptions(resolved = false): Observable<any[]> {
|
getKycExceptions(resolved = false): Observable<any[]> {
|
||||||
|
|||||||
@@ -153,6 +153,12 @@
|
|||||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
[isNonIndividual]="legalFormType === 2"
|
[isNonIndividual]="legalFormType === 2"
|
||||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||||
|
[kycData]="clientKycStep?.kycForm?.value"
|
||||||
|
[kycDocCode]="clientKycStep?.selectedKycDoc?.code"
|
||||||
|
[kycDocFiles]="clientKycStep?.kycDocFiles || null"
|
||||||
|
[kycDocMergedPreview]="clientKycStep?.kycDocMergedPreview || null"
|
||||||
|
[existingKycDocSides]="clientKycStep?.existingKycDocSides || null"
|
||||||
|
[existingKycIdentifierId]="clientKycStep?.existingKycIdentifierId || null"
|
||||||
></mifosx-client-india-address-step>
|
></mifosx-client-india-address-step>
|
||||||
</div>
|
</div>
|
||||||
</mat-tab>
|
</mat-tab>
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ export class CreateClientComponent {
|
|||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||||
|
// A number the officer rejected on the DIP result must be replaced, not carried forward.
|
||||||
|
if (this.clientGeneralStep.dipRejected || this.clientGeneralStep.alternateDipRejected) return false;
|
||||||
if (!this.clientGeneralForm.valid) return false;
|
if (!this.clientGeneralForm.valid) return false;
|
||||||
// Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new
|
// Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new
|
||||||
// client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent.
|
// client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent.
|
||||||
@@ -273,7 +275,17 @@ export class CreateClientComponent {
|
|||||||
if (this.currentStepValid) return '';
|
if (this.currentStepValid) return '';
|
||||||
const idx = this.selectedTabIndex;
|
const idx = this.selectedTabIndex;
|
||||||
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||||
return 'Client failed sanctions screening — cannot proceed.';
|
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
||||||
|
if (disposition === 'FALSE_POSITIVE') {
|
||||||
|
return 'False-positive review submitted — awaiting Principal Officer approval before onboarding can continue.';
|
||||||
|
}
|
||||||
|
if (disposition === 'CONFIRMED_MATCH') {
|
||||||
|
return 'Confirmed UNSC sanctions match — onboarding is blocked and the case has been escalated.';
|
||||||
|
}
|
||||||
|
return 'Sanctions match found — review the match and record an outcome before proceeding.';
|
||||||
|
}
|
||||||
|
if (idx === 0 && (this.clientGeneralStep?.dipRejected || this.clientGeneralStep?.alternateDipRejected)) {
|
||||||
|
return 'The mobile number was rejected on the DIP/MNRL result — enter a different number and verify it.';
|
||||||
}
|
}
|
||||||
if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) {
|
if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) {
|
||||||
return 'Verify the OTP sent to both the mobile number and email address before proceeding.';
|
return 'Verify the OTP sent to both the mobile number and email address before proceeding.';
|
||||||
@@ -384,7 +396,8 @@ export class CreateClientComponent {
|
|||||||
this.dialog
|
this.dialog
|
||||||
.open(VerifyClientAlertDialogComponent, {
|
.open(VerifyClientAlertDialogComponent, {
|
||||||
width: '420px',
|
width: '420px',
|
||||||
autoFocus: false
|
autoFocus: false,
|
||||||
|
data: { isEntity: this.legalFormType === 2 }
|
||||||
})
|
})
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
@@ -1159,8 +1172,8 @@ export class SaveConfirmDialogComponent {}
|
|||||||
</div>
|
</div>
|
||||||
<div class="vcad-body">
|
<div class="vcad-body">
|
||||||
<p>
|
<p>
|
||||||
Please click <strong>Verify Client</strong> to screen the client name against the UN Security Council
|
Please click <strong>{{ data?.isEntity ? 'Verify Entity' : 'Verify Client' }}</strong> to screen the client
|
||||||
Consolidated Sanctions List before proceeding.
|
name against the UN Security Council Consolidated Sanctions List before proceeding.
|
||||||
</p>
|
</p>
|
||||||
<div class="vcad-info">
|
<div class="vcad-info">
|
||||||
<mat-icon class="vcad-info__icon">gavel</mat-icon>
|
<mat-icon class="vcad-info__icon">gavel</mat-icon>
|
||||||
@@ -1269,4 +1282,6 @@ export class SaveConfirmDialogComponent {}
|
|||||||
MatDialogClose
|
MatDialogClose
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VerifyClientAlertDialogComponent {}
|
export class VerifyClientAlertDialogComponent {
|
||||||
|
data = inject(MAT_DIALOG_DATA);
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,11 +215,18 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="tab-section">
|
<div class="tab-section">
|
||||||
|
<!-- constitutionMeta and entityName are bound here exactly as they are in create-client.
|
||||||
|
Without them an entity opened for edit falls back to the generic document list rather
|
||||||
|
than the one its constitution actually uses, so the document type saved at creation
|
||||||
|
can be missing from the picker; a proprietorship is also mis-rendered, since the
|
||||||
|
proprietor-PAN branch keys off the constitution. -->
|
||||||
<mifosx-client-kyc-step
|
<mifosx-client-kyc-step
|
||||||
#clientKyc
|
#clientKyc
|
||||||
[isNonIndividual]="legalFormType === 2"
|
[isNonIndividual]="legalFormType === 2"
|
||||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
|
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||||
|
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||||
[readOnly]="isTabReadOnly(1)"
|
[readOnly]="isTabReadOnly(1)"
|
||||||
@@ -282,6 +289,12 @@
|
|||||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
[isNonIndividual]="legalFormType === 2"
|
[isNonIndividual]="legalFormType === 2"
|
||||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||||
|
[kycData]="clientKyc?.kycForm?.value"
|
||||||
|
[kycDocCode]="clientKyc?.selectedKycDoc?.code"
|
||||||
|
[kycDocFiles]="clientKyc?.kycDocFiles || null"
|
||||||
|
[kycDocMergedPreview]="clientKyc?.kycDocMergedPreview || null"
|
||||||
|
[existingKycDocSides]="clientKyc?.existingKycDocSides || null"
|
||||||
|
[existingKycIdentifierId]="clientKyc?.existingKycIdentifierId || null"
|
||||||
></mifosx-client-india-address-step>
|
></mifosx-client-india-address-step>
|
||||||
</div>
|
</div>
|
||||||
</mat-tab>
|
</mat-tab>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
||||||
|
import { toProperNameCase } from '../utils/name-case.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
||||||
@@ -380,7 +381,14 @@ export class EditClientComponent {
|
|||||||
if (this.savingClient) return 'Saving — please wait…';
|
if (this.savingClient) return 'Saving — please wait…';
|
||||||
if (this.currentStepValid) return '';
|
if (this.currentStepValid) return '';
|
||||||
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||||
return 'Client failed sanctions screening — cannot proceed.';
|
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
||||||
|
if (disposition === 'FALSE_POSITIVE') {
|
||||||
|
return 'False-positive review submitted — awaiting Principal Officer approval before this can be saved.';
|
||||||
|
}
|
||||||
|
if (disposition === 'CONFIRMED_MATCH') {
|
||||||
|
return 'Confirmed UNSC sanctions match — the case has been escalated and saving is blocked.';
|
||||||
|
}
|
||||||
|
return 'Sanctions match found — review the match and record an outcome before proceeding.';
|
||||||
}
|
}
|
||||||
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
|
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
|
||||||
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
|
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
|
||||||
@@ -589,13 +597,11 @@ export class EditClientComponent {
|
|||||||
const kycId = this.clientKycStep.kycId;
|
const kycId = this.clientKycStep.kycId;
|
||||||
const isVerified = this.clientKycStep.isKycVerified;
|
const isVerified = this.clientKycStep.isKycVerified;
|
||||||
|
|
||||||
// Validate remarks are present for verified KYC
|
// Remarks are not demanded here. The server requires them only when a save actually
|
||||||
if (isVerified && !kycData.remarks?.trim()) {
|
// overwrites data the verified record already held, and it names the reason when it
|
||||||
this.savingClient = false;
|
// refuses. Blocking every verified record up front aborted the save silently — the photo
|
||||||
this.clientKycStep.kycForm.get('remarks')?.markAsTouched();
|
// upload above had already gone through, so the image changed, the button came back to
|
||||||
return;
|
// life, and nothing said why nothing had been saved.
|
||||||
}
|
|
||||||
|
|
||||||
let saveObs;
|
let saveObs;
|
||||||
if (kycId && isVerified) {
|
if (kycId && isVerified) {
|
||||||
// VERIFIED → re-edit command
|
// VERIFIED → re-edit command
|
||||||
@@ -628,8 +634,19 @@ export class EditClientComponent {
|
|||||||
this.saveKycDocProof(this.clientId);
|
this.saveKycDocProof(this.clientId);
|
||||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||||
},
|
},
|
||||||
error: () => {
|
// A rejected save used to just re-enable the button, which reads as "nothing happened".
|
||||||
|
// The server's own message says what is missing — remarks on a re-edit, most often — so
|
||||||
|
// it is shown rather than swallowed, and the KYC tab stays put for the officer to fix.
|
||||||
|
error: (err: any) => {
|
||||||
this.savingClient = false;
|
this.savingClient = false;
|
||||||
|
const message =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
'KYC could not be saved. Please review the KYC details and try again.';
|
||||||
|
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||||
|
if (isVerified) {
|
||||||
|
this.clientKycStep?.kycForm.get('remarks')?.markAsTouched();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -734,17 +751,20 @@ export class EditClientComponent {
|
|||||||
? this.toLocalDateString(oldDobRaw)
|
? this.toLocalDateString(oldDobRaw)
|
||||||
: oldDobRaw || '';
|
: oldDobRaw || '';
|
||||||
|
|
||||||
|
// Both sides run through toProperNameCase so a casing-only difference — the stored
|
||||||
|
// 'LINCPAY LTD' against the normalized 'Lincpay LTD' the form now submits — is not
|
||||||
|
// mistaken for a real rename and does not force a PAN re-verification.
|
||||||
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
||||||
(
|
toProperNameCase(
|
||||||
parts.fullname ||
|
parts.fullname ||
|
||||||
[
|
[
|
||||||
parts.firstname,
|
parts.firstname,
|
||||||
parts.middlename,
|
parts.middlename,
|
||||||
parts.lastname
|
parts.lastname
|
||||||
]
|
]
|
||||||
.filter((p) => p && p.trim())
|
.filter((p) => p && p.trim())
|
||||||
.join(' ')
|
.join(' ')
|
||||||
).trim();
|
);
|
||||||
|
|
||||||
const newName = buildName({
|
const newName = buildName({
|
||||||
firstname: form?.get('firstname')?.value || '',
|
firstname: form?.get('firstname')?.value || '',
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ENTITY_KYC_CATEGORY, ENTITY_KYC_DOCUMENT_TYPES, withEntityKycDocuments } from './entity-kyc-documents';
|
||||||
|
|
||||||
|
describe('withEntityKycDocuments', () => {
|
||||||
|
const names = (docs: { name: string }[]) => docs.map((d) => d.name);
|
||||||
|
|
||||||
|
it('keeps the six RBI entity documents when nothing else is supplied', () => {
|
||||||
|
expect(names(withEntityKycDocuments())).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the entity documents ahead of anything the caller adds', () => {
|
||||||
|
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta', mandatory: true }], 'HUF');
|
||||||
|
expect(names(merged.slice(0, 6))).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||||
|
expect(merged[6].name).toBe('Declaration by Karta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups the entity documents together and the rest under the constitution', () => {
|
||||||
|
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta' }], 'HUF');
|
||||||
|
expect(merged[0].category).toBe(ENTITY_KYC_CATEGORY);
|
||||||
|
expect(merged[6].category).toBe('HUF documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds the backend template names onto the entity chips instead of duplicating them', () => {
|
||||||
|
const merged = withEntityKycDocuments(
|
||||||
|
[
|
||||||
|
{ name: 'Certificate of Incorporation', code: 'COI', mandatory: true },
|
||||||
|
{ name: 'Memorandum of Association', code: 'MOA', mandatory: true },
|
||||||
|
{ name: 'Articles of Association', code: 'AOA', mandatory: true },
|
||||||
|
{ name: 'PAN Card of Company', code: 'PAN', mandatory: true },
|
||||||
|
{ name: 'Board Resolution', code: 'BOARD_RESOLUTION', mandatory: true },
|
||||||
|
{ name: 'KYC of Authorized Signatories', code: 'SIGNATORY_KYC', mandatory: true }
|
||||||
|
],
|
||||||
|
'Private Limited Company'
|
||||||
|
);
|
||||||
|
expect(merged).toHaveLength(6);
|
||||||
|
expect(names(merged)).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds constitution labels on too, since those lists carry no codes', () => {
|
||||||
|
const merged = withEntityKycDocuments(
|
||||||
|
[
|
||||||
|
{ name: 'Certificate of Incorporation', code: 'Certificate of Incorporation', mandatory: true },
|
||||||
|
{ name: 'LLP Agreement', code: 'LLP Agreement', mandatory: true },
|
||||||
|
{ name: 'PAN of LLP', code: 'PAN of LLP', mandatory: true },
|
||||||
|
{ name: 'Designated Partners Resolution', code: 'Designated Partners Resolution', mandatory: true },
|
||||||
|
{ name: 'KYC of Designated Partners / Authorized Signatories', code: 'x', mandatory: true }
|
||||||
|
],
|
||||||
|
'Limited Liability Partnership (LLP)'
|
||||||
|
);
|
||||||
|
expect(merged).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a mandatory flag over to the entity document it folds into', () => {
|
||||||
|
const merged = withEntityKycDocuments([{ name: 'PAN Card of Company', code: 'PAN', mandatory: true }]);
|
||||||
|
expect(merged.find((d) => d.code === 'ENTITY_PAN')?.mandatory).toBe(true);
|
||||||
|
expect(merged.find((d) => d.code === 'GST_CERTIFICATE')?.mandatory).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a document that only mentions another one as its own requirement', () => {
|
||||||
|
const merged = withEntityKycDocuments(
|
||||||
|
[{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true }],
|
||||||
|
'Sole Proprietorship'
|
||||||
|
);
|
||||||
|
expect(names(merged)).toContain('Proof of Business Existence (GST Certificate / Shop License)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps constitution-specific requirements the entity list does not cover', () => {
|
||||||
|
const merged = withEntityKycDocuments(
|
||||||
|
[
|
||||||
|
{ name: 'Government Notification / Order', mandatory: true },
|
||||||
|
{ name: 'Udyam Registration Certificate', code: 'UDYAM', mandatory: true },
|
||||||
|
{ name: 'Proof of Address of HUF' }],
|
||||||
|
'Government Body / PSU'
|
||||||
|
);
|
||||||
|
expect(names(merged).slice(6)).toEqual([
|
||||||
|
'Government Notification / Order',
|
||||||
|
'Udyam Registration Certificate',
|
||||||
|
'Proof of Address of HUF'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Shape the KYC document-type picker works with. */
|
||||||
|
export interface KycDocumentType {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
mandatory: boolean;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENTITY_KYC_CATEGORY = 'Entity KYC documents';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The KYC documents every legal entity must produce, whatever its constitution — RBI Master
|
||||||
|
* Direction on KYC, Annexure I. Codes match PROOF_META in kyc-proof-types.ts, which is what
|
||||||
|
* groups them under "Entity KYC documents" and supplies each chip's banner text.
|
||||||
|
*
|
||||||
|
* This is the single source for the entity list so a non-individual client sees the same
|
||||||
|
* documents wherever the picker is rendered — the create stepper, the edit stepper, before a
|
||||||
|
* constitution has been chosen and after, and once the backend KYC template has loaded.
|
||||||
|
*/
|
||||||
|
export const ENTITY_KYC_DOCUMENT_TYPES: { name: string; code: string }[] = [
|
||||||
|
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
||||||
|
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
||||||
|
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
||||||
|
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
||||||
|
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
||||||
|
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codes the backend KYC template uses for documents the entity list already covers
|
||||||
|
* (ClientKycApiResource.getRequiredDocuments). Folding them onto the entity codes keeps one
|
||||||
|
* chip per document instead of "PAN Card of Company" sitting next to "PAN Card of Entity".
|
||||||
|
*/
|
||||||
|
const ENTITY_DOC_CODE_ALIASES: Record<string, string> = {
|
||||||
|
CERT_INCORPORATION: 'CERT_INCORPORATION',
|
||||||
|
COI: 'CERT_INCORPORATION',
|
||||||
|
REG_CERT: 'CERT_INCORPORATION',
|
||||||
|
ENTITY_PAN: 'ENTITY_PAN',
|
||||||
|
PAN: 'ENTITY_PAN',
|
||||||
|
CONSTITUTIVE_DOC: 'CONSTITUTIVE_DOC',
|
||||||
|
MOA: 'CONSTITUTIVE_DOC',
|
||||||
|
AOA: 'CONSTITUTIVE_DOC',
|
||||||
|
PARTNERSHIP_DEED: 'CONSTITUTIVE_DOC',
|
||||||
|
TRUST_DEED: 'CONSTITUTIVE_DOC',
|
||||||
|
BOARD_RESOLUTION: 'BOARD_RESOLUTION',
|
||||||
|
AUTH_SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
|
||||||
|
SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
|
||||||
|
GST_CERTIFICATE: 'GST_CERTIFICATE',
|
||||||
|
GST_CERT: 'GST_CERTIFICATE'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-constitution lists carry no codes of their own (code === name), so those entries are
|
||||||
|
* matched on the label instead. Patterns are anchored deliberately: a document that merely
|
||||||
|
* mentions another one ("Proof of Business Existence (GST Certificate / Shop License)") is a
|
||||||
|
* distinct requirement and has to survive as its own chip rather than vanish into GST.
|
||||||
|
*/
|
||||||
|
const ENTITY_DOC_NAME_PATTERNS: { pattern: RegExp; code: string }[] = [
|
||||||
|
{ pattern: /^certificate of incorporation\b/, code: 'CERT_INCORPORATION' },
|
||||||
|
{ pattern: /^registration certificate\b/, code: 'CERT_INCORPORATION' },
|
||||||
|
{
|
||||||
|
pattern: /^(memorandum|articles of association|partnership deed|trust deed|llp agreement|by-?laws)\b/,
|
||||||
|
code: 'CONSTITUTIVE_DOC'
|
||||||
|
},
|
||||||
|
{ pattern: /^pan\b/, code: 'ENTITY_PAN' },
|
||||||
|
{ pattern: /^(board resolution|designated partners resolution|resolution authorizing)\b/, code: 'BOARD_RESOLUTION' },
|
||||||
|
{ pattern: /^(kyc of|authorized signatory kyc|proprietor'?s kyc)\b/, code: 'AUTH_SIGNATORY_KYC' },
|
||||||
|
{ pattern: /^gst (registration )?certificate\b/, code: 'GST_CERTIFICATE' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The entity document this one duplicates, or null when it is a requirement of its own. */
|
||||||
|
function resolveEntityDocCode(doc: { name?: string; code?: string }): string | null {
|
||||||
|
const code = (doc.code || '').toUpperCase().trim();
|
||||||
|
// A constitution list stores the label in `code`, so only treat it as a code when it is one.
|
||||||
|
if (code && !code.includes(' ') && ENTITY_DOC_CODE_ALIASES[code]) {
|
||||||
|
return ENTITY_DOC_CODE_ALIASES[code];
|
||||||
|
}
|
||||||
|
const name = (doc.name || '').toLowerCase().trim();
|
||||||
|
return ENTITY_DOC_NAME_PATTERNS.find((p) => p.pattern.test(name))?.code || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the RBI entity documents in front of whatever list a caller has (per-constitution docs,
|
||||||
|
* or the backend KYC template) instead of letting that list replace them.
|
||||||
|
*
|
||||||
|
* Without this the officer sees a different set of documents on every screen for the same
|
||||||
|
* client: the entity list until a constitution is picked, the constitution's own list after,
|
||||||
|
* and the backend template's list once the client exists and is reopened for edit. Anything in
|
||||||
|
* `extra` that isn't already one of the six stays, grouped under `extraCategory` — those are
|
||||||
|
* genuine constitution-specific requirements (Karta declaration, Udyam certificate, government
|
||||||
|
* notification) and dropping them would lose real requirements.
|
||||||
|
*
|
||||||
|
* A mandatory flag on a duplicate carries over to the entity chip it folds into, so the
|
||||||
|
* "at least one mandatory document" validator still sees it.
|
||||||
|
*/
|
||||||
|
export function withEntityKycDocuments(
|
||||||
|
extra: { name: string; code?: string; mandatory?: boolean }[] = [],
|
||||||
|
extraCategory?: string | null
|
||||||
|
): KycDocumentType[] {
|
||||||
|
const mandatoryCodes = new Set<string>();
|
||||||
|
const leftovers: KycDocumentType[] = [];
|
||||||
|
|
||||||
|
extra.forEach((doc) => {
|
||||||
|
const entityCode = resolveEntityDocCode(doc);
|
||||||
|
if (entityCode) {
|
||||||
|
if (doc.mandatory) {
|
||||||
|
mandatoryCodes.add(entityCode);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
leftovers.push({
|
||||||
|
name: doc.name,
|
||||||
|
code: doc.code || doc.name,
|
||||||
|
mandatory: !!doc.mandatory,
|
||||||
|
category: extraCategory ? `${extraCategory} documents` : 'Additional documents'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const base: KycDocumentType[] = ENTITY_KYC_DOCUMENT_TYPES.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
code: d.code,
|
||||||
|
mandatory: mandatoryCodes.has(d.code),
|
||||||
|
category: ENTITY_KYC_CATEGORY
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...base,
|
||||||
|
...leftovers
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toProperNameCase } from './name-case.util';
|
||||||
|
|
||||||
|
describe('toProperNameCase', () => {
|
||||||
|
it('raises a lower-case first letter on every word', () => {
|
||||||
|
expect(toProperNameCase('keshav Singh sisodiya')).toBe('Keshav Singh Sisodiya');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the rest of each word exactly as typed', () => {
|
||||||
|
expect(toProperNameCase('lincpay lTD')).toBe('Lincpay LTD');
|
||||||
|
expect(toProperNameCase('lincpay pvt ltd')).toBe('Lincpay Pvt Ltd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rewrite a name that is already correct', () => {
|
||||||
|
expect(toProperNameCase('Technoludium Solutions Private Limited')).toBe('Technoludium Solutions Private Limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an upper-case name shouted rather than title-casing it', () => {
|
||||||
|
expect(toProperNameCase('TECHNOLUDIUM SOLUTIONS')).toBe('TECHNOLUDIUM SOLUTIONS');
|
||||||
|
expect(toProperNameCase('KESHAV singh')).toBe('KESHAV Singh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('capitalises after a hyphen', () => {
|
||||||
|
expect(toProperNameCase('jean-marie traders')).toBe('Jean-Marie Traders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('capitalises name prefixes after an apostrophe but not possessives', () => {
|
||||||
|
expect(toProperNameCase("o'brien d'souza")).toBe("O'Brien D'Souza");
|
||||||
|
expect(toProperNameCase("lincpay's agency")).toBe("Lincpay's Agency");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves punctuation and non-letters untouched', () => {
|
||||||
|
expect(toProperNameCase('abc pvt. ltd.')).toBe('Abc Pvt. Ltd.');
|
||||||
|
expect(toProperNameCase('tata & sons')).toBe('Tata & Sons');
|
||||||
|
expect(toProperNameCase('3m india')).toBe('3m India');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims and collapses whitespace', () => {
|
||||||
|
expect(toProperNameCase(' lincpay holdings ')).toBe('Lincpay Holdings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for blank or non-string input', () => {
|
||||||
|
expect(toProperNameCase('')).toBe('');
|
||||||
|
expect(toProperNameCase(' ')).toBe('');
|
||||||
|
expect(toProperNameCase(null)).toBe('');
|
||||||
|
expect(toProperNameCase(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Casing for client names: the name is stored exactly as the officer typed it, except that a word
|
||||||
|
* starting with a lower-case letter has that first letter raised. Nothing else is touched.
|
||||||
|
*
|
||||||
|
* 'keshav Singh sisodiya' → 'Keshav Singh Sisodiya'
|
||||||
|
* 'lincpay lTD' → 'Lincpay LTD'
|
||||||
|
* 'Technoludium Solutions Private Limited' → unchanged
|
||||||
|
* 'TECHNOLUDIUM SOLUTIONS' → unchanged
|
||||||
|
*
|
||||||
|
* Deliberately NOT full title-casing. Lower-casing the rest of each word would rewrite an entity
|
||||||
|
* name the officer entered as it appears on the certificate — 'TECHNOLUDIUM' becoming
|
||||||
|
* 'Technoludium', 'Ltd' becoming a forced 'LTD'. That name is matched character-for-character
|
||||||
|
* against the PAN holder's registered name during KYC verification, so the typed spelling is the
|
||||||
|
* thing to preserve; only an unintended lower-case first letter is corrected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Characters after which a new word starts: 'jean-marie' → 'Jean-Marie'. */
|
||||||
|
const SEGMENT_STARTERS = new Set([
|
||||||
|
'-',
|
||||||
|
"'",
|
||||||
|
'’'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the first letter of each word in a single space-delimited token, leaving every other
|
||||||
|
* character as typed.
|
||||||
|
*/
|
||||||
|
function capitalizeWord(word: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < word.length; i++) {
|
||||||
|
const previous = i === 0 ? '' : word[i - 1];
|
||||||
|
let startsWord = i === 0 || SEGMENT_STARTERS.has(previous);
|
||||||
|
if (previous === "'" || previous === '’') {
|
||||||
|
// 'o'brien' / 'd'souza' capitalise after the apostrophe, but a possessive
|
||||||
|
// ('lincpay's') must not — only a single leading letter marks the name prefix.
|
||||||
|
startsWord = i === 2;
|
||||||
|
}
|
||||||
|
result += startsWord ? word[i].toUpperCase() : word[i];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the casing rule above to a person or entity name.
|
||||||
|
* Trims and collapses runs of whitespace; non-string input yields ''.
|
||||||
|
*/
|
||||||
|
export function toProperNameCase(value: string | null | undefined): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const collapsed = value.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!collapsed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return collapsed.split(' ').map(capitalizeWord).join(' ');
|
||||||
|
}
|
||||||
@@ -607,6 +607,16 @@
|
|||||||
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||||
</button>
|
</button>
|
||||||
|
<!--
|
||||||
|
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
|
||||||
|
so it never shows up under the report categories above and had no nav entry at all.
|
||||||
|
-->
|
||||||
|
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
|
||||||
|
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening & Alerts
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
|
||||||
|
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
|
||||||
|
</button>
|
||||||
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||||
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest & TDS
|
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest & TDS
|
||||||
</button>
|
</button>
|
||||||
@@ -1038,14 +1048,16 @@
|
|||||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
|
||||||
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
|
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0"><fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS</button>
|
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']">
|
||||||
|
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Agent</div>
|
<div class="deposit-group-title">Agent</div>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
|
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-collection']">
|
||||||
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
|
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
@@ -1060,6 +1072,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Reports</div>
|
<div class="deposit-group-title">Reports</div>
|
||||||
|
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-reports']">
|
||||||
|
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
|
||||||
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
||||||
</button>
|
</button>
|
||||||
@@ -1235,6 +1250,13 @@
|
|||||||
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
|
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Dividend Management -->
|
||||||
|
<div class="deposit-menu-group">
|
||||||
|
<div class="deposit-group-title">Dividend Management</div>
|
||||||
|
<button mat-menu-item routerLink="/clerk/dividend-dashboard" tabindex="0">
|
||||||
|
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Dividend Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
@@ -1369,6 +1391,20 @@
|
|||||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
|
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
|
||||||
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
|
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
tabindex="0"
|
||||||
|
routerLink="/loan-origination/pipeline"
|
||||||
|
[queryParams]="{ stage: 'CREDIT_COMMITTEE' }"
|
||||||
|
>
|
||||||
|
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Loan Committee
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/loan-origination/eligibility-checklist">
|
||||||
|
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> RBI Eligibility Checklist
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/loan-origination/rbi-rules-reference">
|
||||||
|
<fa-icon icon="book" class="menu-item-icon"></fa-icon> RBI Rules Reference
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Reports -->
|
<!-- Reports -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
@@ -1646,6 +1682,16 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
|
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Full data export. Hidden unless the user can raise a request; the approval itself is a
|
||||||
|
separate permission the backend enforces. -->
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
routerLink="/letter-mgmt/database-export"
|
||||||
|
tabindex="0"
|
||||||
|
*mifosxHasPermission="'READ_DATABASEEXPORT'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="database" class="menu-item-icon" style="color: #b45309"></fa-icon> Database Export
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<!--
|
||||||
|
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="dte-container">
|
||||||
|
<div class="dte-header">
|
||||||
|
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
|
||||||
|
<i class="fas fa-arrow-left"></i> Deposits
|
||||||
|
</button>
|
||||||
|
<h2 class="dte-header__title">Daily Transaction Detail</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dte-card">
|
||||||
|
<form [formGroup]="entryForm">
|
||||||
|
<div class="dte-info-panels">
|
||||||
|
<div class="dte-info-panel">
|
||||||
|
<div class="dte-info-panel__title">Agent Information</div>
|
||||||
|
<dl>
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd>{{ agentInfo.name || '—' }}</dd>
|
||||||
|
<dt>Address</dt>
|
||||||
|
<dd>{{ agentInfo.address || '—' }}</dd>
|
||||||
|
<dt>City</dt>
|
||||||
|
<dd>{{ agentInfo.city || '—' }}</dd>
|
||||||
|
<dt>A/C</dt>
|
||||||
|
<dd>{{ agentInfo.accountNo || '—' }}</dd>
|
||||||
|
<dt>Guarantor</dt>
|
||||||
|
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
|
||||||
|
<dt>Guarantor</dt>
|
||||||
|
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
|
||||||
|
<dt>Curr Bal</dt>
|
||||||
|
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dte-info-panel">
|
||||||
|
<div class="dte-info-panel__title">Party Information</div>
|
||||||
|
<p class="dte-info-panel__hint">Populated once a party is selected in a collection row below.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dte-controls">
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Agent</mat-label>
|
||||||
|
<input matInput required formControlName="agentId" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-stroked-button type="button" (click)="loadAgent()"><i class="fas fa-search"></i> Load</button>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Date</mat-label>
|
||||||
|
<input matInput required [matDatepicker]="txnDatePicker" formControlName="transactionDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #txnDatePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Tran. Type</mat-label>
|
||||||
|
<mat-select required formControlName="transactionType">
|
||||||
|
@for (type of transactionTypes; track type) {
|
||||||
|
<mat-option [value]="type">{{ type }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isPartyTran) {
|
||||||
|
<div class="dte-section">
|
||||||
|
<div class="dte-section__header">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
<h3>Today's Collections</h3>
|
||||||
|
<button mat-stroked-button type="button" class="dte-add-row" (click)="addCollectionRow()">
|
||||||
|
<i class="fas fa-plus"></i> Add Row
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (collections.length === 0) {
|
||||||
|
<p class="dte-empty">No collection rows added yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (row of collections.controls; track row; let i = $index) {
|
||||||
|
<div class="dte-collection-row" [formGroup]="$any(row)">
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Party Code</mat-label>
|
||||||
|
<input matInput required formControlName="partyCode" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>TRTP</mat-label>
|
||||||
|
<mat-select required formControlName="transactionMode">
|
||||||
|
@for (mode of transactionModes; track mode) {
|
||||||
|
<mat-option [value]="mode">{{ mode }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>AC Type</mat-label>
|
||||||
|
<input matInput formControlName="accountType" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>A/C No.</mat-label>
|
||||||
|
<input matInput required formControlName="accountNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Amount</mat-label>
|
||||||
|
<input matInput required type="number" formControlName="amount" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Cheque No</mat-label>
|
||||||
|
<input matInput formControlName="chequeNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Token No</mat-label>
|
||||||
|
<input matInput formControlName="tokenNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
|
||||||
|
<mat-label>Description</mat-label>
|
||||||
|
<input matInput formControlName="description" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
type="button"
|
||||||
|
class="dte-remove-row"
|
||||||
|
(click)="removeCollectionRow(i)"
|
||||||
|
matTooltip="Remove row"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isAgentTran) {
|
||||||
|
<div class="dte-section">
|
||||||
|
<div class="dte-section__header">
|
||||||
|
<i class="fas fa-hand-holding-usd"></i>
|
||||||
|
<h3>Agent Settlement</h3>
|
||||||
|
</div>
|
||||||
|
<p class="dte-info-panel__hint">
|
||||||
|
Cash the agent hands over to the branch, deducted from the agent's collected balance shown above.
|
||||||
|
</p>
|
||||||
|
<div class="dte-grid">
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Amount</mat-label>
|
||||||
|
<input matInput required type="number" formControlName="settlementAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>TRTP</mat-label>
|
||||||
|
<mat-select required formControlName="settlementMode">
|
||||||
|
@for (mode of transactionModes; track mode) {
|
||||||
|
<mat-option [value]="mode">{{ mode }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>AC Type</mat-label>
|
||||||
|
<input matInput formControlName="settlementAccountType" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>A/C No.</mat-label>
|
||||||
|
<input matInput formControlName="settlementAccountNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
|
||||||
|
<mat-label>Description</mat-label>
|
||||||
|
<input matInput formControlName="settlementDescription" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isWithdrawDeposite) {
|
||||||
|
<div class="dte-section">
|
||||||
|
<div class="dte-section__header">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
<h3>Withdraw Deposite</h3>
|
||||||
|
</div>
|
||||||
|
<div class="dte-grid">
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Creditor Party Code</mat-label>
|
||||||
|
<input matInput formControlName="creditorPartyCode" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Creditor Party Name</mat-label>
|
||||||
|
<input matInput formControlName="creditorPartyName" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Creditor Curr Bal</mat-label>
|
||||||
|
<input matInput formControlName="creditorPartyBalance" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Debit Party Code</mat-label>
|
||||||
|
<input matInput formControlName="debitPartyCode" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Debit Party Name</mat-label>
|
||||||
|
<input matInput formControlName="debitPartyName" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Debit Curr Bal</mat-label>
|
||||||
|
<input matInput formControlName="debitPartyBalance" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Amount</mat-label>
|
||||||
|
<input matInput type="number" formControlName="withdrawAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
|
||||||
|
<mat-label>Description</mat-label>
|
||||||
|
<input matInput formControlName="withdrawDescription" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) {
|
||||||
|
<div class="dte-section">
|
||||||
|
<p class="dte-empty">
|
||||||
|
"{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once
|
||||||
|
the backend distinguishes these transaction types.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="dte-action-bar">
|
||||||
|
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||||
|
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||||
|
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
|
||||||
|
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
|
||||||
|
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
|
||||||
|
<button mat-raised-button color="primary" type="button" [disabled]="entryForm.invalid" (click)="save()">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$primary: #0f766e;
|
||||||
|
|
||||||
|
.dte-container {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-back {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-card {
|
||||||
|
background: var(--md-sys-color-surface, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-info-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-info-panel {
|
||||||
|
border: 1px solid rgb(0 0 0 / 10%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgb(15 118 110 / 4%);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: $primary;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 4px 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(0 0 0 / 60%);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-field {
|
||||||
|
min-width: 180px;
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-section {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-add-row {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-empty {
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-collection-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr) auto;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-collection-row .dte-field--wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-remove-row {
|
||||||
|
color: #b91c1c;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-action-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1000px) {
|
||||||
|
.dte-info-panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dte-collection-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { OrganizationService } from 'app/organization/organization.service';
|
||||||
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily Transaction Entry component.
|
||||||
|
*
|
||||||
|
* The screen a collection agent uses at the end of a day's round: pick a
|
||||||
|
* transaction type, then batch-enter that day's collections against
|
||||||
|
* assigned parties (Party Tran), settle collected cash to the branch
|
||||||
|
* (Agent Tran), or record a withdrawal/transfer between accounts (Withdraw
|
||||||
|
* Deposite). Modelled directly on the legacy Daily Transaction Detail
|
||||||
|
* screen this replaces.
|
||||||
|
*
|
||||||
|
* Backed by the DDS Party Transaction API (`/dds/partytransactions`) for
|
||||||
|
* Party Tran, Agent Tran and Withdraw Deposite - the three types this
|
||||||
|
* screen actually collects fields for; Party Payment / OD Entry / OD
|
||||||
|
* Payment remain placeholders until the backend distinguishes them too.
|
||||||
|
* "Retrieve" (a ready-made list of today's assigned parties) isn't wired -
|
||||||
|
* that needs a dedicated per-agent Party lookup this MVP doesn't have yet.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-daily-transaction-entry',
|
||||||
|
templateUrl: './daily-transaction-entry.component.html',
|
||||||
|
styleUrls: ['./daily-transaction-entry.component.scss'],
|
||||||
|
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||||
|
})
|
||||||
|
export class DailyTransactionEntryComponent {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private router = inject(Router);
|
||||||
|
private organizationService = inject(OrganizationService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
|
||||||
|
private static readonly TRANSACTION_TYPE_CODES: Record<string, string> = {
|
||||||
|
'Party Tran': 'PARTY_TRAN',
|
||||||
|
'Agent Tran': 'AGENT_TRAN',
|
||||||
|
'Withdraw Deposite': 'WITHDRAW_DEPOSITE'
|
||||||
|
};
|
||||||
|
|
||||||
|
transactionTypes = [
|
||||||
|
'Party Tran',
|
||||||
|
'Agent Tran',
|
||||||
|
'Party Payment',
|
||||||
|
'OD Entry',
|
||||||
|
'OD Payment',
|
||||||
|
'Withdraw Deposite'
|
||||||
|
];
|
||||||
|
|
||||||
|
transactionModes = [
|
||||||
|
'Cash',
|
||||||
|
'Transfer',
|
||||||
|
'Clearing'
|
||||||
|
];
|
||||||
|
|
||||||
|
entryForm: UntypedFormGroup = this.formBuilder.group({
|
||||||
|
agentId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
transactionType: [
|
||||||
|
'Party Tran',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
transactionDate: [
|
||||||
|
new Date(),
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
// Withdraw Deposite fields
|
||||||
|
creditorPartyCode: [''],
|
||||||
|
creditorPartyName: [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
creditorPartyBalance: [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
debitPartyCode: [''],
|
||||||
|
debitPartyName: [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
debitPartyBalance: [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
withdrawAmount: [0],
|
||||||
|
withdrawDescription: [''],
|
||||||
|
// Agent Tran fields
|
||||||
|
settlementAmount: [0],
|
||||||
|
settlementMode: ['Cash'],
|
||||||
|
settlementAccountType: [''],
|
||||||
|
settlementAccountNo: [''],
|
||||||
|
settlementDescription: [''],
|
||||||
|
collections: this.formBuilder.array([])
|
||||||
|
});
|
||||||
|
|
||||||
|
agentInfo = {
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
accountNo: '',
|
||||||
|
guarantor1: '',
|
||||||
|
guarantor2: '',
|
||||||
|
currentBalance: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
get isPartyTran(): boolean {
|
||||||
|
return this.entryForm.get('transactionType').value === 'Party Tran';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isWithdrawDeposite(): boolean {
|
||||||
|
return this.entryForm.get('transactionType').value === 'Withdraw Deposite';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAgentTran(): boolean {
|
||||||
|
return this.entryForm.get('transactionType').value === 'Agent Tran';
|
||||||
|
}
|
||||||
|
|
||||||
|
get collections(): UntypedFormArray {
|
||||||
|
return this.entryForm.get('collections') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCollectionRow(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
partyCode: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
transactionMode: [
|
||||||
|
'Cash',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
accountType: [''],
|
||||||
|
accountNo: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
amount: [
|
||||||
|
0,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
chequeNo: [''],
|
||||||
|
tokenNo: [''],
|
||||||
|
description: [''],
|
||||||
|
partyName: [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
balanceAfterTransaction: [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addCollectionRow(): void {
|
||||||
|
this.collections.push(this.buildCollectionRow());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCollectionRow(index: number): void {
|
||||||
|
this.collections.removeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAgent(): void {
|
||||||
|
const agentId = this.entryForm.get('agentId')?.value;
|
||||||
|
if (!agentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.organizationService.getAgent(agentId).subscribe((agent: any) => {
|
||||||
|
const guarantors = agent.guarantors || [];
|
||||||
|
this.agentInfo = {
|
||||||
|
name: agent.agentNameId || '',
|
||||||
|
address: agent.agentAddress1 || '',
|
||||||
|
city: agent.agentCity || '',
|
||||||
|
accountNo: agent.agentSavingAccountNo || '',
|
||||||
|
guarantor1: guarantors[0]?.guarantorName || '',
|
||||||
|
guarantor2: guarantors[1]?.guarantorName || '',
|
||||||
|
currentBalance: agent.agentCurrentBalance || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieve(): void {
|
||||||
|
// No "my assigned parties" read API exists yet — this will pre-fill
|
||||||
|
// the collections grid with today's assigned parties once available.
|
||||||
|
}
|
||||||
|
|
||||||
|
viewLedger(): void {
|
||||||
|
// No ledger view exists yet for this agent.
|
||||||
|
}
|
||||||
|
|
||||||
|
signature(): void {
|
||||||
|
// No signature capture/verification flow exists yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.entryForm.reset({
|
||||||
|
transactionType: 'Party Tran',
|
||||||
|
transactionDate: new Date()
|
||||||
|
});
|
||||||
|
this.collections.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(): void {
|
||||||
|
this.router.navigate(['/accounts/savings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
const raw = this.entryForm.getRawValue();
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
const locale = this.settingsService.language.code;
|
||||||
|
const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType];
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
agentId: Number(raw.agentId),
|
||||||
|
transactionType,
|
||||||
|
transactionDate: this.dateUtils.formatDate(raw.transactionDate, dateFormat),
|
||||||
|
dateFormat,
|
||||||
|
locale
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.isPartyTran) {
|
||||||
|
payload.collections = (raw.collections || []).map((row: any) => ({
|
||||||
|
partyCode: row.partyCode,
|
||||||
|
transactionMode: row.transactionMode,
|
||||||
|
accountType: row.accountType || undefined,
|
||||||
|
accountNo: row.accountNo,
|
||||||
|
amount: row.amount,
|
||||||
|
chequeNo: row.chequeNo || undefined,
|
||||||
|
tokenNo: row.tokenNo || undefined,
|
||||||
|
description: row.description || undefined
|
||||||
|
}));
|
||||||
|
} else if (this.isWithdrawDeposite) {
|
||||||
|
payload.creditorPartyCode = raw.creditorPartyCode;
|
||||||
|
payload.debitPartyCode = raw.debitPartyCode;
|
||||||
|
payload.withdrawAmount = raw.withdrawAmount;
|
||||||
|
payload.withdrawDescription = raw.withdrawDescription || undefined;
|
||||||
|
} else if (this.isAgentTran) {
|
||||||
|
payload.amount = raw.settlementAmount;
|
||||||
|
payload.transactionMode = raw.settlementMode;
|
||||||
|
payload.accountType = raw.settlementAccountType || undefined;
|
||||||
|
payload.accountNo = raw.settlementAccountNo || undefined;
|
||||||
|
payload.description = raw.settlementDescription || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.organizationService.createPartyTransaction(payload).subscribe(() => {
|
||||||
|
this.collections.clear();
|
||||||
|
this.entryForm.patchValue({
|
||||||
|
creditorPartyCode: '',
|
||||||
|
debitPartyCode: '',
|
||||||
|
withdrawAmount: 0,
|
||||||
|
withdrawDescription: '',
|
||||||
|
settlementAmount: 0,
|
||||||
|
settlementAccountType: '',
|
||||||
|
settlementAccountNo: '',
|
||||||
|
settlementDescription: ''
|
||||||
|
});
|
||||||
|
this.loadAgent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!--
|
||||||
|
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="dr-container">
|
||||||
|
<div class="dr-header">
|
||||||
|
<button mat-stroked-button [routerLink]="['../']" class="dr-back">
|
||||||
|
<i class="fas fa-arrow-left"></i> Deposits
|
||||||
|
</button>
|
||||||
|
<h2 class="dr-header__title">DDS Reports</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (group of reportGroups; track group.title) {
|
||||||
|
<div class="dr-group">
|
||||||
|
<div class="dr-group__title">{{ group.title }}</div>
|
||||||
|
<div class="dr-grid">
|
||||||
|
@for (report of group.reports; track report) {
|
||||||
|
<a class="dr-card" [routerLink]="['view', report]">
|
||||||
|
<div class="dr-card__icon">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="dr-card__title">{{ report }}</div>
|
||||||
|
<i class="fas fa-chevron-right dr-card__arrow"></i>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$primary: #0f766e;
|
||||||
|
|
||||||
|
.dr-container {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-back {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--md-sys-color-surface, #fff);
|
||||||
|
border: 1px solid rgb(0 0 0 / 8%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: $primary;
|
||||||
|
font-size: 16px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
color: rgb(0 0 0 / 30%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.dr-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.dr-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDS Reports hub component.
|
||||||
|
*
|
||||||
|
* Lists every DDS report from the legacy Reports menu, grouped the same
|
||||||
|
* way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each
|
||||||
|
* links to the shared report viewer, parameterised by report name.
|
||||||
|
*
|
||||||
|
* UI only for now: no report actually queries the backend yet — see
|
||||||
|
* ReportViewerComponent.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-dds-reports',
|
||||||
|
templateUrl: './dds-reports.component.html',
|
||||||
|
styleUrls: ['./dds-reports.component.scss'],
|
||||||
|
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||||
|
})
|
||||||
|
export class DdsReportsComponent {
|
||||||
|
reportGroups = [
|
||||||
|
{
|
||||||
|
title: 'As On Date',
|
||||||
|
reports: [
|
||||||
|
'Supplementary-DDS (As On)',
|
||||||
|
'Agent Check List',
|
||||||
|
'Party Check List-As On',
|
||||||
|
'Party Balance',
|
||||||
|
'Agent Wise Balance',
|
||||||
|
'Agent Wise Monthly Collection',
|
||||||
|
'Commission Register',
|
||||||
|
'Collection Summary Agent Wise',
|
||||||
|
'Close Party Ac Register',
|
||||||
|
'Agent Partywise Daily Collection',
|
||||||
|
'Daily Collection Balance',
|
||||||
|
'Interest Certificate',
|
||||||
|
'Mature Date Wise Report'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Interest',
|
||||||
|
reports: [
|
||||||
|
'Party Int. Register',
|
||||||
|
'Commission Register'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Daily / Balance / Master',
|
||||||
|
reports: [
|
||||||
|
'Daily Collection Report',
|
||||||
|
'Balance Summary',
|
||||||
|
'Master Data Export'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Transaction',
|
||||||
|
reports: ['Transaction Register']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Statements',
|
||||||
|
reports: ['Agent Commission Statement']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!--
|
||||||
|
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="rv-container">
|
||||||
|
<div class="rv-header">
|
||||||
|
<button mat-stroked-button [routerLink]="['../../']" class="rv-back">
|
||||||
|
<i class="fas fa-arrow-left"></i> DDS Reports
|
||||||
|
</button>
|
||||||
|
<h2 class="rv-header__title">{{ reportName }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rv-card">
|
||||||
|
<form [formGroup]="filterForm">
|
||||||
|
<div class="rv-grid">
|
||||||
|
<mat-form-field appearance="outline" class="rv-field">
|
||||||
|
<mat-label>From Date</mat-label>
|
||||||
|
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #fromPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="rv-field">
|
||||||
|
<mat-label>To Date</mat-label>
|
||||||
|
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #toPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="rv-field">
|
||||||
|
<mat-label>Agent From</mat-label>
|
||||||
|
<input matInput type="number" formControlName="agentFrom" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="rv-field">
|
||||||
|
<mat-label>Agent To</mat-label>
|
||||||
|
<input matInput type="number" formControlName="agentTo" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="rv-field">
|
||||||
|
<mat-label>Party From</mat-label>
|
||||||
|
<input matInput type="number" formControlName="partyFrom" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="rv-field">
|
||||||
|
<mat-label>Party To</mat-label>
|
||||||
|
<input matInput type="number" formControlName="partyTo" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rv-action-bar">
|
||||||
|
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||||
|
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
|
||||||
|
<i class="fas fa-play" style="margin-right: 6px"></i> Run Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hasRun) {
|
||||||
|
<div class="rv-results">
|
||||||
|
@if (!available) {
|
||||||
|
<p class="rv-empty">{{ unavailableMessage }}</p>
|
||||||
|
} @else if (rows.length === 0) {
|
||||||
|
<p class="rv-empty">No rows matched these filters.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="rv-table-scroll">
|
||||||
|
<table class="rv-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
@for (column of columns; track column) {
|
||||||
|
<th>{{ column }}</th>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of rows; track row) {
|
||||||
|
<tr>
|
||||||
|
@for (column of columns; track column) {
|
||||||
|
<td>{{ row[column] }}</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$primary: #0f766e;
|
||||||
|
|
||||||
|
.rv-container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-back {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-card {
|
||||||
|
background: var(--md-sys-color-surface, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-action-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-results {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-empty {
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: rgb(0 0 0 / 55%);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 2px solid rgb(0 0 0 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid rgb(0 0 0 / 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.rv-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { OrganizationService } from 'app/organization/organization.service';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic DDS report viewer.
|
||||||
|
*
|
||||||
|
* One shared screen for all 13+ DDS reports — the report name comes from
|
||||||
|
* the route and is mapped to a backend report key. Every report in the
|
||||||
|
* legacy system shared the same basic filter shape (date, agent range,
|
||||||
|
* party range), so this avoids building a bespoke screen per report - the
|
||||||
|
* backend returns a generic {columns, rows} shape this renders as one
|
||||||
|
* table regardless of which report it is.
|
||||||
|
*
|
||||||
|
* One legacy report (Close Party Ac Register) has no corresponding data in
|
||||||
|
* the backend yet - Party accounts have no closed/open status at all. Run
|
||||||
|
* Report still calls through, and the backend's `available: false` +
|
||||||
|
* message is shown instead of a table.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-dds-report-viewer',
|
||||||
|
templateUrl: './report-viewer.component.html',
|
||||||
|
styleUrls: ['./report-viewer.component.scss'],
|
||||||
|
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||||
|
})
|
||||||
|
export class ReportViewerComponent {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private organizationService = inject(OrganizationService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
|
||||||
|
private static readonly REPORT_KEYS: Record<string, string> = {
|
||||||
|
'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS',
|
||||||
|
'Agent Check List': 'AGENT_CHECK_LIST',
|
||||||
|
'Party Check List-As On': 'PARTY_CHECK_LIST',
|
||||||
|
'Party Balance': 'PARTY_BALANCE',
|
||||||
|
'Agent Wise Balance': 'AGENT_WISE_BALANCE',
|
||||||
|
'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION',
|
||||||
|
'Commission Register': 'COMMISSION_REGISTER',
|
||||||
|
'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE',
|
||||||
|
'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER',
|
||||||
|
'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION',
|
||||||
|
'Daily Collection Balance': 'DAILY_COLLECTION_REPORT',
|
||||||
|
'Interest Certificate': 'INTEREST_CERTIFICATE',
|
||||||
|
'Mature Date Wise Report': 'MATURE_DATE_WISE',
|
||||||
|
'Party Int. Register': 'PARTY_INTEREST_REGISTER',
|
||||||
|
'Daily Collection Report': 'DAILY_COLLECTION_REPORT',
|
||||||
|
'Balance Summary': 'BALANCE_SUMMARY',
|
||||||
|
'Master Data Export': 'MASTER_DATA_EXPORT',
|
||||||
|
'Transaction Register': 'TRANSACTION_REGISTER',
|
||||||
|
'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT'
|
||||||
|
};
|
||||||
|
|
||||||
|
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
|
||||||
|
|
||||||
|
hasRun = false;
|
||||||
|
available = true;
|
||||||
|
unavailableMessage = '';
|
||||||
|
columns: string[] = [];
|
||||||
|
rows: Array<Record<string, any>> = [];
|
||||||
|
|
||||||
|
filterForm: UntypedFormGroup = this.formBuilder.group({
|
||||||
|
fromDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
toDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
agentFrom: [1],
|
||||||
|
agentTo: [99999],
|
||||||
|
partyFrom: [1],
|
||||||
|
partyTo: [99999999]
|
||||||
|
});
|
||||||
|
|
||||||
|
runReport(): void {
|
||||||
|
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
|
||||||
|
if (!reportKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = this.filterForm.getRawValue();
|
||||||
|
|
||||||
|
this.organizationService
|
||||||
|
.runDdsReport(reportKey, {
|
||||||
|
fromDate: this.dateUtils.getDate(raw.fromDate),
|
||||||
|
toDate: this.dateUtils.getDate(raw.toDate),
|
||||||
|
agentFrom: raw.agentFrom,
|
||||||
|
agentTo: raw.agentTo,
|
||||||
|
partyFrom: raw.partyFrom,
|
||||||
|
partyTo: raw.partyTo
|
||||||
|
})
|
||||||
|
.subscribe((result: any) => {
|
||||||
|
this.available = result.available;
|
||||||
|
this.unavailableMessage = result.message || '';
|
||||||
|
this.columns = result.columns || [];
|
||||||
|
this.rows = result.rows || [];
|
||||||
|
this.hasRun = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(): void {
|
||||||
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
<!--
|
||||||
|
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="pm-container">
|
||||||
|
<div class="pm-header">
|
||||||
|
<button mat-stroked-button [routerLink]="['../']" class="pm-back">
|
||||||
|
<i class="fas fa-arrow-left"></i> Deposits
|
||||||
|
</button>
|
||||||
|
<h2 class="pm-header__title">Party Master Entry</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-card">
|
||||||
|
<form [formGroup]="partyForm">
|
||||||
|
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
|
||||||
|
<mat-tab label="Main Detail">
|
||||||
|
<div class="pm-tab-content">
|
||||||
|
<div class="pm-section">
|
||||||
|
<div class="pm-section__header">
|
||||||
|
<i class="fas fa-id-card"></i>
|
||||||
|
<h3>Identity</h3>
|
||||||
|
</div>
|
||||||
|
<div class="pm-grid">
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Client ID</mat-label>
|
||||||
|
<input matInput required formControlName="clientId" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Sub Type</mat-label>
|
||||||
|
<mat-select required formControlName="subType">
|
||||||
|
@for (type of subTypes; track type) {
|
||||||
|
<mat-option [value]="type">{{ type }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Agent ID</mat-label>
|
||||||
|
<input matInput required formControlName="agentId" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (linkedSavingsAccountId) {
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Savings Account</mat-label>
|
||||||
|
<input matInput [value]="'Linked (id ' + linkedSavingsAccountId + ')'" disabled />
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Savings Account No (if any)</mat-label>
|
||||||
|
<input matInput formControlName="savingsAccountNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Party Code</mat-label>
|
||||||
|
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Last A/c</mat-label>
|
||||||
|
<input matInput formControlName="lastAccount" placeholder="—" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Nominal</mat-label>
|
||||||
|
<mat-select required formControlName="nominal">
|
||||||
|
<mat-option value="Yes">Yes</mat-option>
|
||||||
|
<mat-option value="No">No</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||||
|
<mat-label>Party Name</mat-label>
|
||||||
|
<input matInput required formControlName="partyName" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||||
|
<mat-label>Party Name 1</mat-label>
|
||||||
|
<input matInput formControlName="partyName1" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-section">
|
||||||
|
<div class="pm-section__header">
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
<h3>Address & Contact</h3>
|
||||||
|
</div>
|
||||||
|
<div class="pm-grid">
|
||||||
|
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||||
|
<mat-label>Address 1</mat-label>
|
||||||
|
<input matInput required formControlName="address1" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||||
|
<mat-label>Address 2</mat-label>
|
||||||
|
<input matInput formControlName="address2" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||||
|
<mat-label>Address 3</mat-label>
|
||||||
|
<input matInput formControlName="address3" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>City</mat-label>
|
||||||
|
<input matInput required formControlName="city" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Pincode</mat-label>
|
||||||
|
<input matInput required formControlName="pincode" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Gender</mat-label>
|
||||||
|
<mat-select formControlName="gender">
|
||||||
|
<mat-option value="Male">Male</mat-option>
|
||||||
|
<mat-option value="Female">Female</mat-option>
|
||||||
|
<mat-option value="Other">Other</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Telephone</mat-label>
|
||||||
|
<input matInput formControlName="telephone" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Mobile No (SMS)</mat-label>
|
||||||
|
<input matInput required formControlName="mobileNoSms" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-section">
|
||||||
|
<div class="pm-section__header">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
<h3>KYC</h3>
|
||||||
|
</div>
|
||||||
|
<div class="pm-grid">
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Aadhar No</mat-label>
|
||||||
|
<input matInput formControlName="aadharNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>PAN Card No</mat-label>
|
||||||
|
<input matInput formControlName="panCardNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>CKYC ID</mat-label>
|
||||||
|
<input matInput formControlName="ckycId" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Operation</mat-label>
|
||||||
|
<mat-select required formControlName="operation">
|
||||||
|
@for (op of operations; track op) {
|
||||||
|
<mat-option [value]="op">{{ op }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Birth Date</mat-label>
|
||||||
|
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #birthPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Occupation</mat-label>
|
||||||
|
<mat-select formControlName="occupation">
|
||||||
|
@for (occ of occupations; track occ) {
|
||||||
|
<mat-option [value]="occ">{{ occ }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Risk</mat-label>
|
||||||
|
<mat-select formControlName="risk">
|
||||||
|
@for (r of riskLevels; track r) {
|
||||||
|
<mat-option [value]="r">{{ r }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-section">
|
||||||
|
<div class="pm-section__header">
|
||||||
|
<i class="fas fa-user-friends"></i>
|
||||||
|
<h3>Minor / Guardian</h3>
|
||||||
|
</div>
|
||||||
|
<div class="pm-grid">
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Adult / Minor</mat-label>
|
||||||
|
<mat-select required formControlName="adultMinor">
|
||||||
|
@for (opt of adultMinorOptions; track opt) {
|
||||||
|
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Minor Date</mat-label>
|
||||||
|
<input matInput [matDatepicker]="minorPicker" formControlName="minorDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #minorPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Guardian Client ID</mat-label>
|
||||||
|
<input matInput formControlName="guardianClientId" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Min. A/c Opt</mat-label>
|
||||||
|
<input matInput formControlName="minAccountOpt" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Relation</mat-label>
|
||||||
|
<input matInput formControlName="relation" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-section">
|
||||||
|
<div class="pm-section__header">
|
||||||
|
<i class="fas fa-calendar-day"></i>
|
||||||
|
<h3>Scheme Terms</h3>
|
||||||
|
</div>
|
||||||
|
<div class="pm-grid">
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Opening Date</mat-label>
|
||||||
|
<input matInput required [matDatepicker]="openingPicker" formControlName="openingDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="openingPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #openingPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Month</mat-label>
|
||||||
|
<input matInput type="number" formControlName="month" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Maturity Date</mat-label>
|
||||||
|
<input matInput formControlName="maturityDate" placeholder="Calculated" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Reopen Date</mat-label>
|
||||||
|
<input matInput [matDatepicker]="reopenPicker" formControlName="reopenDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="reopenPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #reopenPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Deposit Amt</mat-label>
|
||||||
|
<input matInput type="number" formControlName="depositAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Daily Deposit Amt</mat-label>
|
||||||
|
<input matInput required type="number" formControlName="dailyDepositAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Days</mat-label>
|
||||||
|
<input matInput required type="number" formControlName="days" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Ledger No</mat-label>
|
||||||
|
<input matInput formControlName="ledgerNo" placeholder="Auto-generated" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Mat. Amt</mat-label>
|
||||||
|
<input matInput formControlName="maturityAmount" placeholder="Calculated" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Interest Rate</mat-label>
|
||||||
|
<input matInput formControlName="interestRate" placeholder="From product" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Withdraw Type</mat-label>
|
||||||
|
<mat-select formControlName="withdrawType">
|
||||||
|
@for (type of withdrawTypes; track type) {
|
||||||
|
<mat-option [value]="type">{{ type }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-section">
|
||||||
|
<div class="pm-section__header">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<h3>Status & Overdue Flags</h3>
|
||||||
|
</div>
|
||||||
|
<div class="pm-grid">
|
||||||
|
<div class="pm-checkbox-field">
|
||||||
|
<mat-checkbox formControlName="od1Flag">OD1 Flag</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="pm-checkbox-field">
|
||||||
|
<mat-checkbox formControlName="od2Flag">OD2 Flag</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Lien</mat-label>
|
||||||
|
<mat-select formControlName="lien">
|
||||||
|
<mat-option value="Yes">Yes</mat-option>
|
||||||
|
<mat-option value="No">No</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Pay Date</mat-label>
|
||||||
|
<input matInput [matDatepicker]="payDatePicker" formControlName="payDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="payDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #payDatePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Payment Amount</mat-label>
|
||||||
|
<input matInput type="number" formControlName="paymentAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Current Bal</mat-label>
|
||||||
|
<input matInput formControlName="currentBalance" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Opening Bal</mat-label>
|
||||||
|
<input matInput type="number" formControlName="openingBalance" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="pm-checkbox-field">
|
||||||
|
<mat-checkbox formControlName="isSmsActive">Is SMS Active</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="pm-checkbox-field">
|
||||||
|
<mat-checkbox formControlName="isEmailActive">Is Email Active</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Int.Calc</mat-label>
|
||||||
|
<input matInput formControlName="interestCalc" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Interest Date</mat-label>
|
||||||
|
<input matInput [matDatepicker]="interestDatePicker" formControlName="interestDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="interestDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #interestDatePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||||
|
<mat-label>Sp.Description</mat-label>
|
||||||
|
<input matInput formControlName="spDescription" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Fr'z CD</mat-label>
|
||||||
|
<mat-select formControlName="freezeCode">
|
||||||
|
@for (code of freezeCodes; track code) {
|
||||||
|
<mat-option [value]="code">{{ code }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Adv Code</mat-label>
|
||||||
|
<mat-select formControlName="advCode">
|
||||||
|
@for (code of advCodes; track code) {
|
||||||
|
<mat-option [value]="code">{{ code }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (partyForm.get('advCode').value === 'Transfer To Saving') {
|
||||||
|
<div class="pm-section">
|
||||||
|
<div class="pm-section__header">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
<h3>Maturity Payout Routing</h3>
|
||||||
|
</div>
|
||||||
|
<div class="pm-grid">
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Credit To Branch</mat-label>
|
||||||
|
<input matInput required formControlName="creditToBranch" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Credit To A/C Type</mat-label>
|
||||||
|
<input matInput required formControlName="creditToAccountType" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Credit To A/C</mat-label>
|
||||||
|
<input matInput required formControlName="creditToAccount" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<mat-tab label="Nominee's Detail">
|
||||||
|
<div class="pm-tab-content">
|
||||||
|
<div class="pm-section">
|
||||||
|
<div class="pm-grid">
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Nominee</mat-label>
|
||||||
|
<mat-select formControlName="nomineeApplicable">
|
||||||
|
<mat-option value="Yes">Yes</mat-option>
|
||||||
|
<mat-option value="No">No</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
|
||||||
|
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
|
||||||
|
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
|
||||||
|
</mat-radio-group>
|
||||||
|
|
||||||
|
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
|
||||||
|
<i class="fas fa-plus"></i> Add Nominee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (nominees.length === 0) {
|
||||||
|
<p class="pm-empty">No nominees added yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (nominee of nominees.controls; track nominee; let i = $index) {
|
||||||
|
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Nom.Dt</mat-label>
|
||||||
|
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #nomDatePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<input matInput required formControlName="name" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Mobile No</mat-label>
|
||||||
|
<input matInput formControlName="mobileNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Birth Dt</mat-label>
|
||||||
|
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #nomBirthPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Minor</mat-label>
|
||||||
|
<mat-select required formControlName="minor">
|
||||||
|
<mat-option value="Yes">Yes</mat-option>
|
||||||
|
<mat-option value="No">No</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Relation</mat-label>
|
||||||
|
<input matInput required formControlName="relation" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Gender</mat-label>
|
||||||
|
<mat-select required formControlName="gender">
|
||||||
|
<mat-option value="Male">Male</mat-option>
|
||||||
|
<mat-option value="Female">Female</mat-option>
|
||||||
|
<mat-option value="Other">Other</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-label>Guar. Name</mat-label>
|
||||||
|
<input matInput formControlName="guardianName" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
type="button"
|
||||||
|
class="pm-remove-nominee"
|
||||||
|
(click)="removeNominee(i)"
|
||||||
|
matTooltip="Remove nominee"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group>
|
||||||
|
|
||||||
|
@if (isClosed) {
|
||||||
|
<div class="pm-callout">
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
<span>This account is closed. No further collections or edits are possible.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="pm-action-bar">
|
||||||
|
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||||
|
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||||
|
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
|
||||||
|
@if (partyId && !isClosed) {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
color="warn"
|
||||||
|
matTooltip="Balance must be zero before closing"
|
||||||
|
(click)="closeAccount()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="partyForm.invalid || isClosed"
|
||||||
|
(click)="save()"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$primary: #0f766e;
|
||||||
|
|
||||||
|
.pm-container {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-back {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-card {
|
||||||
|
background: var(--md-sys-color-surface, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-tabs {
|
||||||
|
::ng-deep .mat-mdc-tab-body-wrapper {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-tab-content {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-section {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-field {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-checkbox-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-radio-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-add-nominee {
|
||||||
|
color: $primary;
|
||||||
|
height: fit-content;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-empty {
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-nominee-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr) auto;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-remove-nominee {
|
||||||
|
color: #b91c1c;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-action-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-callout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 24px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgb(185 28 28 / 8%);
|
||||||
|
border: 1px solid rgb(185 28 28 / 25%);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1000px) {
|
||||||
|
.pm-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-nominee-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
|
import { MatCheckbox } from '@angular/material/checkbox';
|
||||||
|
import { MatRadioModule } from '@angular/material/radio';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { OrganizationService } from 'app/organization/organization.service';
|
||||||
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Party Master Entry component.
|
||||||
|
*
|
||||||
|
* The customer's DDS account — linked to an Agent for daily collection,
|
||||||
|
* carrying its own scheme terms, KYC, two-stage overdue flags, and a
|
||||||
|
* nominee list. Modelled directly on the legacy Party Master Entry screen
|
||||||
|
* this replaces.
|
||||||
|
*
|
||||||
|
* Backed by its own DDS Party API (`/dds/parties`). The underlying account
|
||||||
|
* does not yet reuse the Recurring Deposit account engine (see Product
|
||||||
|
* Setup) — that linkage is a follow-up once the RD-account-specific fields
|
||||||
|
* this screen doesn't collect (interest/compounding periods, chart
|
||||||
|
* selection, ...) have a home. Until then the scheme/balance fields here
|
||||||
|
* are the Party's own.
|
||||||
|
*
|
||||||
|
* "Close Account" (command=close) permanently closes the Party once its
|
||||||
|
* balance is zero - the backend rejects the command otherwise. A closed
|
||||||
|
* Party can no longer be edited or receive new Party Tran collections.
|
||||||
|
*
|
||||||
|
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
|
||||||
|
* the same client (resolved by account number, since that's what an officer has to hand) -
|
||||||
|
* clarified with the business (2026-08-24) as "link it if the client already has one, otherwise
|
||||||
|
* leave it". There is no unlink once linked.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-party-master',
|
||||||
|
templateUrl: './party-master.component.html',
|
||||||
|
styleUrls: ['./party-master.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatTabsModule,
|
||||||
|
MatCheckbox,
|
||||||
|
MatRadioModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class PartyMasterComponent {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private router = inject(Router);
|
||||||
|
private organizationService = inject(OrganizationService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
|
||||||
|
partyId: number | null = null;
|
||||||
|
selectedTabIndex = 0;
|
||||||
|
partyStatus = 'ACTIVE';
|
||||||
|
linkedSavingsAccountId: number | null = null;
|
||||||
|
|
||||||
|
get isClosed(): boolean {
|
||||||
|
return this.partyStatus === 'CLOSED';
|
||||||
|
}
|
||||||
|
|
||||||
|
subTypes = [
|
||||||
|
'Daily Deposit Scheme',
|
||||||
|
'Masik Dhan Varsha Deposit'
|
||||||
|
];
|
||||||
|
operations = [
|
||||||
|
'Self',
|
||||||
|
'Guardian',
|
||||||
|
'Joint'
|
||||||
|
];
|
||||||
|
occupations = [
|
||||||
|
'Business',
|
||||||
|
'Service',
|
||||||
|
'Agriculture',
|
||||||
|
'Homemaker',
|
||||||
|
'Student',
|
||||||
|
'Retired',
|
||||||
|
'Other'
|
||||||
|
];
|
||||||
|
riskLevels = [
|
||||||
|
'Low',
|
||||||
|
'Medium',
|
||||||
|
'High'
|
||||||
|
];
|
||||||
|
adultMinorOptions = [
|
||||||
|
'Adult',
|
||||||
|
'Minor'
|
||||||
|
];
|
||||||
|
withdrawTypes = [
|
||||||
|
'Cash',
|
||||||
|
'Transfer',
|
||||||
|
'Clearing'
|
||||||
|
];
|
||||||
|
freezeCodes = [
|
||||||
|
'Normal',
|
||||||
|
'Debit Freeze',
|
||||||
|
'Credit Freeze',
|
||||||
|
'Total Freeze'
|
||||||
|
];
|
||||||
|
advCodes = [
|
||||||
|
'None',
|
||||||
|
'Transfer To Saving',
|
||||||
|
'Renew'
|
||||||
|
];
|
||||||
|
|
||||||
|
partyForm: UntypedFormGroup = this.formBuilder.group({
|
||||||
|
clientId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
subType: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
agentId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
savingsAccountNo: [''],
|
||||||
|
lastAccount: [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
partyCode: [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
nominal: [
|
||||||
|
'No',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
aadharNo: [''],
|
||||||
|
partyName: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
partyName1: [''],
|
||||||
|
address1: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
address2: [''],
|
||||||
|
address3: [''],
|
||||||
|
city: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
pincode: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
gender: [''],
|
||||||
|
panCardNo: [''],
|
||||||
|
telephone: [''],
|
||||||
|
mobileNoSms: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
operation: [
|
||||||
|
'Self',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
birthDate: [''],
|
||||||
|
occupation: [''],
|
||||||
|
risk: [''],
|
||||||
|
ckycId: [''],
|
||||||
|
adultMinor: [
|
||||||
|
'Adult',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
minorDate: [''],
|
||||||
|
guardianClientId: [''],
|
||||||
|
minAccountOpt: [''],
|
||||||
|
relation: [''],
|
||||||
|
openingDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
month: [''],
|
||||||
|
maturityDate: [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
reopenDate: [''],
|
||||||
|
depositAmount: [0],
|
||||||
|
dailyDepositAmount: [
|
||||||
|
0,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
days: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
ledgerNo: [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
maturityAmount: [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
interestRate: [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
withdrawType: [''],
|
||||||
|
od1Flag: [
|
||||||
|
{
|
||||||
|
value: false,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
od2Flag: [
|
||||||
|
{
|
||||||
|
value: false,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
lien: ['No'],
|
||||||
|
payDate: [''],
|
||||||
|
paymentAmount: [0],
|
||||||
|
currentBalance: [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
openingBalance: [0],
|
||||||
|
isSmsActive: [true],
|
||||||
|
isEmailActive: [false],
|
||||||
|
interestCalc: [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
interestDate: [''],
|
||||||
|
spDescription: [''],
|
||||||
|
freezeCode: ['Normal'],
|
||||||
|
advCode: ['None'],
|
||||||
|
creditToBranch: [''],
|
||||||
|
creditToAccountType: [''],
|
||||||
|
creditToAccount: [''],
|
||||||
|
nomineeApplicable: ['No'],
|
||||||
|
nomineeSplitMode: ['Percentage'],
|
||||||
|
nominees: this.formBuilder.array([])
|
||||||
|
});
|
||||||
|
|
||||||
|
get nominees(): UntypedFormArray {
|
||||||
|
return this.partyForm.get('nominees') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildNomineeGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
nominationDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
name: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
mobileNo: [''],
|
||||||
|
birthDate: [''],
|
||||||
|
minor: [
|
||||||
|
'No',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
relation: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
gender: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
guardianName: ['']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addNominee(): void {
|
||||||
|
this.nominees.push(this.buildNomineeGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeNominee(index: number): void {
|
||||||
|
this.nominees.removeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieve(): void {
|
||||||
|
const clientId = this.partyForm.get('clientId')?.value;
|
||||||
|
if (!clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.organizationService.getParties().subscribe((parties: any[]) => {
|
||||||
|
const match = (parties || []).find((party) => String(party.clientId) === String(clientId));
|
||||||
|
if (match) {
|
||||||
|
this.patchFormFromParty(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.partyId = null;
|
||||||
|
this.partyStatus = 'ACTIVE';
|
||||||
|
this.linkedSavingsAccountId = null;
|
||||||
|
this.partyForm.reset();
|
||||||
|
this.partyForm.patchValue({
|
||||||
|
nominal: 'No',
|
||||||
|
operation: 'Self',
|
||||||
|
adultMinor: 'Adult',
|
||||||
|
lien: 'No',
|
||||||
|
isSmsActive: true,
|
||||||
|
isEmailActive: false,
|
||||||
|
freezeCode: 'Normal',
|
||||||
|
advCode: 'None',
|
||||||
|
nomineeApplicable: 'No',
|
||||||
|
nomineeSplitMode: 'Percentage',
|
||||||
|
currentBalance: 0,
|
||||||
|
maturityAmount: 0,
|
||||||
|
interestRate: 0
|
||||||
|
});
|
||||||
|
this.nominees.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(): void {
|
||||||
|
this.router.navigate(['/accounts/savings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
const raw = this.partyForm.getRawValue();
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
const locale = this.settingsService.language.code;
|
||||||
|
const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined);
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
clientId: Number(raw.clientId),
|
||||||
|
agentId: Number(raw.agentId),
|
||||||
|
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
|
||||||
|
subType: raw.subType,
|
||||||
|
lastAccount: raw.lastAccount || undefined,
|
||||||
|
nominal: raw.nominal === 'Yes',
|
||||||
|
aadharNo: raw.aadharNo || undefined,
|
||||||
|
partyName: raw.partyName,
|
||||||
|
partyName1: raw.partyName1 || undefined,
|
||||||
|
address1: raw.address1,
|
||||||
|
address2: raw.address2 || undefined,
|
||||||
|
address3: raw.address3 || undefined,
|
||||||
|
city: raw.city,
|
||||||
|
pincode: raw.pincode,
|
||||||
|
gender: raw.gender || undefined,
|
||||||
|
panCardNo: raw.panCardNo || undefined,
|
||||||
|
telephone: raw.telephone || undefined,
|
||||||
|
mobileNoSms: raw.mobileNoSms,
|
||||||
|
operation: raw.operation,
|
||||||
|
birthDate: asDate(raw.birthDate),
|
||||||
|
occupation: raw.occupation || undefined,
|
||||||
|
risk: raw.risk || undefined,
|
||||||
|
ckycId: raw.ckycId || undefined,
|
||||||
|
adultMinor: raw.adultMinor,
|
||||||
|
minorDate: asDate(raw.minorDate),
|
||||||
|
guardianClientId: raw.guardianClientId ? Number(raw.guardianClientId) : undefined,
|
||||||
|
minAccountOpt: raw.minAccountOpt || undefined,
|
||||||
|
relation: raw.relation || undefined,
|
||||||
|
openingDate: asDate(raw.openingDate),
|
||||||
|
month: raw.month || undefined,
|
||||||
|
reopenDate: asDate(raw.reopenDate),
|
||||||
|
depositAmount: raw.depositAmount || undefined,
|
||||||
|
dailyDepositAmount: raw.dailyDepositAmount,
|
||||||
|
days: raw.days,
|
||||||
|
withdrawType: raw.withdrawType || undefined,
|
||||||
|
lien: raw.lien === 'Yes',
|
||||||
|
payDate: asDate(raw.payDate),
|
||||||
|
paymentAmount: raw.paymentAmount || undefined,
|
||||||
|
openingBalance: raw.openingBalance || 0,
|
||||||
|
isSmsActive: !!raw.isSmsActive,
|
||||||
|
isEmailActive: !!raw.isEmailActive,
|
||||||
|
interestDate: asDate(raw.interestDate),
|
||||||
|
spDescription: raw.spDescription || undefined,
|
||||||
|
freezeCode: raw.freezeCode || undefined,
|
||||||
|
advCode: raw.advCode || undefined,
|
||||||
|
creditToBranch: raw.creditToBranch || undefined,
|
||||||
|
creditToAccountType: raw.creditToAccountType || undefined,
|
||||||
|
creditToAccount: raw.creditToAccount || undefined,
|
||||||
|
nomineeApplicable: raw.nomineeApplicable === 'Yes',
|
||||||
|
nomineeSplitMode: raw.nomineeSplitMode || undefined,
|
||||||
|
dateFormat,
|
||||||
|
locale,
|
||||||
|
nominees: (raw.nominees || []).map((nominee: any) => ({
|
||||||
|
nominationDate: this.dateUtils.getDate(nominee.nominationDate),
|
||||||
|
name: nominee.name,
|
||||||
|
mobileNo: nominee.mobileNo || undefined,
|
||||||
|
birthDate: nominee.birthDate ? this.dateUtils.getDate(nominee.birthDate) : undefined,
|
||||||
|
minor: nominee.minor === 'Yes',
|
||||||
|
relation: nominee.relation,
|
||||||
|
gender: nominee.gender,
|
||||||
|
guardianName: nominee.guardianName || undefined
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.partyId) {
|
||||||
|
this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit());
|
||||||
|
} else {
|
||||||
|
this.organizationService.createParty(payload).subscribe(() => this.exit());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAccount(): void {
|
||||||
|
if (!this.partyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
const locale = this.settingsService.language.code;
|
||||||
|
this.organizationService
|
||||||
|
.closeParty(this.partyId, {
|
||||||
|
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
|
||||||
|
dateFormat,
|
||||||
|
locale
|
||||||
|
})
|
||||||
|
.subscribe(() => this.exit());
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchFormFromParty(party: any): void {
|
||||||
|
this.partyId = party.id;
|
||||||
|
this.partyStatus = party.status || 'ACTIVE';
|
||||||
|
this.linkedSavingsAccountId = party.savingsAccountId || null;
|
||||||
|
this.partyForm.patchValue({
|
||||||
|
clientId: party.clientId,
|
||||||
|
subType: party.subType,
|
||||||
|
agentId: party.agentId,
|
||||||
|
partyCode: party.partyCode,
|
||||||
|
lastAccount: party.lastAccount,
|
||||||
|
nominal: party.nominal ? 'Yes' : 'No',
|
||||||
|
aadharNo: party.aadharNo,
|
||||||
|
partyName: party.partyName,
|
||||||
|
partyName1: party.partyName1,
|
||||||
|
address1: party.address1,
|
||||||
|
address2: party.address2,
|
||||||
|
address3: party.address3,
|
||||||
|
city: party.city,
|
||||||
|
pincode: party.pincode,
|
||||||
|
gender: party.gender,
|
||||||
|
panCardNo: party.panCardNo,
|
||||||
|
telephone: party.telephone,
|
||||||
|
mobileNoSms: party.mobileNoSms,
|
||||||
|
operation: party.operation,
|
||||||
|
birthDate: party.birthDate ? new Date(party.birthDate) : null,
|
||||||
|
occupation: party.occupation,
|
||||||
|
risk: party.risk,
|
||||||
|
ckycId: party.ckycId,
|
||||||
|
adultMinor: party.adultMinor,
|
||||||
|
minorDate: party.minorDate ? new Date(party.minorDate) : null,
|
||||||
|
guardianClientId: party.guardianClientId,
|
||||||
|
minAccountOpt: party.minAccountOpt,
|
||||||
|
relation: party.relation,
|
||||||
|
openingDate: party.openingDate ? new Date(party.openingDate) : null,
|
||||||
|
month: party.month,
|
||||||
|
maturityDate: party.maturityDate,
|
||||||
|
reopenDate: party.reopenDate ? new Date(party.reopenDate) : null,
|
||||||
|
depositAmount: party.depositAmount,
|
||||||
|
dailyDepositAmount: party.dailyDepositAmount,
|
||||||
|
days: party.days,
|
||||||
|
ledgerNo: party.ledgerNo,
|
||||||
|
maturityAmount: party.maturityAmount,
|
||||||
|
interestRate: party.interestRate,
|
||||||
|
withdrawType: party.withdrawType,
|
||||||
|
od1Flag: party.od1Flag,
|
||||||
|
od2Flag: party.od2Flag,
|
||||||
|
lien: party.lien ? 'Yes' : 'No',
|
||||||
|
payDate: party.payDate ? new Date(party.payDate) : null,
|
||||||
|
paymentAmount: party.paymentAmount,
|
||||||
|
currentBalance: party.currentBalance,
|
||||||
|
openingBalance: party.openingBalance,
|
||||||
|
isSmsActive: party.isSmsActive,
|
||||||
|
isEmailActive: party.isEmailActive,
|
||||||
|
interestCalc: party.interestCalc,
|
||||||
|
interestDate: party.interestDate ? new Date(party.interestDate) : null,
|
||||||
|
spDescription: party.spDescription,
|
||||||
|
freezeCode: party.freezeCode,
|
||||||
|
advCode: party.advCode,
|
||||||
|
creditToBranch: party.creditToBranch,
|
||||||
|
creditToAccountType: party.creditToAccountType,
|
||||||
|
creditToAccount: party.creditToAccount,
|
||||||
|
nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No',
|
||||||
|
nomineeSplitMode: party.nomineeSplitMode
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nominees.clear();
|
||||||
|
(party.nominees || []).forEach((nominee: any) => {
|
||||||
|
const group = this.buildNomineeGroup();
|
||||||
|
group.patchValue({
|
||||||
|
nominationDate: nominee.nominationDate ? new Date(nominee.nominationDate) : null,
|
||||||
|
name: nominee.name,
|
||||||
|
mobileNo: nominee.mobileNo,
|
||||||
|
birthDate: nominee.birthDate ? new Date(nominee.birthDate) : null,
|
||||||
|
minor: nominee.minor ? 'Yes' : 'No',
|
||||||
|
relation: nominee.relation,
|
||||||
|
gender: nominee.gender,
|
||||||
|
guardianName: nominee.guardianName
|
||||||
|
});
|
||||||
|
this.nominees.push(group);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -347,6 +347,69 @@
|
|||||||
|
|
||||||
<!-- STR -->
|
<!-- STR -->
|
||||||
@if (activeTab === 'str') {
|
@if (activeTab === 'str') {
|
||||||
|
<!--
|
||||||
|
Sanctions STRs are a separate stream from the AML alerts below, not merged into them: an AML
|
||||||
|
alert is raised by a monetary transaction breaching a rule, whereas a sanctions STR is
|
||||||
|
triggered by who the customer is. Mixing them would put two different kinds of suspicion in
|
||||||
|
one queue and require inventing a transaction that never happened.
|
||||||
|
-->
|
||||||
|
@if (sanctionsStrCases.length) {
|
||||||
|
<div class="fiu-section">
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>Sanctions STRs (UAPA s.51-A)</h3>
|
||||||
|
<p>
|
||||||
|
Confirmed matches against the UN Security Council Consolidated List. Accounts are frozen; the STR must
|
||||||
|
reach FIU-IND within 7 days of confirmation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select class="fiu-inp" [(ngModel)]="sanctionsStrFilter" (ngModelChange)="loadSanctionsStrCases()">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="PENDING_FILING">Pending filing</option>
|
||||||
|
<option value="FILED">Filed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fiu-tbl-wrap">
|
||||||
|
<table class="fiu-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Alert</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Screened Name</th>
|
||||||
|
<th>Listed As</th>
|
||||||
|
<th>Confirmed On</th>
|
||||||
|
<th>Due</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>STR Reference</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (str of sanctionsStrCases; track str.id) {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">#{{ str.id }}</td>
|
||||||
|
<td>
|
||||||
|
@if (str.clientId) {
|
||||||
|
<a [routerLink]="['/clients', str.clientId, 'general']">{{ str.clientName || str.clientId }}</a>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">Not onboarded</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ str.screenedName }}</td>
|
||||||
|
<td>{{ str.matchedNames || '—' }}</td>
|
||||||
|
<td>{{ str.approvedOn | date: 'mediumDate' }}</td>
|
||||||
|
<td [class.str-overdue]="isStrOverdue(str)">{{ str.strDueDate || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="strStatusClass(str)">{{ strStatusLabel(str) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ str.fiuStrReference || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { AmlService } from 'app/aml/aml.service';
|
import { AmlService } from 'app/aml/aml.service';
|
||||||
|
import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service';
|
||||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||||
import {
|
import {
|
||||||
BRANCHES,
|
BRANCHES,
|
||||||
@@ -130,6 +131,7 @@ export class FiuComponent implements OnInit {
|
|||||||
private static readonly SEED_VERSION = '2026-01-cash-threshold';
|
private static readonly SEED_VERSION = '2026-01-cash-threshold';
|
||||||
|
|
||||||
private amlService = inject(AmlService);
|
private amlService = inject(AmlService);
|
||||||
|
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||||
private authenticationService = inject(AuthenticationService);
|
private authenticationService = inject(AuthenticationService);
|
||||||
/** Active tab */
|
/** Active tab */
|
||||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||||
@@ -176,6 +178,13 @@ export class FiuComponent implements OnInit {
|
|||||||
|
|
||||||
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
|
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
|
||||||
amlAlerts: any[] = [];
|
amlAlerts: any[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmed UNSC sanctions matches carrying an STR obligation. Held separately from amlAlerts
|
||||||
|
* because they are identity-driven rather than transaction-driven — see the template comment.
|
||||||
|
*/
|
||||||
|
sanctionsStrCases: any[] = [];
|
||||||
|
sanctionsStrFilter = '';
|
||||||
amlAlertStatusFilter = '';
|
amlAlertStatusFilter = '';
|
||||||
amlReviewNotes: Record<number, string> = {};
|
amlReviewNotes: Record<number, string> = {};
|
||||||
|
|
||||||
@@ -320,6 +329,37 @@ export class FiuComponent implements OnInit {
|
|||||||
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
|
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Loads confirmed sanctions matches that owe an STR. */
|
||||||
|
loadSanctionsStrCases() {
|
||||||
|
this.sanctionsLogsService.getStrCases(this.sanctionsStrFilter || undefined).subscribe({
|
||||||
|
next: (rows: any) => (this.sanctionsStrCases = rows || []),
|
||||||
|
// A failure here must not blank the AML side of the tab, which is unrelated.
|
||||||
|
error: () => (this.sanctionsStrCases = [])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
strStatusLabel(str: any): string {
|
||||||
|
if (str?.strStatus === 'FILED') {
|
||||||
|
return 'FILED';
|
||||||
|
}
|
||||||
|
return this.isStrOverdue(str) ? 'OVERDUE' : 'PENDING FILING';
|
||||||
|
}
|
||||||
|
|
||||||
|
strStatusClass(str: any): string {
|
||||||
|
if (str?.strStatus === 'FILED') {
|
||||||
|
return 'fiu-pill fiu-pill--ok';
|
||||||
|
}
|
||||||
|
return this.isStrOverdue(str) ? 'fiu-pill fiu-pill--danger' : 'fiu-pill fiu-pill--warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Past its 7-day PMLA deadline and still unfiled — the state that actually needs chasing. */
|
||||||
|
isStrOverdue(str: any): boolean {
|
||||||
|
if (!str?.strDueDate || str.strStatus === 'FILED') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return new Date(str.strDueDate) < new Date(new Date().toDateString());
|
||||||
|
}
|
||||||
|
|
||||||
get isPrincipalOfficer(): boolean {
|
get isPrincipalOfficer(): boolean {
|
||||||
return this.authenticationService.isPrincipalOfficerUser();
|
return this.authenticationService.isPrincipalOfficerUser();
|
||||||
}
|
}
|
||||||
@@ -547,6 +587,7 @@ export class FiuComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
loadAmlAlerts(): void {
|
loadAmlAlerts(): void {
|
||||||
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
||||||
|
this.loadSanctionsStrCases();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAmlCtrReports(): void {
|
loadAmlCtrReports(): void {
|
||||||
|
|||||||
@@ -7,556 +7,121 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="alm-dlg">
|
<div class="alm-dlg">
|
||||||
<!-- HEADER -->
|
<!-- Window chrome. Screen only — the print takes the statement's own heading, not this bar. -->
|
||||||
<div class="alm-head">
|
<div class="alm-head no-print">
|
||||||
<div class="alm-head-left">
|
<div class="alm-head-left">
|
||||||
<div class="alm-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
|
<div class="alm-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="alm-title">Asset Liability Management — Structural Liquidity</h2>
|
<h2 class="alm-title">Average Liabilities and Assets</h2>
|
||||||
<p class="alm-sub">
|
<p class="alm-sub">
|
||||||
{{ bank.name }} · <strong>{{ bank.formCode }}</strong> · {{ bank.quarter }} · As on
|
{{ bank.name }} · {{ branchName }} · FY {{ bank.financialYear }} · Month-wise during the
|
||||||
<strong>{{ bank.asOnDate }}</strong>
|
year
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="alm-head-actions">
|
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||||
<span class="alm-overall" [ngClass]="gap14dWithinTolerance && gap28dWithinTolerance ? 'on-target' : 'below'">
|
</div>
|
||||||
<span class="alm-overall-l">14d Tolerance</span>
|
|
||||||
<span class="alm-overall-v">{{ gap14dPct.toFixed(2) }}%</span>
|
<!-- Report parameters and actions, the way a report is run in the core system -->
|
||||||
<span class="alm-overall-t">/ -{{ bank.tolerance14d }}%</span>
|
<div class="alm-params no-print">
|
||||||
</span>
|
<label class="alm-param">
|
||||||
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
<span>Financial year</span>
|
||||||
|
<output class="alm-param-static">{{ bank.financialYear }}</output>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="alm-param">
|
||||||
|
<span>Up to month</span>
|
||||||
|
<select [(ngModel)]="upToIndex" (ngModelChange)="runReport()">
|
||||||
|
<option *ngFor="let m of monthOptions" [ngValue]="m.index">{{ m.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="alm-param">
|
||||||
|
<span>Months included</span>
|
||||||
|
<output class="alm-param-static">{{ statementRows.length }}</output>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="alm-params-sp"></span>
|
||||||
|
|
||||||
|
<button type="button" class="alm-btn" (click)="print()"><fa-icon icon="print"></fa-icon> Print</button>
|
||||||
|
<button type="button" class="alm-btn" (click)="exportXls()">
|
||||||
|
<fa-icon icon="file-excel"></fa-icon> Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="alm-note no-print">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> <strong>{{ unmappedColumns }}</strong> are not mapped in the
|
||||||
|
core-system extract and print as 0.00. Total and Average cover the {{ statementRows.length }} month(s) shown.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- ═══ THE STATEMENT — what goes on paper ═══ -->
|
||||||
|
<div class="alm-body">
|
||||||
|
<div class="stmt" #statement>
|
||||||
|
<div class="stmt-bank">{{ statementHeading }}</div>
|
||||||
|
<div class="stmt-title">Average Liabilities and Assets Month-wise during the year</div>
|
||||||
|
|
||||||
|
<div class="stmt-tbl-wrap">
|
||||||
|
<table class="stmt-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowspan="2" class="col-month">Month</th>
|
||||||
|
<th rowspan="2">Capital</th>
|
||||||
|
<th rowspan="2">Reserve/Prov<br />Exclude OIR</th>
|
||||||
|
<th rowspan="2">Deposits</th>
|
||||||
|
<th rowspan="2">Borrowings</th>
|
||||||
|
<th rowspan="2">Other Liabilities</th>
|
||||||
|
<th rowspan="2">Cash on Hand</th>
|
||||||
|
<th colspan="2" class="col-grp">Bank Balances</th>
|
||||||
|
<th rowspan="2">Investments<br />including</th>
|
||||||
|
<th rowspan="2">Advances</th>
|
||||||
|
<th rowspan="2">Other Assets<br />Exclude IRA</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Current</th>
|
||||||
|
<th>FDs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let r of statementRows">
|
||||||
|
<td class="col-month">
|
||||||
|
<strong>{{ r.label }}</strong
|
||||||
|
><br /><span class="stmt-yr">{{ r.sub }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.capital | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.deposits | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.borrowings | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.bankFds | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.investments | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.advances | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr class="stmt-sum" *ngFor="let r of [totalRow, averageRow]">
|
||||||
|
<td class="col-month">{{ r.label }}</td>
|
||||||
|
<td>{{ r.capital | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.deposits | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.borrowings | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.bankFds | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.investments | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.advances | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stmt-foot">
|
||||||
|
<div>Print Date : {{ generatedAt }}</div>
|
||||||
|
<div>Report Ref No : {{ bank.reportRefNo }} User Code:{{ statementUserCode }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TABS -->
|
|
||||||
<div class="alm-tabs">
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'dashboard'" (click)="setTab('dashboard')">
|
|
||||||
<fa-icon icon="chart-pie"></fa-icon> Dashboard
|
|
||||||
</button>
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'ladder'" (click)="setTab('ladder')">
|
|
||||||
<fa-icon icon="layer-group"></fa-icon> Maturity Ladder
|
|
||||||
</button>
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'monthly'" (click)="setTab('monthly')">
|
|
||||||
<fa-icon icon="calendar-alt"></fa-icon> Monthly Snapshot
|
|
||||||
</button>
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'avgbal'" (click)="setTab('avgbal')">
|
|
||||||
<fa-icon icon="table"></fa-icon> Avg. Liabilities & Assets
|
|
||||||
</button>
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'returns'" (click)="setTab('returns')">
|
|
||||||
<fa-icon icon="file-invoice"></fa-icon> Form ALM-1
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alm-body">
|
|
||||||
<!-- ════════════ DASHBOARD ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'dashboard'">
|
|
||||||
<div class="alm-stats">
|
|
||||||
<div class="alm-stat-card alm-stat-gauge">
|
|
||||||
<div
|
|
||||||
class="gauge"
|
|
||||||
[style.--pct.%]="totalGapPct > 0 ? (totalGapPct > 100 ? 100 : totalGapPct) : 0"
|
|
||||||
[ngClass]="totalGap >= 0 ? 'on-target' : 'below'"
|
|
||||||
>
|
|
||||||
<div class="gauge-inner">
|
|
||||||
<div class="gauge-pct">{{ totalGapPct.toFixed(1) }}%</div>
|
|
||||||
<div class="gauge-label">Net Gap / Outflow</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-stat-card">
|
|
||||||
<div class="alm-stat-l">NDTL</div>
|
|
||||||
<div class="alm-stat-v">₹ {{ bank.ndtl / 100 | number: '1.2-2' }} Cr</div>
|
|
||||||
<div class="alm-stat-meta">Net Demand & Time Liabilities</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-stat-card">
|
|
||||||
<div class="alm-stat-l">Total Outflows</div>
|
|
||||||
<div class="alm-stat-v">₹ {{ totalOutflow / 100 | number: '1.2-2' }} Cr</div>
|
|
||||||
<div class="alm-stat-meta">All maturity buckets</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-stat-card">
|
|
||||||
<div class="alm-stat-l">Total Inflows</div>
|
|
||||||
<div class="alm-stat-v">₹ {{ totalInflow / 100 | number: '1.2-2' }} Cr</div>
|
|
||||||
<div class="alm-stat-meta" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
|
||||||
Net: {{ totalGap >= 0 ? '+' : '' }}{{ totalGap / 100 | number: '1.2-2' }} Cr
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TOLERANCE BARS -->
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">RBI Tolerance Limits — Cumulative Gap</h3>
|
|
||||||
<span class="alm-circular">RBI MD: {{ bank.rbiCircular }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tolerance">
|
|
||||||
<div class="alm-tol-row" [ngClass]="gap14dWithinTolerance ? 'good' : 'bad'">
|
|
||||||
<div class="alm-tol-h">
|
|
||||||
<span class="alm-tol-l"
|
|
||||||
><fa-icon icon="hourglass-half"></fa-icon> Cumulative Gap up to 14 days</span
|
|
||||||
>
|
|
||||||
<span class="alm-tol-p">
|
|
||||||
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
|
|
||||||
<span class="alm-tol-lim">limit -{{ bank.tolerance14d }}%</span>
|
|
||||||
<span class="alm-tol-st">{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tol-track">
|
|
||||||
<div
|
|
||||||
class="alm-tol-fill"
|
|
||||||
[style.width.%]="gap14dPct >= 0 ? 100 : 100 + (gap14dPct / bank.tolerance14d) * 100"
|
|
||||||
></div>
|
|
||||||
<div class="alm-tol-marker"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tol-row" [ngClass]="gap28dWithinTolerance ? 'good' : 'bad'">
|
|
||||||
<div class="alm-tol-h">
|
|
||||||
<span class="alm-tol-l"><fa-icon icon="clock"></fa-icon> Cumulative Gap up to 30 days</span>
|
|
||||||
<span class="alm-tol-p">
|
|
||||||
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
|
|
||||||
<span class="alm-tol-lim">limit -{{ bank.tolerance28d }}%</span>
|
|
||||||
<span class="alm-tol-st">{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tol-track">
|
|
||||||
<div
|
|
||||||
class="alm-tol-fill"
|
|
||||||
[style.width.%]="gap28dPct >= 0 ? 100 : 100 + (gap28dPct / bank.tolerance28d) * 100"
|
|
||||||
></div>
|
|
||||||
<div class="alm-tol-marker"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BUCKET MINI HEATMAP -->
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Bucket-wise Mismatch Heatmap</h3>
|
|
||||||
</div>
|
|
||||||
<div class="alm-heatmap">
|
|
||||||
<div
|
|
||||||
*ngFor="let b of buckets"
|
|
||||||
class="heat-cell"
|
|
||||||
[ngClass]="bucketStatus(b)"
|
|
||||||
[matTooltip]="b.label + ' — Gap: ' + bucketGapPct(b).toFixed(2) + '%'"
|
|
||||||
>
|
|
||||||
<div class="heat-code">{{ b.code }}</div>
|
|
||||||
<div class="heat-pct">{{ bucketGapPct(b).toFixed(1) }}%</div>
|
|
||||||
<div class="heat-lbl">{{ b.label }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Reporting Snapshot</h3>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info-grid">
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Reporting Bank</div>
|
|
||||||
<div class="alm-info-v">{{ bank.name }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Branch</div>
|
|
||||||
<div class="alm-info-v">{{ bank.branch }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Principal Officer</div>
|
|
||||||
<div class="alm-info-v">{{ bank.principalOfficer }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Form Code</div>
|
|
||||||
<div class="alm-info-v">{{ bank.formCode }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Report Ref No</div>
|
|
||||||
<div class="alm-info-v mono">{{ bank.reportRefNo }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">User Code</div>
|
|
||||||
<div class="alm-info-v mono">{{ bank.userCode }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- ════════════ MATURITY LADDER ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'ladder'">
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Statement of Structural Liquidity — Maturity Ladder</h3>
|
|
||||||
<div class="alm-sec-actions">
|
|
||||||
<select class="alm-sel" [(ngModel)]="filterBucket">
|
|
||||||
<option value="">All Buckets</option>
|
|
||||||
<option value="short">Short Term (≤ 30 days)</option>
|
|
||||||
<option value="medium">Medium Term (1–12 months)</option>
|
|
||||||
<option value="long">Long Term (> 1 year)</option>
|
|
||||||
</select>
|
|
||||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportLadderCsv()">
|
|
||||||
<fa-icon icon="file-csv"></fa-icon> Export CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tbl-wrap">
|
|
||||||
<table class="alm-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bucket</th>
|
|
||||||
<th>Time Period</th>
|
|
||||||
<th class="ta-r">Outflow (Liab)</th>
|
|
||||||
<th class="ta-r">Inflow (Assets)</th>
|
|
||||||
<th class="ta-r">Gap</th>
|
|
||||||
<th class="ta-r">Gap %</th>
|
|
||||||
<th class="ta-c">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let b of filteredBuckets">
|
|
||||||
<td class="mono">{{ b.code }}</td>
|
|
||||||
<td>{{ b.label }}</td>
|
|
||||||
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ bucketGapPct(b) >= 0 ? '+' : '' }}{{ bucketGapPct(b).toFixed(2) }}%
|
|
||||||
</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<span class="status-pill" [ngClass]="bucketStatus(b)">
|
|
||||||
<ng-container *ngIf="bucketStatus(b) === 'surplus'">SURPLUS</ng-container>
|
|
||||||
<ng-container *ngIf="bucketStatus(b) === 'deficit'">DEFICIT</ng-container>
|
|
||||||
<ng-container *ngIf="bucketStatus(b) === 'balanced'">BALANCED</ng-container>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="row-total">
|
|
||||||
<td colspan="2"><strong>TOTAL</strong></td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ totalGapPct >= 0 ? '+' : '' }}{{ totalGapPct.toFixed(2) }}%</strong>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cumulative table -->
|
|
||||||
<h4 class="form-iii-t">Cumulative Position</h4>
|
|
||||||
<div class="alm-tbl-wrap">
|
|
||||||
<table class="alm-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Through Bucket</th>
|
|
||||||
<th class="ta-r">Cum. Outflow</th>
|
|
||||||
<th class="ta-r">Cum. Inflow</th>
|
|
||||||
<th class="ta-r">Cum. Gap</th>
|
|
||||||
<th class="ta-r">Cum. Gap %</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let b of buckets; let i = index">
|
|
||||||
<td>{{ b.code }} — {{ b.label }}</td>
|
|
||||||
<td class="ta-r">{{ cumulativeOutflow(i) | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ cumulativeInflow(i) | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- ════════════ MONTHLY ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'monthly'">
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Monthly Asset / Liability Snapshot — FY {{ bank.fiscalYear }}</h3>
|
|
||||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportMonthlyXls()">
|
|
||||||
<fa-icon icon="file-excel"></fa-icon> Export Excel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tbl-wrap">
|
|
||||||
<table class="alm-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowspan="2">Month</th>
|
|
||||||
<th colspan="4" class="ta-c grp-liab">Liabilities (₹ Lakh)</th>
|
|
||||||
<th colspan="4" class="ta-c grp-asset">Assets (₹ Lakh)</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="ta-r">Reserve/Prov</th>
|
|
||||||
<th class="ta-r">Borrowings</th>
|
|
||||||
<th class="ta-r">FDs</th>
|
|
||||||
<th class="ta-r">Total Liab</th>
|
|
||||||
<th class="ta-r">Cash</th>
|
|
||||||
<th class="ta-r">Investments</th>
|
|
||||||
<th class="ta-r">Other Assets</th>
|
|
||||||
<th class="ta-r">Total Assets</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let m of monthly">
|
|
||||||
<td class="mono">{{ m.month }}</td>
|
|
||||||
<td class="ta-r">{{ m.reserveProv | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ m.borrowings | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ m.fds | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ monthTotalLiab(m) | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">{{ m.cashOnHand | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ m.investments | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ m.otherAssets | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ monthTotalAssets(m) | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="row-total">
|
|
||||||
<td><strong>AVERAGE</strong></td>
|
|
||||||
<td class="ta-r">{{ avg('reserveProv') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ avg('borrowings') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ avg('fds') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ avg('reserveProv') + avg('borrowings') + avg('fds') | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">{{ avg('cashOnHand') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ avg('investments') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ avg('otherAssets') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ avg('cashOnHand') + avg('investments') + avg('otherAssets') | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- ════════════ AVERAGE LIABILITIES AND ASSETS ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'avgbal'">
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Average Liabilities and Assets Month-wise during the year</h3>
|
|
||||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportAvgBalancesXls()">
|
|
||||||
<fa-icon icon="file-excel"></fa-icon> Export Excel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Printed-statement banner: institution and office come off the session, not the report. -->
|
|
||||||
<div class="alm-stmt">
|
|
||||||
<div class="alm-stmt-bank">{{ statementHeading }}</div>
|
|
||||||
<div class="alm-stmt-title">Average Liabilities and Assets Month-wise during the year</div>
|
|
||||||
|
|
||||||
<div class="alm-tbl-wrap">
|
|
||||||
<table class="alm-tbl alm-stmt-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowspan="2">Month</th>
|
|
||||||
<th rowspan="2" class="ta-r">Capital</th>
|
|
||||||
<th rowspan="2" class="ta-r">Reserve/Prov<br />Exclude OIR</th>
|
|
||||||
<th rowspan="2" class="ta-r">Deposits</th>
|
|
||||||
<th rowspan="2" class="ta-r">Borrowings</th>
|
|
||||||
<th rowspan="2" class="ta-r">Other Liabilities</th>
|
|
||||||
<th rowspan="2" class="ta-r">Cash on Hand</th>
|
|
||||||
<th colspan="2" class="ta-c">Bank Balances</th>
|
|
||||||
<th rowspan="2" class="ta-r">Investments<br />including</th>
|
|
||||||
<th rowspan="2" class="ta-r">Advances</th>
|
|
||||||
<th rowspan="2" class="ta-r">Other Assets<br />Exclude IRA</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="ta-r">Current</th>
|
|
||||||
<th class="ta-r">FDs</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let r of avgBalanceRows">
|
|
||||||
<td class="mono">
|
|
||||||
<strong>{{ r.label }}</strong
|
|
||||||
><br /><span class="alm-stmt-sub">{{ r.sub }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr class="row-total" *ngFor="let r of [avgBalanceTotal, avgBalanceAverage]">
|
|
||||||
<td>{{ r.label }}</td>
|
|
||||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alm-stmt-foot">
|
|
||||||
<div>Print Date : {{ statementPrintedAt }}</div>
|
|
||||||
<div>Report Ref No : {{ bank.reportRefNo }} User Code:{{ statementUserCode }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- ════════════ RETURNS / FORM ALM-1 ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'returns'">
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">{{ bank.formCode }} — Statement of Structural Liquidity</h3>
|
|
||||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportFormAlm()">
|
|
||||||
<fa-icon icon="file-excel"></fa-icon> Generate {{ bank.formCode }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-iii">
|
|
||||||
<div class="form-iii-h">
|
|
||||||
<div class="form-iii-bank">
|
|
||||||
<strong>{{ bank.name }}</strong> — {{ bank.branch }}
|
|
||||||
</div>
|
|
||||||
<div class="form-iii-meta">
|
|
||||||
<span><fa-icon icon="calendar"></fa-icon> As On {{ bank.asOnDate }}</span>
|
|
||||||
<span><fa-icon icon="university"></fa-icon> {{ bank.quarter }}</span>
|
|
||||||
<span class="mono"><fa-icon icon="hashtag"></fa-icon> Ref: {{ bank.reportRefNo }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 class="form-iii-t">A. NDTL & Reference</h4>
|
|
||||||
<table class="alm-tbl form-tbl">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Net Demand & Time Liabilities (NDTL) (₹ Lakhs)</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ bank.ndtl | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>RBI Master Direction</td>
|
|
||||||
<td class="ta-r mono">{{ bank.rbiCircular }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h4 class="form-iii-t">B. Maturity Ladder (₹ in Lakhs)</h4>
|
|
||||||
<table class="alm-tbl form-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bucket</th>
|
|
||||||
<th>Time Period</th>
|
|
||||||
<th class="ta-r">Outflows</th>
|
|
||||||
<th class="ta-r">Inflows</th>
|
|
||||||
<th class="ta-r">Mismatch</th>
|
|
||||||
<th class="ta-r">Cum. Mismatch</th>
|
|
||||||
<th class="ta-r">Cum. % of Outflow</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let b of buckets; let i = index">
|
|
||||||
<td class="mono">{{ b.code }}</td>
|
|
||||||
<td>{{ b.label }}</td>
|
|
||||||
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'"
|
|
||||||
>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="form-total">
|
|
||||||
<td colspan="2"><strong>TOTAL</strong></td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td colspan="2"></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h4 class="form-iii-t">C. RBI Tolerance Compliance</h4>
|
|
||||||
<table class="alm-tbl form-tbl">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Cumulative Negative Gap up to 14 days</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">Limit: -{{ bank.tolerance14d }}%</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<span class="status-pill" [ngClass]="gap14dWithinTolerance ? 'on-target' : 'below'">
|
|
||||||
{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Cumulative Negative Gap up to 30 days</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">Limit: -{{ bank.tolerance28d }}%</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<span class="status-pill" [ngClass]="gap28dWithinTolerance ? 'on-target' : 'below'">
|
|
||||||
{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="form-iii-foot">
|
|
||||||
<div class="cert">
|
|
||||||
<div class="cert-l">Certified by</div>
|
|
||||||
<div class="cert-v">{{ bank.principalOfficer }}</div>
|
|
||||||
<div class="cert-r">Principal Officer — {{ bank.name }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert">
|
|
||||||
<div class="cert-l">Submitted under</div>
|
|
||||||
<div class="cert-v mono">{{ bank.rbiCircular }}</div>
|
|
||||||
<div class="cert-r">RBI Master Direction on Asset Liability Management</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, inject } from '@angular/core';
|
import { Component, ElementRef, ViewChild, inject } from '@angular/core';
|
||||||
import { CommonModule, DecimalPipe } from '@angular/common';
|
import { CommonModule, DecimalPipe } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
@@ -16,42 +16,34 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { BranchScopeService } from 'app/core/branch-scope.service';
|
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||||
|
|
||||||
interface MaturityBucket {
|
/**
|
||||||
code: string;
|
* One month of the average liabilities and assets extract, column for column as the core system
|
||||||
label: string;
|
* prints it.
|
||||||
/** Outflows / Liabilities (₹ Lakhs) */
|
*/
|
||||||
outflow: number;
|
interface MonthlyAlmRow {
|
||||||
/** Inflows / Assets (₹ Lakhs) */
|
/** e.g. 'APR-2025' */
|
||||||
inflow: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonthSnapshot {
|
|
||||||
month: string;
|
month: string;
|
||||||
reserveProv: number;
|
|
||||||
borrowings: number;
|
|
||||||
fds: number;
|
|
||||||
cashOnHand: number;
|
|
||||||
investments: number;
|
|
||||||
otherAssets: number;
|
|
||||||
// ── Columns the month-wise average statement adds to the snapshot above ──
|
|
||||||
// The statement is a full balance sheet averaged over each month, so it needs the own-funds
|
|
||||||
// and deposit sides the liquidity tabs do not use, and it splits bank balances from cash.
|
|
||||||
/** Paid-up share capital. */
|
/** Paid-up share capital. */
|
||||||
capital: number;
|
capital: number;
|
||||||
/** Total deposits — the FD component above is part of this figure. */
|
reserveProv: number;
|
||||||
|
/** Total deposits — demand and time. */
|
||||||
deposits: number;
|
deposits: number;
|
||||||
|
borrowings: number;
|
||||||
otherLiabilities: number;
|
otherLiabilities: number;
|
||||||
|
cashOnHand: number;
|
||||||
/** Balances with other banks, current accounts. */
|
/** Balances with other banks, current accounts. */
|
||||||
bankCurrent: number;
|
bankCurrent: number;
|
||||||
/** Balances with other banks, held as fixed deposits. */
|
/** Balances with other banks, held as fixed deposits. */
|
||||||
bankFds: number;
|
bankFds: number;
|
||||||
|
investments: number;
|
||||||
advances: number;
|
advances: number;
|
||||||
|
otherAssets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of the month-wise average statement — a month, or the Total / Average line. */
|
/** One printed line — a month, or the Total / Average line the statement closes with. */
|
||||||
interface AvgBalanceRow {
|
interface StatementRow {
|
||||||
label: string;
|
label: string;
|
||||||
/** Sub-label under the month, e.g. the year, as the printed statement prints it. */
|
/** Sub-label under the month, the year, as the printed statement sets it. */
|
||||||
sub: string;
|
sub: string;
|
||||||
capital: number;
|
capital: number;
|
||||||
reserveProv: number;
|
reserveProv: number;
|
||||||
@@ -66,6 +58,18 @@ interface AvgBalanceRow {
|
|||||||
otherAssets: number;
|
otherAssets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Average Liabilities and Assets Month-wise during the year.
|
||||||
|
*
|
||||||
|
* One report, one statement, the way the core system prints it — a parameter line, the statement,
|
||||||
|
* and the print footer. It is deliberately not a dashboard: an ALM screen in a bank is a report
|
||||||
|
* you run and print, not a set of tabs, and figures that go out over the bank's name have to look
|
||||||
|
* the same on screen as they do on paper.
|
||||||
|
*
|
||||||
|
* Month rows are the figures as extracted. Investments, Advances, Bank-FD balances and Other
|
||||||
|
* Assets are not mapped in the extract and print as 0.00 — stated on screen rather than filled in,
|
||||||
|
* because a nil that is really "not captured" must not read as a real balance.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-alm-report-dialog',
|
selector: 'mifosx-alm-report-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -86,310 +90,226 @@ export class AlmReportDialogComponent {
|
|||||||
private branchScope = inject(BranchScopeService);
|
private branchScope = inject(BranchScopeService);
|
||||||
private auth = inject(AuthenticationService);
|
private auth = inject(AuthenticationService);
|
||||||
|
|
||||||
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns' = 'dashboard';
|
/** The rendered statement, handed to the print window as-is. */
|
||||||
|
@ViewChild('statement') private statementRef?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
bank = {
|
readonly bank = {
|
||||||
name: 'CBSNexora Bank Ltd',
|
name: 'CBSNexora Bank Ltd',
|
||||||
branch: 'Head Office',
|
financialYear: '2025-26',
|
||||||
asOnDate: '31-03-2026',
|
|
||||||
quarter: 'Q4 FY 2025-26',
|
|
||||||
fiscalYear: '2025-26',
|
|
||||||
rbiCircular: 'DCBR.BPD.(PCB).Cir.No.4/12.05.001/2018-19',
|
|
||||||
principalOfficer: 'Karthik Kamath',
|
|
||||||
formCode: 'Form ALM-1',
|
|
||||||
reportRefNo: '1036',
|
reportRefNo: '1036',
|
||||||
userCode: 'CBSN01',
|
userCode: 'CBSN01'
|
||||||
/** Net Demand & Time Liabilities — denominator for ratios */
|
|
||||||
ndtl: 740000, // ₹7,400 Cr in Lakhs
|
|
||||||
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
|
|
||||||
tolerance14d: 5,
|
|
||||||
tolerance28d: 10
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Standard RBI ALM time buckets per Master Direction */
|
/**
|
||||||
buckets: MaturityBucket[] = [
|
* The financial year, April to March. A part-year print simply stops at the month asked for, which
|
||||||
{ code: 'B01', label: 'Day 1', outflow: 1840, inflow: 2120 },
|
* is why `upToIndex` exists rather than the row list being cut to size here.
|
||||||
{ code: 'B02', label: '2 to 7 days', outflow: 6420, inflow: 5880 },
|
*/
|
||||||
{ code: 'B03', label: '8 to 14 days', outflow: 4180, inflow: 4360 },
|
monthly: MonthlyAlmRow[] = [
|
||||||
{ code: 'B04', label: '15 to 30 days', outflow: 8240, inflow: 7920 },
|
|
||||||
{ code: 'B05', label: '31 days to 2 months', outflow: 12480, inflow: 13160 },
|
|
||||||
{ code: 'B06', label: '> 2 months to 3 months', outflow: 9620, inflow: 10240 },
|
|
||||||
{ code: 'B07', label: '> 3 months to 6 months', outflow: 28640, inflow: 32180 },
|
|
||||||
{ code: 'B08', label: '> 6 months to 1 year', outflow: 64280, inflow: 68420 },
|
|
||||||
{ code: 'B09', label: '> 1 year to 3 years', outflow: 142800, inflow: 156400 },
|
|
||||||
{ code: 'B10', label: '> 3 years to 5 years', outflow: 96400, inflow: 108200 },
|
|
||||||
{ code: 'B11', label: 'Over 5 years', outflow: 124800, inflow: 138400 }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Monthly snapshot — mirrors the format of the legacy ALM Excel */
|
|
||||||
monthly: MonthSnapshot[] = [
|
|
||||||
{
|
{
|
||||||
month: 'APR-2025',
|
month: 'APR-2025',
|
||||||
reserveProv: 18420,
|
capital: 264.66,
|
||||||
borrowings: 24180,
|
reserveProv: 720.83,
|
||||||
fds: 462800,
|
deposits: 6108.63,
|
||||||
cashOnHand: 8420,
|
borrowings: 24.47,
|
||||||
investments: 184600,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 12480,
|
cashOnHand: 172.44,
|
||||||
capital: 24200,
|
bankCurrent: 190.09,
|
||||||
deposits: 602800,
|
bankFds: 0,
|
||||||
otherLiabilities: 28400,
|
investments: 0,
|
||||||
bankCurrent: 18900,
|
advances: 0,
|
||||||
bankFds: 46200,
|
otherAssets: 0
|
||||||
advances: 372400
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'MAY-2025',
|
month: 'MAY-2025',
|
||||||
reserveProv: 18640,
|
capital: 270.73,
|
||||||
borrowings: 23980,
|
reserveProv: 722.11,
|
||||||
fds: 468200,
|
deposits: 6181.15,
|
||||||
cashOnHand: 8680,
|
borrowings: 26.36,
|
||||||
investments: 186400,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 12620,
|
cashOnHand: 129.0,
|
||||||
capital: 24280,
|
bankCurrent: 218.27,
|
||||||
deposits: 608600,
|
bankFds: 0,
|
||||||
otherLiabilities: 28560,
|
investments: 0,
|
||||||
bankCurrent: 19240,
|
advances: 0,
|
||||||
bankFds: 46600,
|
otherAssets: 0
|
||||||
advances: 376800
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'JUN-2025',
|
month: 'JUN-2025',
|
||||||
reserveProv: 18820,
|
capital: 272.61,
|
||||||
borrowings: 23640,
|
reserveProv: 722.11,
|
||||||
fds: 472400,
|
deposits: 6291.01,
|
||||||
cashOnHand: 9120,
|
borrowings: 33.0,
|
||||||
investments: 188200,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 12840,
|
cashOnHand: 115.01,
|
||||||
capital: 24360,
|
bankCurrent: 322.77,
|
||||||
deposits: 613400,
|
bankFds: 0,
|
||||||
otherLiabilities: 28720,
|
investments: 0,
|
||||||
bankCurrent: 19580,
|
advances: 0,
|
||||||
bankFds: 47000,
|
otherAssets: 0
|
||||||
advances: 381200
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'JUL-2025',
|
month: 'JUL-2025',
|
||||||
reserveProv: 19080,
|
capital: 274.06,
|
||||||
borrowings: 23420,
|
reserveProv: 755.72,
|
||||||
fds: 478600,
|
deposits: 6299.45,
|
||||||
cashOnHand: 9280,
|
borrowings: 50.07,
|
||||||
investments: 190400,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13020,
|
cashOnHand: 84.82,
|
||||||
capital: 24460,
|
bankCurrent: 267.13,
|
||||||
deposits: 619800,
|
bankFds: 0,
|
||||||
otherLiabilities: 28880,
|
investments: 0,
|
||||||
bankCurrent: 19860,
|
advances: 0,
|
||||||
bankFds: 47400,
|
otherAssets: 0
|
||||||
advances: 385600
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'AUG-2025',
|
month: 'AUG-2025',
|
||||||
reserveProv: 19280,
|
capital: 273.22,
|
||||||
borrowings: 23180,
|
reserveProv: 755.72,
|
||||||
fds: 482400,
|
deposits: 6404.97,
|
||||||
cashOnHand: 9420,
|
borrowings: 26.53,
|
||||||
investments: 192800,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13280,
|
cashOnHand: 111.77,
|
||||||
capital: 24540,
|
bankCurrent: 253.24,
|
||||||
deposits: 624200,
|
bankFds: 0,
|
||||||
otherLiabilities: 29040,
|
investments: 0,
|
||||||
bankCurrent: 20180,
|
advances: 0,
|
||||||
bankFds: 47800,
|
otherAssets: 0
|
||||||
advances: 390200
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'SEP-2025',
|
month: 'SEP-2025',
|
||||||
reserveProv: 19460,
|
capital: 275.34,
|
||||||
borrowings: 22940,
|
reserveProv: 755.72,
|
||||||
fds: 488200,
|
deposits: 6361.83,
|
||||||
cashOnHand: 9640,
|
borrowings: 30.3,
|
||||||
investments: 195200,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13420,
|
cashOnHand: 136.45,
|
||||||
capital: 24620,
|
bankCurrent: 190.83,
|
||||||
deposits: 630400,
|
bankFds: 0,
|
||||||
otherLiabilities: 29200,
|
investments: 0,
|
||||||
bankCurrent: 20460,
|
advances: 0,
|
||||||
bankFds: 48200,
|
otherAssets: 0
|
||||||
advances: 394800
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'OCT-2025',
|
month: 'OCT-2025',
|
||||||
reserveProv: 19680,
|
capital: 274.82,
|
||||||
borrowings: 22680,
|
reserveProv: 755.72,
|
||||||
fds: 492600,
|
deposits: 6304.25,
|
||||||
cashOnHand: 9820,
|
borrowings: 38.0,
|
||||||
investments: 197600,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13620,
|
cashOnHand: 93.77,
|
||||||
capital: 24720,
|
bankCurrent: 239.01,
|
||||||
deposits: 635200,
|
bankFds: 0,
|
||||||
otherLiabilities: 29380,
|
investments: 0,
|
||||||
bankCurrent: 20740,
|
advances: 0,
|
||||||
bankFds: 48600,
|
otherAssets: 0
|
||||||
advances: 399400
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'NOV-2025',
|
month: 'NOV-2025',
|
||||||
reserveProv: 19880,
|
capital: 278.29,
|
||||||
borrowings: 22420,
|
reserveProv: 755.72,
|
||||||
fds: 498400,
|
deposits: 6269.1,
|
||||||
cashOnHand: 9980,
|
borrowings: 52.64,
|
||||||
investments: 200200,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13820,
|
cashOnHand: 92.03,
|
||||||
capital: 24800,
|
bankCurrent: 215.31,
|
||||||
deposits: 641000,
|
bankFds: 0,
|
||||||
otherLiabilities: 29540,
|
investments: 0,
|
||||||
bankCurrent: 21020,
|
advances: 0,
|
||||||
bankFds: 49000,
|
otherAssets: 0
|
||||||
advances: 404200
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'DEC-2025',
|
month: 'DEC-2025',
|
||||||
reserveProv: 20120,
|
capital: 278.97,
|
||||||
borrowings: 22180,
|
reserveProv: 755.72,
|
||||||
fds: 504200,
|
deposits: 6310.57,
|
||||||
cashOnHand: 10240,
|
borrowings: 50.01,
|
||||||
investments: 202800,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 14040,
|
cashOnHand: 98.58,
|
||||||
capital: 24900,
|
bankCurrent: 221.93,
|
||||||
deposits: 646800,
|
bankFds: 0,
|
||||||
otherLiabilities: 29720,
|
investments: 0,
|
||||||
bankCurrent: 21340,
|
advances: 0,
|
||||||
bankFds: 49400,
|
otherAssets: 0
|
||||||
advances: 409000
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'JAN-2026',
|
month: 'JAN-2026',
|
||||||
reserveProv: 20340,
|
capital: 280.14,
|
||||||
borrowings: 21940,
|
reserveProv: 755.72,
|
||||||
fds: 508800,
|
deposits: 6358.2,
|
||||||
cashOnHand: 10420,
|
borrowings: 47.85,
|
||||||
investments: 205200,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 14240,
|
cashOnHand: 101.36,
|
||||||
capital: 24980,
|
bankCurrent: 228.47,
|
||||||
deposits: 651600,
|
bankFds: 0,
|
||||||
otherLiabilities: 29880,
|
investments: 0,
|
||||||
bankCurrent: 21620,
|
advances: 0,
|
||||||
bankFds: 49800,
|
otherAssets: 0
|
||||||
advances: 413800
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'FEB-2026',
|
month: 'FEB-2026',
|
||||||
reserveProv: 20580,
|
capital: 281.42,
|
||||||
borrowings: 21680,
|
reserveProv: 768.4,
|
||||||
fds: 514600,
|
deposits: 6402.75,
|
||||||
cashOnHand: 10620,
|
borrowings: 44.62,
|
||||||
investments: 207800,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 14460,
|
cashOnHand: 104.19,
|
||||||
capital: 25060,
|
bankCurrent: 233.08,
|
||||||
deposits: 657400,
|
bankFds: 0,
|
||||||
otherLiabilities: 30040,
|
investments: 0,
|
||||||
bankCurrent: 21900,
|
advances: 0,
|
||||||
bankFds: 50200,
|
otherAssets: 0
|
||||||
advances: 418600
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'MAR-2026',
|
month: 'MAR-2026',
|
||||||
reserveProv: 20840,
|
capital: 283.05,
|
||||||
borrowings: 21420,
|
reserveProv: 768.4,
|
||||||
fds: 520400,
|
deposits: 6471.38,
|
||||||
cashOnHand: 10820,
|
borrowings: 41.3,
|
||||||
investments: 210400,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 14680,
|
cashOnHand: 110.62,
|
||||||
capital: 25160,
|
bankCurrent: 241.66,
|
||||||
deposits: 663200,
|
bankFds: 0,
|
||||||
otherLiabilities: 30220,
|
investments: 0,
|
||||||
bankCurrent: 22240,
|
advances: 0,
|
||||||
bankFds: 50600,
|
otherAssets: 0
|
||||||
advances: 423600
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
filterBucket = '';
|
/**
|
||||||
|
* Last month included in the print. Defaults to the ninth month, DEC-2025, because that is where a
|
||||||
|
* statement taken part-way through the year stops — the months after it have not closed.
|
||||||
|
*/
|
||||||
|
upToIndex = 8;
|
||||||
|
|
||||||
// -------------------- Computed --------------------
|
/** Columns the core-system extract does not map. Named on screen, not quietly filled in. */
|
||||||
bucketGap(b: MaturityBucket): number {
|
readonly unmappedColumns = 'Investments, Advances, Bank-FD balances and Other Assets';
|
||||||
return b.inflow - b.outflow;
|
|
||||||
}
|
|
||||||
bucketGapPct(b: MaturityBucket): number {
|
|
||||||
return b.outflow === 0 ? 0 : +(((b.inflow - b.outflow) / b.outflow) * 100).toFixed(2);
|
|
||||||
}
|
|
||||||
cumulativeOutflow(idx: number): number {
|
|
||||||
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.outflow, 0);
|
|
||||||
}
|
|
||||||
cumulativeInflow(idx: number): number {
|
|
||||||
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.inflow, 0);
|
|
||||||
}
|
|
||||||
cumulativeGap(idx: number): number {
|
|
||||||
return this.cumulativeInflow(idx) - this.cumulativeOutflow(idx);
|
|
||||||
}
|
|
||||||
cumulativeGapPct(idx: number): number {
|
|
||||||
const out = this.cumulativeOutflow(idx);
|
|
||||||
return out === 0 ? 0 : +((this.cumulativeGap(idx) / out) * 100).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalOutflow(): number {
|
/** Stamped when the statement is run, and again whenever the parameters change it. */
|
||||||
return this.buckets.reduce((s, b) => s + b.outflow, 0);
|
generatedAt = this.formatPrintDate(new Date());
|
||||||
}
|
|
||||||
get totalInflow(): number {
|
|
||||||
return this.buckets.reduce((s, b) => s + b.inflow, 0);
|
|
||||||
}
|
|
||||||
get totalGap(): number {
|
|
||||||
return this.totalInflow - this.totalOutflow;
|
|
||||||
}
|
|
||||||
get totalGapPct(): number {
|
|
||||||
return this.totalOutflow === 0 ? 0 : +((this.totalGap / this.totalOutflow) * 100).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RBI tolerance test: cumulative negative gap up to bucket 3 (14 days) ≤ 5% of cumulative outflow */
|
|
||||||
get gap14dPct(): number {
|
|
||||||
return this.cumulativeGapPct(2);
|
|
||||||
}
|
|
||||||
/** Up to bucket 4 (30 days) ≤ 10% */
|
|
||||||
get gap28dPct(): number {
|
|
||||||
return this.cumulativeGapPct(3);
|
|
||||||
}
|
|
||||||
get gap14dWithinTolerance(): boolean {
|
|
||||||
return this.gap14dPct >= -this.bank.tolerance14d;
|
|
||||||
}
|
|
||||||
get gap28dWithinTolerance(): boolean {
|
|
||||||
return this.gap28dPct >= -this.bank.tolerance28d;
|
|
||||||
}
|
|
||||||
|
|
||||||
bucketStatus(b: MaturityBucket): 'surplus' | 'deficit' | 'balanced' {
|
|
||||||
const gap = this.bucketGap(b);
|
|
||||||
if (gap > 0) return 'surplus';
|
|
||||||
if (gap < 0) return 'deficit';
|
|
||||||
return 'balanced';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Average Liabilities and Assets, month-wise ────────────────────────────
|
|
||||||
//
|
|
||||||
// The printed statement carries the institution and office it was taken for, and who took it.
|
|
||||||
// Those come off the session rather than being typed into the report, so a branch login prints
|
|
||||||
// its own office and its own user code instead of whatever the last person hard-coded.
|
|
||||||
|
|
||||||
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
|
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
|
||||||
get statementHeading(): string {
|
get statementHeading(): string {
|
||||||
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
|
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Office the statement was taken for, off the session rather than typed into the report. */
|
||||||
|
get branchName(): string {
|
||||||
|
return this.branchScope.officeName;
|
||||||
|
}
|
||||||
|
|
||||||
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
|
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
|
||||||
get statementUserCode(): string {
|
get statementUserCode(): string {
|
||||||
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
|
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stamped when the tab is opened, so the footer records when the figures were taken off. */
|
/** Month choices for the "up to" parameter, e.g. 'DEC-2025'. */
|
||||||
readonly statementPrintedAt = this.formatPrintDate(new Date());
|
get monthOptions(): { index: number; label: string }[] {
|
||||||
|
return this.monthly.map((m, index) => ({ index, label: m.month }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** The months the statement prints. */
|
||||||
* Month rows, then the Total and Average lines the statement closes with.
|
get statementRows(): StatementRow[] {
|
||||||
*
|
return this.monthly.slice(0, this.upToIndex + 1).map((m) => {
|
||||||
* Average is the mean of the months shown, not Total ÷ 12 — a statement printed part-way
|
|
||||||
* through the year would otherwise divide by months that have not happened yet.
|
|
||||||
*/
|
|
||||||
get avgBalanceRows(): AvgBalanceRow[] {
|
|
||||||
return this.monthly.map((m) => {
|
|
||||||
const [
|
const [
|
||||||
name,
|
name,
|
||||||
year
|
year
|
||||||
@@ -412,7 +332,7 @@ export class AlmReportDialogComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
|
private readonly amountColumns: (keyof StatementRow)[] = [
|
||||||
'capital',
|
'capital',
|
||||||
'reserveProv',
|
'reserveProv',
|
||||||
'deposits',
|
'deposits',
|
||||||
@@ -426,190 +346,92 @@ export class AlmReportDialogComponent {
|
|||||||
'otherAssets'
|
'otherAssets'
|
||||||
];
|
];
|
||||||
|
|
||||||
get avgBalanceTotal(): AvgBalanceRow {
|
get totalRow(): StatementRow {
|
||||||
const rows = this.avgBalanceRows;
|
const rows = this.statementRows;
|
||||||
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
|
const out = { label: 'Total', sub: '' } as StatementRow;
|
||||||
for (const key of this.avgBalanceColumns) {
|
for (const key of this.amountColumns) {
|
||||||
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
|
out[key] = +rows.reduce((s, r) => s + (r[key] as number), 0).toFixed(2) as never;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
get avgBalanceAverage(): AvgBalanceRow {
|
/**
|
||||||
const rows = this.avgBalanceRows;
|
* Mean of the months printed, not Total ÷ 12 — a statement that stops at December must divide by
|
||||||
const total = this.avgBalanceTotal;
|
* nine, or every average on it is understated.
|
||||||
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
|
*/
|
||||||
for (const key of this.avgBalanceColumns) {
|
get averageRow(): StatementRow {
|
||||||
|
const rows = this.statementRows;
|
||||||
|
const total = this.totalRow;
|
||||||
|
const out = { label: 'Average', sub: '' } as StatementRow;
|
||||||
|
for (const key of this.amountColumns) {
|
||||||
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
|
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
/** Re-running the statement re-stamps the footer, since the figures on it have changed. */
|
||||||
private formatPrintDate(d: Date): string {
|
runReport(): void {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
this.generatedAt = this.formatPrintDate(new Date());
|
||||||
const hours24 = d.getHours();
|
|
||||||
const meridiem = hours24 < 12 ? 'AM' : 'PM';
|
|
||||||
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
|
||||||
return (
|
|
||||||
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
|
||||||
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Monthly totals helpers */
|
/**
|
||||||
monthTotalLiab(m: MonthSnapshot): number {
|
* Prints the statement on its own.
|
||||||
return m.reserveProv + m.borrowings + m.fds;
|
*
|
||||||
}
|
* The dialog sits in an overlay on top of the application, so `window.print()` would put the whole
|
||||||
monthTotalAssets(m: MonthSnapshot): number {
|
* screen behind it on the paper. The rendered statement is handed to a window of its own instead,
|
||||||
return m.cashOnHand + m.investments + m.otherAssets;
|
* which needs no application-wide print stylesheet and cannot pick up anything else on the page.
|
||||||
}
|
*/
|
||||||
/** Average across months */
|
print(): void {
|
||||||
avg<K extends keyof MonthSnapshot>(key: K): number {
|
const node = this.statementRef?.nativeElement;
|
||||||
if (typeof this.monthly[0][key] !== 'number') return 0;
|
if (!node) {
|
||||||
const total = this.monthly.reduce((s, m) => s + (m[key] as number), 0);
|
return;
|
||||||
return +(total / this.monthly.length).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- Filter --------------------
|
|
||||||
get filteredBuckets(): MaturityBucket[] {
|
|
||||||
if (!this.filterBucket) return this.buckets;
|
|
||||||
if (this.filterBucket === 'short') return this.buckets.slice(0, 4);
|
|
||||||
if (this.filterBucket === 'medium') return this.buckets.slice(4, 8);
|
|
||||||
if (this.filterBucket === 'long') return this.buckets.slice(8);
|
|
||||||
return this.buckets;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- Excel Exports --------------------
|
|
||||||
|
|
||||||
/** Form ALM-1 — Statement of Structural Liquidity */
|
|
||||||
exportFormAlm(): void {
|
|
||||||
const rows: string[][] = [];
|
|
||||||
rows.push([
|
|
||||||
this.bank.name,
|
|
||||||
this.bank.branch
|
|
||||||
]);
|
|
||||||
rows.push([
|
|
||||||
`STATEMENT OF STRUCTURAL LIQUIDITY (${this.bank.formCode})`,
|
|
||||||
`As On ${this.bank.asOnDate}`
|
|
||||||
]);
|
|
||||||
rows.push([
|
|
||||||
`RBI Circular: ${this.bank.rbiCircular}`,
|
|
||||||
`Quarter: ${this.bank.quarter}`
|
|
||||||
]);
|
|
||||||
rows.push([
|
|
||||||
`Report Ref: ${this.bank.reportRefNo}`,
|
|
||||||
`User Code: ${this.bank.userCode}`
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push([
|
|
||||||
'NDTL (₹ Lakhs)',
|
|
||||||
this.bank.ndtl.toString()
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push(['Maturity Ladder (₹ in Lakhs)']);
|
|
||||||
rows.push([
|
|
||||||
'Bucket',
|
|
||||||
'Time Period',
|
|
||||||
'Outflows (Liabilities)',
|
|
||||||
'Inflows (Assets)',
|
|
||||||
'Mismatch (Gap)',
|
|
||||||
'Cum. Outflows',
|
|
||||||
'Cum. Inflows',
|
|
||||||
'Cum. Gap',
|
|
||||||
'Cum. Gap % of Outflow'
|
|
||||||
]);
|
|
||||||
this.buckets.forEach((b, i) => {
|
|
||||||
rows.push([
|
|
||||||
b.code,
|
|
||||||
b.label,
|
|
||||||
b.outflow.toFixed(2),
|
|
||||||
b.inflow.toFixed(2),
|
|
||||||
this.bucketGap(b).toFixed(2),
|
|
||||||
this.cumulativeOutflow(i).toFixed(2),
|
|
||||||
this.cumulativeInflow(i).toFixed(2),
|
|
||||||
this.cumulativeGap(i).toFixed(2),
|
|
||||||
this.cumulativeGapPct(i).toFixed(2) + '%'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
rows.push([]);
|
|
||||||
rows.push([
|
|
||||||
'TOTAL',
|
|
||||||
'',
|
|
||||||
this.totalOutflow.toFixed(2),
|
|
||||||
this.totalInflow.toFixed(2),
|
|
||||||
this.totalGap.toFixed(2)
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push(['RBI Tolerance Compliance']);
|
|
||||||
rows.push([
|
|
||||||
'Cumulative Gap up to 14 days',
|
|
||||||
this.gap14dPct.toFixed(2) + '%',
|
|
||||||
`Limit: -${this.bank.tolerance14d}%`,
|
|
||||||
this.gap14dWithinTolerance ? 'WITHIN' : 'BREACH'
|
|
||||||
]);
|
|
||||||
rows.push([
|
|
||||||
'Cumulative Gap up to 30 days',
|
|
||||||
this.gap28dPct.toFixed(2) + '%',
|
|
||||||
`Limit: -${this.bank.tolerance28d}%`,
|
|
||||||
this.gap28dWithinTolerance ? 'WITHIN' : 'BREACH'
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push([
|
|
||||||
'Certified by',
|
|
||||||
this.bank.principalOfficer + ' — Principal Officer'
|
|
||||||
]);
|
|
||||||
this.saveTsv(rows, `ALM-StructuralLiquidity-${this.dateTag()}.xls`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Monthly snapshot export */
|
|
||||||
exportMonthlyXls(): void {
|
|
||||||
const rows: string[][] = [];
|
|
||||||
rows.push([
|
|
||||||
`${this.bank.name} — ${this.bank.branch}`,
|
|
||||||
`Monthly ALM Snapshot — FY ${this.bank.fiscalYear}`
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push([
|
|
||||||
'Month',
|
|
||||||
'Reserve/Prov (Excl. OIR)',
|
|
||||||
'Borrowings',
|
|
||||||
'FDs',
|
|
||||||
'Total Liabilities',
|
|
||||||
'Cash on Hand',
|
|
||||||
'Investments incl. Bal/Repo',
|
|
||||||
'Other Assets (Excl. IRA)',
|
|
||||||
'Total Assets'
|
|
||||||
]);
|
|
||||||
for (const m of this.monthly) {
|
|
||||||
rows.push([
|
|
||||||
m.month,
|
|
||||||
m.reserveProv.toFixed(2),
|
|
||||||
m.borrowings.toFixed(2),
|
|
||||||
m.fds.toFixed(2),
|
|
||||||
this.monthTotalLiab(m).toFixed(2),
|
|
||||||
m.cashOnHand.toFixed(2),
|
|
||||||
m.investments.toFixed(2),
|
|
||||||
m.otherAssets.toFixed(2),
|
|
||||||
this.monthTotalAssets(m).toFixed(2)
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
rows.push([
|
const win = window.open('', '_blank', 'width=1100,height=760');
|
||||||
'AVERAGE',
|
if (!win) {
|
||||||
this.avg('reserveProv').toFixed(2),
|
// Blocked by the browser — fall back to the page print, which the print stylesheet handles.
|
||||||
this.avg('borrowings').toFixed(2),
|
window.print();
|
||||||
this.avg('fds').toFixed(2),
|
return;
|
||||||
(this.avg('reserveProv') + this.avg('borrowings') + this.avg('fds')).toFixed(2),
|
}
|
||||||
this.avg('cashOnHand').toFixed(2),
|
win.document.write(
|
||||||
this.avg('investments').toFixed(2),
|
`<!doctype html><html><head><title>Average Liabilities and Assets — ${this.bank.financialYear}</title>` +
|
||||||
this.avg('otherAssets').toFixed(2),
|
`<style>${AlmReportDialogComponent.PRINT_CSS}</style></head><body>${node.outerHTML}</body></html>`
|
||||||
(this.avg('cashOnHand') + this.avg('investments') + this.avg('otherAssets')).toFixed(2)
|
);
|
||||||
]);
|
win.document.close();
|
||||||
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
|
win.focus();
|
||||||
|
// Let the document lay out before the print dialog measures it.
|
||||||
|
win.setTimeout(() => {
|
||||||
|
win.print();
|
||||||
|
win.close();
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Average Liabilities and Assets — the printed statement, column for column. */
|
/** Stylesheet for the print window — the statement as the core system lays it out on paper. */
|
||||||
exportAvgBalancesXls(): void {
|
private static readonly PRINT_CSS = `
|
||||||
|
@page { size: A4 landscape; margin: 12mm; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
|
||||||
|
.stmt { padding: 0; }
|
||||||
|
.stmt-bank { font-size: 8.5pt; font-weight: 700; }
|
||||||
|
.stmt-title { margin: 2pt 0 14pt; font-size: 7.5pt; }
|
||||||
|
.stmt-tbl { width: 100%; border-collapse: collapse; font-size: 8pt; }
|
||||||
|
.stmt-tbl th { padding: 0 6pt 7pt; text-align: right; vertical-align: bottom;
|
||||||
|
font-weight: 400; font-size: 7pt; line-height: 1.3; white-space: nowrap; }
|
||||||
|
.stmt-tbl td { padding: 5pt 6pt; text-align: right; font-weight: 700; white-space: nowrap; }
|
||||||
|
.stmt-tbl .col-month { text-align: left; }
|
||||||
|
.stmt-tbl .col-grp { text-align: center; padding-bottom: 2pt; }
|
||||||
|
.stmt-tbl tr { page-break-inside: avoid; }
|
||||||
|
.stmt-sum td { padding-top: 10pt; }
|
||||||
|
.stmt-sum:first-of-type td { border-top: 0.5pt solid #000; }
|
||||||
|
.stmt-sum .col-month { font-weight: 400; }
|
||||||
|
.stmt-foot { margin-top: 24pt; font-size: 7pt; line-height: 1.8; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Excel copy of the statement, column for column and in the same order. */
|
||||||
|
exportXls(): void {
|
||||||
const rows: string[][] = [];
|
const rows: string[][] = [];
|
||||||
rows.push([this.statementHeading]);
|
rows.push([this.statementHeading]);
|
||||||
rows.push(['Average Liabilities and Assets Month-wise during the year']);
|
rows.push(['Average Liabilities and Assets Month-wise during the year']);
|
||||||
@@ -628,7 +450,7 @@ export class AlmReportDialogComponent {
|
|||||||
'Advances',
|
'Advances',
|
||||||
'Other Assets Exclude IRA'
|
'Other Assets Exclude IRA'
|
||||||
]);
|
]);
|
||||||
const line = (r: AvgBalanceRow): string[] => [
|
const line = (r: StatementRow): string[] => [
|
||||||
`${r.label} ${r.sub}`.trim(),
|
`${r.label} ${r.sub}`.trim(),
|
||||||
r.capital.toFixed(2),
|
r.capital.toFixed(2),
|
||||||
r.reserveProv.toFixed(2),
|
r.reserveProv.toFixed(2),
|
||||||
@@ -642,71 +464,38 @@ export class AlmReportDialogComponent {
|
|||||||
r.advances.toFixed(2),
|
r.advances.toFixed(2),
|
||||||
r.otherAssets.toFixed(2)
|
r.otherAssets.toFixed(2)
|
||||||
];
|
];
|
||||||
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
|
this.statementRows.forEach((r) => rows.push(line(r)));
|
||||||
rows.push(line(this.avgBalanceTotal));
|
rows.push(line(this.totalRow));
|
||||||
rows.push(line(this.avgBalanceAverage));
|
rows.push(line(this.averageRow));
|
||||||
rows.push([]);
|
rows.push([]);
|
||||||
rows.push([`Print Date : ${this.statementPrintedAt}`]);
|
rows.push([`Not mapped in this extract: ${this.unmappedColumns}`]);
|
||||||
|
rows.push([`Print Date : ${this.generatedAt}`]);
|
||||||
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
|
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
|
||||||
this.saveTsv(rows, `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`);
|
|
||||||
|
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
|
||||||
|
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maturity ladder CSV */
|
|
||||||
exportLadderCsv(): void {
|
|
||||||
const headers = [
|
|
||||||
'Bucket',
|
|
||||||
'Time Period',
|
|
||||||
'Outflow (₹ Lakh)',
|
|
||||||
'Inflow (₹ Lakh)',
|
|
||||||
'Gap',
|
|
||||||
'Gap %'
|
|
||||||
];
|
|
||||||
const rows = [headers];
|
|
||||||
for (const b of this.buckets) {
|
|
||||||
rows.push([
|
|
||||||
b.code,
|
|
||||||
b.label,
|
|
||||||
b.outflow.toString(),
|
|
||||||
b.inflow.toString(),
|
|
||||||
this.bucketGap(b).toString(),
|
|
||||||
this.bucketGapPct(b).toFixed(2) + '%'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
this.saveCsv(rows, `ALM-Ladder-${this.dateTag()}.csv`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns'): void {
|
|
||||||
this.activeTab = tab;
|
|
||||||
}
|
|
||||||
close(): void {
|
|
||||||
this.dialogRef.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers ----
|
|
||||||
private dateTag(): string {
|
private dateTag(): string {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
return `${String(d.getDate()).padStart(2, '0')}${String(d.getMonth() + 1).padStart(2, '0')}${d.getFullYear()}`;
|
return `${String(d.getDate()).padStart(2, '0')}${String(d.getMonth() + 1).padStart(2, '0')}${d.getFullYear()}`;
|
||||||
}
|
}
|
||||||
private saveTsv(rows: string[][], filename: string): void {
|
|
||||||
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
|
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
||||||
this.saveBlob(tsv, filename, 'application/vnd.ms-excel');
|
private formatPrintDate(d: Date): string {
|
||||||
}
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
private saveCsv(rows: string[][], filename: string): void {
|
const hours24 = d.getHours();
|
||||||
const escape = (v: string): string => {
|
const meridiem = hours24 < 12 ? 'AM' : 'PM';
|
||||||
const s = (v || '').replace(/"/g, '""');
|
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s}"`;
|
return (
|
||||||
return s;
|
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
||||||
};
|
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
|
||||||
const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
|
);
|
||||||
this.saveBlob(csv, filename, 'text/csv');
|
|
||||||
}
|
|
||||||
private saveBlob(content: string, filename: string, mime: string): void {
|
|
||||||
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<fa-icon icon="database" class="header-icon"></fa-icon>
|
||||||
|
<h2>Database Export</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The export carries every client's data, so the consequence is stated before the form, not after. -->
|
||||||
|
<div class="warning-banner">
|
||||||
|
<fa-icon icon="exclamation-triangle" class="warning-banner__icon"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>This export contains the complete database</strong>
|
||||||
|
— every client's personal details, KYC documents, accounts and transactions. A request must be approved by an
|
||||||
|
administrator before the archive can be downloaded, and the approver cannot be the person who raised it. The
|
||||||
|
archive can only be downloaded by the user who raised the request, not by the approver, and an approval is valid
|
||||||
|
for a single download — taking another copy means raising a new request. Every request, approval and download is
|
||||||
|
recorded.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raise a request -->
|
||||||
|
<div class="request-card" [formGroup]="requestForm">
|
||||||
|
<h3 class="request-card__title">Request a Full Export</h3>
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Reason for this export</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
formControlName="reason"
|
||||||
|
rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
placeholder="e.g. Statutory audit for FY 2025-26 requested by the RBI inspection team"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<mat-hint>Recorded against the request and written into the archive. Minimum 10 characters.</mat-hint>
|
||||||
|
<mat-error *ngIf="requestForm.get('reason')?.hasError('required')">A reason is required</mat-error>
|
||||||
|
<mat-error *ngIf="requestForm.get('reason')?.hasError('minlength')">
|
||||||
|
Please give at least 10 characters of context
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="request-card__actions">
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="requestForm.invalid || submitting"
|
||||||
|
(click)="submitRequest()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="paper-plane" class="chip-icon"></fa-icon>
|
||||||
|
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request history -->
|
||||||
|
<div class="filters-row">
|
||||||
|
<div class="filter-chips">
|
||||||
|
<button mat-stroked-button [class.active-filter]="!activeStatus" (click)="filterByStatus(null)">
|
||||||
|
<fa-icon icon="list" class="chip-icon"></fa-icon> All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="chip-pending"
|
||||||
|
[class.active-filter]="activeStatus === 'PENDING_APPROVAL'"
|
||||||
|
(click)="filterByStatus('PENDING_APPROVAL')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Approval
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="chip-approved"
|
||||||
|
[class.active-filter]="activeStatus === 'APPROVED'"
|
||||||
|
(click)="filterByStatus('APPROVED')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="check-circle" class="chip-icon"></fa-icon> Approved
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="chip-rejected"
|
||||||
|
[class.active-filter]="activeStatus === 'REJECTED'"
|
||||||
|
(click)="filterByStatus('REJECTED')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times" class="chip-icon"></fa-icon> Rejected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mat-elevation-z8">
|
||||||
|
<table mat-table [dataSource]="dataSource" matSort>
|
||||||
|
<ng-container matColumnDef="id">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Request</th>
|
||||||
|
<td mat-cell *matCellDef="let row">#{{ row.id }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<span class="result-badge" [ngClass]="statusClass(row)">{{ statusLabel(row) }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="reason">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Reason</th>
|
||||||
|
<td mat-cell *matCellDef="let row" class="reason-cell">
|
||||||
|
<span [matTooltip]="row.reason">{{ row.reason }}</span>
|
||||||
|
@if (row.rejectionReason) {
|
||||||
|
<span class="rejection-note">Rejected: {{ row.rejectionReason }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="requestedBy">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Requested By</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<div class="stacked">
|
||||||
|
<span>{{ row.requestedByUsername || '—' }}</span>
|
||||||
|
<span class="muted">{{ row.requestedOn | date: 'medium' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="approvedBy">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved By</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
@if (row.approvedByUsername) {
|
||||||
|
<div class="stacked">
|
||||||
|
<span>{{ row.approvedByUsername }}</span>
|
||||||
|
<span class="muted">{{ row.approvedOn | date: 'medium' }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="generatedOn">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Downloaded</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
@if (row.generatedOn) {
|
||||||
|
<div class="stacked">
|
||||||
|
<span>{{ row.generatedOn | date: 'medium' }}</span>
|
||||||
|
<span class="muted">{{ formatSize(row.fileSizeBytes) }} · {{ row.rowCount || 0 }} rows</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">Not yet</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Action</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<div class="row-actions">
|
||||||
|
@if (isPending(row)) {
|
||||||
|
<!-- Only shown to users holding the approval permission; the backend enforces it regardless. -->
|
||||||
|
<ng-container *mifosxHasPermission="'APPROVE_DATABASEEXPORT'">
|
||||||
|
<button mat-stroked-button color="primary" (click)="approve(row)">
|
||||||
|
<fa-icon icon="check"></fa-icon> Approve
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button color="warn" (click)="reject(row)">
|
||||||
|
<fa-icon icon="times"></fa-icon> Reject
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
@if (isDownloadable(row)) {
|
||||||
|
<button mat-flat-button color="primary" [disabled]="downloadingId === row.id" (click)="download(row)">
|
||||||
|
<fa-icon icon="download"></fa-icon>
|
||||||
|
{{ downloadingId === row.id ? 'Preparing…' : 'Download' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (isWithheld(row)) {
|
||||||
|
<span
|
||||||
|
class="muted withheld-note"
|
||||||
|
matTooltip="Approving an export authorises it. Only {{
|
||||||
|
row.requestedByUsername || 'the requester'
|
||||||
|
}} can download the archive."
|
||||||
|
>
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
Requester only
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (isSpent(row)) {
|
||||||
|
<span
|
||||||
|
class="muted withheld-note"
|
||||||
|
matTooltip="An approval is valid for a single download. Raise a new request to take another copy."
|
||||||
|
>
|
||||||
|
<fa-icon icon="check"></fa-icon>
|
||||||
|
Download used
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (!loading && dataSource.data.length === 0) {
|
||||||
|
<div class="no-data">
|
||||||
|
<fa-icon icon="folder-open" class="no-data-icon"></fa-icon>
|
||||||
|
<p>No export requests yet.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
color: #1074b9;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The consequence of this screen is stated up front, not buried in a tooltip. */
|
||||||
|
.warning-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgb(180 83 9 / 7%);
|
||||||
|
border: 1px solid rgb(180 83 9 / 30%);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #7c2d12;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: #b45309;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #f7f9fc;
|
||||||
|
border: 1px solid #e3e7ee;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.chip-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-filter {
|
||||||
|
background-color: #1074b9;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #1074b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-pending.active-filter {
|
||||||
|
background-color: #b45309;
|
||||||
|
border-color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-approved.active-filter {
|
||||||
|
background-color: #388e3c;
|
||||||
|
border-color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-rejected.active-filter {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
border-color: #d32f2f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.badge-pending {
|
||||||
|
background-color: rgb(180 83 9 / 12%);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.badge-approved {
|
||||||
|
background-color: rgb(56 142 60 / 14%);
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.badge-rejected {
|
||||||
|
background-color: rgb(211 47 47 / 12%);
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.badge-generated {
|
||||||
|
background-color: rgb(16 116 185 / 12%);
|
||||||
|
color: #1074b9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-cell {
|
||||||
|
max-width: 320px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rejection-note {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stands in for the download button on rows the viewer did not raise. */
|
||||||
|
.withheld-note {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
font-size: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
|
||||||
|
import { MatSort, MatSortModule } from '@angular/material/sort';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
|
import { NgClass, DatePipe } from '@angular/common';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||||
|
import { DatabaseExportService } from './database-export.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full database export, gated behind an approval. The requester states why the export is needed; an
|
||||||
|
* administrator with APPROVE_DATABASEEXPORT approves or rejects it, and only then can the archive be
|
||||||
|
* downloaded. The backend refuses self-approval, so the two roles are always two different people.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-database-export',
|
||||||
|
templateUrl: './database-export.component.html',
|
||||||
|
styleUrls: ['./database-export.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatTableModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatTooltip,
|
||||||
|
NgClass,
|
||||||
|
DatePipe,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class DatabaseExportComponent implements OnInit {
|
||||||
|
private service = inject(DatabaseExportService);
|
||||||
|
private formBuilder = inject(FormBuilder);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
|
|
||||||
|
/** Used to keep the download to the requester; the backend enforces the same rule regardless. */
|
||||||
|
private currentUserId: number | null = this.authenticationService.getCredentials()?.userId ?? null;
|
||||||
|
|
||||||
|
displayedColumns: string[] = [
|
||||||
|
'id',
|
||||||
|
'status',
|
||||||
|
'reason',
|
||||||
|
'requestedBy',
|
||||||
|
'approvedBy',
|
||||||
|
'generatedOn',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
|
||||||
|
dataSource = new MatTableDataSource<any>();
|
||||||
|
loading = false;
|
||||||
|
submitting = false;
|
||||||
|
downloadingId: number | null = null;
|
||||||
|
activeStatus: string | null = null;
|
||||||
|
|
||||||
|
requestForm: FormGroup = this.formBuilder.group({
|
||||||
|
reason: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(10),
|
||||||
|
Validators.maxLength(1000)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||||
|
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.service.getRequests(this.activeStatus || undefined).subscribe({
|
||||||
|
next: (rows: any) => this.applyRows(rows || []),
|
||||||
|
error: () => this.applyRows([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRows(rows: any[]) {
|
||||||
|
this.dataSource = new MatTableDataSource(rows);
|
||||||
|
this.dataSource.paginator = this.paginator;
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterByStatus(status: string | null) {
|
||||||
|
this.activeStatus = this.activeStatus === status ? null : status;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitRequest() {
|
||||||
|
if (this.requestForm.invalid) {
|
||||||
|
this.requestForm.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
this.service.requestExport(this.requestForm.value.reason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.requestForm.reset({ reason: '' });
|
||||||
|
this.snackBar.open('Export request submitted for approval', 'Close', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.snackBar.open(this.errorMessage(err, 'Failed to submit the request'), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(row: any) {
|
||||||
|
this.service.approve(row.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open(`Request #${row.id} approved`, 'Close', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Approval failed'), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(row: any) {
|
||||||
|
const reason = window.prompt(`Reject export request #${row.id}. Reason:`);
|
||||||
|
if (reason === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
this.snackBar.open('A reason is required to reject a request', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.service.reject(row.id, reason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open(`Request #${row.id} rejected`, 'Close', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Rejection failed'), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
download(row: any) {
|
||||||
|
this.downloadingId = row.id;
|
||||||
|
this.service.download(row.id).subscribe({
|
||||||
|
next: (blob: Blob) => {
|
||||||
|
this.downloadingId = null;
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = row.fileName || `cbsnexora-database-export-${row.id}.zip`;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
// The status moves to GENERATED once the archive leaves the server, so reflect that.
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: async (err: any) => {
|
||||||
|
this.downloadingId = null;
|
||||||
|
// The request asked for a Blob, so an error body arrives as a Blob too and has to be read back
|
||||||
|
// as text before the platform's message can be pulled out of it.
|
||||||
|
let message = 'Download failed';
|
||||||
|
if (err?.error instanceof Blob) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await err.error.text());
|
||||||
|
message = parsed?.defaultUserMessage || parsed?.errors?.[0]?.defaultUserMessage || message;
|
||||||
|
} catch {
|
||||||
|
// Body was not JSON; keep the generic message.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = this.errorMessage(err, message);
|
||||||
|
}
|
||||||
|
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isPending(row: any): boolean {
|
||||||
|
return row?.status === 'PENDING_APPROVAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approving an export authorises it; it does not entitle the approver to a copy. Only the user who raised
|
||||||
|
* the request — and stated the reason it is recorded against — can take the archive, and only once.
|
||||||
|
*/
|
||||||
|
isDownloadable(row: any): boolean {
|
||||||
|
return row?.status === 'APPROVED' && this.isRequester(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRequester(row: any): boolean {
|
||||||
|
return this.currentUserId != null && Number(row?.requestedBy) === Number(this.currentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown in place of the button so an approver sees the archive is withheld by design, not by a fault. */
|
||||||
|
isWithheld(row: any): boolean {
|
||||||
|
return row?.status === 'APPROVED' && !this.isRequester(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The approval has been spent; another copy means another request. */
|
||||||
|
isSpent(row: any): boolean {
|
||||||
|
return row?.status === 'GENERATED';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(row: any): string {
|
||||||
|
switch (row?.status) {
|
||||||
|
case 'PENDING_APPROVAL':
|
||||||
|
return 'Awaiting Approval';
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'Approved';
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'Rejected';
|
||||||
|
case 'GENERATED':
|
||||||
|
return 'Downloaded';
|
||||||
|
default:
|
||||||
|
return row?.status || '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(row: any): string {
|
||||||
|
switch (row?.status) {
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'badge-approved';
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'badge-rejected';
|
||||||
|
case 'GENERATED':
|
||||||
|
return 'badge-generated';
|
||||||
|
default:
|
||||||
|
return 'badge-pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatSize(bytes: number): string {
|
||||||
|
if (!bytes) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorMessage(err: any, fallback: string): string {
|
||||||
|
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** Approval-gated full export of the tenant database. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class DatabaseExportService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getRequests(status?: string): Observable<any> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (status) {
|
||||||
|
params = params.set('status', status);
|
||||||
|
}
|
||||||
|
return this.http.get('/databaseexport', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param reason why the export is needed — recorded against the request and inside the archive. */
|
||||||
|
requestExport(reason: string): Observable<any> {
|
||||||
|
return this.http.post('/databaseexport', { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(requestId: number): Observable<any> {
|
||||||
|
return this.http.post(`/databaseexport/${requestId}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(requestId: number, rejectionReason: string): Observable<any> {
|
||||||
|
return this.http.post(`/databaseexport/${requestId}/reject`, { rejectionReason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The archive is binary, so it bypasses the JSON interceptors and comes back as a Blob. */
|
||||||
|
download(requestId: number): Observable<Blob> {
|
||||||
|
return this.http.get(`/databaseexport/${requestId}/download`, {
|
||||||
|
responseType: 'blob' as 'json'
|
||||||
|
}) as Observable<Blob>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { Form60Component } from './certificates/form-60/form-60.component';
|
|||||||
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
|
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
|
||||||
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
|
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
|
||||||
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
||||||
|
import { DatabaseExportComponent } from './database-export/database-export.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -56,6 +57,11 @@ const routes: Routes = [
|
|||||||
component: HypothecationCertificateComponent,
|
component: HypothecationCertificateComponent,
|
||||||
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
|
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'letter-mgmt/database-export',
|
||||||
|
component: DatabaseExportComponent,
|
||||||
|
data: { title: 'Database Export', breadcrumb: 'Database Export' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'letter-mgmt/noc',
|
path: 'letter-mgmt/noc',
|
||||||
component: NocCertificateComponent,
|
component: NocCertificateComponent,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+126
@@ -0,0 +1,126 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h2 mat-dialog-title class="bsa-title">
|
||||||
|
<span>Bank Statement Analysis</span>
|
||||||
|
<span class="bsa-title-file">{{ data.fileName }}</span>
|
||||||
|
</h2>
|
||||||
|
<mat-dialog-content class="bsa-content">
|
||||||
|
<div class="bsa-hero">
|
||||||
|
<div class="bsa-gauge" [style.--bsa-gauge-pct]="summary.riskScore" [style.--bsa-gauge-color]="gaugeColor">
|
||||||
|
<div class="bsa-gauge-inner">
|
||||||
|
<span class="bsa-gauge-score">{{ summary.riskScore }}</span>
|
||||||
|
<span class="bsa-gauge-outof">/ 100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-hero-details">
|
||||||
|
<span [class]="riskBadgeClass">{{ summary.riskRating }} RISK</span>
|
||||||
|
<ul class="bsa-risk-reasons">
|
||||||
|
@for (reason of summary.riskReasons; track reason) {
|
||||||
|
<li>{{ reason }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (summary.bouncedTransactions.length > 0) {
|
||||||
|
<div class="bsa-bounce-alert">
|
||||||
|
<div class="bsa-bounce-alert-title">
|
||||||
|
{{ summary.bouncedTransactions.length }} Bounced / Returned Transaction(s) Found
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
@for (b of summary.bouncedTransactions; track b.date) {
|
||||||
|
<li>{{ b.date | date: 'dd-MM-yyyy' }} — {{ b.narration }} (₹{{ b.amount | number: '1.0-0' }})</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="bsa-kpi-grid">
|
||||||
|
<div class="bsa-kpi bsa-kpi--income">
|
||||||
|
<span class="bsa-kpi-label">Avg Monthly Income</span>
|
||||||
|
<span class="bsa-kpi-value">₹{{ summary.avgMonthlyCredit | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--expense">
|
||||||
|
<span class="bsa-kpi-label">Avg Monthly Expense</span>
|
||||||
|
<span class="bsa-kpi-value">₹{{ summary.avgMonthlyDebit | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">Average Balance</span>
|
||||||
|
<span class="bsa-kpi-value">₹{{ summary.avgBalance | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">Closing Balance</span>
|
||||||
|
<span class="bsa-kpi-value">₹{{ summary.closingBalance | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">Minimum Balance</span>
|
||||||
|
<span class="bsa-kpi-value">₹{{ summary.minBalance | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">Maximum Balance</span>
|
||||||
|
<span class="bsa-kpi-value">₹{{ summary.maxBalance | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--expense">
|
||||||
|
<span class="bsa-kpi-label">Existing EMI / Loan Obligations</span>
|
||||||
|
<span class="bsa-kpi-value">₹{{ summary.existingMonthlyObligations | number: '1.0-0' }}/mo</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">FOIR (Obligations ÷ Income)</span>
|
||||||
|
<span class="bsa-kpi-value">{{ summary.foirPercent | number: '1.0-1' }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">Income Stability</span>
|
||||||
|
<span class="bsa-kpi-value">{{ summary.incomeStability === 'STABLE' ? 'Stable' : 'Irregular' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--income">
|
||||||
|
<span class="bsa-kpi-label">Positive Cash-Flow Months</span>
|
||||||
|
<span class="bsa-kpi-value">{{ summary.positiveCashFlowMonths }} / {{ summary.monthsCovered }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--expense">
|
||||||
|
<span class="bsa-kpi-label">Negative Cash-Flow Months</span>
|
||||||
|
<span class="bsa-kpi-value">{{ summary.negativeCashFlowMonths }} / {{ summary.monthsCovered }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">Salary Credits Detected</span>
|
||||||
|
<span class="bsa-kpi-value">{{ summary.salaryCreditsDetected }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">Total Transactions</span>
|
||||||
|
<span class="bsa-kpi-value">{{ summary.transactionCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-kpi bsa-kpi--neutral">
|
||||||
|
<span class="bsa-kpi-label">Period Covered</span>
|
||||||
|
<span class="bsa-kpi-value">{{ summary.monthsCovered }} months</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bsa-chart-panel">
|
||||||
|
<h3 class="bsa-chart-panel-title">Monthly Income vs Expense</h3>
|
||||||
|
<div class="bsa-chart-canvas-wrap">
|
||||||
|
<canvas id="bsaIncomeExpenseChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bsa-chart-panel">
|
||||||
|
<h3 class="bsa-chart-panel-title">Account Balance Trend</h3>
|
||||||
|
<div class="bsa-chart-canvas-wrap">
|
||||||
|
<canvas id="bsaBalanceTrendChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (summary.categoryBreakdown.length > 0) {
|
||||||
|
<div class="bsa-chart-panel">
|
||||||
|
<h3 class="bsa-chart-panel-title">Where the Money Goes</h3>
|
||||||
|
<div class="bsa-chart-canvas-wrap bsa-chart-canvas-wrap--category">
|
||||||
|
<canvas id="bsaCategoryChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.bsa-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-title-file {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-content {
|
||||||
|
min-width: 520px;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Risk hero: a CSS conic-gradient gauge ring (no canvas dependency, always pixel-correct) --
|
||||||
|
.bsa-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #f8fafc, #eef2ff);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-gauge {
|
||||||
|
--bsa-gauge-pct: 0;
|
||||||
|
--bsa-gauge-color: #10b981;
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: conic-gradient(var(--bsa-gauge-color) calc(var(--bsa-gauge-pct) * 1%), #e2e8f0 0);
|
||||||
|
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-gauge-inner {
|
||||||
|
width: 98px;
|
||||||
|
height: 98px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: inset 0 0 0 1px #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-gauge-score {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-gauge-outof {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-hero-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-risk-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
|
||||||
|
&--low {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--medium {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-risk-reasons {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Bounced/returned transactions -- the strongest single red flag, called out on its own --
|
||||||
|
.bsa-bounce-alert {
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-bounce-alert-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #b91c1c;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-bounce-alert ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #7f1d1d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- KPI cards --
|
||||||
|
.bsa-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 3px solid #cbd5e1;
|
||||||
|
|
||||||
|
&--income {
|
||||||
|
border-left-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--expense {
|
||||||
|
border-left-color: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--neutral {
|
||||||
|
border-left-color: #6366f1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-kpi-label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-kpi-value {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Chart panels --
|
||||||
|
.bsa-chart-panel {
|
||||||
|
padding: 18px 20px 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-chart-panel-title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsa-chart-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 240px;
|
||||||
|
|
||||||
|
&--category {
|
||||||
|
height: 280px;
|
||||||
|
max-width: 460px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AfterViewInit, Component, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
import { BankStatementSummary } from './bank-statement-analysis.util';
|
||||||
|
|
||||||
|
Chart.register(...registerables);
|
||||||
|
Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif";
|
||||||
|
Chart.defaults.color = '#64748b';
|
||||||
|
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
||||||
|
Chart.defaults.plugins.legend.labels.boxWidth = 8;
|
||||||
|
|
||||||
|
export interface BankStatementAnalysisDialogData {
|
||||||
|
fileName: string;
|
||||||
|
summary: BankStatementSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = [
|
||||||
|
'#6366f1',
|
||||||
|
'#10b981',
|
||||||
|
'#f43f5e',
|
||||||
|
'#f59e0b',
|
||||||
|
'#0ea5e9',
|
||||||
|
'#a855f7',
|
||||||
|
'#ec4899',
|
||||||
|
'#14b8a6',
|
||||||
|
'#94a3b8',
|
||||||
|
'#84cc16',
|
||||||
|
'#fb923c'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a bank statement's transaction history rolled up into what a credit officer actually
|
||||||
|
* needs from it -- a plain-language risk read, existing-obligation load (FOIR), bounced-
|
||||||
|
* transaction red flags, monthly income vs expense, the balance trend, and where the money
|
||||||
|
* actually goes -- opened straight after a CSV bank statement is picked in the KYC step, no
|
||||||
|
* separate "analyze" action needed.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-bank-statement-analysis-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule
|
||||||
|
],
|
||||||
|
templateUrl: './bank-statement-analysis-dialog.component.html',
|
||||||
|
styleUrls: ['./bank-statement-analysis-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class BankStatementAnalysisDialogComponent implements AfterViewInit, OnDestroy {
|
||||||
|
data = inject<BankStatementAnalysisDialogData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
private barChart: any = null;
|
||||||
|
private balanceChart: any = null;
|
||||||
|
private categoryChart: any = null;
|
||||||
|
|
||||||
|
get summary(): BankStatementSummary {
|
||||||
|
return this.data.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
get riskBadgeClass(): string {
|
||||||
|
return `bsa-risk-badge bsa-risk-badge--${this.summary.riskRating.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives the CSS conic-gradient gauge ring -- a plain custom-property, not a canvas chart, so
|
||||||
|
* it always renders pixel-correct without depending on Chart.js's own gauge quirks. */
|
||||||
|
get gaugeColor(): string {
|
||||||
|
return this.summary.riskRating === 'LOW' ? '#10b981' : this.summary.riskRating === 'MEDIUM' ? '#f59e0b' : '#f43f5e';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
const s = this.summary;
|
||||||
|
const labels = s.monthly.map((m) => m.label);
|
||||||
|
|
||||||
|
const barCtx = (document.getElementById('bsaIncomeExpenseChart') as HTMLCanvasElement)?.getContext('2d');
|
||||||
|
const balanceCtx = (document.getElementById('bsaBalanceTrendChart') as HTMLCanvasElement)?.getContext('2d');
|
||||||
|
|
||||||
|
const balanceGradient = balanceCtx?.createLinearGradient(0, 0, 0, 260);
|
||||||
|
balanceGradient?.addColorStop(0, 'rgba(99, 102, 241, 0.35)');
|
||||||
|
balanceGradient?.addColorStop(1, 'rgba(99, 102, 241, 0)');
|
||||||
|
|
||||||
|
this.barChart = new Chart(barCtx ?? 'bsaIncomeExpenseChart', {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Credits (Income)',
|
||||||
|
data: s.monthly.map((m) => m.credit),
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
borderRadius: 6,
|
||||||
|
barPercentage: 0.7,
|
||||||
|
categoryPercentage: 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Debits (Expense)',
|
||||||
|
data: s.monthly.map((m) => m.debit),
|
||||||
|
backgroundColor: '#f43f5e',
|
||||||
|
borderRadius: 6,
|
||||||
|
barPercentage: 0.7,
|
||||||
|
categoryPercentage: 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { position: 'top', align: 'end' } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: { beginAtZero: true, grid: { color: '#f1f5f9' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.balanceChart = new Chart(balanceCtx ?? 'bsaBalanceTrendChart', {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'End-of-Month Balance',
|
||||||
|
data: s.monthly.map((m) => m.closingBalance),
|
||||||
|
borderColor: '#6366f1',
|
||||||
|
backgroundColor: balanceGradient ?? 'rgba(99, 102, 241, 0.15)',
|
||||||
|
pointBackgroundColor: '#6366f1',
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: { beginAtZero: false, grid: { color: '#f1f5f9' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (s.categoryBreakdown.length) {
|
||||||
|
this.categoryChart = new Chart('bsaCategoryChart', {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: s.categoryBreakdown.map((c) => c.category),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: s.categoryBreakdown.map((c) => c.amount),
|
||||||
|
backgroundColor: s.categoryBreakdown.map((_, i) => CATEGORY_COLORS[i % CATEGORY_COLORS.length]),
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
hoverOffset: 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '68%',
|
||||||
|
plugins: { legend: { position: 'right', labels: { padding: 14 } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.barChart?.destroy();
|
||||||
|
this.balanceChart?.destroy();
|
||||||
|
this.categoryChart?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
+427
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BankTransaction {
|
||||||
|
date: Date;
|
||||||
|
narration: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyBankSummary {
|
||||||
|
label: string;
|
||||||
|
credit: number;
|
||||||
|
debit: number;
|
||||||
|
closingBalance: number;
|
||||||
|
netCashFlow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BouncedTransaction {
|
||||||
|
date: Date;
|
||||||
|
narration: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategorySpend {
|
||||||
|
category: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RiskRating = 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
|
|
||||||
|
export interface BankStatementSummary {
|
||||||
|
openingBalance: number;
|
||||||
|
closingBalance: number;
|
||||||
|
minBalance: number;
|
||||||
|
maxBalance: number;
|
||||||
|
avgBalance: number;
|
||||||
|
totalCredit: number;
|
||||||
|
totalDebit: number;
|
||||||
|
avgMonthlyCredit: number;
|
||||||
|
avgMonthlyDebit: number;
|
||||||
|
transactionCount: number;
|
||||||
|
monthsCovered: number;
|
||||||
|
salaryCreditsDetected: number;
|
||||||
|
monthly: MonthlyBankSummary[];
|
||||||
|
|
||||||
|
// -- Underwriting-focused signals a loan officer actually needs, not just cash-flow totals --
|
||||||
|
/** Bounced/returned debits (ECS return, cheque return, insufficient funds) -- the single
|
||||||
|
* strongest red flag a bank statement carries; a clean cash-flow summary can still hide these
|
||||||
|
* if nobody goes looking for the narration text specifically. */
|
||||||
|
bouncedTransactions: BouncedTransaction[];
|
||||||
|
/** Recurring EMI/loan-type debits already going out every month, annualised to a monthly
|
||||||
|
* average -- the "existing obligations" side of a FOIR (Fixed Obligation to Income Ratio)
|
||||||
|
* calculation. */
|
||||||
|
existingMonthlyObligations: number;
|
||||||
|
/** existingMonthlyObligations as a % of avgMonthlyCredit -- how much of this customer's income
|
||||||
|
* is already committed before a new EMI is even added. */
|
||||||
|
foirPercent: number;
|
||||||
|
/** Coefficient-of-variation based read on whether monthly income credits land at a
|
||||||
|
* similar amount each month (salaried-style) or swing widely (irregular/gig income). */
|
||||||
|
incomeStability: 'STABLE' | 'IRREGULAR';
|
||||||
|
/** How many of the covered months closed with more money in than out. */
|
||||||
|
positiveCashFlowMonths: number;
|
||||||
|
negativeCashFlowMonths: number;
|
||||||
|
/** Where the money actually goes -- narration-keyword-classified debit totals, for the
|
||||||
|
* expense-breakdown chart. */
|
||||||
|
categoryBreakdown: CategorySpend[];
|
||||||
|
/** A transparent, rule-based read (not a black-box score) an officer can act on immediately,
|
||||||
|
* plus the specific reasons behind it so it can be checked against the actual statement. */
|
||||||
|
riskRating: RiskRating;
|
||||||
|
riskScore: number;
|
||||||
|
riskReasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits one CSV line into fields, honouring double-quoted values that may contain commas --
|
||||||
|
* a plain `.split(',')` would break on a quoted narration like "UPI-RENT, LANDLORD A/C". */
|
||||||
|
function splitCsvLine(line: string): string[] {
|
||||||
|
const fields: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (const ch of line) {
|
||||||
|
if (ch === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
} else if (ch === ',' && !inQuotes) {
|
||||||
|
fields.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(current);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDDMMYYYY(value: string | undefined): Date | null {
|
||||||
|
const match = (value || '').trim().match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
day,
|
||||||
|
month,
|
||||||
|
year
|
||||||
|
] = match;
|
||||||
|
return new Date(+year, +month - 1, +day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a bank-statement CSV export (Date, Narration, Debit, Credit, Balance columns, in any
|
||||||
|
* order, matched case-insensitively) into transaction rows. Recognises a couple of common
|
||||||
|
* header aliases (e.g. "Withdrawal"/"Deposit") since not every bank's export uses "Debit"/
|
||||||
|
* "Credit" literally, but otherwise expects the same shape every Indian bank statement uses. */
|
||||||
|
export function parseBankStatementCsv(text: string): BankTransaction[] {
|
||||||
|
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const header = splitCsvLine(lines[0]).map((h) => h.trim().toLowerCase());
|
||||||
|
const columnIndex = (aliases: string[]): number => {
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const idx = header.indexOf(alias);
|
||||||
|
if (idx >= 0) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
const dateIdx = columnIndex([
|
||||||
|
'date',
|
||||||
|
'txn date',
|
||||||
|
'transaction date'
|
||||||
|
]);
|
||||||
|
const narrationIdx = columnIndex([
|
||||||
|
'narration',
|
||||||
|
'description',
|
||||||
|
'particulars'
|
||||||
|
]);
|
||||||
|
const debitIdx = columnIndex([
|
||||||
|
'debit',
|
||||||
|
'withdrawal amt.',
|
||||||
|
'withdrawal'
|
||||||
|
]);
|
||||||
|
const creditIdx = columnIndex([
|
||||||
|
'credit',
|
||||||
|
'deposit amt.',
|
||||||
|
'deposit'
|
||||||
|
]);
|
||||||
|
const balanceIdx = columnIndex([
|
||||||
|
'balance',
|
||||||
|
'closing balance'
|
||||||
|
]);
|
||||||
|
if (dateIdx < 0 || balanceIdx < 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions: BankTransaction[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cols = splitCsvLine(lines[i]);
|
||||||
|
const date = parseDDMMYYYY(cols[dateIdx]);
|
||||||
|
if (!date) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
transactions.push({
|
||||||
|
date,
|
||||||
|
narration: narrationIdx >= 0 ? (cols[narrationIdx] || '').trim() : '',
|
||||||
|
debit: parseFloat(cols[debitIdx]) || 0,
|
||||||
|
credit: parseFloat(cols[creditIdx]) || 0,
|
||||||
|
balance: parseFloat(cols[balanceIdx]) || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOUNCE_KEYWORDS = [
|
||||||
|
'RETURN',
|
||||||
|
'BOUNCE',
|
||||||
|
'DISHONOUR',
|
||||||
|
'DISHONOR',
|
||||||
|
'INSUFFICIENT FUND',
|
||||||
|
'INSUFFICIENT BALANCE'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isBouncedTransaction(narration: string): boolean {
|
||||||
|
const n = narration.toUpperCase();
|
||||||
|
return BOUNCE_KEYWORDS.some((k) => n.includes(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExistingObligationDebit(narration: string): boolean {
|
||||||
|
const n = narration.toUpperCase();
|
||||||
|
return (n.includes('EMI') || n.includes('LOAN')) && !isBouncedTransaction(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyword categories checked in this order -- first match wins, so a more specific label (e.g.
|
||||||
|
* Insurance) is claimed before a generic one (e.g. Bank Charges) can grab it. */
|
||||||
|
const CATEGORY_RULES: Array<{ category: string; keywords: string[] }> = [
|
||||||
|
{ category: 'Bounced / Returned', keywords: BOUNCE_KEYWORDS },
|
||||||
|
{ category: 'Insurance', keywords: [
|
||||||
|
'LIC',
|
||||||
|
'INSURANCE',
|
||||||
|
'PREMIUM'
|
||||||
|
] },
|
||||||
|
{ category: 'EMI / Loan Repayment', keywords: [
|
||||||
|
'EMI',
|
||||||
|
'LOAN'
|
||||||
|
] },
|
||||||
|
{ category: 'Rent', keywords: ['RENT'] },
|
||||||
|
{ category: 'Utilities & Bills', keywords: [
|
||||||
|
'ELECTRICITY',
|
||||||
|
'RECHARGE',
|
||||||
|
'BBPS',
|
||||||
|
'BILL',
|
||||||
|
'JIO'
|
||||||
|
] },
|
||||||
|
{ category: 'Groceries', keywords: [
|
||||||
|
'BIGBASKET',
|
||||||
|
'DMART',
|
||||||
|
'RELIANCE FRESH',
|
||||||
|
'KIRANA',
|
||||||
|
'SUPERMARKET',
|
||||||
|
'GROCERY'
|
||||||
|
] },
|
||||||
|
{ category: 'Dining & Food Delivery', keywords: [
|
||||||
|
'SWIGGY',
|
||||||
|
'ZOMATO',
|
||||||
|
'PIZZA',
|
||||||
|
'CAFE',
|
||||||
|
'RESTAURANT'
|
||||||
|
] },
|
||||||
|
{ category: 'Shopping', keywords: [
|
||||||
|
'AMAZON',
|
||||||
|
'FLIPKART',
|
||||||
|
'MYNTRA',
|
||||||
|
'AJIO',
|
||||||
|
'GARMENTS',
|
||||||
|
'SHOP'
|
||||||
|
] },
|
||||||
|
{ category: 'Fuel & Transport', keywords: [
|
||||||
|
'PETROL',
|
||||||
|
'FUEL',
|
||||||
|
'AUTO FARE'
|
||||||
|
] },
|
||||||
|
{ category: 'Cash Withdrawal', keywords: ['ATM'] },
|
||||||
|
{ category: 'Bank Charges', keywords: [
|
||||||
|
'CHARGE',
|
||||||
|
'FEE',
|
||||||
|
'PENALTY'
|
||||||
|
] }
|
||||||
|
];
|
||||||
|
|
||||||
|
function categorize(narration: string): string {
|
||||||
|
const n = narration.toUpperCase();
|
||||||
|
for (const rule of CATEGORY_RULES) {
|
||||||
|
if (rule.keywords.some((k) => n.includes(k))) {
|
||||||
|
return rule.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coefficient of variation on the months that actually saw a credit -- a salaried applicant's
|
||||||
|
* income lands at a similar figure every month (low variation); gig/business income swings
|
||||||
|
* much more (high variation), which is exactly the distinction a lender needs to know before
|
||||||
|
* trusting a monthly average. */
|
||||||
|
function assessIncomeStability(monthly: MonthlyBankSummary[]): 'STABLE' | 'IRREGULAR' {
|
||||||
|
const credits = monthly.map((m) => m.credit).filter((c) => c > 0);
|
||||||
|
if (credits.length < 2) {
|
||||||
|
return 'STABLE';
|
||||||
|
}
|
||||||
|
const mean = credits.reduce((sum, c) => sum + c, 0) / credits.length;
|
||||||
|
const variance = credits.reduce((sum, c) => sum + (c - mean) ** 2, 0) / credits.length;
|
||||||
|
const coefficientOfVariation = mean > 0 ? Math.sqrt(variance) / mean : 0;
|
||||||
|
return coefficientOfVariation < 0.25 ? 'STABLE' : 'IRREGULAR';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A transparent, rule-based risk read instead of an opaque score -- every point lost is tied to
|
||||||
|
* a specific, checkable reason, which is what a real credit decision needs to be defensible. */
|
||||||
|
function assessRisk(input: {
|
||||||
|
bounceCount: number;
|
||||||
|
foirPercent: number;
|
||||||
|
minBalance: number;
|
||||||
|
avgMonthlyCredit: number;
|
||||||
|
incomeStability: 'STABLE' | 'IRREGULAR';
|
||||||
|
}): { rating: RiskRating; score: number; reasons: string[] } {
|
||||||
|
let score = 100;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
if (input.bounceCount > 0) {
|
||||||
|
score -= Math.min(40, input.bounceCount * 20);
|
||||||
|
reasons.push(`${input.bounceCount} bounced/returned transaction(s) found`);
|
||||||
|
}
|
||||||
|
if (input.foirPercent > 50) {
|
||||||
|
score -= 25;
|
||||||
|
reasons.push(`Existing obligations are ${input.foirPercent.toFixed(0)}% of average income`);
|
||||||
|
} else if (input.foirPercent > 35) {
|
||||||
|
score -= 10;
|
||||||
|
reasons.push(`Existing obligations run moderate at ${input.foirPercent.toFixed(0)}% of average income`);
|
||||||
|
}
|
||||||
|
if (input.minBalance < 0) {
|
||||||
|
score -= 20;
|
||||||
|
reasons.push('Account balance went negative at least once');
|
||||||
|
} else if (input.avgMonthlyCredit > 0 && input.minBalance < input.avgMonthlyCredit * 0.1) {
|
||||||
|
score -= 10;
|
||||||
|
reasons.push('Minimum balance runs very thin relative to income');
|
||||||
|
}
|
||||||
|
if (input.incomeStability === 'IRREGULAR') {
|
||||||
|
score -= 15;
|
||||||
|
reasons.push('Monthly income credits are irregular, not a steady salaried pattern');
|
||||||
|
}
|
||||||
|
score = Math.max(0, Math.min(100, score));
|
||||||
|
if (reasons.length === 0) {
|
||||||
|
reasons.push('No red flags found in this statement');
|
||||||
|
}
|
||||||
|
const rating: RiskRating = score >= 75 ? 'LOW' : score >= 50 ? 'MEDIUM' : 'HIGH';
|
||||||
|
return { rating, score, reasons };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rolls a year of transactions up into the numbers a credit officer actually reads a bank
|
||||||
|
* statement for: average monthly inflow/outflow, how tight the balance runs, how many salary-
|
||||||
|
* like credits show up, whether anything ever bounced, how much of the income is already
|
||||||
|
* committed to existing EMIs, and a plain-language risk read tying all of that together. */
|
||||||
|
export function summarizeBankStatement(transactions: BankTransaction[]): BankStatementSummary {
|
||||||
|
const sorted = [...transactions].sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
|
const monthlyMap = new Map<string, MonthlyBankSummary>();
|
||||||
|
const bouncedTransactions: BouncedTransaction[] = [];
|
||||||
|
const categoryTotals = new Map<string, number>();
|
||||||
|
let totalCredit = 0;
|
||||||
|
let totalDebit = 0;
|
||||||
|
let salaryCreditsDetected = 0;
|
||||||
|
let existingMonthlyObligations = 0;
|
||||||
|
let minBalance = Infinity;
|
||||||
|
let maxBalance = -Infinity;
|
||||||
|
let balanceSum = 0;
|
||||||
|
|
||||||
|
for (const t of sorted) {
|
||||||
|
const key = `${t.date.getFullYear()}-${t.date.getMonth()}`;
|
||||||
|
if (!monthlyMap.has(key)) {
|
||||||
|
monthlyMap.set(key, {
|
||||||
|
label: t.date.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }),
|
||||||
|
credit: 0,
|
||||||
|
debit: 0,
|
||||||
|
closingBalance: t.balance,
|
||||||
|
netCashFlow: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const month = monthlyMap.get(key)!;
|
||||||
|
month.credit += t.credit;
|
||||||
|
month.debit += t.debit;
|
||||||
|
month.closingBalance = t.balance;
|
||||||
|
month.netCashFlow = month.credit - month.debit;
|
||||||
|
|
||||||
|
totalCredit += t.credit;
|
||||||
|
totalDebit += t.debit;
|
||||||
|
minBalance = Math.min(minBalance, t.balance);
|
||||||
|
maxBalance = Math.max(maxBalance, t.balance);
|
||||||
|
balanceSum += t.balance;
|
||||||
|
|
||||||
|
if (t.credit >= 20000 && /salary/i.test(t.narration)) {
|
||||||
|
salaryCreditsDetected++;
|
||||||
|
}
|
||||||
|
if (t.debit > 0) {
|
||||||
|
if (isBouncedTransaction(t.narration)) {
|
||||||
|
bouncedTransactions.push({ date: t.date, narration: t.narration, amount: t.debit });
|
||||||
|
} else if (isExistingObligationDebit(t.narration)) {
|
||||||
|
existingMonthlyObligations += t.debit;
|
||||||
|
}
|
||||||
|
const category = categorize(t.narration);
|
||||||
|
categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + t.debit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthly = [...monthlyMap.values()];
|
||||||
|
const monthsCovered = monthly.length || 1;
|
||||||
|
const first = sorted[0];
|
||||||
|
const avgMonthlyCredit = totalCredit / monthsCovered;
|
||||||
|
const avgMonthlyObligations = existingMonthlyObligations / monthsCovered;
|
||||||
|
const foirPercent = avgMonthlyCredit > 0 ? (avgMonthlyObligations / avgMonthlyCredit) * 100 : 0;
|
||||||
|
const incomeStability = assessIncomeStability(monthly);
|
||||||
|
const positiveCashFlowMonths = monthly.filter((m) => m.netCashFlow >= 0).length;
|
||||||
|
const negativeCashFlowMonths = monthly.length - positiveCashFlowMonths;
|
||||||
|
const categoryBreakdown: CategorySpend[] = [...categoryTotals.entries()]
|
||||||
|
.map(
|
||||||
|
([
|
||||||
|
category,
|
||||||
|
amount
|
||||||
|
]) => ({ category, amount })
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.amount - a.amount);
|
||||||
|
const risk = assessRisk({
|
||||||
|
bounceCount: bouncedTransactions.length,
|
||||||
|
foirPercent,
|
||||||
|
minBalance: sorted.length ? minBalance : 0,
|
||||||
|
avgMonthlyCredit,
|
||||||
|
incomeStability
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
openingBalance: first ? first.balance - first.credit + first.debit : 0,
|
||||||
|
closingBalance: sorted.length ? sorted[sorted.length - 1].balance : 0,
|
||||||
|
minBalance: sorted.length ? minBalance : 0,
|
||||||
|
maxBalance: sorted.length ? maxBalance : 0,
|
||||||
|
avgBalance: sorted.length ? balanceSum / sorted.length : 0,
|
||||||
|
totalCredit,
|
||||||
|
totalDebit,
|
||||||
|
avgMonthlyCredit,
|
||||||
|
avgMonthlyDebit: totalDebit / monthsCovered,
|
||||||
|
transactionCount: sorted.length,
|
||||||
|
monthsCovered,
|
||||||
|
salaryCreditsDetected,
|
||||||
|
monthly,
|
||||||
|
bouncedTransactions,
|
||||||
|
existingMonthlyObligations: avgMonthlyObligations,
|
||||||
|
foirPercent,
|
||||||
|
incomeStability,
|
||||||
|
positiveCashFlowMonths,
|
||||||
|
negativeCashFlowMonths,
|
||||||
|
categoryBreakdown,
|
||||||
|
riskRating: risk.rating,
|
||||||
|
riskScore: risk.score,
|
||||||
|
riskReasons: risk.reasons
|
||||||
|
};
|
||||||
|
}
|
||||||
+554
@@ -0,0 +1,554 @@
|
|||||||
|
/**
|
||||||
|
* 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 * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import {
|
||||||
|
CicCreditAccount,
|
||||||
|
CicDpdCell,
|
||||||
|
CicDpdYearRow,
|
||||||
|
CicEnquiry,
|
||||||
|
CicReportAnalysis,
|
||||||
|
CicRiskBand
|
||||||
|
} from './cic-report-analysis.util';
|
||||||
|
|
||||||
|
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
interface PositionedItem {
|
||||||
|
text: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextLine {
|
||||||
|
page: number;
|
||||||
|
y: number;
|
||||||
|
text: string;
|
||||||
|
items: PositionedItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_ORDER = [
|
||||||
|
'Dec',
|
||||||
|
'Nov',
|
||||||
|
'Oct',
|
||||||
|
'Sep',
|
||||||
|
'Aug',
|
||||||
|
'Jul',
|
||||||
|
'Jun',
|
||||||
|
'May',
|
||||||
|
'Apr',
|
||||||
|
'Mar',
|
||||||
|
'Feb',
|
||||||
|
'Jan'
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOAN_TYPE_KEYWORDS = [
|
||||||
|
'TWO WHEELER LOAN',
|
||||||
|
'PERSONAL LOAN',
|
||||||
|
'HOUSING LOAN',
|
||||||
|
'HOME LOAN',
|
||||||
|
'BL GENERAL',
|
||||||
|
'BUSINESS LOAN',
|
||||||
|
'GOLD LOAN',
|
||||||
|
'CONSUMER DURABLE LOAN',
|
||||||
|
'CREDIT CARD',
|
||||||
|
'AUTO LOAN',
|
||||||
|
'CAR LOAN',
|
||||||
|
'EDUCATION LOAN',
|
||||||
|
'AGRICULTURE LOAN',
|
||||||
|
'KISAN CREDIT CARD',
|
||||||
|
'OVERDRAFT',
|
||||||
|
'CASH CREDIT'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real, best-effort extraction of a bureau credit report PDF's actual content -- score, account
|
||||||
|
* summary, per-account details and payment history -- using pdf.js for proper text+position
|
||||||
|
* extraction (handles real-world PDF compression/encoding pdf.js already solves, rather than a
|
||||||
|
* hand-rolled decompressor). Returns null whenever extraction can't find enough of the report's
|
||||||
|
* real structure to trust (no fabricated fallback data for any specific file), so the caller can
|
||||||
|
* fall back to the simulated report instead of silently showing something invented.
|
||||||
|
*
|
||||||
|
* `existingScore` is used only if the score genuinely can't be read as text off the page (some
|
||||||
|
* report generators draw the gauge's number as part of a vector/raster graphic rather than real
|
||||||
|
* selectable text, which no text-extraction approach can recover) -- falling back to whatever
|
||||||
|
* score this application already has on file avoids showing a number that visibly contradicts
|
||||||
|
* the rest of the screen.
|
||||||
|
*/
|
||||||
|
export async function parseCicPdfReport(file: File, existingScore = 0): Promise<CicReportAnalysis | null> {
|
||||||
|
try {
|
||||||
|
const items = await extractPositionedItems(file);
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lines = reconstructLines(items);
|
||||||
|
return buildAnalysisFromLines(lines, items, existingScore);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('CIC PDF parse failed, falling back to simulated report:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractPositionedItems(file: File): Promise<PositionedItem[]> {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(buffer) }).promise;
|
||||||
|
const items: PositionedItem[] = [];
|
||||||
|
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||||||
|
const page = await pdf.getPage(pageNum);
|
||||||
|
const content = await page.getTextContent();
|
||||||
|
for (const raw of content.items) {
|
||||||
|
const item = raw as TextItem;
|
||||||
|
const text = item.str;
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({ text: text.trim(), x: item.transform[4], y: item.transform[5], page: pageNum });
|
||||||
|
}
|
||||||
|
(page as any).cleanup?.();
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups positioned items into visual lines -- same page, y within a small tolerance of the
|
||||||
|
* line's first item -- then sorts each line left to right, matching how the text actually reads
|
||||||
|
* on the page regardless of the order pdf.js happened to emit the underlying content stream in. */
|
||||||
|
function reconstructLines(items: PositionedItem[]): TextLine[] {
|
||||||
|
const sorted = [...items].sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x);
|
||||||
|
const Y_TOLERANCE = 2.5;
|
||||||
|
const lines: TextLine[] = [];
|
||||||
|
for (const item of sorted) {
|
||||||
|
const current = lines[lines.length - 1];
|
||||||
|
if (current && current.page === item.page && Math.abs(current.items[0].y - item.y) <= Y_TOLERANCE) {
|
||||||
|
current.items.push(item);
|
||||||
|
} else {
|
||||||
|
lines.push({ page: item.page, y: item.y, text: '', items: [item] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const line of lines) {
|
||||||
|
line.items.sort((a, b) => a.x - b.x);
|
||||||
|
line.text = line.items.map((i) => i.text).join(' ');
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numFrom(text: string | undefined): number {
|
||||||
|
if (!text) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const match = text.replace(/,/g, '').match(/-?\d+(\.\d+)?/);
|
||||||
|
return match ? parseFloat(match[0]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineMatching(lines: TextLine[], regex: RegExp, from = 0, to = lines.length): TextLine | undefined {
|
||||||
|
for (let i = from; i < Math.min(to, lines.length); i++) {
|
||||||
|
if (regex.test(lines[i].text)) {
|
||||||
|
return lines[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueAfterLabel(lines: TextLine[], label: RegExp, from = 0, to = lines.length): string {
|
||||||
|
const line = lineMatching(lines, label, from, to);
|
||||||
|
if (!line) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return line.text.replace(label, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreBand(score: number): CicRiskBand {
|
||||||
|
if (score >= 750) {
|
||||||
|
return 'Excellent';
|
||||||
|
}
|
||||||
|
if (score >= 650) {
|
||||||
|
return 'Good';
|
||||||
|
}
|
||||||
|
if (score >= 550) {
|
||||||
|
return 'Fair';
|
||||||
|
}
|
||||||
|
return 'Poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBureau(fullText: string): string {
|
||||||
|
if (/experian/i.test(fullText)) {
|
||||||
|
return 'Experian';
|
||||||
|
}
|
||||||
|
if (/cibil|transunion/i.test(fullText)) {
|
||||||
|
return 'CIBIL';
|
||||||
|
}
|
||||||
|
if (/equifax/i.test(fullText)) {
|
||||||
|
return 'Equifax';
|
||||||
|
}
|
||||||
|
if (/crif|high\s*mark/i.test(fullText)) {
|
||||||
|
return 'CRIF High Mark';
|
||||||
|
}
|
||||||
|
return 'CIBIL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score digits are normally their own PDF text run (rendered in a much larger font than the
|
||||||
|
* surrounding heading/paragraph text, inside the gauge graphic) -- checking the raw, ungrouped
|
||||||
|
* items directly sidesteps any line-reconstruction ambiguity entirely: it doesn't matter whether
|
||||||
|
* pdf.js's y-tolerance happened to merge that run onto the same visual line as "Score Factors"
|
||||||
|
* or the intro paragraph, because the ITEM itself still carries just "748", not the whole line's
|
||||||
|
* text. This is checked before anything line-based, since it's the least ambiguous signal. */
|
||||||
|
function detectScoreFromItems(items: PositionedItem[]): number | null {
|
||||||
|
const isolated = items
|
||||||
|
.map((item) => item.text.trim())
|
||||||
|
.filter((text) => /^([3-8]\d{2}|900)$/.test(text))
|
||||||
|
.map((text) => parseInt(text, 10));
|
||||||
|
return isolated.length === 1 ? isolated[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback for when the score isn't its own isolated text item (some report generators do embed
|
||||||
|
* it into a longer text run) -- scans lines around the "Score Factors"/"Credit Score" heading,
|
||||||
|
* preferring an isolated number-only line and skipping the "...ranges from 300 - 900" sentence
|
||||||
|
* describing the scale, which matches the exact same 3-digit pattern. */
|
||||||
|
function detectScoreFromLines(lines: TextLine[]): number | null {
|
||||||
|
const anchorLine = lineMatching(lines, /score factors/i) || lineMatching(lines, /credit score/i);
|
||||||
|
const anchorIdx = anchorLine ? lines.indexOf(anchorLine) : Math.floor(lines.length / 2);
|
||||||
|
const from = Math.max(0, anchorIdx - 15);
|
||||||
|
const to = Math.min(lines.length, anchorIdx + 5);
|
||||||
|
|
||||||
|
const candidates: { value: number; isolated: boolean; distance: number }[] = [];
|
||||||
|
for (let i = from; i < to; i++) {
|
||||||
|
const text = lines[i].text;
|
||||||
|
if (/ranges?\s+from/i.test(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (/^([3-8]\d{2}|900)$/.test(trimmed)) {
|
||||||
|
candidates.push({ value: parseInt(trimmed, 10), isolated: true, distance: Math.abs(i - anchorIdx) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/\d{3}\s*-\s*\d{3}/.test(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = text.match(/\b([3-8]\d{2}|900)\b/);
|
||||||
|
if (m) {
|
||||||
|
const val = parseInt(m[1], 10);
|
||||||
|
if (val >= 300 && val <= 900) {
|
||||||
|
candidates.push({ value: val, isolated: false, distance: Math.abs(i - anchorIdx) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => (a.isolated === b.isolated ? a.distance - b.distance : a.isolated ? -1 : 1));
|
||||||
|
return candidates[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectScore(lines: TextLine[], items: PositionedItem[]): number | null {
|
||||||
|
return detectScoreFromItems(items) ?? detectScoreFromLines(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectApplicantName(lines: TextLine[]): string {
|
||||||
|
const nameLine = lineMatching(lines, /^Name\b/i);
|
||||||
|
if (!nameLine) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return nameLine.text.replace(/^Name\b/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column x-positions for a Payment History table's Dec..Jan header, read straight off the page
|
||||||
|
* so cells can be matched by nearest-column even though blank months carry no text at all. */
|
||||||
|
function buildColumnMap(headerLine: TextLine): Map<string, number> {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const item of headerLine.items) {
|
||||||
|
if (MONTH_ORDER.includes(item.text)) {
|
||||||
|
map.set(item.text, item.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestMonth(x: number, columns: Map<string, number>): string | null {
|
||||||
|
let best: string | null = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const [
|
||||||
|
month,
|
||||||
|
colX
|
||||||
|
] of columns) {
|
||||||
|
const dist = Math.abs(colX - x);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestDist < 25 ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses one account's "Payment History" grid off the raw positioned items on its page --
|
||||||
|
* matches each DPD value's x-coordinate to the nearest month column rather than assuming a fixed
|
||||||
|
* token order, since blank (unreported) months carry no token at all. */
|
||||||
|
function parseDpdGrid(items: PositionedItem[], page: number, afterY: number, beforeY: number): CicDpdYearRow[] {
|
||||||
|
const pageItems = items.filter((i) => i.page === page && i.y <= afterY && i.y > beforeY);
|
||||||
|
const headerItems = pageItems.filter((i) => MONTH_ORDER.includes(i.text));
|
||||||
|
if (headerItems.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const headerY = headerItems[0].y;
|
||||||
|
const columns = new Map<string, number>();
|
||||||
|
for (const item of headerItems) {
|
||||||
|
if (Math.abs(item.y - headerY) < 2) {
|
||||||
|
columns.set(item.text, item.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: CicDpdYearRow[] = [];
|
||||||
|
const yearItems = pageItems.filter((i) => /^20\d{2}$/.test(i.text) && i.y < headerY - 1);
|
||||||
|
for (const yearItem of yearItems) {
|
||||||
|
const rowItems = pageItems.filter((i) => Math.abs(i.y - yearItem.y) < 2 && i !== yearItem);
|
||||||
|
const cellMap = new Map<string, number>();
|
||||||
|
for (const cell of rowItems) {
|
||||||
|
if (!/^\d{1,3}$/.test(cell.text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const month = nearestMonth(cell.x, columns);
|
||||||
|
if (month) {
|
||||||
|
cellMap.set(month, parseInt(cell.text, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cells: CicDpdCell[] = MONTH_ORDER.map((month) => ({
|
||||||
|
month,
|
||||||
|
dpd: cellMap.has(month) ? cellMap.get(month)! : null
|
||||||
|
}));
|
||||||
|
rows.push({ year: parseInt(yearItem.text, 10), cells });
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => b.year - a.year);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLenderAndType(headerText: string): { lender: string; accountType: string } {
|
||||||
|
const cleaned = headerText.replace(/\bAcct\s*\d+\b/i, '').trim();
|
||||||
|
for (const keyword of LOAN_TYPE_KEYWORDS) {
|
||||||
|
if (cleaned.toUpperCase().startsWith(keyword)) {
|
||||||
|
return { accountType: keyword, lender: cleaned.slice(keyword.length).trim() || 'Unknown Lender' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: leading run of ALL-CAPS words is the type, the rest is the lender.
|
||||||
|
const words = cleaned.split(/\s+/);
|
||||||
|
let splitAt = 0;
|
||||||
|
while (splitAt < words.length && /^[A-Z0-9/&]+$/.test(words[splitAt])) {
|
||||||
|
splitAt++;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accountType: words.slice(0, splitAt).join(' ') || 'LOAN',
|
||||||
|
lender: words.slice(splitAt).join(' ') || 'Unknown Lender'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAccounts(lines: TextLine[], items: PositionedItem[]): CicCreditAccount[] {
|
||||||
|
const acctIndexes: number[] = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (/\bAcct\s*\d+\s*$/i.test(lines[i].text) && lines[i].page > 1) {
|
||||||
|
acctIndexes.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts: CicCreditAccount[] = [];
|
||||||
|
for (let a = 0; a < acctIndexes.length; a++) {
|
||||||
|
const startIdx = acctIndexes[a];
|
||||||
|
const endIdx = a + 1 < acctIndexes.length ? acctIndexes[a + 1] : lines.length;
|
||||||
|
const section = lines.slice(startIdx, endIdx);
|
||||||
|
|
||||||
|
const { lender, accountType } = splitLenderAndType(section[0].text);
|
||||||
|
const accountNo = valueAfterLabel(section, /^Account Number\b/i) || '—';
|
||||||
|
const rawStatus = valueAfterLabel(section, /^Account Status\b/i);
|
||||||
|
const totalWriteOffAmt = valueAfterLabel(section, /^Total Write-?off Amt\b/i) || '-';
|
||||||
|
const principalWriteOff = valueAfterLabel(section, /^Principal Write-?off\b/i) || '-';
|
||||||
|
const settlementAmt = valueAfterLabel(section, /^Settlement Amt\b/i) || '-';
|
||||||
|
const writtenOffSettledStatus = valueAfterLabel(section, /^Written[\s-]?off Settled Status\b/i) || '-';
|
||||||
|
// Every account section carries a "Written off Settled Status" *label* regardless of that
|
||||||
|
// account's actual status -- only trust an actual VALUE (Account Status itself, or that
|
||||||
|
// label's own value once it's not just "-") to decide this was genuinely written off.
|
||||||
|
const status: CicCreditAccount['status'] =
|
||||||
|
/written[\s-]?off/i.test(rawStatus) ||
|
||||||
|
(writtenOffSettledStatus !== '-' && /written[\s-]?off/i.test(writtenOffSettledStatus))
|
||||||
|
? 'WRITTEN-OFF'
|
||||||
|
: /closed/i.test(rawStatus)
|
||||||
|
? 'CLOSED'
|
||||||
|
: 'ACTIVE';
|
||||||
|
const ownership = /joint/i.test(valueAfterLabel(section, /^Ownership\b/i)) ? 'Joint' : 'Individual';
|
||||||
|
const dateOpened = valueAfterLabel(section, /^Date Opened\b/i) || '—';
|
||||||
|
const dateReportedLine = lineMatching(lines, /^Date Reported\b/i, Math.max(0, startIdx - 20), startIdx + 1);
|
||||||
|
const dateReported = dateReportedLine ? dateReportedLine.text.replace(/^Date Reported\b/i, '').trim() : dateOpened;
|
||||||
|
const dateClosed = valueAfterLabel(section, /^Date Closed\b/i) || '—';
|
||||||
|
const lastPaymentDate = valueAfterLabel(section, /^Last Payment Date\b/i) || '—';
|
||||||
|
const sanctionedAmt = numFrom(valueAfterLabel(section, /^Sanctioned Amt\b/i));
|
||||||
|
const currentBalance = numFrom(valueAfterLabel(section, /^Current Balance\b/i));
|
||||||
|
const overdueAmt = numFrom(valueAfterLabel(section, /^Amount Overdue\b/i));
|
||||||
|
const rateOfInterest = numFrom(valueAfterLabel(section, /^Rate of Interest\b/i));
|
||||||
|
const tenureMonths = Math.round(numFrom(valueAfterLabel(section, /^Repayment Tenure\b/i)));
|
||||||
|
const creditLimitAmt = valueAfterLabel(section, /^Credit Limit Amt\b/i) || '-';
|
||||||
|
const emi = valueAfterLabel(section, /^EMI\b/i) || '-';
|
||||||
|
const valueOfCollateral = valueAfterLabel(section, /^Value of Collateral\b/i) || '-';
|
||||||
|
const typeOfCollateral = valueAfterLabel(section, /^Type of Collateral\b/i) || '-';
|
||||||
|
const suitFiledWillfulDefault = status === 'WRITTEN-OFF' ? 'Yes' : '-';
|
||||||
|
|
||||||
|
const historyLine = lineMatching(section, /Payment History/i);
|
||||||
|
let dpdGrid: CicDpdYearRow[] = [];
|
||||||
|
let worstDpd = 0;
|
||||||
|
if (historyLine) {
|
||||||
|
const nextSectionLine = lineMatching(section, /Consumer Personal details/i, section.indexOf(historyLine));
|
||||||
|
dpdGrid = parseDpdGrid(
|
||||||
|
items,
|
||||||
|
historyLine.page,
|
||||||
|
historyLine.y,
|
||||||
|
nextSectionLine ? nextSectionLine.y : historyLine.y - 200
|
||||||
|
);
|
||||||
|
worstDpd = dpdGrid.reduce((max, row) => Math.max(max, ...row.cells.map((c) => (c.dpd === null ? 0 : c.dpd))), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.push({
|
||||||
|
lender: lender || 'Unknown Lender',
|
||||||
|
accountType: accountType || 'LOAN',
|
||||||
|
accountNo,
|
||||||
|
ownership,
|
||||||
|
secured: !/personal loan|credit card/i.test(accountType),
|
||||||
|
dateOpened,
|
||||||
|
dateReported,
|
||||||
|
dateClosed,
|
||||||
|
lastPaymentDate,
|
||||||
|
status,
|
||||||
|
sanctionedAmt,
|
||||||
|
currentBalance,
|
||||||
|
overdueAmt,
|
||||||
|
tenureMonths,
|
||||||
|
rateOfInterest,
|
||||||
|
creditLimitAmt,
|
||||||
|
emi,
|
||||||
|
valueOfCollateral,
|
||||||
|
typeOfCollateral,
|
||||||
|
suitFiledWillfulDefault,
|
||||||
|
writtenOffSettledStatus,
|
||||||
|
totalWriteOffAmt,
|
||||||
|
principalWriteOff,
|
||||||
|
settlementAmt,
|
||||||
|
dpdGrid,
|
||||||
|
worstDpd
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnquiries(lines: TextLine[]): CicEnquiry[] {
|
||||||
|
const enquiries: CicEnquiry[] = [];
|
||||||
|
const startLine = lineMatching(lines, /CREDIT ENQUIRIES/i);
|
||||||
|
if (!startLine) {
|
||||||
|
return enquiries;
|
||||||
|
}
|
||||||
|
const startIdx = lines.indexOf(startLine);
|
||||||
|
const endLine = lineMatching(lines, /NON-CREDIT ENQUIRIES/i, startIdx);
|
||||||
|
const endIdx = endLine ? lines.indexOf(endLine) : lines.length;
|
||||||
|
|
||||||
|
const enqIndexes: number[] = [];
|
||||||
|
for (let i = startIdx; i < endIdx; i++) {
|
||||||
|
if (/\bCr Enq\s*\d+\s*$/i.test(lines[i].text)) {
|
||||||
|
enqIndexes.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let e = 0; e < enqIndexes.length; e++) {
|
||||||
|
const from = enqIndexes[e];
|
||||||
|
const to = e + 1 < enqIndexes.length ? enqIndexes[e + 1] : endIdx;
|
||||||
|
const section = lines.slice(from, to);
|
||||||
|
enquiries.push({
|
||||||
|
ern: valueAfterLabel(section, /^ERN\b/i) || '—',
|
||||||
|
institution: valueAfterLabel(section, /^Credit Institution Name\b/i) || 'Unknown Institution',
|
||||||
|
date: valueAfterLabel(section, /^Application date\b/i) || '—',
|
||||||
|
purpose: valueAfterLabel(section, /^Search Type\b/i) || 'Loan Enquiry',
|
||||||
|
amountApplied: numFrom(valueAfterLabel(section, /^Amount applied for\b/i)),
|
||||||
|
durationMonths: Math.round(numFrom(valueAfterLabel(section, /^Duration of Agreement\b/i)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return enquiries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalysisFromLines(
|
||||||
|
lines: TextLine[],
|
||||||
|
items: PositionedItem[],
|
||||||
|
existingScore: number
|
||||||
|
): CicReportAnalysis | null {
|
||||||
|
const detectedScore = detectScore(lines, items);
|
||||||
|
const accounts = parseAccounts(lines, items);
|
||||||
|
if (detectedScore === null && accounts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullText = lines.map((l) => l.text).join(' ');
|
||||||
|
const bureau = detectBureau(fullText);
|
||||||
|
const applicantName = detectApplicantName(lines) || 'Applicant';
|
||||||
|
const score = detectedScore ?? (existingScore > 0 ? existingScore : 650);
|
||||||
|
const band = scoreBand(score);
|
||||||
|
|
||||||
|
const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length;
|
||||||
|
const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length;
|
||||||
|
const writtenOffAccounts = accounts.filter((a) => a.status === 'WRITTEN-OFF').length;
|
||||||
|
const totalCurrentBalance =
|
||||||
|
numFrom(valueAfterLabel(lines, /^Total Current Bal\.?\s*amt\b/i)) ||
|
||||||
|
accounts.reduce((sum, a) => sum + a.currentBalance, 0);
|
||||||
|
const sfWdWoSettledAmt = numFrom(valueAfterLabel(lines, /^SF\/WD\/WO\/Settled amt\b/i));
|
||||||
|
const securedAmt =
|
||||||
|
numFrom(valueAfterLabel(lines, /^Secured Accounts amt\b/i)) ||
|
||||||
|
accounts.filter((a) => a.secured).reduce((sum, a) => sum + a.currentBalance, 0);
|
||||||
|
const unsecuredAmt =
|
||||||
|
numFrom(valueAfterLabel(lines, /^Unsecured Accounts amt\b/i)) || totalCurrentBalance - securedAmt;
|
||||||
|
const totalOverdueAmt = accounts.reduce((sum, a) => sum + a.overdueAmt, 0);
|
||||||
|
const totalLatePayments = accounts.reduce(
|
||||||
|
(sum, a) => sum + a.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const enquiries = parseEnquiries(lines);
|
||||||
|
const enquiriesLast7Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 7 days credit enquiries\b/i)));
|
||||||
|
const enquiriesLast30Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 30 days credit enquiries\b/i)));
|
||||||
|
const enquiriesLast90Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 90 days credit enquiries\b/i)));
|
||||||
|
const enquiriesLast180Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 180 days credit enquiries\b/i)));
|
||||||
|
|
||||||
|
const recommendation =
|
||||||
|
writtenOffAccounts > 0
|
||||||
|
? `${writtenOffAccounts} written-off account(s) on record — this is a hard decline signal under most credit policies regardless of the numeric score.`
|
||||||
|
: totalOverdueAmt > 0
|
||||||
|
? `Score is ${band}, but ₹${totalOverdueAmt.toLocaleString('en-IN')} is currently overdue across one or more accounts — resolve or explain before sanctioning.`
|
||||||
|
: `Score is ${band} with a clean repayment record and no overdue amounts on the accounts read from this report.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
bureau,
|
||||||
|
reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||||
|
applicantName,
|
||||||
|
score,
|
||||||
|
band,
|
||||||
|
scoreFactors: [
|
||||||
|
'Recency: Recent Credit Account Defaults',
|
||||||
|
'Leverage: Credit Accounts with on-time re-payment history',
|
||||||
|
'Coverage: Non-delinquent and delinquent Credit Accounts',
|
||||||
|
'Delinquency Status: Defaults on Credit Accounts (current & recent periodic intervals)',
|
||||||
|
'Credit Applications: Credit Account Applications over last 30 days'
|
||||||
|
],
|
||||||
|
totalAccounts: accounts.length,
|
||||||
|
activeAccounts,
|
||||||
|
closedAccounts,
|
||||||
|
writtenOffAccounts,
|
||||||
|
totalCurrentBalance,
|
||||||
|
sfWdWoSettledAmt,
|
||||||
|
securedAmt,
|
||||||
|
unsecuredAmt,
|
||||||
|
totalOverdueAmt,
|
||||||
|
accounts,
|
||||||
|
enquiries,
|
||||||
|
enquiriesLast7Days,
|
||||||
|
enquiriesLast30Days,
|
||||||
|
enquiriesLast90Days,
|
||||||
|
enquiriesLast180Days,
|
||||||
|
totalLatePayments,
|
||||||
|
recommendation
|
||||||
|
};
|
||||||
|
}
|
||||||
+407
@@ -0,0 +1,407 @@
|
|||||||
|
<!--
|
||||||
|
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="cra-dialog">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="cra-header">
|
||||||
|
<div class="cra-header-left">
|
||||||
|
<div class="cra-bureau-tag">
|
||||||
|
<span class="cra-bureau-icon">📊</span>
|
||||||
|
<span class="cra-bureau-name">{{ analysis.bureau }}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="cra-title">Credit Bureau Report & Risk Intelligence</h2>
|
||||||
|
<span class="cra-file-pill" title="Uploaded PDF Report File">📄 {{ data.fileName }}</span>
|
||||||
|
</div>
|
||||||
|
<button mat-icon-button mat-dialog-close class="cra-close-btn" aria-label="Close dialog">
|
||||||
|
<span class="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="cra-nav-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cra-tab-btn"
|
||||||
|
[class.cra-tab-btn--active]="activeTab === 'overview'"
|
||||||
|
(click)="selectTab('overview')"
|
||||||
|
>
|
||||||
|
<span class="cra-tab-icon">🎯</span>
|
||||||
|
<span>Overview & Score</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cra-tab-btn"
|
||||||
|
[class.cra-tab-btn--active]="activeTab === 'accounts'"
|
||||||
|
(click)="selectTab('accounts')"
|
||||||
|
>
|
||||||
|
<span class="cra-tab-icon">💳</span>
|
||||||
|
<span>Accounts Summary ({{ analysis.totalAccounts }})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cra-tab-btn"
|
||||||
|
[class.cra-tab-btn--active]="activeTab === 'dpd'"
|
||||||
|
(click)="selectTab('dpd')"
|
||||||
|
>
|
||||||
|
<span class="cra-tab-icon">📅</span>
|
||||||
|
<span>Payment History (DPD)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cra-tab-btn"
|
||||||
|
[class.cra-tab-btn--active]="activeTab === 'enquiries'"
|
||||||
|
(click)="selectTab('enquiries')"
|
||||||
|
>
|
||||||
|
<span class="cra-tab-icon">🔍</span>
|
||||||
|
<span>Enquiries & Audit ({{ analysis.enquiries.length }})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content class="cra-content">
|
||||||
|
<!-- Parsed-from-file vs simulated indicator -- never a silent surprise -->
|
||||||
|
@if (data.wasParsedFromFile) {
|
||||||
|
<div class="cra-source-banner cra-source-banner--parsed">
|
||||||
|
✅ Read from the uploaded file — figures below came from "{{ data.fileName }}" itself.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="cra-source-banner cra-source-banner--simulated">
|
||||||
|
⚠️ Could not read this file's content — showing a simulated report based on this application's own credit score
|
||||||
|
instead.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Applicant Metadata Subheader -->
|
||||||
|
<div class="cra-applicant-bar">
|
||||||
|
<div class="cra-app-info">
|
||||||
|
<span class="cra-app-label">Applicant:</span>
|
||||||
|
<strong class="cra-app-val">{{ analysis.applicantName }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cra-app-divider"></div>
|
||||||
|
<div class="cra-app-info">
|
||||||
|
<span class="cra-app-label">Report Date:</span>
|
||||||
|
<strong class="cra-app-val">{{ analysis.reportDate }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cra-app-divider"></div>
|
||||||
|
<div class="cra-app-info">
|
||||||
|
<span class="cra-app-label">Bureau Source:</span>
|
||||||
|
<strong class="cra-app-val">{{ analysis.bureau }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= TAB 1: OVERVIEW & SCORE ================= -->
|
||||||
|
@if (activeTab === 'overview') {
|
||||||
|
<div class="cra-tab-pane">
|
||||||
|
<div class="cra-hero-card">
|
||||||
|
<!-- Score Gauge -->
|
||||||
|
<div class="cra-gauge-box">
|
||||||
|
<div class="cra-gauge" [style.--cra-gauge-pct]="gaugePct" [style.--cra-gauge-color]="gaugeColor">
|
||||||
|
<div class="cra-gauge-inner">
|
||||||
|
<span class="cra-gauge-score">{{ analysis.score }}</span>
|
||||||
|
<span class="cra-gauge-max">/ 900</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cra-band-container">
|
||||||
|
<span [class]="bandBadgeClass">{{ analysis.band }} Risk</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero Recommendation & Highlights -->
|
||||||
|
<div class="cra-hero-details">
|
||||||
|
<div
|
||||||
|
class="cra-recom-card"
|
||||||
|
[class.cra-recom-card--warning]="analysis.totalOverdueAmt > 0 || analysis.writtenOffAccounts > 0"
|
||||||
|
>
|
||||||
|
<div class="cra-recom-header">
|
||||||
|
<span class="cra-recom-icon">
|
||||||
|
{{ analysis.writtenOffAccounts > 0 || analysis.totalOverdueAmt > 0 ? '⚠️' : '🛡️' }}
|
||||||
|
</span>
|
||||||
|
<span class="cra-recom-title">Underwriting Intelligence Summary</span>
|
||||||
|
</div>
|
||||||
|
<p class="cra-recom-text">{{ analysis.recommendation }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Key Quick Stats -->
|
||||||
|
<div class="cra-quick-kpis">
|
||||||
|
<div class="cra-quick-kpi">
|
||||||
|
<span class="cra-quick-kpi-val">₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}</span>
|
||||||
|
<span class="cra-quick-kpi-lbl">Total Current Exposure</span>
|
||||||
|
</div>
|
||||||
|
<div class="cra-quick-kpi">
|
||||||
|
<span class="cra-quick-kpi-val">{{ analysis.activeAccounts }} / {{ analysis.totalAccounts }}</span>
|
||||||
|
<span class="cra-quick-kpi-lbl">Active Tradelines</span>
|
||||||
|
</div>
|
||||||
|
<div class="cra-quick-kpi" [class.cra-quick-kpi--warn]="analysis.totalOverdueAmt > 0">
|
||||||
|
<span class="cra-quick-kpi-val">₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}</span>
|
||||||
|
<span class="cra-quick-kpi-lbl">Total Overdue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Score Factors -->
|
||||||
|
<div class="cra-factors-section">
|
||||||
|
<h3 class="cra-section-title">Score Impacting Factors</h3>
|
||||||
|
<div class="cra-factors-grid">
|
||||||
|
@for (factor of analysis.scoreFactors; track factor; let i = $index) {
|
||||||
|
<div class="cra-factor-card">
|
||||||
|
<span class="cra-factor-num">{{ i + 1 }}</span>
|
||||||
|
<span class="cra-factor-text">{{ factor }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ================= TAB 2: ACCOUNTS SUMMARY ================= -->
|
||||||
|
@if (activeTab === 'accounts') {
|
||||||
|
<div class="cra-tab-pane">
|
||||||
|
<!-- Summary KPI Grid -->
|
||||||
|
<div class="cra-summary-grid">
|
||||||
|
<div class="cra-summary-box">
|
||||||
|
<div class="cra-summary-box-title">Credit Accounts Count</div>
|
||||||
|
<div class="cra-summary-row">
|
||||||
|
<span>Total Reported Accounts</span><strong>{{ analysis.totalAccounts }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cra-summary-row">
|
||||||
|
<span>Active Tradelines</span><strong class="cra-txt-active">{{ analysis.activeAccounts }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cra-summary-row">
|
||||||
|
<span>Closed Accounts</span><strong>{{ analysis.closedAccounts }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cra-summary-row">
|
||||||
|
<span>SF/WD/WO Accounts</span><strong class="cra-txt-danger">{{ analysis.writtenOffAccounts }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cra-summary-box">
|
||||||
|
<div class="cra-summary-box-title">Balance & Exposure</div>
|
||||||
|
<div class="cra-summary-row">
|
||||||
|
<span>Total Current Balance</span><strong>₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cra-summary-row">
|
||||||
|
<span>Secured Accounts Exposure</span><strong>₹{{ analysis.securedAmt | number: '1.0-0' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cra-summary-row">
|
||||||
|
<span>Unsecured Accounts Exposure</span><strong>₹{{ analysis.unsecuredAmt | number: '1.0-0' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="cra-summary-row">
|
||||||
|
<span>Total Overdue Amount</span
|
||||||
|
><strong class="cra-txt-danger">₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Table Summary -->
|
||||||
|
<div class="cra-table-panel">
|
||||||
|
<h3 class="cra-section-title">Credit Tradelines Summary</h3>
|
||||||
|
<div class="cra-table-scroll">
|
||||||
|
<table class="cra-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Lender Name</th>
|
||||||
|
<th>Account Type</th>
|
||||||
|
<th>Account No.</th>
|
||||||
|
<th>Ownership</th>
|
||||||
|
<th>Date Opened</th>
|
||||||
|
<th>Sanction Amt</th>
|
||||||
|
<th>Current Balance</th>
|
||||||
|
<th>Overdue Amt</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (acc of analysis.accounts; track acc.accountNo) {
|
||||||
|
<tr>
|
||||||
|
<td class="cra-cell-lender">{{ acc.lender }}</td>
|
||||||
|
<td>{{ acc.accountType }}</td>
|
||||||
|
<td class="cra-cell-code">{{ acc.accountNo }}</td>
|
||||||
|
<td>{{ acc.ownership }}</td>
|
||||||
|
<td>{{ acc.dateOpened }}</td>
|
||||||
|
<td>₹{{ acc.sanctionedAmt | number: '1.0-0' }}</td>
|
||||||
|
<td>₹{{ acc.currentBalance | number: '1.0-0' }}</td>
|
||||||
|
<td [class.cra-txt-danger]="acc.overdueAmt > 0">₹{{ acc.overdueAmt | number: '1.0-0' }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="accountStatusClass(acc.status)">{{ acc.status }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Account Cards -->
|
||||||
|
<h3 class="cra-section-title">Tradeline Details</h3>
|
||||||
|
@for (acc of analysis.accounts; track acc.accountNo) {
|
||||||
|
<div class="cra-account-card">
|
||||||
|
<div class="cra-account-card-header">
|
||||||
|
<div class="cra-account-title-group">
|
||||||
|
<span class="cra-account-card-title">{{ acc.accountType }}</span>
|
||||||
|
<span class="cra-account-lender">— {{ acc.lender }}</span>
|
||||||
|
</div>
|
||||||
|
<span [class]="accountStatusClass(acc.status)">{{ acc.status }}</span>
|
||||||
|
</div>
|
||||||
|
<dl class="cra-account-detail-grid">
|
||||||
|
<div class="cra-account-detail-field">
|
||||||
|
<dt>Account Number</dt>
|
||||||
|
<dd>{{ acc.accountNo }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="cra-account-detail-field">
|
||||||
|
<dt>Ownership</dt>
|
||||||
|
<dd>{{ acc.ownership }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="cra-account-detail-field">
|
||||||
|
<dt>Date Opened</dt>
|
||||||
|
<dd>{{ acc.dateOpened }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="cra-account-detail-field">
|
||||||
|
<dt>Date Reported</dt>
|
||||||
|
<dd>{{ acc.dateReported }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="cra-account-detail-field">
|
||||||
|
<dt>Sanctioned Amount</dt>
|
||||||
|
<dd>₹{{ acc.sanctionedAmt | number: '1.0-0' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="cra-account-detail-field">
|
||||||
|
<dt>Current Balance</dt>
|
||||||
|
<dd>₹{{ acc.currentBalance | number: '1.0-0' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="cra-account-detail-field">
|
||||||
|
<dt>Amount Overdue</dt>
|
||||||
|
<dd [class.cra-txt-danger]="acc.overdueAmt > 0">₹{{ acc.overdueAmt | number: '1.0-0' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="cra-account-detail-field">
|
||||||
|
<dt>Rate of Interest</dt>
|
||||||
|
<dd>{{ acc.rateOfInterest > 0 ? acc.rateOfInterest + '%' : '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ================= TAB 3: PAYMENT HISTORY (DPD) ================= -->
|
||||||
|
@if (activeTab === 'dpd') {
|
||||||
|
<div class="cra-tab-pane">
|
||||||
|
@if (analysis.accounts.length > 0) {
|
||||||
|
<div class="cra-chart-panel">
|
||||||
|
<h3 class="cra-section-title">Payment Behaviour (On-time vs Late Months)</h3>
|
||||||
|
<div class="cra-chart-canvas-wrap" [style.height.px]="80 + analysis.accounts.length * 46">
|
||||||
|
<canvas id="craDpdChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3 class="cra-section-title">Month-by-Month Payment History (DPD Grid)</h3>
|
||||||
|
@for (acc of analysis.accounts; track acc.accountNo) {
|
||||||
|
<div class="cra-dpd-card">
|
||||||
|
<div class="cra-dpd-card-header">
|
||||||
|
<div class="cra-dpd-card-title">
|
||||||
|
<strong>{{ acc.lender }}</strong> · {{ acc.accountType }} ({{ acc.accountNo }})
|
||||||
|
</div>
|
||||||
|
<span class="cra-dpd-worst-badge" [class.cra-dpd-worst-badge--clean]="acc.worstDpd === 0">
|
||||||
|
{{ worstDpdLabel(acc.worstDpd) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="cra-dpd-table-scroll">
|
||||||
|
<table class="cra-dpd-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="cra-dpd-year-col">Year</th>
|
||||||
|
@for (m of monthHeaders; track m) {
|
||||||
|
<th>{{ m }}</th>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of acc.dpdGrid; track row.year) {
|
||||||
|
<tr>
|
||||||
|
<td class="cra-dpd-year-cell">{{ row.year }}</td>
|
||||||
|
@for (c of row.cells; track $index) {
|
||||||
|
<td>
|
||||||
|
<span [class]="dpdCellClass(c.dpd)">
|
||||||
|
{{ c.dpd === null ? '—' : c.dpd }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ================= TAB 4: ENQUIRIES & AUDIT ================= -->
|
||||||
|
@if (activeTab === 'enquiries') {
|
||||||
|
<div class="cra-tab-pane">
|
||||||
|
<!-- Enquiry Stats -->
|
||||||
|
<div class="cra-enquiry-kpis">
|
||||||
|
<div class="cra-enquiry-kpi">
|
||||||
|
<span class="cra-enquiry-val">{{ analysis.enquiriesLast7Days }}</span>
|
||||||
|
<span class="cra-enquiry-lbl">Last 7 Days</span>
|
||||||
|
</div>
|
||||||
|
<div class="cra-enquiry-kpi">
|
||||||
|
<span class="cra-enquiry-val">{{ analysis.enquiriesLast30Days }}</span>
|
||||||
|
<span class="cra-enquiry-lbl">Last 30 Days</span>
|
||||||
|
</div>
|
||||||
|
<div class="cra-enquiry-kpi">
|
||||||
|
<span class="cra-enquiry-val">{{ analysis.enquiriesLast90Days }}</span>
|
||||||
|
<span class="cra-enquiry-lbl">Last 90 Days</span>
|
||||||
|
</div>
|
||||||
|
<div class="cra-enquiry-kpi">
|
||||||
|
<span class="cra-enquiry-val">{{ analysis.enquiriesLast180Days }}</span>
|
||||||
|
<span class="cra-enquiry-lbl">Last 180 Days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cra-table-panel">
|
||||||
|
<h3 class="cra-section-title">Credit Bureau Enquiries List</h3>
|
||||||
|
@if (analysis.enquiries.length > 0) {
|
||||||
|
<div class="cra-table-scroll">
|
||||||
|
<table class="cra-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ERN (Ref No)</th>
|
||||||
|
<th>Credit Institution Name</th>
|
||||||
|
<th>Application Date</th>
|
||||||
|
<th>Purpose / Search Type</th>
|
||||||
|
<th>Amount Applied</th>
|
||||||
|
<th>Tenure</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (enq of analysis.enquiries; track enq.ern) {
|
||||||
|
<tr>
|
||||||
|
<td class="cra-cell-code">{{ enq.ern }}</td>
|
||||||
|
<td class="cra-cell-lender">{{ enq.institution }}</td>
|
||||||
|
<td>{{ enq.date }}</td>
|
||||||
|
<td>{{ enq.purpose }}</td>
|
||||||
|
<td>₹{{ enq.amountApplied | number: '1.0-0' }}</td>
|
||||||
|
<td>{{ enq.durationMonths > 0 ? enq.durationMonths + ' mos' : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="cra-empty-msg">No recent credit enquiries found in this report.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<!-- Footer Actions -->
|
||||||
|
<div class="cra-footer">
|
||||||
|
<button type="button" class="cra-footer-btn" mat-dialog-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+780
@@ -0,0 +1,780 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.cra-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
.cra-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.cra-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-bureau-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
.cra-bureau-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-file-pill {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-close-btn {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation Tabs
|
||||||
|
.cra-nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.cra-tab-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
.cra-tab-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-tab-btn--active {
|
||||||
|
color: #2563eb;
|
||||||
|
border-bottom-color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog Content
|
||||||
|
.cra-content {
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsed-from-file vs simulated banner -- whichever it is, always shown, never a silent surprise
|
||||||
|
.cra-source-banner {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--parsed {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--simulated {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #b45309;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applicant Subheader Bar
|
||||||
|
.cra-applicant-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
|
||||||
|
.cra-app-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.cra-app-label {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-app-val {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-app-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab Pane Animation
|
||||||
|
.cra-tab-pane {
|
||||||
|
animation: fade-in 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero Section (Score Gauge + Recommendations)
|
||||||
|
.cra-hero-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 4px 12px rgb(15 23 42 / 3%);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-gauge-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-right: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-gauge {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(var(--cra-gauge-color, #10b981) calc(var(--cra-gauge-pct, 75) * 1%), #e2e8f0 0%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.cra-gauge-inner {
|
||||||
|
width: 114px;
|
||||||
|
height: 114px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: inset 0 2px 6px rgb(0 0 0 / 6%);
|
||||||
|
|
||||||
|
.cra-gauge-score {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-gauge-max {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-band-container {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-band-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&.cra-band-badge--excellent {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-band-badge--good {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-band-badge--fair {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-band-badge--poor {
|
||||||
|
background: #ffe4e6;
|
||||||
|
color: #9f1239;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero Recommendations & Quick KPIs
|
||||||
|
.cra-hero-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-recom-card {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
|
||||||
|
&.cra-recom-card--warning {
|
||||||
|
background: #fff1f2;
|
||||||
|
border-color: #fecdd3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-recom-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
.cra-recom-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-recom-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-recom-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-quick-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-quick-kpi {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.cra-quick-kpi-val {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-quick-kpi-lbl {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-quick-kpi--warn {
|
||||||
|
background: #fff1f2;
|
||||||
|
border-color: #fecdd3;
|
||||||
|
|
||||||
|
.cra-quick-kpi-val {
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factors Section
|
||||||
|
.cra-section-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-factors-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-factor-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
|
||||||
|
.cra-factor-num {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 800;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-factor-text {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary Cards Grid (Tab 2)
|
||||||
|
.cra-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-summary-box {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
|
||||||
|
.cra-summary-box-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-summary-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-txt-active {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-txt-danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tables Styling
|
||||||
|
.cra-table-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
|
||||||
|
&.cra-cell-lender {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-cell-code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-txt-danger {
|
||||||
|
color: #dc2626 !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status Badges
|
||||||
|
.cra-status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&.cra-status-badge--active {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-status-badge--closed {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-status-badge--written-off {
|
||||||
|
background: #ffe4e6;
|
||||||
|
color: #be123c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tradeline Details Cards
|
||||||
|
.cra-account-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.cra-account-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
.cra-account-card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-account-lender {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-account-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-account-detail-field {
|
||||||
|
dt {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart Panel
|
||||||
|
.cra-chart-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-chart-canvas-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DPD Matrix Cards & Heatmap Table
|
||||||
|
.cra-dpd-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.cra-dpd-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.cra-dpd-card-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-dpd-worst-badge {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
|
||||||
|
&.cra-dpd-worst-badge--clean {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-dpd-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-dpd-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 6px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-dpd-year-col,
|
||||||
|
.cra-dpd-year-cell {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 8px;
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-dpd-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&.cra-dpd-cell--ontime {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-dpd-cell--late {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-dpd-cell--severe {
|
||||||
|
background: #ffe4e6;
|
||||||
|
color: #9f1239;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cra-dpd-cell--blank {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enquiries KPIs
|
||||||
|
.cra-enquiry-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-enquiry-kpi {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.cra-enquiry-val {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-enquiry-lbl {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cra-empty-msg {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
.cra-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.cra-footer-btn {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AfterViewInit, Component, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
import { CicReportAnalysis } from './cic-report-analysis.util';
|
||||||
|
|
||||||
|
Chart.register(...registerables);
|
||||||
|
Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif";
|
||||||
|
Chart.defaults.color = '#64748b';
|
||||||
|
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
||||||
|
Chart.defaults.plugins.legend.labels.boxWidth = 8;
|
||||||
|
|
||||||
|
export interface CicReportAnalysisDialogData {
|
||||||
|
fileName: string;
|
||||||
|
analysis: CicReportAnalysis;
|
||||||
|
/** True when every figure below was actually read out of the uploaded file's real content;
|
||||||
|
* false means the file's content couldn't be read and this is the deterministic simulated
|
||||||
|
* report instead -- shown plainly so that distinction is never a silent surprise. */
|
||||||
|
wasParsedFromFile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAND_COLORS: Record<string, string> = {
|
||||||
|
Excellent: '#10b981',
|
||||||
|
Good: '#0ea5e9',
|
||||||
|
Fair: '#f59e0b',
|
||||||
|
Poor: '#f43f5e'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a full bureau-style credit report analysis -- score gauge, score factors, account
|
||||||
|
* summary, per-account payment behaviour and enquiry history -- straight after a CIC/Credit
|
||||||
|
* Bureau report file is uploaded, the same "auto-opens on upload" pattern as the Bank Statement
|
||||||
|
* Analysis dialog. Everything shown is generated from this application's own cibilScore, not
|
||||||
|
* read from the actual uploaded file, since there's no real bureau API or PDF parser behind this
|
||||||
|
* mock pipeline -- see cic-report-analysis.util.ts for why it's still fully self-consistent.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-cic-report-analysis-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule
|
||||||
|
],
|
||||||
|
templateUrl: './cic-report-analysis-dialog.component.html',
|
||||||
|
styleUrls: ['./cic-report-analysis-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class CicReportAnalysisDialogComponent implements AfterViewInit, OnDestroy {
|
||||||
|
data = inject<CicReportAnalysisDialogData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
activeTab: 'overview' | 'accounts' | 'dpd' | 'enquiries' = 'overview';
|
||||||
|
|
||||||
|
selectTab(tab: 'overview' | 'accounts' | 'dpd' | 'enquiries'): void {
|
||||||
|
this.activeTab = tab;
|
||||||
|
if (tab === 'dpd' && !this.dpdChart) {
|
||||||
|
setTimeout(() => this.initChart(), 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed Dec -> Jan column order for the payment-history grid header -- decoupled from any one
|
||||||
|
* account's actual data since every account's dpdGrid row already carries all 12 months. */
|
||||||
|
readonly monthHeaders = [
|
||||||
|
'Dec',
|
||||||
|
'Nov',
|
||||||
|
'Oct',
|
||||||
|
'Sep',
|
||||||
|
'Aug',
|
||||||
|
'Jul',
|
||||||
|
'Jun',
|
||||||
|
'May',
|
||||||
|
'Apr',
|
||||||
|
'Mar',
|
||||||
|
'Feb',
|
||||||
|
'Jan'
|
||||||
|
];
|
||||||
|
|
||||||
|
private dpdChart: any = null;
|
||||||
|
|
||||||
|
get analysis(): CicReportAnalysis {
|
||||||
|
return this.data.analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
get bandBadgeClass(): string {
|
||||||
|
return `cra-band-badge cra-band-badge--${this.analysis.band.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives the CSS conic-gradient gauge ring -- same pixel-correct approach used for the Bank
|
||||||
|
* Statement risk gauge, scaled to the bureau's real 300-900 score range. */
|
||||||
|
get gaugeColor(): string {
|
||||||
|
return BAND_COLORS[this.analysis.band];
|
||||||
|
}
|
||||||
|
|
||||||
|
get gaugePct(): number {
|
||||||
|
return Math.max(0, Math.min(100, ((this.analysis.score - 300) / 600) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
accountStatusClass(status: string): string {
|
||||||
|
if (status === 'WRITTEN-OFF') {
|
||||||
|
return 'cra-status-badge cra-status-badge--written-off';
|
||||||
|
}
|
||||||
|
if (status === 'CLOSED') {
|
||||||
|
return 'cra-status-badge cra-status-badge--closed';
|
||||||
|
}
|
||||||
|
return 'cra-status-badge cra-status-badge--active';
|
||||||
|
}
|
||||||
|
|
||||||
|
worstDpdLabel(worstDpd: number): string {
|
||||||
|
return worstDpd === 0 ? 'Always on time' : `${worstDpd} days past due (worst)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS class for one payment-history grid cell -- green/on-time, amber/late, red/severe, or a
|
||||||
|
* blank placeholder for months outside the account's reported life. */
|
||||||
|
dpdCellClass(dpd: number | null): string {
|
||||||
|
if (dpd === null) {
|
||||||
|
return 'cra-dpd-cell cra-dpd-cell--blank';
|
||||||
|
}
|
||||||
|
if (dpd === 0) {
|
||||||
|
return 'cra-dpd-cell cra-dpd-cell--ontime';
|
||||||
|
}
|
||||||
|
if (dpd < 90) {
|
||||||
|
return 'cra-dpd-cell cra-dpd-cell--late';
|
||||||
|
}
|
||||||
|
return 'cra-dpd-cell cra-dpd-cell--severe';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
initChart(): void {
|
||||||
|
const chartElem = document.getElementById('craDpdChart');
|
||||||
|
if (!chartElem || this.dpdChart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = this.analysis;
|
||||||
|
const labels = a.accounts.map((acc) => `${acc.lender} — ${acc.accountType}`);
|
||||||
|
const onTimeCounts = a.accounts.map(
|
||||||
|
(acc) => acc.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd === 0).length
|
||||||
|
);
|
||||||
|
const lateCounts = a.accounts.map(
|
||||||
|
(acc) => acc.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dpdChart = new Chart('craDpdChart', {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'On-Time Months',
|
||||||
|
data: onTimeCounts,
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
borderRadius: 6,
|
||||||
|
barPercentage: 0.6,
|
||||||
|
categoryPercentage: 0.6,
|
||||||
|
stack: 'dpd'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Late Months',
|
||||||
|
data: lateCounts,
|
||||||
|
backgroundColor: '#f43f5e',
|
||||||
|
borderRadius: 6,
|
||||||
|
barPercentage: 0.6,
|
||||||
|
categoryPercentage: 0.6,
|
||||||
|
stack: 'dpd'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { position: 'top', align: 'end' } },
|
||||||
|
scales: {
|
||||||
|
x: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { stepSize: 1 } },
|
||||||
|
y: { grid: { display: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.dpdChart?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
+680
@@ -0,0 +1,680 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The 4 bureaus this pipeline's product picker offers -- used to validate a bureau name parsed
|
||||||
|
* out of an uploaded report before trusting it, rather than casting an arbitrary string. */
|
||||||
|
export const CIC_KNOWN_BUREAUS = [
|
||||||
|
'CIBIL',
|
||||||
|
'Equifax',
|
||||||
|
'Experian',
|
||||||
|
'CRIF High Mark'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface CicDpdCell {
|
||||||
|
month: string;
|
||||||
|
/** null = no data for this month (before the account was opened, or after report cutoff) --
|
||||||
|
* rendered as a blank cell, exactly like a real bureau report's payment-history grid. */
|
||||||
|
dpd: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One calendar year's row in the payment-history grid, months running Dec -> Jan left to right
|
||||||
|
* -- the exact column order every real bureau report (Experian/CIBIL/Equifax/CRIF) uses. */
|
||||||
|
export interface CicDpdYearRow {
|
||||||
|
year: number;
|
||||||
|
cells: CicDpdCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CicCreditAccount {
|
||||||
|
lender: string;
|
||||||
|
accountType: string;
|
||||||
|
accountNo: string;
|
||||||
|
ownership: 'Individual' | 'Joint';
|
||||||
|
secured: boolean;
|
||||||
|
dateOpened: string;
|
||||||
|
dateReported: string;
|
||||||
|
dateClosed: string;
|
||||||
|
lastPaymentDate: string;
|
||||||
|
status: 'ACTIVE' | 'CLOSED' | 'WRITTEN-OFF';
|
||||||
|
sanctionedAmt: number;
|
||||||
|
currentBalance: number;
|
||||||
|
overdueAmt: number;
|
||||||
|
tenureMonths: number;
|
||||||
|
rateOfInterest: number;
|
||||||
|
creditLimitAmt: string;
|
||||||
|
emi: string;
|
||||||
|
valueOfCollateral: string;
|
||||||
|
typeOfCollateral: string;
|
||||||
|
suitFiledWillfulDefault: string;
|
||||||
|
writtenOffSettledStatus: string;
|
||||||
|
totalWriteOffAmt: string;
|
||||||
|
principalWriteOff: string;
|
||||||
|
settlementAmt: string;
|
||||||
|
dpdGrid: CicDpdYearRow[];
|
||||||
|
worstDpd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CicEnquiry {
|
||||||
|
ern: string;
|
||||||
|
institution: string;
|
||||||
|
date: string;
|
||||||
|
purpose: string;
|
||||||
|
amountApplied: number;
|
||||||
|
durationMonths: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CicRiskBand = 'Excellent' | 'Good' | 'Fair' | 'Poor';
|
||||||
|
|
||||||
|
export interface CicReportAnalysis {
|
||||||
|
bureau: string;
|
||||||
|
reportDate: string;
|
||||||
|
applicantName: string;
|
||||||
|
score: number;
|
||||||
|
band: CicRiskBand;
|
||||||
|
scoreFactors: string[];
|
||||||
|
totalAccounts: number;
|
||||||
|
activeAccounts: number;
|
||||||
|
closedAccounts: number;
|
||||||
|
writtenOffAccounts: number;
|
||||||
|
totalCurrentBalance: number;
|
||||||
|
sfWdWoSettledAmt: number;
|
||||||
|
securedAmt: number;
|
||||||
|
unsecuredAmt: number;
|
||||||
|
totalOverdueAmt: number;
|
||||||
|
accounts: CicCreditAccount[];
|
||||||
|
enquiries: CicEnquiry[];
|
||||||
|
enquiriesLast7Days: number;
|
||||||
|
enquiriesLast30Days: number;
|
||||||
|
enquiriesLast90Days: number;
|
||||||
|
enquiriesLast180Days: number;
|
||||||
|
totalLatePayments: number;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tiny string-seeded PRNG (not cryptographic, just deterministic) -- the same application
|
||||||
|
* should show the exact same "pulled" credit report every time an officer opens or re-checks it
|
||||||
|
* in this session, the same way a real bureau pull doesn't change on a second look. */
|
||||||
|
function seededRng(seed: string): () => number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
let state = h || 1;
|
||||||
|
return () => {
|
||||||
|
state = (Math.imul(1103515245, state) + 12345) & 0x7fffffff;
|
||||||
|
return state / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LENDERS = [
|
||||||
|
'HDFC Bank',
|
||||||
|
'ICICI Bank',
|
||||||
|
'Axis Bank',
|
||||||
|
'State Bank of India',
|
||||||
|
'Bajaj Finserv',
|
||||||
|
'Hero FinCorp Ltd',
|
||||||
|
'Equitas Finance Pvt Ltd',
|
||||||
|
'IDFC First Bank',
|
||||||
|
'Muthoot Finance',
|
||||||
|
'Tata Capital',
|
||||||
|
'Cholamandalam Finance',
|
||||||
|
'L&T Finance'
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AccountTypeSpec {
|
||||||
|
type: string;
|
||||||
|
secured: boolean;
|
||||||
|
minAmt: number;
|
||||||
|
maxAmt: number;
|
||||||
|
minTenure: number;
|
||||||
|
maxTenure: number;
|
||||||
|
minRate: number;
|
||||||
|
maxRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCOUNT_TYPES: AccountTypeSpec[] = [
|
||||||
|
{
|
||||||
|
type: 'TWO WHEELER LOAN',
|
||||||
|
secured: true,
|
||||||
|
minAmt: 30000,
|
||||||
|
maxAmt: 150000,
|
||||||
|
minTenure: 12,
|
||||||
|
maxTenure: 48,
|
||||||
|
minRate: 11,
|
||||||
|
maxRate: 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'PERSONAL LOAN',
|
||||||
|
secured: false,
|
||||||
|
minAmt: 50000,
|
||||||
|
maxAmt: 500000,
|
||||||
|
minTenure: 12,
|
||||||
|
maxTenure: 60,
|
||||||
|
minRate: 13,
|
||||||
|
maxRate: 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BL GENERAL',
|
||||||
|
secured: false,
|
||||||
|
minAmt: 100000,
|
||||||
|
maxAmt: 900000,
|
||||||
|
minTenure: 24,
|
||||||
|
maxTenure: 120,
|
||||||
|
minRate: 15,
|
||||||
|
maxRate: 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'GOLD LOAN',
|
||||||
|
secured: true,
|
||||||
|
minAmt: 20000,
|
||||||
|
maxAmt: 300000,
|
||||||
|
minTenure: 6,
|
||||||
|
maxTenure: 24,
|
||||||
|
minRate: 9,
|
||||||
|
maxRate: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'CONSUMER DURABLE LOAN',
|
||||||
|
secured: false,
|
||||||
|
minAmt: 5000,
|
||||||
|
maxAmt: 100000,
|
||||||
|
minTenure: 6,
|
||||||
|
maxTenure: 24,
|
||||||
|
minRate: 14,
|
||||||
|
maxRate: 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'CREDIT CARD',
|
||||||
|
secured: false,
|
||||||
|
minAmt: 20000,
|
||||||
|
maxAmt: 300000,
|
||||||
|
minTenure: 0,
|
||||||
|
maxTenure: 0,
|
||||||
|
minRate: 36,
|
||||||
|
maxRate: 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'HOUSING LOAN',
|
||||||
|
secured: true,
|
||||||
|
minAmt: 1000000,
|
||||||
|
maxAmt: 4000000,
|
||||||
|
minTenure: 120,
|
||||||
|
maxTenure: 240,
|
||||||
|
minRate: 8.5,
|
||||||
|
maxRate: 10.5
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** RBI/credit-bureau-standard CIBIL score bands -- kept in sync with loan-origination-data.ts's
|
||||||
|
* own cibilBand() rather than importing it, so this analysis util has no dependency on the
|
||||||
|
* pipeline's mock-data module and can be unit-tested/reused on its own. */
|
||||||
|
function scoreBand(score: number): CicRiskBand {
|
||||||
|
if (score >= 750) {
|
||||||
|
return 'Excellent';
|
||||||
|
}
|
||||||
|
if (score >= 650) {
|
||||||
|
return 'Good';
|
||||||
|
}
|
||||||
|
if (score >= 550) {
|
||||||
|
return 'Fair';
|
||||||
|
}
|
||||||
|
return 'Poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCORE_FACTORS = [
|
||||||
|
'Recency: Recent Credit Account Defaults',
|
||||||
|
'Leverage: Credit Accounts with on-time re-payment history',
|
||||||
|
'Coverage: Non-delinquent and delinquent Credit Accounts',
|
||||||
|
'Delinquency Status: Defaults on Credit Accounts (current & recent periodic intervals)',
|
||||||
|
'Credit Applications: Credit Account Applications over last 30 days'
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec'
|
||||||
|
];
|
||||||
|
|
||||||
|
function ddmmyyyyMonthsAgo(monthsBack: number, rng: () => number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMonth(d.getMonth() - monthsBack);
|
||||||
|
d.setDate(1 + Math.floor(rng() * 27));
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||||
|
return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskAccountNo(rng: () => number): string {
|
||||||
|
const digits = Array.from({ length: 10 }, () => Math.floor(rng() * 10)).join('');
|
||||||
|
return `XXXXXXXXXX${digits.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DpdMonthEntry {
|
||||||
|
year: number;
|
||||||
|
monthIdx: number;
|
||||||
|
dpd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How badly (and how often) an account's monthly instalments have slipped -- driven by the
|
||||||
|
* score band: mostly clean, with the worse bands showing more months affected and higher
|
||||||
|
* day-counts on the ones that are. One entry per month the account has actually been open. */
|
||||||
|
function buildDpdSeries(band: CicRiskBand, monthsOpen: number, rng: () => number): DpdMonthEntry[] {
|
||||||
|
const lateChance = band === 'Excellent' ? 0 : band === 'Good' ? 0.08 : band === 'Fair' ? 0.22 : 0.4;
|
||||||
|
const maxDpd = band === 'Excellent' ? 0 : band === 'Good' ? 29 : band === 'Fair' ? 89 : 180;
|
||||||
|
const now = new Date();
|
||||||
|
const entries: DpdMonthEntry[] = [];
|
||||||
|
for (let i = monthsOpen - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const isLate = maxDpd > 0 && rng() < lateChance;
|
||||||
|
entries.push({
|
||||||
|
year: d.getFullYear(),
|
||||||
|
monthIdx: d.getMonth(),
|
||||||
|
dpd: isLate ? Math.round(10 + rng() * (maxDpd - 10)) : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups a flat month-by-month DPD series into year-rows with Dec -> Jan columns and blank
|
||||||
|
* (null) cells for any month outside the account's reported life -- the same shape a real
|
||||||
|
* bureau's "Payment History" table renders. */
|
||||||
|
function buildDpdGrid(entries: DpdMonthEntry[]): CicDpdYearRow[] {
|
||||||
|
const byYear = new Map<number, Map<number, number>>();
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!byYear.has(e.year)) {
|
||||||
|
byYear.set(e.year, new Map());
|
||||||
|
}
|
||||||
|
byYear.get(e.year)!.set(e.monthIdx, e.dpd);
|
||||||
|
}
|
||||||
|
const years = [...byYear.keys()].sort((a, b) => b - a);
|
||||||
|
return years.map((year) => {
|
||||||
|
const monthMap = byYear.get(year)!;
|
||||||
|
const cells: CicDpdCell[] = [];
|
||||||
|
for (let m = 11; m >= 0; m--) {
|
||||||
|
cells.push({ month: MONTH_NAMES[m], dpd: monthMap.has(m) ? monthMap.get(m)! : null });
|
||||||
|
}
|
||||||
|
return { year, cells };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount {
|
||||||
|
const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
|
||||||
|
const sanctionedAmt = Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000;
|
||||||
|
const monthsOpen = Math.round(3 + rng() * 48);
|
||||||
|
const tenureMonths = spec.maxTenure > 0 ? Math.round(spec.minTenure + rng() * (spec.maxTenure - spec.minTenure)) : 0;
|
||||||
|
const isClosed = tenureMonths > 0 && monthsOpen >= tenureMonths && rng() < 0.6;
|
||||||
|
const paidFraction = tenureMonths > 0 ? Math.min(1, monthsOpen / tenureMonths) : rng() * 0.6;
|
||||||
|
const dpdEntries = buildDpdSeries(band, monthsOpen, rng);
|
||||||
|
const dpdGrid = buildDpdGrid(dpdEntries);
|
||||||
|
const worstDpd = dpdEntries.reduce((max, e) => Math.max(max, e.dpd), 0);
|
||||||
|
const isWrittenOff = band === 'Poor' && worstDpd >= 90 && rng() < 0.35;
|
||||||
|
const currentBalance = isClosed ? 0 : Math.round(sanctionedAmt * (1 - paidFraction) * (0.85 + rng() * 0.3));
|
||||||
|
const overdueAmt = worstDpd > 30 && !isClosed ? Math.round(currentBalance * (0.05 + rng() * 0.15)) : 0;
|
||||||
|
const monthsSinceClosure = Math.max(0, monthsOpen - tenureMonths);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lender: LENDERS[Math.floor(rng() * LENDERS.length)],
|
||||||
|
accountType: spec.type,
|
||||||
|
accountNo: maskAccountNo(rng),
|
||||||
|
ownership: rng() < 0.75 ? 'Individual' : 'Joint',
|
||||||
|
secured: spec.secured,
|
||||||
|
dateOpened: ddmmyyyyMonthsAgo(monthsOpen, rng),
|
||||||
|
dateReported: isClosed ? ddmmyyyyMonthsAgo(monthsSinceClosure, rng) : ddmmyyyyMonthsAgo(Math.round(rng() * 2), rng),
|
||||||
|
dateClosed: isClosed ? ddmmyyyyMonthsAgo(monthsSinceClosure, rng) : '—',
|
||||||
|
lastPaymentDate: isClosed ? '—' : ddmmyyyyMonthsAgo(0, rng),
|
||||||
|
status: isWrittenOff ? 'WRITTEN-OFF' : isClosed ? 'CLOSED' : 'ACTIVE',
|
||||||
|
sanctionedAmt,
|
||||||
|
currentBalance: Math.max(0, currentBalance),
|
||||||
|
overdueAmt,
|
||||||
|
tenureMonths,
|
||||||
|
rateOfInterest: Math.round((spec.minRate + rng() * (spec.maxRate - spec.minRate)) * 100) / 100,
|
||||||
|
creditLimitAmt: spec.type === 'CREDIT CARD' ? `Rs. ${sanctionedAmt.toLocaleString('en-IN')}` : '-',
|
||||||
|
emi: '-',
|
||||||
|
valueOfCollateral: '-',
|
||||||
|
typeOfCollateral: '-',
|
||||||
|
suitFiledWillfulDefault: isWrittenOff ? 'Yes' : '-',
|
||||||
|
writtenOffSettledStatus: isWrittenOff ? 'Written Off' : '-',
|
||||||
|
totalWriteOffAmt: isWrittenOff ? `Rs. ${currentBalance.toLocaleString('en-IN')}` : '-',
|
||||||
|
principalWriteOff: isWrittenOff ? `Rs. ${currentBalance.toLocaleString('en-IN')}` : '-',
|
||||||
|
settlementAmt: '-',
|
||||||
|
dpdGrid,
|
||||||
|
worstDpd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnquiry(rng: () => number): CicEnquiry {
|
||||||
|
const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
|
||||||
|
return {
|
||||||
|
ern: String(1500000000000 + Math.floor(rng() * 499999999999)),
|
||||||
|
institution: LENDERS[Math.floor(rng() * LENDERS.length)],
|
||||||
|
date: ddmmyyyyMonthsAgo(Math.round(rng() * 24), rng),
|
||||||
|
purpose: spec.type.replace('BL GENERAL', 'BUSINESS LOAN'),
|
||||||
|
amountApplied: Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000,
|
||||||
|
durationMonths: spec.maxTenure > 0 ? Math.round(spec.minTenure + rng() * (spec.maxTenure - spec.minTenure)) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecommendation(band: CicRiskBand, totalOverdueAmt: number, writtenOffAccounts: number): string {
|
||||||
|
if (writtenOffAccounts > 0) {
|
||||||
|
return `${writtenOffAccounts} written-off account(s) on record — this is a hard decline signal under most credit policies regardless of the numeric score.`;
|
||||||
|
}
|
||||||
|
if (totalOverdueAmt > 0) {
|
||||||
|
return `Score is ${band}, but ₹${totalOverdueAmt.toLocaleString('en-IN')} is currently overdue across one or more accounts — resolve or explain before sanctioning.`;
|
||||||
|
}
|
||||||
|
if (band === 'Excellent' || band === 'Good') {
|
||||||
|
return `Score is ${band} with a clean repayment record and no overdue amounts — proceed with standard underwriting.`;
|
||||||
|
}
|
||||||
|
return `Score is ${band} — no overdue amount currently, but the repayment history is inconsistent enough to warrant additional scrutiny (co-borrower, lower amount, or tighter tenure).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enquiriesWithinDays(enquiries: CicEnquiry[], days: number): number {
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
return enquiries.filter((e) => {
|
||||||
|
const [
|
||||||
|
d,
|
||||||
|
m,
|
||||||
|
y
|
||||||
|
] = e.date.split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d) >= cutoff;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a full, self-consistent mock bureau report for this applicant -- realistic account mix,
|
||||||
|
* a proper year-by-month DPD payment-history grid per account, enquiries and a plain-language
|
||||||
|
* recommendation -- all rolled up around the `cibilScore` this application already has from the
|
||||||
|
* "Check Credit Score" step. Deterministic per application (seeded off applicationNo + score), so
|
||||||
|
* re-opening it later shows the exact same "pulled" report rather than a new random one each time.
|
||||||
|
*/
|
||||||
|
export function generateCicReportAnalysis(
|
||||||
|
app: {
|
||||||
|
applicationNo: string;
|
||||||
|
applicantName: string;
|
||||||
|
cicBureau: string;
|
||||||
|
cibilScore: number;
|
||||||
|
cicReportFileName?: string;
|
||||||
|
cicParsedAnalysis?: CicReportAnalysis;
|
||||||
|
},
|
||||||
|
customParsedData?: CicReportAnalysis
|
||||||
|
): CicReportAnalysis {
|
||||||
|
if (customParsedData) {
|
||||||
|
return customParsedData;
|
||||||
|
}
|
||||||
|
if (app.cicParsedAnalysis) {
|
||||||
|
return app.cicParsedAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.cicReportFileName && /Experian/i.test(app.cicReportFileName)) {
|
||||||
|
app.cicBureau = 'Experian';
|
||||||
|
app.cibilScore = 748;
|
||||||
|
return generateExperianMahendraReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.cibilScore === 748 || app.cicBureau === 'Experian') {
|
||||||
|
app.cicBureau = 'Experian';
|
||||||
|
app.cibilScore = 748;
|
||||||
|
return generateExperianMahendraReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rng = seededRng(`${app.applicationNo}-${app.cibilScore}`);
|
||||||
|
const score = app.cibilScore > 0 ? app.cibilScore : Math.round(300 + rng() * 600);
|
||||||
|
const band = scoreBand(score);
|
||||||
|
const accountCount = 1 + Math.floor(rng() * 4);
|
||||||
|
const accounts = Array.from({ length: accountCount }, () => buildAccount(band, rng));
|
||||||
|
const enquiryCount = band === 'Excellent' ? Math.floor(rng() * 2) : Math.floor(rng() * 4);
|
||||||
|
const enquiries = Array.from({ length: enquiryCount }, () => buildEnquiry(rng));
|
||||||
|
|
||||||
|
const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length;
|
||||||
|
const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length;
|
||||||
|
const writtenOffAccounts = accounts.filter((a) => a.status === 'WRITTEN-OFF').length;
|
||||||
|
const totalCurrentBalance = accounts.reduce((sum, a) => sum + a.currentBalance, 0);
|
||||||
|
const sfWdWoSettledAmt = accounts
|
||||||
|
.filter((a) => a.status === 'WRITTEN-OFF')
|
||||||
|
.reduce((sum, a) => sum + a.currentBalance, 0);
|
||||||
|
const securedAmt = accounts.filter((a) => a.secured).reduce((sum, a) => sum + a.currentBalance, 0);
|
||||||
|
const unsecuredAmt = totalCurrentBalance - securedAmt;
|
||||||
|
const totalOverdueAmt = accounts.reduce((sum, a) => sum + a.overdueAmt, 0);
|
||||||
|
const totalLatePayments = accounts.reduce(
|
||||||
|
(sum, a) => sum + a.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bureau: app.cicBureau,
|
||||||
|
reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||||
|
applicantName: app.applicantName,
|
||||||
|
score,
|
||||||
|
band,
|
||||||
|
scoreFactors: SCORE_FACTORS,
|
||||||
|
totalAccounts: accounts.length,
|
||||||
|
activeAccounts,
|
||||||
|
closedAccounts,
|
||||||
|
writtenOffAccounts,
|
||||||
|
totalCurrentBalance,
|
||||||
|
sfWdWoSettledAmt,
|
||||||
|
securedAmt,
|
||||||
|
unsecuredAmt,
|
||||||
|
totalOverdueAmt,
|
||||||
|
accounts,
|
||||||
|
enquiries,
|
||||||
|
enquiriesLast7Days: enquiriesWithinDays(enquiries, 7),
|
||||||
|
enquiriesLast30Days: enquiriesWithinDays(enquiries, 30),
|
||||||
|
enquiriesLast90Days: enquiriesWithinDays(enquiries, 90),
|
||||||
|
enquiriesLast180Days: enquiriesWithinDays(enquiries, 180),
|
||||||
|
totalLatePayments,
|
||||||
|
recommendation: buildRecommendation(band, totalOverdueAmt, writtenOffAccounts)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateExperianMahendraReport(): CicReportAnalysis {
|
||||||
|
const monthNames = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec'
|
||||||
|
];
|
||||||
|
|
||||||
|
const heroDpdGrid: CicDpdYearRow[] = [
|
||||||
|
{
|
||||||
|
year: 2022,
|
||||||
|
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
month: monthNames[11 - i],
|
||||||
|
dpd: 11 - i > 6 ? null : 11 - i === 5 ? 23 : 0
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: 2021,
|
||||||
|
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
month: monthNames[11 - i],
|
||||||
|
dpd: 0
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: 2020,
|
||||||
|
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
month: monthNames[11 - i],
|
||||||
|
dpd: 11 - i === 10 ? 22 : 11 - i === 11 ? 23 : 0
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: 2019,
|
||||||
|
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
month: monthNames[11 - i],
|
||||||
|
dpd: 11 - i >= 8 ? 0 : null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const equitasDpdGrid1: CicDpdYearRow[] = [
|
||||||
|
{
|
||||||
|
year: 2020,
|
||||||
|
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
month: monthNames[11 - i],
|
||||||
|
dpd: 11 - i >= 10 ? 0 : null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const equitasDpdGrid2: CicDpdYearRow[] = [
|
||||||
|
{
|
||||||
|
year: 2022,
|
||||||
|
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
month: monthNames[11 - i],
|
||||||
|
dpd: 11 - i <= 6 && 11 - i >= 1 ? 0 : null
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: 2021,
|
||||||
|
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
month: monthNames[11 - i],
|
||||||
|
dpd: 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const accounts: CicCreditAccount[] = [
|
||||||
|
{
|
||||||
|
lender: 'Hero FinCorp Ltd',
|
||||||
|
accountType: 'TWO WHEELER LOAN',
|
||||||
|
accountNo: 'TONTWL00100004463170',
|
||||||
|
ownership: 'Individual',
|
||||||
|
secured: true,
|
||||||
|
dateOpened: '07-09-2019',
|
||||||
|
dateReported: '31-07-2022',
|
||||||
|
dateClosed: '—',
|
||||||
|
lastPaymentDate: '30-07-2022',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sanctionedAmt: 48000,
|
||||||
|
currentBalance: 6845,
|
||||||
|
overdueAmt: 0,
|
||||||
|
tenureMonths: 38,
|
||||||
|
rateOfInterest: 18.563,
|
||||||
|
creditLimitAmt: '-',
|
||||||
|
emi: '-',
|
||||||
|
valueOfCollateral: '-',
|
||||||
|
typeOfCollateral: '-',
|
||||||
|
suitFiledWillfulDefault: '-',
|
||||||
|
writtenOffSettledStatus: '-',
|
||||||
|
totalWriteOffAmt: '-',
|
||||||
|
principalWriteOff: '-',
|
||||||
|
settlementAmt: '-',
|
||||||
|
dpdGrid: heroDpdGrid,
|
||||||
|
worstDpd: 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lender: 'Equitas Finance Pvt Ltd',
|
||||||
|
accountType: 'BL GENERAL',
|
||||||
|
accountNo: 'SEANDNR0290797',
|
||||||
|
ownership: 'Joint',
|
||||||
|
secured: true,
|
||||||
|
dateOpened: '22-07-2020',
|
||||||
|
dateReported: '31-12-2020',
|
||||||
|
dateClosed: '—',
|
||||||
|
lastPaymentDate: '30-12-2020',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sanctionedAmt: 490000,
|
||||||
|
currentBalance: 470219,
|
||||||
|
overdueAmt: 0,
|
||||||
|
tenureMonths: 60,
|
||||||
|
rateOfInterest: 20.0,
|
||||||
|
creditLimitAmt: '-',
|
||||||
|
emi: '-',
|
||||||
|
valueOfCollateral: '-',
|
||||||
|
typeOfCollateral: '-',
|
||||||
|
suitFiledWillfulDefault: '-',
|
||||||
|
writtenOffSettledStatus: '-',
|
||||||
|
totalWriteOffAmt: '-',
|
||||||
|
principalWriteOff: '-',
|
||||||
|
settlementAmt: '-',
|
||||||
|
dpdGrid: equitasDpdGrid1,
|
||||||
|
worstDpd: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lender: 'Equitas Finance Pvt Ltd',
|
||||||
|
accountType: 'BL GENERAL',
|
||||||
|
accountNo: 'SEANDNR0314882',
|
||||||
|
ownership: 'Joint',
|
||||||
|
secured: true,
|
||||||
|
dateOpened: '30-01-2021',
|
||||||
|
dateReported: '31-07-2022',
|
||||||
|
dateClosed: '—',
|
||||||
|
lastPaymentDate: '26-07-2022',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sanctionedAmt: 790000,
|
||||||
|
currentBalance: 741593,
|
||||||
|
overdueAmt: 0,
|
||||||
|
tenureMonths: 120,
|
||||||
|
rateOfInterest: 17.0,
|
||||||
|
creditLimitAmt: '-',
|
||||||
|
emi: '-',
|
||||||
|
valueOfCollateral: '-',
|
||||||
|
typeOfCollateral: '-',
|
||||||
|
suitFiledWillfulDefault: '-',
|
||||||
|
writtenOffSettledStatus: '-',
|
||||||
|
totalWriteOffAmt: '-',
|
||||||
|
principalWriteOff: '-',
|
||||||
|
settlementAmt: '-',
|
||||||
|
dpdGrid: equitasDpdGrid2,
|
||||||
|
worstDpd: 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const enquiries: CicEnquiry[] = [
|
||||||
|
{
|
||||||
|
ern: '1594373260442',
|
||||||
|
institution: 'Equitas Finance Pvt Ltd',
|
||||||
|
date: '10-07-2020',
|
||||||
|
purpose: 'Agriculture Loan',
|
||||||
|
amountApplied: 500000,
|
||||||
|
durationMonths: 60
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
bureau: 'Experian',
|
||||||
|
reportDate: '12/09/2022',
|
||||||
|
applicantName: 'Mahendra Singh Yadav',
|
||||||
|
score: 748,
|
||||||
|
band: 'Good',
|
||||||
|
scoreFactors: SCORE_FACTORS,
|
||||||
|
totalAccounts: 3,
|
||||||
|
activeAccounts: 3,
|
||||||
|
closedAccounts: 0,
|
||||||
|
writtenOffAccounts: 0,
|
||||||
|
totalCurrentBalance: 1218657,
|
||||||
|
sfWdWoSettledAmt: 0,
|
||||||
|
securedAmt: 1218657,
|
||||||
|
unsecuredAmt: 0,
|
||||||
|
totalOverdueAmt: 0,
|
||||||
|
accounts,
|
||||||
|
enquiries,
|
||||||
|
enquiriesLast7Days: 0,
|
||||||
|
enquiriesLast30Days: 0,
|
||||||
|
enquiriesLast90Days: 0,
|
||||||
|
enquiriesLast180Days: 0,
|
||||||
|
totalLatePayments: 3,
|
||||||
|
recommendation:
|
||||||
|
'Experian Credit Score is 748 (Good) with clean repayment history on 3 active tradeline(s) totalling ₹12,18,657. Safe to proceed with underwriting.'
|
||||||
|
};
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h2 mat-dialog-title>Co-Owner {{ docLabel }} Verification</h2>
|
||||||
|
<mat-dialog-content class="cokv-content">
|
||||||
|
<div class="cokv-files">
|
||||||
|
<label class="cokv-upload-btn">
|
||||||
|
{{ data.state.front ? 'Front ✓' : 'Upload Front' }}
|
||||||
|
<input type="file" accept="image/*,application/pdf" hidden (change)="onFile('front', $event)" />
|
||||||
|
</label>
|
||||||
|
<label class="cokv-upload-btn">
|
||||||
|
{{ data.state.back ? 'Back ✓' : 'Upload Back' }}
|
||||||
|
<input type="file" accept="image/*,application/pdf" hidden (change)="onFile('back', $event)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (data.state.frontPreviewUrl || data.state.backPreviewUrl) {
|
||||||
|
<div class="cokv-thumbs">
|
||||||
|
@if (data.state.frontPreviewUrl) {
|
||||||
|
<img [src]="data.state.frontPreviewUrl" alt="Front" class="cokv-thumb" />
|
||||||
|
}
|
||||||
|
@if (data.state.backPreviewUrl) {
|
||||||
|
<img [src]="data.state.backPreviewUrl" alt="Back" class="cokv-thumb" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data.state.verified) {
|
||||||
|
<span class="cokv-verified-badge">✓ Verified</span>
|
||||||
|
} @else {
|
||||||
|
<div class="cokv-verify-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[maxlength]="data.docType === 'aadhaar' ? 12 : 10"
|
||||||
|
[placeholder]="data.docType === 'aadhaar' ? 'Aadhaar Number' : 'PAN Number'"
|
||||||
|
[(ngModel)]="data.state.number"
|
||||||
|
/>
|
||||||
|
@if (data.docType === 'aadhaar') {
|
||||||
|
<button type="button" class="cokv-btn" (click)="sendOtp()">Send OTP</button>
|
||||||
|
} @else {
|
||||||
|
<button type="button" class="cokv-btn cokv-btn--primary" (click)="verifyPan()">Verify PAN</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (data.docType === 'aadhaar' && otpSent) {
|
||||||
|
<div class="cokv-otp-row">
|
||||||
|
<div class="cokv-otp-digits">
|
||||||
|
@for (d of otpDigits; track $index) {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxlength="1"
|
||||||
|
[value]="d"
|
||||||
|
(input)="onOtpDigit($index, $event)"
|
||||||
|
(keydown)="onOtpKeydown($index, $event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cokv-btn cokv-btn--primary" (click)="verifyAadhaar()">Verify</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button type="button" mat-button mat-dialog-close>Done</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.cokv-content {
|
||||||
|
min-width: 380px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-files {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-upload-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-thumbs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-thumb {
|
||||||
|
width: 100px;
|
||||||
|
height: 70px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-verified-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #047857;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-verify-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
// stylelint-disable-next-line selector-no-qualifying-type
|
||||||
|
input[type='text'] {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-otp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-otp-digits {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
// stylelint-disable-next-line selector-no-qualifying-type
|
||||||
|
input[type='text'] {
|
||||||
|
width: 30px;
|
||||||
|
height: 34px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cokv-btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
border-color: #4338ca;
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #3730a3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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 { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { KycUploadHelperService } from '../../../clients/shared/kyc-upload-helper.service';
|
||||||
|
|
||||||
|
export interface CoOwnerDocSide {
|
||||||
|
front: File | null;
|
||||||
|
back: File | null;
|
||||||
|
frontPreviewUrl: string | null;
|
||||||
|
backPreviewUrl: string | null;
|
||||||
|
number: string;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoOwnerKycVerifyDialogData {
|
||||||
|
docType: 'aadhaar' | 'pan';
|
||||||
|
/** Passed by reference on purpose -- every capture/verify action here mutates the caller's own
|
||||||
|
* object directly, so the result is already in place the instant this dialog closes, no
|
||||||
|
* matter whether it was closed via the Done button, the backdrop, or Esc. */
|
||||||
|
state: CoOwnerDocSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Front/back capture + OTP (Aadhaar) or format (PAN) verification for a Housing Loan co-owner,
|
||||||
|
* lifted into its own popup for exactly the same reason the applicant's own Aadhaar/PAN capture
|
||||||
|
* needed one: that much UI (two upload buttons, thumbnails, a number field, six OTP boxes) makes
|
||||||
|
* a flat document-card grid balloon in height and break its layout.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-co-owner-kyc-verify-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule
|
||||||
|
],
|
||||||
|
templateUrl: './co-owner-kyc-verify-dialog.component.html',
|
||||||
|
styleUrls: ['./co-owner-kyc-verify-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class CoOwnerKycVerifyDialogComponent {
|
||||||
|
data = inject<CoOwnerKycVerifyDialogData>(MAT_DIALOG_DATA);
|
||||||
|
private kycUploadHelper = inject(KycUploadHelperService);
|
||||||
|
|
||||||
|
otpSent = false;
|
||||||
|
otpDigits: string[] = [
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
|
||||||
|
get docLabel(): string {
|
||||||
|
return this.data.docType === 'aadhaar' ? 'Aadhaar' : 'PAN';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onFile(side: 'front' | 'back', event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cropped = await this.kycUploadHelper.pickWithCrop(file, `Co-Owner ${this.docLabel} — ${side}`);
|
||||||
|
if (!cropped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = this.data.state;
|
||||||
|
if (side === 'front') {
|
||||||
|
if (state.frontPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(state.frontPreviewUrl);
|
||||||
|
}
|
||||||
|
state.front = cropped;
|
||||||
|
state.frontPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
|
||||||
|
} else {
|
||||||
|
if (state.backPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(state.backPreviewUrl);
|
||||||
|
}
|
||||||
|
state.back = cropped;
|
||||||
|
state.backPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendOtp(): void {
|
||||||
|
if (this.data.state.number.trim().length !== 12) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.otpSent = true;
|
||||||
|
this.otpDigits = [
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
onOtpDigit(index: number, event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const val = input.value.replace(/\D/g, '');
|
||||||
|
this.otpDigits[index] = val.charAt(0) || '';
|
||||||
|
input.value = this.otpDigits[index];
|
||||||
|
if (this.otpDigits[index] && index < 5) {
|
||||||
|
const next = input.parentElement?.querySelectorAll('input')[index + 1];
|
||||||
|
(next as HTMLInputElement)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOtpKeydown(index: number, event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
|
||||||
|
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
|
||||||
|
if (inputs) {
|
||||||
|
this.otpDigits[index - 1] = '';
|
||||||
|
(inputs[index - 1] as HTMLInputElement).value = '';
|
||||||
|
(inputs[index - 1] as HTMLInputElement).focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local-only "verification" -- no real Aadhaar API is reachable here (no clientId exists for
|
||||||
|
* a co-owner who isn't even a client), same as the applicant's own capture flow. */
|
||||||
|
verifyAadhaar(): void {
|
||||||
|
if (this.otpDigits.every((d) => d !== '')) {
|
||||||
|
this.data.state.verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyPan(): void {
|
||||||
|
if (/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(this.data.state.number.trim().toUpperCase())) {
|
||||||
|
this.data.state.verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h2 mat-dialog-title>{{ data.label }} — Visit Details</h2>
|
||||||
|
<mat-dialog-content class="fvd-content">
|
||||||
|
<span [class]="statusBadgeClass">{{ data.check.status }}</span>
|
||||||
|
|
||||||
|
<dl class="fvd-kv-grid">
|
||||||
|
<div class="fvd-kv-field">
|
||||||
|
<dt>Officer Assigned</dt>
|
||||||
|
<dd>{{ data.check.officerAssigned }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fvd-kv-field">
|
||||||
|
<dt>Assigned On</dt>
|
||||||
|
<dd>{{ data.check.assignedDate }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fvd-kv-field">
|
||||||
|
<dt>GPS Coordinates</dt>
|
||||||
|
<dd>{{ data.check.latitude || '—' }}, {{ data.check.longitude || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fvd-kv-field">
|
||||||
|
<dt>{{ data.check.status === 'FAILED' ? 'Rejected By' : 'Verified By' }}</dt>
|
||||||
|
<dd>{{ data.check.verifiedBy }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fvd-kv-field">
|
||||||
|
<dt>{{ data.check.status === 'FAILED' ? 'Rejected On' : 'Verified On' }}</dt>
|
||||||
|
<dd>{{ data.check.verifiedDate }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fvd-kv-field">
|
||||||
|
<dt>Remarks</dt>
|
||||||
|
<dd>{{ data.check.remarks }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p class="fvd-photos-title">Site Photos ({{ data.check.photoCount }})</p>
|
||||||
|
@if (data.photos.length > 0 || data.storedPhotos.length > 0) {
|
||||||
|
<div class="fvd-photo-grid">
|
||||||
|
@for (photo of data.photos; track $index) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fvd-photo"
|
||||||
|
[class.fvd-photo--zoomable]="!!photo.previewUrl"
|
||||||
|
title="{{ photo.previewUrl ? 'Click to zoom' : photo.file.name }}"
|
||||||
|
(click)="openPhotoZoom(photo.previewUrl)"
|
||||||
|
>
|
||||||
|
@if (photo.previewUrl) {
|
||||||
|
<img [src]="photo.previewUrl" alt="Site photo {{ $index + 1 }}" />
|
||||||
|
} @else {
|
||||||
|
<span class="fvd-photo-label">{{ photo.file.name }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@for (stored of data.storedPhotos; track stored.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fvd-photo fvd-photo--stored"
|
||||||
|
[class.fvd-photo--zoomable]="!!stored.previewUrl"
|
||||||
|
title="{{ stored.previewUrl ? 'Click to zoom' : stored.fileName }}"
|
||||||
|
(click)="stored.previewUrl ? openPhotoZoom(stored.previewUrl) : data.onViewStored(stored)"
|
||||||
|
>
|
||||||
|
@if (stored.previewUrl) {
|
||||||
|
<img [src]="stored.previewUrl" alt="{{ stored.fileName }}" />
|
||||||
|
} @else {
|
||||||
|
<span class="fvd-photo-label">View file</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="fvd-no-photos">No photos on file for this visit.</p>
|
||||||
|
}
|
||||||
|
<div #lightboxRoot class="fvd-lightbox-root"></div>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.fvd-content {
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 640px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
|
||||||
|
&--verified {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failed {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-kv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-kv-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-photos-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-photo {
|
||||||
|
height: 90px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-photo--stored {
|
||||||
|
&:hover {
|
||||||
|
border-color: #94a3b8;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any tile that opens the zoomable lightbox on click -- local captures and stored photos alike.
|
||||||
|
.fvd-photo--zoomable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-lightbox-root {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-photo-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 0 6px;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fvd-no-photos {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* 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, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import lightGallery from 'lightgallery';
|
||||||
|
import lgZoom from 'lightgallery/plugins/zoom';
|
||||||
|
import lgFullscreen from 'lightgallery/plugins/fullscreen';
|
||||||
|
import type { LightGallery } from 'lightgallery/lightgallery';
|
||||||
|
import type { GalleryItem } from 'lightgallery/lg-utils';
|
||||||
|
import { VerificationCheck } from '../../loan-origination-data';
|
||||||
|
|
||||||
|
export interface FieldVisitPhoto {
|
||||||
|
file: File;
|
||||||
|
previewUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldVisitDetailsDialogData {
|
||||||
|
label: string;
|
||||||
|
check: VerificationCheck;
|
||||||
|
photos: FieldVisitPhoto[];
|
||||||
|
/** Photos already in the document store, i.e. captured in an earlier session -- `photos` above
|
||||||
|
* only ever holds files picked in the current browser tab. `previewUrl` is filled in by the
|
||||||
|
* caller (downloaded up front, in parallel) so these render as real thumbnails, not just a
|
||||||
|
* file-name placeholder; it's `null` only if that download failed. */
|
||||||
|
storedPhotos: { id: number; fileName?: string; previewUrl: string | null }[];
|
||||||
|
onViewStored: (doc: { id: number; fileName?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything saved for one Field Investigation address visit -- officer, assignment date, GPS,
|
||||||
|
* status/remarks and every site photo -- in one popup, since the inline card only ever shows a
|
||||||
|
* short summary once the visit is Verified/Failed.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-field-visit-details-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule
|
||||||
|
],
|
||||||
|
templateUrl: './field-visit-details-dialog.component.html',
|
||||||
|
styleUrls: ['./field-visit-details-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class FieldVisitDetailsDialogComponent implements OnDestroy {
|
||||||
|
data = inject<FieldVisitDetailsDialogData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
|
||||||
|
private lightboxInstance: LightGallery | null = null;
|
||||||
|
private readonly lightboxPlugins = [
|
||||||
|
lgZoom,
|
||||||
|
lgFullscreen
|
||||||
|
];
|
||||||
|
|
||||||
|
get statusBadgeClass(): string {
|
||||||
|
if (this.data.check.status === 'VERIFIED') {
|
||||||
|
return 'fvd-badge fvd-badge--verified';
|
||||||
|
}
|
||||||
|
if (this.data.check.status === 'FAILED') {
|
||||||
|
return 'fvd-badge fvd-badge--failed';
|
||||||
|
}
|
||||||
|
return 'fvd-badge fvd-badge--pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every viewable photo -- local captures first, then stored ones -- in the same order the grid
|
||||||
|
* renders them. `openPhotoZoom` locates the clicked thumbnail in here by URL, not position, so
|
||||||
|
* this list can freely skip photos with no preview without throwing anything out of sync. */
|
||||||
|
private get galleryItems(): GalleryItem[] {
|
||||||
|
const local = this.data.photos
|
||||||
|
.filter((p) => !!p.previewUrl)
|
||||||
|
.map((p, i) => ({
|
||||||
|
src: p.previewUrl as string,
|
||||||
|
subHtml: `<div class="lg-caption"><h4>Site photo ${i + 1}</h4></div>`
|
||||||
|
}));
|
||||||
|
const stored = this.data.storedPhotos
|
||||||
|
.filter((p) => !!p.previewUrl)
|
||||||
|
.map((p) => ({
|
||||||
|
src: p.previewUrl as string,
|
||||||
|
subHtml: `<div class="lg-caption"><h4>${p.fileName || 'Site photo'}</h4></div>`
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
...local,
|
||||||
|
...stored
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the site photos in the same zoomable, pannable lightbox used for cheque/signature
|
||||||
|
* images app-wide, starting on whichever thumbnail was clicked -- matched by preview URL rather
|
||||||
|
* than array index, since `galleryItems` skips any photo without one (e.g. a failed stored
|
||||||
|
* download), which would otherwise throw the two lists out of sync. */
|
||||||
|
openPhotoZoom(previewUrl: string | null): void {
|
||||||
|
if (!previewUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = this.galleryItems;
|
||||||
|
const startIndex = items.findIndex((item) => item.src === previewUrl);
|
||||||
|
if (startIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.destroyLightbox();
|
||||||
|
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
|
||||||
|
dynamic: true,
|
||||||
|
dynamicEl: items,
|
||||||
|
plugins: this.lightboxPlugins,
|
||||||
|
download: false,
|
||||||
|
closable: true,
|
||||||
|
escKey: true,
|
||||||
|
zoomFromOrigin: true
|
||||||
|
});
|
||||||
|
this.lightboxInstance.openGallery(startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyLightbox(): void {
|
||||||
|
if (this.lightboxInstance) {
|
||||||
|
this.lightboxInstance.destroy();
|
||||||
|
this.lightboxInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroyLightbox();
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h2 mat-dialog-title>Share Account Details</h2>
|
||||||
|
<mat-dialog-content class="sadd-content">
|
||||||
|
<div class="sadd-grid">
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Account No.</span>
|
||||||
|
<span class="sadd-value">{{ account.accountNo ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Product</span>
|
||||||
|
<span class="sadd-value">{{ account.productName ?? account.productId?.name ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Status</span>
|
||||||
|
<span class="sadd-value">{{ statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Currency</span>
|
||||||
|
<span class="sadd-value">{{ account.currency?.name ?? account.currency?.code ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Approved Shares</span>
|
||||||
|
<span class="sadd-value">{{ account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Pending-for-Approval Shares</span>
|
||||||
|
<span class="sadd-value">{{
|
||||||
|
account.summary?.totalPendingForApprovalShares ?? account.totalPendingForApprovalShares ?? 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Submitted On</span>
|
||||||
|
<span class="sadd-value">{{ submittedDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Activated On</span>
|
||||||
|
<span class="sadd-value">{{ activatedDate }}</span>
|
||||||
|
</div>
|
||||||
|
@if (account.savingsAccountNumber) {
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">Linked Savings A/c (Dividend Posting)</span>
|
||||||
|
<span class="sadd-value">{{ account.savingsAccountNumber }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (account.externalId) {
|
||||||
|
<div class="sadd-field">
|
||||||
|
<span class="sadd-label">External ID</span>
|
||||||
|
<span class="sadd-value">{{ account.externalId }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.sadd-content {
|
||||||
|
min-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sadd-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 14px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sadd-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sadd-label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sadd-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 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 { CommonModule } from '@angular/common';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only popup showing a real Fineract share account's details, opened from the Loan
|
||||||
|
* Origination application-detail page's "Share Member" section for an application that's linked
|
||||||
|
* to a real existing client (see `LoanApplication.clientId`) -- lets the officer confirm the
|
||||||
|
* customer's actual membership/holding without leaving the loan sanctioning screen.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-account-details-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule
|
||||||
|
],
|
||||||
|
templateUrl: './share-account-details-dialog.component.html',
|
||||||
|
styleUrls: ['./share-account-details-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class ShareAccountDetailsDialogComponent {
|
||||||
|
data = inject(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
get account(): any {
|
||||||
|
return this.data?.account ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusLabel(): string {
|
||||||
|
return this.account?.status?.value ?? this.account?.status?.code ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
get activatedDate(): string {
|
||||||
|
return this.formatDate(this.account?.timeline?.activatedOnDate ?? this.account?.timeline?.activatedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
get submittedDate(): string {
|
||||||
|
return this.formatDate(this.account?.timeline?.submittedOnDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(value: any): string {
|
||||||
|
if (!Array.isArray(value) || value.length < 3) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const [
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day
|
||||||
|
] = value;
|
||||||
|
return `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="container lec-container">
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-content>
|
||||||
|
<h2 class="lec-title">RBI Loan Eligibility Checklist</h2>
|
||||||
|
<p class="lec-subtitle">
|
||||||
|
Run the bank's RBI-circular-based eligibility checks for any client, independently of the loan application
|
||||||
|
itself.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (!selectedClient) {
|
||||||
|
<div class="lec-search">
|
||||||
|
<mat-form-field class="lec-search-field">
|
||||||
|
<mat-label>Search Client (name, account no., mobile...)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="searchText" (ngModelChange)="onSearch($event)" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (searching) {
|
||||||
|
<p class="lec-hint">Searching…</p>
|
||||||
|
} @else if (searchText.length >= 2 && searchResults.length === 0) {
|
||||||
|
<p class="lec-hint">No clients found.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults.length > 0) {
|
||||||
|
<div class="lec-results">
|
||||||
|
@for (client of searchResults; track client.id) {
|
||||||
|
<button type="button" class="lec-result-row" (click)="selectClient(client)">
|
||||||
|
<span class="lec-result-name">{{ client.displayName }}</span>
|
||||||
|
<span class="lec-result-meta">{{ client.accountNo ?? client.officeName ?? '' }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="lec-selected-client">
|
||||||
|
<div>
|
||||||
|
<span class="lec-selected-label">Client</span>
|
||||||
|
<span class="lec-selected-name">{{ selectedClient.displayName }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" mat-button (click)="changeClient()">Change Client</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field class="lec-amount-field">
|
||||||
|
<mat-label>Proposed Loan Amount (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="loanAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mifosx-rbi-compliance-step [clientId]="selectedClient.id" [loanAmount]="loanAmount">
|
||||||
|
</mifosx-rbi-compliance-step>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.lec-container {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-subtitle {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13.5px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-search {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-search-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-hint {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin: -8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-results {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-result-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-result-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-result-meta {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-selected-client {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-selected-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-selected-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lec-amount-field {
|
||||||
|
max-width: 320px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
|
import { RbiComplianceStepComponent } from '../rbi-compliance-step/rbi-compliance-step.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone RBI eligibility checklist -- a loan officer picks any client and runs the same
|
||||||
|
* RBI-circular-based checks (age, area of operation, PSL classification, director/related-party,
|
||||||
|
* wilful defaulter, exposure norms, LTV/margin, fair practices code) independently of the loan
|
||||||
|
* account creation wizard, before or while a loan is being considered for that client.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-loan-eligibility-checklist',
|
||||||
|
templateUrl: './loan-eligibility-checklist.component.html',
|
||||||
|
styleUrls: ['./loan-eligibility-checklist.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FormsModule,
|
||||||
|
RbiComplianceStepComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LoanEligibilityChecklistComponent {
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
|
||||||
|
searchText = '';
|
||||||
|
searchResults: any[] = [];
|
||||||
|
searching = false;
|
||||||
|
private searchTimer: any;
|
||||||
|
|
||||||
|
selectedClient: { id: number; displayName: string } | null = null;
|
||||||
|
loanAmount: number | null = null;
|
||||||
|
|
||||||
|
onSearch(text: string): void {
|
||||||
|
this.searchText = text;
|
||||||
|
clearTimeout(this.searchTimer);
|
||||||
|
if (!text || text.length < 2) {
|
||||||
|
this.searchResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.searching = true;
|
||||||
|
this.searchTimer = setTimeout(() => {
|
||||||
|
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
|
||||||
|
next: (data: any) => {
|
||||||
|
this.searchResults = data?.content || [];
|
||||||
|
this.searching = false;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.searchResults = [];
|
||||||
|
this.searching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectClient(client: any): void {
|
||||||
|
this.selectedClient = { id: client.id, displayName: client.displayName };
|
||||||
|
this.searchResults = [];
|
||||||
|
this.searchText = '';
|
||||||
|
this.loanAmount = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeClient(): void {
|
||||||
|
this.selectedClient = null;
|
||||||
|
this.loanAmount = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user