Compare commits
94
Commits
uate
..
03f2f1d794
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03f2f1d794 | ||
|
|
ee47a1ce68 | ||
|
|
9739ff0758 | ||
|
|
7a2cb25538 | ||
|
|
6624009d8c | ||
|
|
cb3e43fd44 | ||
|
|
e89b1cfc23 | ||
|
|
44b23ef110 | ||
|
|
9f95d01196 | ||
|
|
07a4c8b8e3 | ||
|
|
fe81d159f1 | ||
|
|
da1f57c3b6 | ||
|
|
ce840d73d7 | ||
|
|
0d145c72a6 | ||
|
|
79ffde51ff | ||
|
|
83e58376cb | ||
|
|
395fb8ace8 | ||
|
|
1273bdbe0e | ||
|
|
0b79b0b0d3 | ||
|
|
f0e059c621 | ||
|
|
5c76aa6b27 | ||
|
|
e58fd3c79a | ||
|
|
1b2ef5126e | ||
|
|
b953afe2da | ||
|
|
ff781e50e3 | ||
|
|
7ef75c902d | ||
|
|
c18219fce5 | ||
|
|
2d93f80d50 | ||
|
|
1d1e76497c | ||
|
|
352a65cb5d | ||
|
|
8d67a22f18 | ||
|
|
997e8920e4 | ||
|
|
5581d4180d | ||
|
|
75df8865dd | ||
|
|
5a6fb1f9b5 | ||
|
|
f379b2415c | ||
|
|
c34f6a0131 | ||
|
|
7b895a0e2b | ||
|
|
01e86173c1 | ||
|
|
4281a596af | ||
|
|
9f9c9bfab6 | ||
|
|
513e863bba | ||
|
|
13333088d7 | ||
|
|
89bb76455e | ||
|
|
7e69468995 | ||
|
|
63ab9643ce | ||
|
|
8255c8398d | ||
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
b609f19e9f | ||
|
|
7f836f6f71 | ||
|
|
a5124ca3e8 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
504d7ee7f6 | ||
|
|
26091c68b0 | ||
|
|
f8c532f74a | ||
|
|
44b41e504a | ||
|
|
e8d91c8f60 | ||
|
|
8dd9767bdf | ||
|
|
c868beb344 | ||
|
|
b43ed87771 | ||
|
|
ebe67cc410 | ||
|
|
d7f5bfe5f5 | ||
|
|
be6103e0b3 | ||
|
|
5cd9998cfc | ||
|
|
0ca6842bde | ||
|
|
3030e2f34e | ||
|
|
b4608cbbe4 | ||
|
|
27cdaf7fc2 | ||
|
|
90beea64cb | ||
|
|
1317a93838 | ||
|
|
0832017508 | ||
|
|
8ac4010967 | ||
|
|
1c125d97c5 | ||
|
|
c935e2a62a | ||
|
|
2a9df828ca | ||
|
|
bd753f4983 | ||
|
|
4261f9659e | ||
|
|
63ac900e42 | ||
|
|
fd28788d23 | ||
|
|
b9b37397fe | ||
|
|
f2525080c8 | ||
|
|
a982083b14 | ||
|
|
43e3c546a7 | ||
|
|
b68b25f789 | ||
|
|
1dc378c2dd |
+2
-2
@@ -69,8 +69,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "13mb",
|
"maximumWarning": "15mb",
|
||||||
"maximumError": "14mb"
|
"maximumError": "17mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
+8
-4
@@ -15,9 +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:demo": "npm run env -s && ng serve --proxy-config proxy.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",
|
||||||
"serve:dev": "npm run env -s && ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
|
||||||
|
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
||||||
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
|
"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",
|
||||||
@@ -35,7 +36,8 @@
|
|||||||
"playwright:headed": "playwright test --headed",
|
"playwright:headed": "playwright test --headed",
|
||||||
"playwright:debug": "playwright test --debug",
|
"playwright:debug": "playwright test --debug",
|
||||||
"headers:check": "node scripts/check-file-headers.js",
|
"headers:check": "node scripts/check-file-headers.js",
|
||||||
"headers:add": "node scripts/add-file-headers.js"
|
"headers:add": "node scripts/add-file-headers.js",
|
||||||
|
"mock:dip": "node \"../cbsnexora-backend/scripts/mock-dip-lookup-service.js\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "20.3.16",
|
"@angular/animations": "20.3.16",
|
||||||
@@ -72,6 +74,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",
|
||||||
@@ -105,6 +108,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",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { CreateFinancialActivityMappingComponent } from './financial-activity-ma
|
|||||||
import { ViewFinancialActivityMappingComponent } from './financial-activity-mappings/view-financial-activity-mapping/view-financial-activity-mapping.component';
|
import { ViewFinancialActivityMappingComponent } from './financial-activity-mappings/view-financial-activity-mapping/view-financial-activity-mapping.component';
|
||||||
import { EditFinancialActivityMappingComponent } from './financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component';
|
import { EditFinancialActivityMappingComponent } from './financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component';
|
||||||
import { MigrateOpeningBalancesComponent } from './migrate-opening-balances/migrate-opening-balances.component';
|
import { MigrateOpeningBalancesComponent } from './migrate-opening-balances/migrate-opening-balances.component';
|
||||||
|
import { MigrateCashDenominationComponent } from './migrate-cash-denomination/migrate-cash-denomination.component';
|
||||||
import { ChartOfAccountsComponent } from './chart-of-accounts/chart-of-accounts.component';
|
import { ChartOfAccountsComponent } from './chart-of-accounts/chart-of-accounts.component';
|
||||||
import { CreateGlAccountComponent } from './chart-of-accounts/create-gl-account/create-gl-account.component';
|
import { CreateGlAccountComponent } from './chart-of-accounts/create-gl-account/create-gl-account.component';
|
||||||
import { ViewGlAccountComponent } from './chart-of-accounts/view-gl-account/view-gl-account.component';
|
import { ViewGlAccountComponent } from './chart-of-accounts/view-gl-account/view-gl-account.component';
|
||||||
@@ -185,6 +186,14 @@ const routes: Routes = [
|
|||||||
currencies: CurrenciesResolver
|
currencies: CurrenciesResolver
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'migrate-cash-denomination',
|
||||||
|
component: MigrateCashDenominationComponent,
|
||||||
|
data: { title: 'Migrate Cash Denomination', breadcrumb: 'Migrate Cash Denomination' },
|
||||||
|
resolve: {
|
||||||
|
offices: OfficesResolver
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'chart-of-accounts',
|
path: 'chart-of-accounts',
|
||||||
data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' },
|
data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' },
|
||||||
|
|||||||
@@ -88,6 +88,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Migrate Cash Denomination -->
|
||||||
|
<a
|
||||||
|
class="acc-card"
|
||||||
|
[routerLink]="['migrate-cash-denomination']"
|
||||||
|
*mifosxHasPermission="'READ_JOURNALENTRY'"
|
||||||
|
#migrateCashDenomination
|
||||||
|
>
|
||||||
|
<div class="acc-card__icon amber"><i class="fas fa-coins"></i></div>
|
||||||
|
<div class="acc-card__info">
|
||||||
|
<span class="acc-card__title">Migrate Cash Denomination</span>
|
||||||
|
<span class="acc-card__desc">
|
||||||
|
Count the vault opening cash into notes and coins, and open the first business day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Chart of Accounts -->
|
<!-- Chart of Accounts -->
|
||||||
<a class="acc-card" [routerLink]="['chart-of-accounts']" *mifosxHasPermission="'READ_GLACCOUNT'" #chartofAccounts>
|
<a class="acc-card" [routerLink]="['chart-of-accounts']" *mifosxHasPermission="'READ_GLACCOUNT'" #chartofAccounts>
|
||||||
<div class="acc-card__icon blue"><i class="fas fa-sitemap"></i></div>
|
<div class="acc-card__icon blue"><i class="fas fa-sitemap"></i></div>
|
||||||
|
|||||||
@@ -32,6 +32,48 @@ 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much profit a share dividend may actually be declared out of — net profit reduced by the
|
||||||
|
* statutory appropriations that legally precede a dividend (Statutory Reserve Fund, education
|
||||||
|
* fund) and by dividends already declared for other share products over an overlapping period.
|
||||||
|
* @param {string} fromDate Period start, yyyy-MM-dd.
|
||||||
|
* @param {string} toDate Period end, yyyy-MM-dd.
|
||||||
|
* @param {string} excludeProductId Leave this product's own declarations out of `alreadyDeclared`
|
||||||
|
* (its overlapping declaration is separately blocked outright, so counting it would double up).
|
||||||
|
* @returns {Observable<any>} `{ netProfit, statutoryReserveAmount, educationFundAmount,
|
||||||
|
* distributableProfit, alreadyDeclared, availableForDeclaration, maximumRatePercentage,
|
||||||
|
* existingDeclarations[] }`.
|
||||||
|
*/
|
||||||
|
getDistributableProfit(fromDate: string, toDate: string, excludeProductId?: string): Observable<any> {
|
||||||
|
let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
|
||||||
|
if (excludeProductId) {
|
||||||
|
httpParams = httpParams.set('excludeProductId', excludeProductId);
|
||||||
|
}
|
||||||
|
return this.http.get('/accounting/distributable-profit', { 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.
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
<!--
|
||||||
|
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 mcd">
|
||||||
|
<!-- Step 1: which branch's vault is being counted. -->
|
||||||
|
<mat-card class="mcd-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="mcd-intro">
|
||||||
|
<mat-icon>inventory_2</mat-icon>
|
||||||
|
<span>
|
||||||
|
Count the branch's opening cash into notes and coins. If the Cash GL account has no balance yet, this count
|
||||||
|
defines it; if it already carries one, the count must match it exactly. Either way, this also opens the
|
||||||
|
branch's first business day with the same count.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcd-row">
|
||||||
|
<mat-form-field class="mcd-office">
|
||||||
|
<mat-label>Branch</mat-label>
|
||||||
|
<mat-select required [(ngModel)]="officeId" (selectionChange)="onOfficeChange()">
|
||||||
|
@for (office of officeData; track office) {
|
||||||
|
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="mcd-field" (click)="datePicker.open()">
|
||||||
|
<mat-label>Business Date</mat-label>
|
||||||
|
<input matInput [max]="maxDate" [matDatepicker]="datePicker" [(ngModel)]="businessDate" readonly />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="datePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #datePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
@if (glLoading) {
|
||||||
|
<div class="mcd-loading">Checking the cash GL account…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (glError) {
|
||||||
|
<div class="mcd-gl mcd-gl--error">
|
||||||
|
<mat-icon>error</mat-icon>
|
||||||
|
<div>{{ glError }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (glStatus && !glError) {
|
||||||
|
<!-- Step 2: what the ledger already says, so the count is counted against something concrete. -->
|
||||||
|
<div class="mcd-gl" [class.mcd-gl--fresh]="!glHasBalance">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
<div>
|
||||||
|
<div class="mcd-gl__label">Cash GL Account</div>
|
||||||
|
<div class="mcd-gl__value">({{ glStatus.glCode }}) {{ glStatus.glAccountName }}</div>
|
||||||
|
@if (glHasBalance) {
|
||||||
|
<div class="mcd-gl__hint">
|
||||||
|
Already holds
|
||||||
|
<strong>₹{{ glTarget.toLocaleString('en-IN') }}</strong>
|
||||||
|
— count the drawer to exactly this amount.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="mcd-gl__hint">
|
||||||
|
No opening balance posted yet — whatever you count below becomes this account's opening balance.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: the actual count. -->
|
||||||
|
<mat-card class="mcd-card mcd-card--flush">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="mcd-grid mcd-grid--head">
|
||||||
|
<div class="mcd-grid__denom">Denomination</div>
|
||||||
|
<div class="mcd-grid__count">Count</div>
|
||||||
|
<div class="mcd-grid__amt">Amount</div>
|
||||||
|
</div>
|
||||||
|
@for (d of allDenoms; track d.id) {
|
||||||
|
<div class="mcd-grid mcd-grid--row">
|
||||||
|
<div class="mcd-grid__denom">
|
||||||
|
{{ d.label }} <span class="mcd-grid__kind">{{ d.kind }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcd-grid__count">
|
||||||
|
<button type="button" mat-icon-button (click)="bumpCount(d, -1)"><mat-icon>remove</mat-icon></button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="mcd-grid__input"
|
||||||
|
[ngModel]="countFor(d)"
|
||||||
|
(ngModelChange)="onCountChange(d, $event)"
|
||||||
|
/>
|
||||||
|
<button type="button" mat-icon-button (click)="bumpCount(d, 1)"><mat-icon>add</mat-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="mcd-grid__amt">₹{{ subtotalFor(d).toLocaleString('en-IN') }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- The one number that matters. -->
|
||||||
|
<div class="mcd-balance" [class.mcd-balance--ok]="isReconciled">
|
||||||
|
<div class="mcd-balance__figure">
|
||||||
|
<span class="mcd-balance__label">Counted Total</span>
|
||||||
|
<span class="mcd-balance__amt">₹{{ countedTotal.toLocaleString('en-IN') }}</span>
|
||||||
|
</div>
|
||||||
|
@if (glHasBalance) {
|
||||||
|
<div class="mcd-balance__figure">
|
||||||
|
<span class="mcd-balance__label">GL Target</span>
|
||||||
|
<span class="mcd-balance__amt">₹{{ glTarget.toLocaleString('en-IN') }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mcd-balance__status">
|
||||||
|
@if (countedTotal <= 0) {
|
||||||
|
<mat-icon>edit_note</mat-icon>
|
||||||
|
<span>Count each denomination above</span>
|
||||||
|
} @else if (isReconciled) {
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
<span>{{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }}</span>
|
||||||
|
} @else {
|
||||||
|
<mat-icon>error</mat-icon>
|
||||||
|
<span
|
||||||
|
>Off by ₹{{
|
||||||
|
(countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString('en-IN')
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (needsDifferenceReason) {
|
||||||
|
<mat-card class="mcd-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-form-field class="mcd-reason">
|
||||||
|
<mat-label>Why does the count differ from the GL balance?</mat-label>
|
||||||
|
<input matInput [(ngModel)]="differenceReason" />
|
||||||
|
</mat-form-field>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (errorMsg) {
|
||||||
|
<div class="mcd-error">{{ errorMsg }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (successMsg) {
|
||||||
|
<div class="mcd-success">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
<div>
|
||||||
|
{{ successMsg }}
|
||||||
|
<div class="mcd-success__actions">
|
||||||
|
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!successMsg) {
|
||||||
|
<div class="mcd-actions">
|
||||||
|
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
|
||||||
|
{{ submitting ? 'Submitting…' : 'Migrate & Open Business Day' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mcd {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-card {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&--flush mat-card-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-intro {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-office {
|
||||||
|
flex: 1 1 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-field {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-loading {
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-gl {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 4px solid #0ea5e9;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #0ea5e9;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--fresh {
|
||||||
|
border-left-color: #f59e0b;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
border-left-color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
margin: 2px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 160px 120px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
|
||||||
|
&--head {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--row {
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__denom {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__kind {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-left: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-balance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-left: 4px solid #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
border-left-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__figure {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #b91c1c;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok &__status {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-reason {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-error {
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-success {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #14532d;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #16a34a;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcd-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 560px) {
|
||||||
|
.mcd-grid {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
row-gap: 4px;
|
||||||
|
|
||||||
|
&--head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* 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 { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { catchError, of } from 'rxjs';
|
||||||
|
import { CashierService } from '../../cashier/cashier.service';
|
||||||
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
interface Denom {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
kind: 'note' | 'coin';
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate Cash Denomination — the vault-side counterpart to Migrate Opening Balances.
|
||||||
|
*
|
||||||
|
* A freshly set-up institution's chart of accounts carries a "Cash at Vault" GL balance (defined via
|
||||||
|
* Migrate Opening Balances, or not yet defined at all), but that figure is meaningless to a cashier
|
||||||
|
* standing at the drawer until it is broken into actual notes and coins, and the branch has nowhere
|
||||||
|
* to track that breakdown until its first business day is open. This screen is the one place both of
|
||||||
|
* those happen together: count the drawer, and it either defines the cash GL's opening balance from
|
||||||
|
* that count (nothing posted to it yet) or checks the count against what is already posted, then
|
||||||
|
* opens the business day with that same breakdown - so a new bank goes from "GL seeded" straight to
|
||||||
|
* "vault countable" without a separate trip through Manage Tellers or Open Business Day first.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-migrate-cash-denomination',
|
||||||
|
templateUrl: './migrate-cash-denomination.component.html',
|
||||||
|
styleUrls: ['./migrate-cash-denomination.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule,
|
||||||
|
...STANDALONE_SHARED_IMPORTS
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MigrateCashDenominationComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
officeData: any[] = [];
|
||||||
|
officeId: number | null = null;
|
||||||
|
/** Bound to the datepicker as a Date, converted to a strict yyyy-MM-dd string only when
|
||||||
|
* submitting — the branchday endpoints parse this with LocalDate.parse() directly, with no
|
||||||
|
* locale/dateFormat hint alongside it, so a locale-formatted string (what dateUtils.formatDate
|
||||||
|
* produces) is rejected outright. */
|
||||||
|
businessDate: Date = new Date();
|
||||||
|
maxDate = new Date();
|
||||||
|
|
||||||
|
glLoading = false;
|
||||||
|
glStatus: any = null;
|
||||||
|
glError = '';
|
||||||
|
|
||||||
|
notes: Denom[] = [];
|
||||||
|
coins: Denom[] = [];
|
||||||
|
counts: Record<number, number> = {};
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
errorMsg = '';
|
||||||
|
successMsg = '';
|
||||||
|
result: any = null;
|
||||||
|
|
||||||
|
/** Only asked for when the counted total disagrees with an already-posted GL balance — mirrors
|
||||||
|
* the same "explain the gap" gate Open Business Day applies. */
|
||||||
|
differenceReason = '';
|
||||||
|
|
||||||
|
get allDenoms(): Denom[] {
|
||||||
|
return [
|
||||||
|
...this.notes,
|
||||||
|
...this.coins
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.maxDate = this.settingsService.businessDate;
|
||||||
|
this.businessDate = this.maxDate;
|
||||||
|
|
||||||
|
this.route.data.subscribe((data: { offices: any }) => {
|
||||||
|
this.officeData = data.offices || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.service.getActiveDenominations().subscribe({
|
||||||
|
next: (list) => {
|
||||||
|
const rows: Denom[] = (list || []).map((x: any) => ({
|
||||||
|
id: x.id,
|
||||||
|
value: x.denomination,
|
||||||
|
kind: x.kind,
|
||||||
|
label: '₹' + x.denomination
|
||||||
|
}));
|
||||||
|
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
|
||||||
|
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.notes = [];
|
||||||
|
this.coins = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onOfficeChange(): void {
|
||||||
|
this.glStatus = null;
|
||||||
|
this.glError = '';
|
||||||
|
this.result = null;
|
||||||
|
this.successMsg = '';
|
||||||
|
this.errorMsg = '';
|
||||||
|
if (!this.officeId) return;
|
||||||
|
|
||||||
|
this.glLoading = true;
|
||||||
|
this.service
|
||||||
|
.cashGlStatus(this.officeId)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.glLoading = false;
|
||||||
|
this.glStatus = res;
|
||||||
|
if (res && res.configured === false) {
|
||||||
|
this.glError = res.message || 'No cash GL account is configured for this tenant yet.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
countFor(d: Denom): number {
|
||||||
|
return this.counts[d.id] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
subtotalFor(d: Denom): number {
|
||||||
|
return this.countFor(d) * d.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCountChange(d: Denom, raw: any): void {
|
||||||
|
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpCount(d: Denom, delta: number): void {
|
||||||
|
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
get countedTotal(): number {
|
||||||
|
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this office's GL already carries a balance the count has to match, vs. an empty
|
||||||
|
* account the count itself will define. */
|
||||||
|
get glHasBalance(): boolean {
|
||||||
|
return !!this.glStatus?.hasBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
get glTarget(): number {
|
||||||
|
return Number(this.glStatus?.currentBalance ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReconciled(): boolean {
|
||||||
|
if (!this.glStatus || this.glStatus.configured === false) return false;
|
||||||
|
if (this.countedTotal <= 0) return false;
|
||||||
|
return !this.glHasBalance || this.countedTotal === this.glTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
get needsDifferenceReason(): boolean {
|
||||||
|
return (
|
||||||
|
this.glHasBalance && this.countedTotal > 0 && this.countedTotal !== this.glTarget && !this.differenceReason.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSubmit(): boolean {
|
||||||
|
return (
|
||||||
|
!!this.officeId &&
|
||||||
|
!!this.businessDate &&
|
||||||
|
!this.glError &&
|
||||||
|
this.countedTotal > 0 &&
|
||||||
|
!this.needsDifferenceReason &&
|
||||||
|
!this.submitting
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private denominationPayload() {
|
||||||
|
return this.allDenoms
|
||||||
|
.filter((d) => this.countFor(d) > 0)
|
||||||
|
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict yyyy-MM-dd, independent of the viewer's locale/date-format setting — see the comment on
|
||||||
|
* `businessDate` for why this can't go through dateUtils.formatDate(). */
|
||||||
|
private toIsoDate(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (!this.canSubmit || !this.officeId) return;
|
||||||
|
this.submitting = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.successMsg = '';
|
||||||
|
this.service
|
||||||
|
.migrateCashDenomination(
|
||||||
|
this.officeId,
|
||||||
|
this.toIsoDate(this.businessDate),
|
||||||
|
this.denominationPayload(),
|
||||||
|
this.differenceReason.trim() || undefined
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||||
|
err?.error?.message ??
|
||||||
|
'Could not migrate cash denomination.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.submitting = false;
|
||||||
|
if (res) {
|
||||||
|
this.result = res;
|
||||||
|
this.successMsg = res.glOpeningBalancePosted
|
||||||
|
? `Cash GL opening balance defined at ₹${this.countedTotal.toLocaleString('en-IN')} and business day ${res.businessDate} opened.`
|
||||||
|
: `Business day ${res.businessDate} opened — count matched the existing GL balance.`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToBranchDay(): void {
|
||||||
|
this.router.navigate(['/branch-ops/day-register']);
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
-109
@@ -6,48 +6,67 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!-- TODO: Responsiveness/design enhancement and reduction in form loading time -->
|
<div class="container mob">
|
||||||
<div class="container">
|
<form [formGroup]="openingBalancesForm" (ngSubmit)="submit()">
|
||||||
<mat-card>
|
<!-- Step 1: pick the branch whose old books are being carried into this GL. -->
|
||||||
<form [formGroup]="openingBalancesForm" (ngSubmit)="submit()">
|
<mat-card class="mob-card">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div #searchFormRef class="layout-row-wrap responsive-column gap-2px">
|
<div class="mob-intro">
|
||||||
<div class="m-r-30 flex-83">
|
<mat-icon>info</mat-icon>
|
||||||
<mat-form-field class="flex-96">
|
<span>
|
||||||
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
|
Bring a branch's existing balances into this system's ledger, one time, as a single balanced entry against
|
||||||
<mat-select required formControlName="officeId">
|
the Opening Balances Contra account below.
|
||||||
@for (office of officeData; track office) {
|
</span>
|
||||||
<mat-option [value]="office.id">
|
|
||||||
{{ office.name }}
|
|
||||||
</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
@if (openingBalancesForm.controls.officeId.hasError('required')) {
|
|
||||||
<mat-error>
|
|
||||||
{{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }}
|
|
||||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-15">
|
|
||||||
<button mat-raised-button color="primary" (click)="retrieveOpeningBalances()">
|
|
||||||
{{ 'labels.buttons.Retrieve' | translate }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div #searchFormRef class="mob-row">
|
||||||
|
<mat-form-field class="mob-office">
|
||||||
|
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
|
||||||
|
<mat-select required formControlName="officeId">
|
||||||
|
@for (office of officeData; track office) {
|
||||||
|
<mat-option [value]="office.id">
|
||||||
|
{{ office.name }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (openingBalancesForm.controls.officeId.hasError('required')) {
|
||||||
|
<mat-error>
|
||||||
|
{{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
|
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
[disabled]="!openingBalancesForm.controls.officeId.value"
|
||||||
|
(click)="retrieveOpeningBalances()"
|
||||||
|
>
|
||||||
|
{{ 'labels.buttons.Retrieve' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
@if (openingBalancesData) {
|
@if (openingBalancesData) {
|
||||||
<div class="layout-row-wrap gap-2percent responsive-column">
|
<!-- Step 2: the contra account this entry balances against, plus currency and date. -->
|
||||||
<mifosx-gl-account-display
|
<mat-card class="mob-card">
|
||||||
class="flex-100"
|
<mat-card-content>
|
||||||
[accountTitle]="'Opening Balances Contra Account'"
|
<div class="mob-contra">
|
||||||
[glAccount]="openingBalancesData.contraAccount"
|
<mat-icon>account_balance</mat-icon>
|
||||||
[withTitle]="'50%'"
|
<div>
|
||||||
>
|
<div class="mob-contra__label">Opening Balances Contra Account</div>
|
||||||
</mifosx-gl-account-display>
|
<div class="mob-contra__value">
|
||||||
<mat-form-field class="flex-48">
|
<mifosx-gl-account-display [glAccount]="openingBalancesData.contraAccount"></mifosx-gl-account-display>
|
||||||
|
</div>
|
||||||
|
<div class="mob-contra__hint">
|
||||||
|
Every debit and credit below posts against this one account, so the whole entry always balances to zero.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mob-row">
|
||||||
|
<mat-form-field class="mob-field">
|
||||||
<mat-label>{{ 'labels.inputs.Currency' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Currency' | translate }}</mat-label>
|
||||||
<mat-select required formControlName="currencyCode">
|
<mat-select required formControlName="currencyCode">
|
||||||
@for (currency of currencyData; track currency) {
|
@for (currency of currencyData; track currency) {
|
||||||
@@ -63,7 +82,7 @@
|
|||||||
</mat-error>
|
</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field class="flex-48" (click)="openingBalancesDatePicker.open()">
|
<mat-form-field class="mob-field" (click)="openingBalancesDatePicker.open()">
|
||||||
<mat-label>{{ 'labels.inputs.Opening Balances Date' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Opening Balances Date' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
@@ -82,79 +101,103 @@
|
|||||||
</mat-error>
|
</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<div class="layout-row-wrap gap-2percent responsive-column flex-100 content">
|
</div>
|
||||||
<div class="header flex-8">
|
</mat-card-content>
|
||||||
{{ 'labels.inputs.Type' | translate }}
|
</mat-card>
|
||||||
</div>
|
|
||||||
<div class="header flex-18">
|
<!-- The one number that matters: does the sheet balance yet. Pinned above the entry grid so it
|
||||||
{{ 'labels.inputs.GL Code' | translate }}
|
never has to be scrolled to. -->
|
||||||
</div>
|
<div class="mob-balance" [class.mob-balance--ok]="debitsSum === creditsSum && debitsSum > 0">
|
||||||
<div class="header flex-18">
|
<div class="mob-balance__figure">
|
||||||
{{ 'labels.inputs.GL Name' | translate }}
|
<span class="mob-balance__label">{{ 'labels.inputs.Total Debit' | translate }}</span>
|
||||||
{{ 'labels.inputs.Account' | translate }}
|
<span class="mob-balance__amt mob-balance__amt--dr">{{
|
||||||
</div>
|
debitsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2'
|
||||||
<div class="header r-amount flex-18">
|
}}</span>
|
||||||
{{ 'labels.inputs.Total Debit' | translate }}:
|
</div>
|
||||||
{{ debitsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
<div class="mob-balance__figure">
|
||||||
</div>
|
<span class="mob-balance__label">{{ 'labels.inputs.Total Credit' | translate }}</span>
|
||||||
<div class="header r-amount flex-23">
|
<span class="mob-balance__amt mob-balance__amt--cr">{{
|
||||||
{{ 'labels.inputs.Total Credit' | translate }}:
|
creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2'
|
||||||
{{ creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mob-balance__status">
|
||||||
|
@if (debitsSum === creditsSum && debitsSum > 0) {
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
<span>Balanced — ready to submit</span>
|
||||||
|
} @else if (debitsSum === 0 && creditsSum === 0) {
|
||||||
|
<mat-icon>edit_note</mat-icon>
|
||||||
|
<span>Enter each account's opening balance below</span>
|
||||||
|
} @else {
|
||||||
|
<mat-icon>error</mat-icon>
|
||||||
|
<span
|
||||||
|
>Out of balance by {{ debitsSum - creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: one row per GL account, grouped by account type, debit on one side and credit on
|
||||||
|
the other — a debit balance goes in the Debit box, a credit balance in the Credit box,
|
||||||
|
never both. -->
|
||||||
|
<mat-card class="mob-card mob-card--flush">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="mob-grid mob-grid--head">
|
||||||
|
<div class="mob-grid__code">{{ 'labels.inputs.GL Code' | translate }}</div>
|
||||||
|
<div class="mob-grid__name">
|
||||||
|
{{ 'labels.inputs.GL Name' | translate }} {{ 'labels.inputs.Account' | translate }}
|
||||||
</div>
|
</div>
|
||||||
@for (credit of glAccountEntries.controls; track credit; let i = $index) {
|
<div class="mob-grid__amt">{{ 'labels.inputs.Debit' | translate }}</div>
|
||||||
<div class="flex-fill layout-row-wrap responsive-column content" formArrayName="glAccountEntries">
|
<div class="mob-grid__amt">{{ 'labels.inputs.Credit' | translate }}</div>
|
||||||
<div class="flex-fill layout-row-wrap gap-2percent responsive-column" [formGroupName]="i">
|
</div>
|
||||||
<div class="flex-8">
|
|
||||||
{{
|
@for (credit of glAccountEntries.controls; track credit; let i = $index) {
|
||||||
openingBalancesData.glAccounts[i].glAccountType.value !==
|
@if (
|
||||||
openingBalancesData.glAccounts[i - 1]?.glAccountType.value
|
openingBalancesData.glAccounts[i].glAccountType.value !==
|
||||||
? glAccountTypeLabel(openingBalancesData.glAccounts[i].glAccountType.value)
|
openingBalancesData.glAccounts[i - 1]?.glAccountType.value
|
||||||
: ''
|
) {
|
||||||
}}
|
<div class="mob-grid__section">
|
||||||
</div>
|
{{ glAccountTypeLabel(openingBalancesData.glAccounts[i].glAccountType.value) }}
|
||||||
<div class="flex-13">
|
|
||||||
{{ openingBalancesData.glAccounts[i].glAccountCode }}
|
|
||||||
</div>
|
|
||||||
<div class="flex-29">
|
|
||||||
{{ openingBalancesData.glAccounts[i].glAccountName }}
|
|
||||||
</div>
|
|
||||||
<div class="flex-20">
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>{{ 'labels.inputs.Debit' | translate }}</mat-label>
|
|
||||||
<input matInput type="number" formControlName="debit" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div class="flex-20">
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>{{ 'labels.inputs.Credit' | translate }}</mat-label>
|
|
||||||
<input matInput type="number" formControlName="credit" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
<div class="mob-grid mob-grid--data" formArrayName="glAccountEntries">
|
||||||
}
|
<div class="mob-grid__row" [formGroupName]="i">
|
||||||
</mat-card-content>
|
<div class="mob-grid__code">{{ openingBalancesData.glAccounts[i].glAccountCode }}</div>
|
||||||
|
<div class="mob-grid__name">{{ openingBalancesData.glAccounts[i].glAccountName }}</div>
|
||||||
|
<div class="mob-grid__amt">
|
||||||
|
<mat-form-field class="mob-grid__input">
|
||||||
|
<input matInput type="number" min="0" formControlName="debit" placeholder="0.00" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="mob-grid__amt">
|
||||||
|
<mat-form-field class="mob-grid__input">
|
||||||
|
<input matInput type="number" min="0" formControlName="credit" placeholder="0.00" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
@if (openingBalancesData) {
|
<div class="mob-actions">
|
||||||
<mat-card-actions class="layout-row gap-5px align-center responsive-column">
|
<button type="button" mat-stroked-button [routerLink]="['../']">
|
||||||
<button type="button" mat-raised-button [routerLink]="['../']">
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
</button>
|
||||||
</button>
|
<span class="mob-actions__spacer"></span>
|
||||||
<button
|
@if (!(debitsSum === creditsSum && debitsSum > 0)) {
|
||||||
mat-raised-button
|
<span class="mob-actions__hint">Total Debit must equal Total Credit before this can be submitted</span>
|
||||||
color="primary"
|
}
|
||||||
[disabled]="!openingBalancesForm.valid"
|
<button
|
||||||
*mifosxHasPermission="'DEFINEOPENINGBALANCE_JOURNALENTRY'"
|
mat-raised-button
|
||||||
>
|
color="primary"
|
||||||
{{ 'labels.buttons.Submit' | translate }}
|
[disabled]="!openingBalancesForm.valid || !(debitsSum === creditsSum && debitsSum > 0)"
|
||||||
</button>
|
*mifosxHasPermission="'DEFINEOPENINGBALANCE_JOURNALENTRY'"
|
||||||
</mat-card-actions>
|
>
|
||||||
}
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
</form>
|
</button>
|
||||||
</mat-card>
|
</div>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #templateSearchFormRef let-popover="popover">
|
<ng-template #templateSearchFormRef let-popover="popover">
|
||||||
|
|||||||
+291
-6
@@ -6,14 +6,299 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.content {
|
.mob {
|
||||||
padding-top: 20px;
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
|
||||||
div {
|
.mob-card {
|
||||||
word-wrap: break-word;
|
margin-bottom: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
&.header {
|
&--flush mat-card-content {
|
||||||
font-weight: 500;
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-intro {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-office {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-field {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-contra {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 4px solid #0ea5e9;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #0ea5e9;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
margin: 2px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one figure that decides whether this can be submitted — kept visually separate from the
|
||||||
|
cards around it so it reads as a status, not just another form section. */
|
||||||
|
.mob-balance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-left: 4px solid #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
border-left-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__figure {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--dr {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--cr {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #b91c1c;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok &__status {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Account entry grid: a header row, a section label per account type, then one row per account. */
|
||||||
|
.mob-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 1fr 160px 160px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 14px;
|
||||||
|
|
||||||
|
&--head {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
padding: 8px 14px 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #0ea5e9;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--data {
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-form-field-infix {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-text-field-wrapper {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-actions__spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-actions__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
.mob-grid {
|
||||||
|
grid-template-columns: 70px 1fr;
|
||||||
|
row-gap: 4px;
|
||||||
|
|
||||||
|
&--head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
'code name'
|
||||||
|
'debit debit'
|
||||||
|
'credit credit';
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
grid-area: code;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
grid-area: name;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt:nth-of-type(1) {
|
||||||
|
grid-area: debit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt:nth-of-type(2) {
|
||||||
|
grid-area: credit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt {
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { AccountingService } from '../accounting.service';
|
import { AccountingService } from '../accounting.service';
|
||||||
@@ -41,7 +42,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
GlAccountDisplayComponent,
|
GlAccountDisplayComponent,
|
||||||
CurrencyPipe
|
CurrencyPipe,
|
||||||
|
MatIconModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
|
export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
|
||||||
const STATE_KEY = 'gl_ledger_filter_state';
|
const STATE_KEY = 'gl_ledger_filter_state';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -34,6 +34,52 @@ export class AmlService {
|
|||||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ Recurring-alert review queue ═══
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customers whose transactions repeatedly trip the engine. Each row carries the triggering
|
||||||
|
* alerts, so the reviewer can see why the customer keeps surfacing.
|
||||||
|
*/
|
||||||
|
getRecurringAlerts(minAlerts: number, windowDays: number, onlyNeedingReview: boolean): Observable<any[]> {
|
||||||
|
const params = new HttpParams()
|
||||||
|
.set('minAlerts', String(minAlerts))
|
||||||
|
.set('windowDays', String(windowDays))
|
||||||
|
.set('onlyNeedingReview', String(onlyNeedingReview));
|
||||||
|
return this.http.get<any[]>('/aml/recurring-alerts', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */
|
||||||
|
reviewRecurringAlert(
|
||||||
|
clientId: number,
|
||||||
|
outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED',
|
||||||
|
notes: string,
|
||||||
|
windowDays: number
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post(`/aml/recurring-alerts/${clientId}/review`, { outcome, notes, windowDays });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Manual referrals from the general ledger ═══
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ledger entries a user has already marked, keyed on `journalEntryId`.
|
||||||
|
*
|
||||||
|
* Separate from `getAlerts()` because these are not engine alerts — they carry the
|
||||||
|
* journal entry id rather than a savings or loan transaction id, so the ledger
|
||||||
|
* screen can match them to the rows it is showing.
|
||||||
|
*/
|
||||||
|
getManualReferrals(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/alerts/manual-referrals');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
|
||||||
|
* case for the Principal Officer, the same road an escalated engine alert takes.
|
||||||
|
* The entry must have a customer behind it or the server rejects it.
|
||||||
|
*/
|
||||||
|
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||||
|
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ STR cases (Principal Officer) ═══
|
// ═══ STR cases (Principal Officer) ═══
|
||||||
|
|
||||||
getStrCases(): Observable<any[]> {
|
getStrCases(): Observable<any[]> {
|
||||||
|
|||||||
@@ -62,9 +62,15 @@ import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module';
|
|||||||
import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
||||||
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||||
import { DeafModule } from './deaf/deaf.module';
|
import { DeafModule } from './deaf/deaf.module';
|
||||||
|
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
||||||
import { DicgcModule } from './dicgc/dicgc.module';
|
import { DicgcModule } from './dicgc/dicgc.module';
|
||||||
import { FiuModule } from './fiu/fiu.module';
|
import { FiuModule } from './fiu/fiu.module';
|
||||||
|
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||||
import { SftModule } from './sft/sft.module';
|
import { SftModule } from './sft/sft.module';
|
||||||
|
import { InvestmentModule } from './investment/investment.module';
|
||||||
|
import { NpaModule } from './npa/npa.module';
|
||||||
|
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||||
|
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||||
import { DigitalModule } from './digital/digital.module';
|
import { DigitalModule } from './digital/digital.module';
|
||||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||||
import { AccountsModule } from './accounts/accounts.module';
|
import { AccountsModule } from './accounts/accounts.module';
|
||||||
@@ -169,9 +175,15 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
CfcfrmsModule,
|
CfcfrmsModule,
|
||||||
BankCalendarModule,
|
BankCalendarModule,
|
||||||
DeafModule,
|
DeafModule,
|
||||||
|
SavingsDailyInterestModule,
|
||||||
DicgcModule,
|
DicgcModule,
|
||||||
FiuModule,
|
FiuModule,
|
||||||
|
GlReportsModule,
|
||||||
SftModule,
|
SftModule,
|
||||||
|
InvestmentModule,
|
||||||
|
NpaModule,
|
||||||
|
ReconciliationModule,
|
||||||
|
LoanOriginationModule,
|
||||||
DigitalModule,
|
DigitalModule,
|
||||||
LetterMgmtModule,
|
LetterMgmtModule,
|
||||||
AccountsModule,
|
AccountsModule,
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg ar-print">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">REPORTS · ACCOUNT REGISTER</div>
|
||||||
|
<h1 class="pg-title">Account Register</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Every account as it stood on the chosen date — which ones opened and closed that day, the balance each held
|
||||||
|
then, and what it holds now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar ar-noprint">
|
||||||
|
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
|
||||||
|
<mat-icon>chevron_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>As on date</span>
|
||||||
|
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon> Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Shown only on paper, where the toolbar is hidden and the date has to be stated. -->
|
||||||
|
<p class="ar-printhead">Account Register as on {{ date }} · {{ section }}</p>
|
||||||
|
|
||||||
|
<div class="db-filters ar-noprint">
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Section</span>
|
||||||
|
<select [(ngModel)]="section" (change)="load()">
|
||||||
|
@for (s of sections; track s.value) {
|
||||||
|
<option [value]="s.value">{{ s.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="pg-field ar-check">
|
||||||
|
<span>Movement</span>
|
||||||
|
<span class="ar-check__box">
|
||||||
|
<input type="checkbox" [(ngModel)]="movementOnly" />
|
||||||
|
Only accounts opened / closed on this date
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="pg-field db-filters__search">
|
||||||
|
<span>Search</span>
|
||||||
|
<input type="text" [(ngModel)]="search" placeholder="Account no, client, product, status…" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="pg-btn" (click)="printRegister()" [disabled]="filteredRows.length === 0">
|
||||||
|
<mat-icon>print</mat-icon> Print
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
|
||||||
|
<mat-icon>download</mat-icon> CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMsg) {
|
||||||
|
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && loaded) {
|
||||||
|
<!-- What actually happened that day, read before the list itself. -->
|
||||||
|
<div class="ar-summary">
|
||||||
|
<div class="ar-summary__item ar-summary__item--in">
|
||||||
|
<span class="ar-summary__label">Accounts opened on {{ date }}</span>
|
||||||
|
<span class="ar-summary__value">{{ openedCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ar-summary__item ar-summary__item--out">
|
||||||
|
<span class="ar-summary__label">Accounts closed on {{ date }}</span>
|
||||||
|
<span class="ar-summary__value">{{ closedCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ar-summary__item">
|
||||||
|
<span class="ar-summary__label">Balance on {{ date }}</span>
|
||||||
|
<span class="ar-summary__value">₹{{ shownBalanceOnDate | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ar-summary__item">
|
||||||
|
<span class="ar-summary__label">Current balance</span>
|
||||||
|
<span class="ar-summary__value">₹{{ shownCurrentBalance | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && loaded && filteredRows.length === 0) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
<mat-icon>account_balance_wallet</mat-icon>
|
||||||
|
<p>
|
||||||
|
@if (movementOnly) {
|
||||||
|
No account was opened or closed on {{ date }}.
|
||||||
|
} @else {
|
||||||
|
No accounts existed on {{ date }}.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (filteredRows.length > 0) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Section</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Opened</th>
|
||||||
|
<th>Closed</th>
|
||||||
|
<th class="num">Balance on {{ date }}</th>
|
||||||
|
<th class="num">Current Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of filteredRows; track r.section + r.accountId) {
|
||||||
|
<tr [class.ar-row--opened]="r.openedOnDate" [class.ar-row--closed]="r.closedOnDate">
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.accountNo }}</span>
|
||||||
|
@if (r.openedOnDate) {
|
||||||
|
<span class="ar-tag ar-tag--in">OPENED</span>
|
||||||
|
}
|
||||||
|
@if (r.closedOnDate) {
|
||||||
|
<span class="ar-tag ar-tag--out">CLOSED</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ r.section }}</td>
|
||||||
|
<td>{{ r.clientName ?? '—' }}</td>
|
||||||
|
<td>{{ r.productName ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.openedOn ?? '—' }}</td>
|
||||||
|
<td>{{ r.closedOn ?? '—' }}</td>
|
||||||
|
<td class="num">₹{{ r.balanceOnDate | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
₹{{ r.currentBalance | number: '1.2-2' }}
|
||||||
|
@if (r.section === 'SHARE' && r.sharesHeld) {
|
||||||
|
<span class="db-muted">· {{ r.sharesHeld }} sh</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">
|
||||||
|
{{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ shownBalanceOnDate | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ shownCurrentBalance | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Page chrome comes from cashier-shared.scss; badges, filters and muted text are shared with the
|
||||||
|
day book. Only what belongs to the account register lives here. */
|
||||||
|
|
||||||
|
.ar-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
border-left: 4px solid #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
border-left: 4px solid #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox sits inside a pg-field, which is laid out for inputs — this keeps the label and box on
|
||||||
|
one line without fighting that. */
|
||||||
|
.ar-check {
|
||||||
|
&__box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 38px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marks the accounts that actually moved on the chosen date — the reason someone opens a register
|
||||||
|
for a past day at all. */
|
||||||
|
.ar-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-row--opened td:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-row--closed td:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only shown on paper: the screen already states the date in the toolbar. */
|
||||||
|
.ar-printhead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A register is meant to be printed and filed, so paper gets its own treatment: the toolbar and
|
||||||
|
filters go, the table gains borders, and rows are not split across pages. */
|
||||||
|
@media print {
|
||||||
|
.ar-noprint {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-printhead {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-summary__item {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-table {
|
||||||
|
font-size: 10px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #999;
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table headers repeat on every page, so a multi-page register stays readable. */
|
||||||
|
thead {
|
||||||
|
display: table-header-group;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* 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 { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { catchError, of } from 'rxjs';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
interface RegisterRow {
|
||||||
|
accountId: number;
|
||||||
|
accountNo: string;
|
||||||
|
section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE';
|
||||||
|
clientName: string | null;
|
||||||
|
productName: string | null;
|
||||||
|
status: string;
|
||||||
|
openedOn: string | null;
|
||||||
|
closedOn: string | null;
|
||||||
|
/** True when this account was opened (or closed) on the very date being viewed. */
|
||||||
|
openedOnDate: boolean;
|
||||||
|
closedOnDate: boolean;
|
||||||
|
/** The account's balance as it stood on the register's date, for every section. */
|
||||||
|
balanceOnDate: number | null;
|
||||||
|
currentBalance: number | null;
|
||||||
|
sharesHeld?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account register for one date: which accounts opened and closed that day, each account's balance
|
||||||
|
* as it stood then, and what it holds now.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-account-register',
|
||||||
|
templateUrl: './account-register.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'../day-book/day-book.component.scss',
|
||||||
|
'./account-register.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AccountRegisterComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
|
||||||
|
date = '';
|
||||||
|
section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL';
|
||||||
|
search = '';
|
||||||
|
/** Narrows to just the accounts that opened or closed on the chosen date. */
|
||||||
|
movementOnly = false;
|
||||||
|
|
||||||
|
rows: RegisterRow[] = [];
|
||||||
|
openedCount = 0;
|
||||||
|
closedCount = 0;
|
||||||
|
totalBalanceOnDate = 0;
|
||||||
|
totalCurrentBalance = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
errorMsg = '';
|
||||||
|
|
||||||
|
readonly sections = [
|
||||||
|
{ value: 'ALL', label: 'All accounts' },
|
||||||
|
{ value: 'SAVINGS', label: 'Savings' },
|
||||||
|
{ value: 'FD', label: 'Fixed Deposit' },
|
||||||
|
{ value: 'RD', label: 'Recurring Deposit' },
|
||||||
|
{ value: 'SHARE', label: 'Share' }
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.date = this.today();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
|
||||||
|
private today(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
if (!this.date) return;
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.accountRegister(this.date, this.section)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||||
|
err?.error?.message ??
|
||||||
|
'Could not load the account register.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
this.rows = res?.accounts ?? [];
|
||||||
|
this.openedCount = Number(res?.openedCount ?? 0);
|
||||||
|
this.closedCount = Number(res?.closedCount ?? 0);
|
||||||
|
this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0);
|
||||||
|
this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
shiftDate(days: number): void {
|
||||||
|
const [
|
||||||
|
y,
|
||||||
|
m,
|
||||||
|
d
|
||||||
|
] = this.date.split('-').map(Number);
|
||||||
|
const dt = new Date(y, m - 1, d + days);
|
||||||
|
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToday(): void {
|
||||||
|
this.date = this.today();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free-text narrowing over the rows already fetched — no round trip per keystroke. */
|
||||||
|
get filteredRows(): RegisterRow[] {
|
||||||
|
const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows;
|
||||||
|
const q = this.search.trim().toLowerCase();
|
||||||
|
if (!q) return base;
|
||||||
|
return base.filter((r) =>
|
||||||
|
[
|
||||||
|
r.accountNo,
|
||||||
|
r.clientName,
|
||||||
|
r.productName,
|
||||||
|
r.status,
|
||||||
|
r.section
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((v) => String(v).toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Totals for what is on screen, so a filter or search still reconciles. */
|
||||||
|
get shownBalanceOnDate(): number {
|
||||||
|
return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get shownCurrentBalance(): number {
|
||||||
|
return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'ACTIVE':
|
||||||
|
return 'db-badge db-badge--ok';
|
||||||
|
case 'CLOSED':
|
||||||
|
case 'PREMATURELY CLOSED':
|
||||||
|
case 'REJECTED':
|
||||||
|
case 'WITHDRAWN':
|
||||||
|
return 'db-badge db-badge--rejected';
|
||||||
|
case 'SUBMITTED':
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'db-badge db-badge--pending';
|
||||||
|
default:
|
||||||
|
return 'db-badge';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportCsv(): void {
|
||||||
|
const header = [
|
||||||
|
'Account No',
|
||||||
|
'Section',
|
||||||
|
'Client',
|
||||||
|
'Product',
|
||||||
|
'Status',
|
||||||
|
'Opened On',
|
||||||
|
'Closed On',
|
||||||
|
`Balance on ${this.date}`,
|
||||||
|
'Current Balance'
|
||||||
|
];
|
||||||
|
const lines = this.filteredRows.map((r) =>
|
||||||
|
[
|
||||||
|
r.accountNo,
|
||||||
|
r.section,
|
||||||
|
r.clientName ?? '',
|
||||||
|
r.productName ?? '',
|
||||||
|
r.status,
|
||||||
|
r.openedOn ?? '',
|
||||||
|
r.closedOn ?? '',
|
||||||
|
r.balanceOnDate == null ? '' : String(r.balanceOnDate),
|
||||||
|
String(r.currentBalance ?? 0)
|
||||||
|
]
|
||||||
|
// Quote every field and double embedded quotes: client names routinely contain commas.
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
const csv = [
|
||||||
|
header.join(','),
|
||||||
|
...lines
|
||||||
|
].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `account-register-${this.date}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */
|
||||||
|
printRegister(): void {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">BRANCH OPERATIONS · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}</div>
|
||||||
|
<h1 class="pg-title">{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
@if (missedOnly) {
|
||||||
|
Business days that were opened and never closed. While a day stays open its closing balance never carries
|
||||||
|
forward, a back-dated entry can still be posted into it, and any cash difference on that date goes unnoticed.
|
||||||
|
} @else {
|
||||||
|
The branch's business day, opened each morning and closed each evening. A day left open never carries its
|
||||||
|
closing balance forward, and any cash difference on that date goes unnoticed — so the ones still hanging are
|
||||||
|
listed first.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
@if (canOpenToday && !missedOnly) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="startOpen()">
|
||||||
|
<mat-icon>lock_open</mat-icon> Open Day
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon> Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (errorMsg) {
|
||||||
|
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||||
|
}
|
||||||
|
@if (successMsg) {
|
||||||
|
<div class="bd-success">{{ successMsg }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!canOperate && loaded) {
|
||||||
|
<div class="ve-note">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
You can see the register but not open or close a day — a Clerk, Head Cashier or Bank Manager does that, since a
|
||||||
|
cashier works inside the day and cannot declare it closed.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Missed closes first: these are the ones that need action. -->
|
||||||
|
@if (missed.length > 0) {
|
||||||
|
<div class="db-exceptions">
|
||||||
|
<div class="db-exceptions__head">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<strong>{{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed</strong>
|
||||||
|
</div>
|
||||||
|
@for (m of missed; track m.id) {
|
||||||
|
<div class="bd-missed">
|
||||||
|
<span class="pg-chip">{{ m.businessDate }}</span>
|
||||||
|
<strong class="bd-missed__age"> open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }} </strong>
|
||||||
|
<span class="db-muted">
|
||||||
|
opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }}
|
||||||
|
</span>
|
||||||
|
@if (canOperate) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--warn bd-missed__act" (click)="startClose(m)">
|
||||||
|
Close this day
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- On the missed-close screen, nothing to report is itself the answer — and has to be stated,
|
||||||
|
or the page looks like it failed to load. -->
|
||||||
|
@if (missedOnly && loaded && missed.length === 0) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
<p>No missed closes. Every business day opened for this branch has been closed.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Current day -->
|
||||||
|
@if (!missedOnly && openDay) {
|
||||||
|
<div class="bd-current">
|
||||||
|
<div class="bd-current__main">
|
||||||
|
<span class="db-badge db-badge--pending">DAY OPEN</span>
|
||||||
|
<strong class="bd-current__date">{{ openDay.businessDate }}</strong>
|
||||||
|
<span class="db-muted">
|
||||||
|
opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{
|
||||||
|
openDay.openingCash | number: '1.2-2'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (canOperate) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
|
||||||
|
<mat-icon>lock</mat-icon> Close Day
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (loaded && canOperate) {
|
||||||
|
<div class="bd-current bd-current--shut">
|
||||||
|
<div class="bd-current__main">
|
||||||
|
<span class="db-badge">NO DAY OPEN</span>
|
||||||
|
<span class="db-muted">Open the business day before any cash transaction is accepted.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Open-day form -->
|
||||||
|
@if (showOpenForm) {
|
||||||
|
<div class="db-panel bd-panel">
|
||||||
|
<div class="db-panel__head">
|
||||||
|
<strong>Open business day</strong>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="cancelOpen()" title="Close">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="db-panel__body">
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Business date</span>
|
||||||
|
<input type="date" [(ngModel)]="openDate" />
|
||||||
|
</label>
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Opening cash (leave blank to carry forward)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
[(ngModel)]="openingCash"
|
||||||
|
[disabled]="openDenomTotal > 0"
|
||||||
|
placeholder="Carried from last close"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="bd-hint">
|
||||||
|
@if (hasAutoFilledOpenCounts) {
|
||||||
|
Auto-filled below from yesterday's own closing count (₹{{ carriedForwardTotal | number: '1.2-2' }}) — correct
|
||||||
|
only the rows where today's physical count differs before opening.
|
||||||
|
} @else {
|
||||||
|
Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which
|
||||||
|
is what keeps the opening balance from drifting. Counting the drawer below is what actually records the
|
||||||
|
note/coin stock the branch can check a vault handout against later.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (hasAutoFilledOpenCounts && openLiveDifference !== null) {
|
||||||
|
@if (openLiveDifference === 0) {
|
||||||
|
<div class="db-cmp db-cmp--good">
|
||||||
|
<span class="db-cmp__label">vs. yesterday's close</span>
|
||||||
|
<span class="db-cmp__value">Tallied</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="db-cmp db-cmp--bad">
|
||||||
|
<span class="db-cmp__label">vs. yesterday's close</span>
|
||||||
|
<span class="db-cmp__value">
|
||||||
|
{{ openLiveDifference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(openLiveDifference) | number: '1.2-2' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="pg-card bd-denom">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Denomination</th>
|
||||||
|
<th style="text-align: center">Count</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (notes.length) {
|
||||||
|
<tr class="pg-section">
|
||||||
|
<td colspan="3">Bank Notes</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@for (d of notes; track d.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ d.label }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="display: flex; flex-direction: column; align-items: center">
|
||||||
|
<div class="pg-step">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="bumpCount(openCounts, d, -1)"
|
||||||
|
[disabled]="countFor(openCounts, d) === 0"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[value]="countFor(openCounts, d)"
|
||||||
|
(input)="onCountChange(openCounts, d, $any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<button type="button" (click)="bumpCount(openCounts, d, 1)">+</button>
|
||||||
|
</div>
|
||||||
|
@if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) {
|
||||||
|
<span class="db-muted bd-denom__auto">yesterday closed {{ autoOpenCountFor(d) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (coins.length) {
|
||||||
|
<tr class="pg-section">
|
||||||
|
<td colspan="3">Coins</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@for (d of coins; track d.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="display: flex; flex-direction: column; align-items: center">
|
||||||
|
<div class="pg-step">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="bumpCount(openCounts, d, -1)"
|
||||||
|
[disabled]="countFor(openCounts, d) === 0"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[value]="countFor(openCounts, d)"
|
||||||
|
(input)="onCountChange(openCounts, d, $any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<button type="button" (click)="bumpCount(openCounts, d, 1)">+</button>
|
||||||
|
</div>
|
||||||
|
@if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) {
|
||||||
|
<span class="db-muted bd-denom__auto">yesterday closed {{ autoOpenCountFor(d) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (openDenomTotal > 0) {
|
||||||
|
<p class="bd-hint">
|
||||||
|
Denomination total: <strong>₹{{ openDenomTotal | number: '1.2-2' }}</strong>
|
||||||
|
@if (hasAutoFilledOpenCounts) {
|
||||||
|
<button type="button" class="pg-btn bd-denom__reset" (click)="resetOpenToAutoFill()">
|
||||||
|
Reset to yesterday's count
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (openLiveDifference !== null && openLiveDifference !== 0) {
|
||||||
|
<label class="pg-field bd-panel__wide">
|
||||||
|
<span>Why does this differ from yesterday's closing count? (required)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="openDifferenceReason"
|
||||||
|
placeholder="e.g. notes exchanged for smaller denominations, extra cash added from head office"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="db-panel__actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="!canSubmitOpen">
|
||||||
|
Open Day
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelOpen()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Close-day form -->
|
||||||
|
@if (closeTarget) {
|
||||||
|
<div class="db-panel bd-panel" [class.bd-panel--late]="isLateClose">
|
||||||
|
<div class="db-panel__head">
|
||||||
|
<strong>Close business day — {{ closeTarget.businessDate }}</strong>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="cancelClose()" title="Close">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isLateClose) {
|
||||||
|
<p class="db-panel__warn">
|
||||||
|
This day is being closed after its own date. It will be recorded as a
|
||||||
|
<strong>force close</strong>, with the reason below kept permanently against it.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (previewLoading) {
|
||||||
|
<p class="db-muted">Working out the day's figure…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- The arithmetic, spelled out. Whoever closes the day can see where the expected figure
|
||||||
|
comes from instead of being asked to agree with a number they cannot check. -->
|
||||||
|
@if (preview) {
|
||||||
|
<div class="bd-calc">
|
||||||
|
<div class="bd-calc__row">
|
||||||
|
<span>Opening cash (carried forward)</span>
|
||||||
|
<span class="bd-calc__amt">₹{{ preview.openingCash | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bd-calc__row bd-calc__row--in">
|
||||||
|
<span>+ Cash deposits taken (approved)</span>
|
||||||
|
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bd-calc__row bd-calc__row--out">
|
||||||
|
<span>− Cash paid out (approved withdrawals)</span>
|
||||||
|
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bd-calc__row bd-calc__row--total">
|
||||||
|
<span>Cash the branch should be holding</span>
|
||||||
|
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (preview.vaultIn > 0 || preview.settledOut > 0) {
|
||||||
|
<div class="bd-calc__memo">
|
||||||
|
Vault activity today — moves cash within the branch, not counted in the total above: ₹{{
|
||||||
|
preview.vaultIn | number: '1.2-2'
|
||||||
|
}}
|
||||||
|
handed to a cashier, ₹{{ preview.settledOut | number: '1.2-2' }} settled back to the vault.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- What every cash point under this branch is actually holding right now, fetched
|
||||||
|
automatically — so closing the day doesn't mean separately checking Vault Position and
|
||||||
|
every cashier's own Cashier Position screen by hand. -->
|
||||||
|
@if (eodLoading) {
|
||||||
|
<p class="db-muted">Fetching vault and cashier positions…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- The question that actually matters at close: does the WHOLE branch tally, not just the
|
||||||
|
vault. Only shown once both the book preview and every teller/cashier position have
|
||||||
|
loaded, since it adds them together. -->
|
||||||
|
@if (branchTotalSummary; as bt) {
|
||||||
|
<div class="bd-eod bd-eod--total">
|
||||||
|
<div class="bd-eod__head">
|
||||||
|
<mat-icon>account_balance_wallet</mat-icon>
|
||||||
|
<strong>Branch Total (Vault + All Cashiers)</strong>
|
||||||
|
</div>
|
||||||
|
<div class="bd-eod__totals">
|
||||||
|
<span>Vault ₹{{ bt.vaultTotal | number: '1.2-2' }}</span>
|
||||||
|
<span>+ Cashiers ₹{{ bt.cashierTotal | number: '1.2-2' }}</span>
|
||||||
|
<span class="bd-eod__totals-sum">= ₹{{ bt.grandTotal | number: '1.2-2' }}</span>
|
||||||
|
<span class="db-muted">vs. book ₹{{ bt.expected | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (bt.difference === 0) {
|
||||||
|
<span class="db-badge db-badge--ok">Branch Tallied</span>
|
||||||
|
} @else {
|
||||||
|
<span class="db-badge db-badge--rejected">
|
||||||
|
{{ bt.difference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(bt.difference) | number: '1.2-2' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (t of eodTellers; track t.tellerId) {
|
||||||
|
<div class="bd-eod">
|
||||||
|
<div class="bd-eod__head">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
<strong>{{ t.tellerName }}</strong>
|
||||||
|
<span class="db-muted">
|
||||||
|
vault holds
|
||||||
|
{{ t.vault?.hasBreakdown ? '₹' + (t.vault.totalAmount | number: '1.2-2') : 'no breakdown recorded' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (t.vault?.hasBreakdown && t.vault.denominations.length > 0) {
|
||||||
|
<div class="bd-eod__denoms">
|
||||||
|
@for (d of t.vault.denominations; track d.denominationId) {
|
||||||
|
<span class="bd-eod__chip">₹{{ d.denominationValue }} × {{ d.count }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (t.cashiers.length === 0) {
|
||||||
|
<p class="db-muted bd-eod__empty">No cashiers allocated under this vault.</p>
|
||||||
|
}
|
||||||
|
@for (c of t.cashiers; track c.cashierId) {
|
||||||
|
<div class="bd-eod__cashier">
|
||||||
|
<div class="bd-eod__cashier-head">
|
||||||
|
<span>{{ c.cashierName }}</span>
|
||||||
|
<span class="db-muted">
|
||||||
|
morning ₹{{ c.morningTotal | number: '1.2-2' }} → current ₹{{ c.currentTotal | number: '1.2-2' }} ·
|
||||||
|
{{ c.txnCount ?? '—' }} txn{{ c.txnCount === 1 ? '' : 's' }} today
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bd-eod__status">
|
||||||
|
@if (c.reconciled === true) {
|
||||||
|
<span class="db-badge db-badge--ok">Reconciled</span>
|
||||||
|
} @else if (c.reconciled === false) {
|
||||||
|
<span class="db-badge db-badge--pending">Not Reconciled</span>
|
||||||
|
}
|
||||||
|
@if (c.alreadySettled === true) {
|
||||||
|
<span class="db-badge db-badge--ok">Settled to Vault</span>
|
||||||
|
} @else if (c.alreadySettled === false) {
|
||||||
|
<span class="db-badge">Not Settled</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (c.denominations.length > 0) {
|
||||||
|
<div class="bd-eod__denoms">
|
||||||
|
@for (d of c.denominations; track d.denominationId) {
|
||||||
|
<span
|
||||||
|
class="bd-eod__chip"
|
||||||
|
[class.bd-eod__chip--up]="d.delta > 0"
|
||||||
|
[class.bd-eod__chip--down]="d.delta < 0"
|
||||||
|
>
|
||||||
|
₹{{ d.denominationValue }}: {{ d.morningCount }} → {{ d.count }}
|
||||||
|
@if (d.delta !== 0) {
|
||||||
|
({{ d.delta > 0 ? '+' : '' }}{{ d.delta }})
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="db-muted bd-eod__empty">No balance.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="db-panel__body">
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Physical cash counted (or count denominations below)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
[(ngModel)]="closingCash"
|
||||||
|
[disabled]="closeDenomTotal > 0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
@if (preview && liveDifference !== null) {
|
||||||
|
@if (liveDifference === 0) {
|
||||||
|
<div class="db-cmp db-cmp--good">
|
||||||
|
<span class="db-cmp__label">Difference</span>
|
||||||
|
<span class="db-cmp__value">Tallied</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="db-cmp db-cmp--bad">
|
||||||
|
<span class="db-cmp__label">Difference</span>
|
||||||
|
<span class="db-cmp__value">
|
||||||
|
{{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{
|
||||||
|
liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (preview && liveDifference !== 0) {
|
||||||
|
<button type="button" class="pg-btn" (click)="useExpected()">Use expected figure</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="bd-hint">
|
||||||
|
@if (hasAutoFilledCounts) {
|
||||||
|
Auto-filled below from the vault and every cashier's position shown above — correct only the rows where the
|
||||||
|
physical count differs, note by note.
|
||||||
|
} @else {
|
||||||
|
Pre-filled with what the books expect — change it only if the drawer counted something different, and that
|
||||||
|
difference is then recorded against the day. Counting the denominations below overrides the typed figure with
|
||||||
|
what was actually counted, note by note.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="pg-card bd-denom">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Denomination</th>
|
||||||
|
<th style="text-align: center">Count</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (notes.length) {
|
||||||
|
<tr class="pg-section">
|
||||||
|
<td colspan="3">Bank Notes</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@for (d of notes; track d.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ d.label }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="display: flex; flex-direction: column; align-items: center">
|
||||||
|
<div class="pg-step">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="bumpCount(closeCounts, d, -1)"
|
||||||
|
[disabled]="countFor(closeCounts, d) === 0"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[value]="countFor(closeCounts, d)"
|
||||||
|
(input)="onCountChange(closeCounts, d, $any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button>
|
||||||
|
</div>
|
||||||
|
@if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) {
|
||||||
|
<span class="db-muted bd-denom__auto">system counted {{ autoCountFor(d) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (coins.length) {
|
||||||
|
<tr class="pg-section">
|
||||||
|
<td colspan="3">Coins</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@for (d of coins; track d.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="display: flex; flex-direction: column; align-items: center">
|
||||||
|
<div class="pg-step">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="bumpCount(closeCounts, d, -1)"
|
||||||
|
[disabled]="countFor(closeCounts, d) === 0"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
[value]="countFor(closeCounts, d)"
|
||||||
|
(input)="onCountChange(closeCounts, d, $any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button>
|
||||||
|
</div>
|
||||||
|
@if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) {
|
||||||
|
<span class="db-muted bd-denom__auto">system counted {{ autoCountFor(d) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (closeDenomTotal > 0) {
|
||||||
|
<p class="bd-hint">
|
||||||
|
Denomination total: <strong>₹{{ closeDenomTotal | number: '1.2-2' }}</strong>
|
||||||
|
@if (hasAutoFilledCounts) {
|
||||||
|
<button type="button" class="pg-btn bd-denom__reset" (click)="resetToAutoFill()">
|
||||||
|
Reset to auto-filled count
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isLateClose) {
|
||||||
|
<label class="pg-field bd-panel__wide">
|
||||||
|
<span>Why was this day left open? (required)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="forceReason"
|
||||||
|
placeholder="e.g. cashier on leave, cash not settled that evening"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
<label class="pg-field bd-panel__wide">
|
||||||
|
<span>Remarks (optional)</span>
|
||||||
|
<input type="text" [(ngModel)]="closeRemarks" placeholder="Anything worth recording about this day" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="bd-hint">
|
||||||
|
The figure above is stored against the record, so a difference stays readable later even if a transaction is
|
||||||
|
reversed afterwards.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="db-panel__actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose">
|
||||||
|
{{ isLateClose ? 'Force Close Day' : 'Close Day' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelClose()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && loaded && days.length === 0 && !missedOnly) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
<mat-icon>event_note</mat-icon>
|
||||||
|
<p>No business day has been opened for this branch yet.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- The register -->
|
||||||
|
@if (days.length > 0 && !missedOnly) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Business Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Opened By</th>
|
||||||
|
<th class="num">Opening ₹</th>
|
||||||
|
<th>Closed By</th>
|
||||||
|
<th class="num">Closing ₹</th>
|
||||||
|
<th class="num">Book ₹</th>
|
||||||
|
<th>Difference</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (d of days; track d.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ d.businessDate }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (d.status === 'OPEN') {
|
||||||
|
<span class="db-badge db-badge--pending">OPEN</span>
|
||||||
|
} @else if (d.forceClosed) {
|
||||||
|
<span class="db-badge db-badge--rejected" [title]="d.forceCloseReason ?? ''">FORCE CLOSED</span>
|
||||||
|
} @else {
|
||||||
|
<span class="db-badge db-badge--ok">CLOSED</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ d.openedByName ?? '—' }}
|
||||||
|
<span class="db-muted">· {{ timeOnly(d.openedAt) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ d.openingCash | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
{{ d.closedByName ?? '—' }}
|
||||||
|
@if (d.closedAt) {
|
||||||
|
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
|
||||||
|
<td class="num">{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (d.cashDifference == null) {
|
||||||
|
—
|
||||||
|
} @else if (d.cashDifference === 0) {
|
||||||
|
<span class="db-badge db-badge--ok">Tallied</span>
|
||||||
|
} @else {
|
||||||
|
<span class="db-badge db-badge--rejected">{{ differenceLabel(d.cashDifference) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="bd-note">
|
||||||
|
@if (d.forceCloseReason) {
|
||||||
|
<strong>Left open:</strong> {{ d.forceCloseReason }}
|
||||||
|
}
|
||||||
|
@if (d.remarks) {
|
||||||
|
<span class="db-muted">{{ d.remarks }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared
|
||||||
|
with the day book. Only what is specific to the day register lives here. */
|
||||||
|
|
||||||
|
/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so
|
||||||
|
this component does not depend on another component's stylesheet. */
|
||||||
|
.ve-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-success {
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #14532d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One row per never-closed day, inside the shared red exception panel. */
|
||||||
|
.bd-missed {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 7px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px dotted #fecaca;
|
||||||
|
|
||||||
|
&__age {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pushed right so the date and age read before the action. */
|
||||||
|
&__act {
|
||||||
|
margin-left: auto;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The branch's current day — the one thing someone opening this screen is looking for. */
|
||||||
|
.bd-current {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
/* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */
|
||||||
|
&--shut {
|
||||||
|
border-left-color: #94a3b8;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-panel {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
/* A late close is a different act from an ordinary one, and looks like it. */
|
||||||
|
&--late {
|
||||||
|
border-left: 4px solid #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wide {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against
|
||||||
|
the screen it came from. */
|
||||||
|
.bd-calc {
|
||||||
|
max-width: 520px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 5px 0;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--in .bd-calc__amt {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out .bd-calc__amt {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 2px solid #e2e8f0;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__memo {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-note {
|
||||||
|
max-width: 280px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The denomination grid inside the open/close panels — same table chrome as pg-card elsewhere,
|
||||||
|
just tightened to sit inside the panel instead of spanning the page. */
|
||||||
|
.bd-denom {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
max-width: 520px;
|
||||||
|
|
||||||
|
/* Flags a row where the hand-count no longer matches what the vault/cashier positions reported. */
|
||||||
|
&__auto {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reset {
|
||||||
|
margin-left: 10px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */
|
||||||
|
.bd-eod {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #0ea5e9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__denoms {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #334155;
|
||||||
|
|
||||||
|
&--up {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--down {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cashier {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-top: 1px dashed #e2e8f0;
|
||||||
|
|
||||||
|
&-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
border-left: 4px solid #0ea5e9;
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__totals {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&-sum {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
/**
|
||||||
|
* 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 { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
interface Denom {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
kind: 'note' | 'coin';
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchDay {
|
||||||
|
id: number;
|
||||||
|
officeId: number;
|
||||||
|
officeName: string | null;
|
||||||
|
businessDate: string;
|
||||||
|
openedByName: string | null;
|
||||||
|
openedAt: string | null;
|
||||||
|
openingCash: number | null;
|
||||||
|
closedByName: string | null;
|
||||||
|
closedAt: string | null;
|
||||||
|
closingCash: number | null;
|
||||||
|
bookCash: number | null;
|
||||||
|
cashDifference: number | null;
|
||||||
|
forceClosed: boolean;
|
||||||
|
forceCloseReason: string | null;
|
||||||
|
remarks: string | null;
|
||||||
|
status: 'OPEN' | 'CLOSED';
|
||||||
|
/** Only present on the missed-close list. */
|
||||||
|
daysOpen?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch day register — opening and closing the branch's business day, and the list of days that
|
||||||
|
* were opened and never closed.
|
||||||
|
*
|
||||||
|
* A day left open is not a tidiness problem: its closing balance never carries forward, a
|
||||||
|
* back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-branch-day',
|
||||||
|
templateUrl: './branch-day.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'../day-book/day-book.component.scss',
|
||||||
|
'./branch-day.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class BranchDayComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set from the route: the missed-close register is the same data as the day register, shown with
|
||||||
|
* only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
|
||||||
|
* Close Register" looked like nothing had happened whenever the branch was up to date.
|
||||||
|
*/
|
||||||
|
missedOnly = false;
|
||||||
|
|
||||||
|
days: BranchDay[] = [];
|
||||||
|
missed: BranchDay[] = [];
|
||||||
|
openDay: any = null;
|
||||||
|
today = '';
|
||||||
|
/** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
|
||||||
|
canOperate = false;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
errorMsg = '';
|
||||||
|
successMsg = '';
|
||||||
|
|
||||||
|
// Open-day form
|
||||||
|
showOpenForm = false;
|
||||||
|
openDate = '';
|
||||||
|
openingCash: number | null = null;
|
||||||
|
/** Required only when the counted/typed opening figure differs from yesterday's own closing
|
||||||
|
* figure — the backend rejects the open without it in that case. */
|
||||||
|
openDifferenceReason = '';
|
||||||
|
opening = false;
|
||||||
|
|
||||||
|
// Close-day form. The date is fixed by which day is being closed, never typed.
|
||||||
|
closeTarget: BranchDay | null = null;
|
||||||
|
closingCash: number | null = null;
|
||||||
|
closeRemarks = '';
|
||||||
|
forceReason = '';
|
||||||
|
closing = false;
|
||||||
|
|
||||||
|
// Denomination breakdown, shared by both the open and close forms — a physical note/coin count
|
||||||
|
// is the true source of the lump-sum figure, not a typed-in guess. Counted separately per form
|
||||||
|
// since opening and closing are two independent physical counts.
|
||||||
|
notes: Denom[] = [];
|
||||||
|
coins: Denom[] = [];
|
||||||
|
openCounts: Record<number, number> = {};
|
||||||
|
closeCounts: Record<number, number> = {};
|
||||||
|
/** What yesterday's own close counted, note by note — the open form starts from this instead of
|
||||||
|
* zero. The backend already carries this figure forward on its own when nothing is submitted for
|
||||||
|
* the day being opened; showing it here is only about letting whoever opens the day see and
|
||||||
|
* correct it, not a second place the carry-forward logic lives. */
|
||||||
|
openCountsAuto: Record<number, number> = {};
|
||||||
|
/** What the vault + every cashier's live position added up to when the close form opened — the
|
||||||
|
* physical-count table starts from this instead of zero, since the EOD summary already fetched
|
||||||
|
* it. Kept separately from closeCounts so a manual recount can be told apart from it and undone. */
|
||||||
|
closeCountsAuto: Record<number, number> = {};
|
||||||
|
|
||||||
|
get allDenoms(): Denom[] {
|
||||||
|
return [
|
||||||
|
...this.notes,
|
||||||
|
...this.coins
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
|
||||||
|
this.load();
|
||||||
|
this.service.getActiveDenominations().subscribe({
|
||||||
|
next: (list) => {
|
||||||
|
const rows: Denom[] = (list || []).map((x: any) => ({
|
||||||
|
id: x.id,
|
||||||
|
value: x.denomination,
|
||||||
|
kind: x.kind,
|
||||||
|
label: '₹' + x.denomination
|
||||||
|
}));
|
||||||
|
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
|
||||||
|
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.notes = [];
|
||||||
|
this.coins = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
countFor(counts: Record<number, number>, d: Denom): number {
|
||||||
|
return counts[d.id] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
subtotalFor(counts: Record<number, number>, d: Denom): number {
|
||||||
|
return this.countFor(counts, d) * d.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCountChange(counts: Record<number, number>, d: Denom, raw: any): void {
|
||||||
|
counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpCount(counts: Record<number, number>, d: Denom, delta: number): void {
|
||||||
|
counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalOf(counts: Record<number, number>): number {
|
||||||
|
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(counts, d), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Denominations with a positive count, shaped for the API payload. */
|
||||||
|
private denominationPayload(counts: Record<number, number>) {
|
||||||
|
return this.allDenoms
|
||||||
|
.filter((d) => this.countFor(counts, d) > 0)
|
||||||
|
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(counts, d) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
get openDenomTotal(): number {
|
||||||
|
return this.totalOf(this.openCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
get closeDenomTotal(): number {
|
||||||
|
return this.totalOf(this.closeCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.branchDayRegister()
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
this.days = res?.days ?? [];
|
||||||
|
this.openDay = res?.openDay ?? null;
|
||||||
|
this.today = res?.today ?? '';
|
||||||
|
this.canOperate = !!res?.canOperate;
|
||||||
|
this.openDate = this.today;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.service
|
||||||
|
.branchDayMissed()
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.missed = res?.missed ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the branch has no open day at all, so today can be opened. */
|
||||||
|
get canOpenToday(): boolean {
|
||||||
|
return this.canOperate && !this.openDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
startOpen(): void {
|
||||||
|
this.showOpenForm = true;
|
||||||
|
this.openDate = this.today;
|
||||||
|
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
|
||||||
|
// over it is how a carried balance drifts.
|
||||||
|
this.openingCash = null;
|
||||||
|
this.openCounts = {};
|
||||||
|
this.openCountsAuto = {};
|
||||||
|
this.openDifferenceReason = '';
|
||||||
|
this.successMsg = '';
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.loadCarriedForwardDenominations();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelOpen(): void {
|
||||||
|
this.showOpenForm = false;
|
||||||
|
this.openCountsAuto = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls yesterday's own closing note/coin count so the open form shows what is about to be
|
||||||
|
* carried forward instead of a blank table the caller has to trust blindly. Only the most
|
||||||
|
* recently closed day is used — the same day the backend itself falls back to when the caller
|
||||||
|
* submits no denominations and no lump sum.
|
||||||
|
*/
|
||||||
|
private loadCarriedForwardDenominations(): void {
|
||||||
|
const lastClosed = this.days.find((d) => d.status === 'CLOSED' && d.closingCash != null);
|
||||||
|
if (!lastClosed) return;
|
||||||
|
this.service
|
||||||
|
.branchDayDenominations(lastClosed.officeId, lastClosed.businessDate)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
const closing = res?.closing || [];
|
||||||
|
if (!closing.length) return;
|
||||||
|
const totals: Record<number, number> = {};
|
||||||
|
for (const d of closing) {
|
||||||
|
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
|
||||||
|
}
|
||||||
|
this.openCountsAuto = totals;
|
||||||
|
// Only overwrite the table if nothing has been typed/counted since the form opened.
|
||||||
|
if (this.openDenomTotal === 0 && this.openingCash == null) {
|
||||||
|
this.openCounts = { ...totals };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasAutoFilledOpenCounts(): boolean {
|
||||||
|
return Object.keys(this.openCountsAuto).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoOpenCountFor(d: Denom): number {
|
||||||
|
return this.openCountsAuto[d.id] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetOpenToAutoFill(): void {
|
||||||
|
this.openCounts = { ...this.openCountsAuto };
|
||||||
|
}
|
||||||
|
|
||||||
|
get carriedForwardTotal(): number {
|
||||||
|
return this.totalOf(this.openCountsAuto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The figure the form would actually submit right now — the denomination total when any note/coin
|
||||||
|
* has been counted, otherwise the plain typed-in amount. Mirrors {@link effectiveClosingFigure}. */
|
||||||
|
get effectiveOpeningFigure(): number | null {
|
||||||
|
return this.openDenomTotal > 0 ? this.openDenomTotal : this.openingCash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Difference between what is about to be opened with and what yesterday actually closed with —
|
||||||
|
* visible before saving, the same way the close form shows a live difference against the book
|
||||||
|
* figure, so a short/excess opening count isn't discovered only after the day is already running. */
|
||||||
|
get openLiveDifference(): number | null {
|
||||||
|
if (!this.hasAutoFilledOpenCounts || this.effectiveOpeningFigure == null) return null;
|
||||||
|
return this.effectiveOpeningFigure - this.carriedForwardTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once a reason is needed but hasn't been typed — the same "explain the gap before it's
|
||||||
|
* allowed" gate the close form already applies to a late close. */
|
||||||
|
get needsOpenDifferenceReason(): boolean {
|
||||||
|
return this.openLiveDifference != null && this.openLiveDifference !== 0 && !this.openDifferenceReason.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSubmitOpen(): boolean {
|
||||||
|
return !!this.openDate && !this.opening && !this.needsOpenDifferenceReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitOpen(): void {
|
||||||
|
if (!this.openDate || this.needsOpenDifferenceReason) return;
|
||||||
|
this.opening = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
const denominations = this.denominationPayload(this.openCounts);
|
||||||
|
this.service
|
||||||
|
.openBranchDay(
|
||||||
|
this.openDate,
|
||||||
|
this.openingCash ?? undefined,
|
||||||
|
undefined,
|
||||||
|
denominations,
|
||||||
|
this.openDifferenceReason.trim() || undefined
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.opening = false;
|
||||||
|
if (res) {
|
||||||
|
this.showOpenForm = false;
|
||||||
|
this.successMsg = `Business day ${res.businessDate} opened.`;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day's expected figure and the working behind it, fetched when the close form opens. */
|
||||||
|
preview: any = null;
|
||||||
|
previewLoading = false;
|
||||||
|
|
||||||
|
startClose(day: BranchDay): void {
|
||||||
|
this.closeTarget = day;
|
||||||
|
this.closingCash = null;
|
||||||
|
this.closeCounts = {};
|
||||||
|
this.closeCountsAuto = {};
|
||||||
|
this.closeRemarks = '';
|
||||||
|
this.forceReason = '';
|
||||||
|
this.successMsg = '';
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.preview = null;
|
||||||
|
|
||||||
|
// Pre-fill the counted figure with what the books expect. It stays editable — the day is still
|
||||||
|
// closed on what was physically counted — but in the ordinary case where the drawer tallies
|
||||||
|
// there is nothing to type, and no chance of a slip of the keyboard inventing a difference.
|
||||||
|
this.previewLoading = true;
|
||||||
|
this.service
|
||||||
|
.branchDayClosePreview(day.businessDate)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.previewLoading = false;
|
||||||
|
this.preview = res;
|
||||||
|
if (res?.expectedCash != null && this.closingCash == null) {
|
||||||
|
this.closingCash = Number(res.expectedCash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadEodSummary(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EOD denomination summary: what's in the vault, and what every cashier under it is
|
||||||
|
// currently holding, fetched automatically so closing the day doesn't require separately
|
||||||
|
// checking Vault Position and every cashier's own Cashier Position screen by hand. ──
|
||||||
|
|
||||||
|
eodLoading = false;
|
||||||
|
eodTellers: any[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault + every cashier's current holding, added up across the whole branch and checked against
|
||||||
|
* the book figure — the question a manager actually needs answered at close: does the BRANCH
|
||||||
|
* tally, not just the vault alone. Computed on read rather than stored, so it never has to be
|
||||||
|
* kept in sync across the two independent loads (the book preview and the EOD summary) that feed
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
get branchTotalSummary(): {
|
||||||
|
vaultTotal: number;
|
||||||
|
cashierTotal: number;
|
||||||
|
grandTotal: number;
|
||||||
|
expected: number;
|
||||||
|
difference: number;
|
||||||
|
} | null {
|
||||||
|
if (!this.preview || this.eodTellers.length === 0) return null;
|
||||||
|
let vaultTotal = 0;
|
||||||
|
let cashierTotal = 0;
|
||||||
|
for (const t of this.eodTellers) {
|
||||||
|
vaultTotal += Number(t.vault?.totalAmount ?? 0);
|
||||||
|
for (const c of t.cashiers) {
|
||||||
|
cashierTotal += Number(c.currentTotal ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const grandTotal = vaultTotal + cashierTotal;
|
||||||
|
const expected = Number(this.preview.expectedCash ?? 0);
|
||||||
|
return { vaultTotal, cashierTotal, grandTotal, expected, difference: grandTotal - expected };
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadEodSummary(day: BranchDay): void {
|
||||||
|
this.eodLoading = true;
|
||||||
|
this.eodTellers = [];
|
||||||
|
this.service
|
||||||
|
.getTellers(day.officeId)
|
||||||
|
.pipe(
|
||||||
|
switchMap((tellers) => {
|
||||||
|
const list = tellers || [];
|
||||||
|
if (!list.length) return of([]);
|
||||||
|
return forkJoin(list.map((t: any) => this.loadTellerSummary(t, day.businessDate)));
|
||||||
|
}),
|
||||||
|
catchError(() => of([]))
|
||||||
|
)
|
||||||
|
.subscribe((results: any[]) => {
|
||||||
|
this.eodLoading = false;
|
||||||
|
this.eodTellers = results;
|
||||||
|
this.autoFillCloseCounts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the physical-count table from what the vault and every cashier are already shown to be
|
||||||
|
* holding, instead of leaving it at zero and making whoever closes the day re-key numbers that
|
||||||
|
* are already on screen above. It stays a starting point, not a lock — any row can still be
|
||||||
|
* corrected against what was actually counted, and {@link resetToAutoFill} undoes a correction.
|
||||||
|
*/
|
||||||
|
private autoFillCloseCounts(): void {
|
||||||
|
const totals: Record<number, number> = {};
|
||||||
|
for (const t of this.eodTellers) {
|
||||||
|
for (const d of t.vault?.denominations || []) {
|
||||||
|
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
|
||||||
|
}
|
||||||
|
for (const c of t.cashiers || []) {
|
||||||
|
for (const d of c.denominations || []) {
|
||||||
|
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(totals).length === 0) return;
|
||||||
|
this.closeCountsAuto = totals;
|
||||||
|
this.closeCounts = { ...totals };
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasAutoFilledCounts(): boolean {
|
||||||
|
return Object.keys(this.closeCountsAuto).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The auto-fetched count for one denomination, so a row that was hand-edited away from it can
|
||||||
|
* still show what the vault + cashier positions said. */
|
||||||
|
autoCountFor(d: Denom): number {
|
||||||
|
return this.closeCountsAuto[d.id] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToAutoFill(): void {
|
||||||
|
this.closeCounts = { ...this.closeCountsAuto };
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTellerSummary(teller: any, date: string) {
|
||||||
|
return forkJoin({
|
||||||
|
vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))),
|
||||||
|
cashiers: this.service.getCashiersForTeller(teller.id).pipe(catchError(() => of({ cashiers: [] })))
|
||||||
|
}).pipe(
|
||||||
|
switchMap(({ vault, cashiers }) => {
|
||||||
|
const cashierList = cashiers?.cashiers || [];
|
||||||
|
if (!cashierList.length) {
|
||||||
|
return of({ tellerId: teller.id, tellerName: teller.name, vault, cashiers: [] });
|
||||||
|
}
|
||||||
|
return forkJoin(cashierList.map((c: any) => this.loadCashierSummary(c, date))).pipe(
|
||||||
|
map((cashierSummaries: any[]) => ({
|
||||||
|
tellerId: teller.id,
|
||||||
|
tellerName: teller.name,
|
||||||
|
vault,
|
||||||
|
cashiers: cashierSummaries
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCashierSummary(cashier: any, date: string) {
|
||||||
|
return forkJoin({
|
||||||
|
position: this.service.cashierPositionReport(cashier.id, date).pipe(catchError(() => of(null))),
|
||||||
|
morning: this.service.getCashierMorningBalance(cashier.id, date).pipe(catchError(() => of(null))),
|
||||||
|
reconciliation: this.service.checkReconciliation(cashier.id, date).pipe(catchError(() => of(null))),
|
||||||
|
settlement: this.service.checkSettlement(cashier.id, date).pipe(catchError(() => of(null))),
|
||||||
|
glEntries: this.service.getGlEntries(date, cashier.id, undefined, 0, 1).pipe(catchError(() => of(null)))
|
||||||
|
}).pipe(
|
||||||
|
map(({ position, morning, reconciliation, settlement, glEntries }: any) => {
|
||||||
|
const morningMap = new Map<number, number>();
|
||||||
|
(morning?.denominations || []).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0));
|
||||||
|
return {
|
||||||
|
cashierId: cashier.id,
|
||||||
|
cashierName: cashier.staffName,
|
||||||
|
morningTotal: Number(morning?.totalMorning ?? 0),
|
||||||
|
currentTotal: Number(position?.totalAmount ?? 0),
|
||||||
|
reconciled: reconciliation?.reconciled ?? null,
|
||||||
|
alreadySettled: settlement?.alreadySettled ?? null,
|
||||||
|
txnCount: glEntries?.totalFilteredRecords ?? null,
|
||||||
|
denominations: ((position?.denominations || []) as any[]).map((d: any) => ({
|
||||||
|
...d,
|
||||||
|
morningCount: morningMap.get(d.denominationId) ?? 0,
|
||||||
|
delta: d.count - (morningMap.get(d.denominationId) ?? 0)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The counted figure the form would submit right now — the denomination total when any note/coin
|
||||||
|
* has been counted, otherwise the plain typed-in amount. */
|
||||||
|
get effectiveClosingFigure(): number | null {
|
||||||
|
return this.closeDenomTotal > 0 ? this.closeDenomTotal : this.closingCash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Difference the form would record right now, so it is visible before saving. */
|
||||||
|
get liveDifference(): number | null {
|
||||||
|
if (!this.preview || this.effectiveClosingFigure == null) return null;
|
||||||
|
return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts the expected figure back after an edit. */
|
||||||
|
useExpected(): void {
|
||||||
|
if (this.preview?.expectedCash != null) {
|
||||||
|
this.closingCash = Number(this.preview.expectedCash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelClose(): void {
|
||||||
|
this.closeTarget = null;
|
||||||
|
this.eodTellers = [];
|
||||||
|
this.closeCountsAuto = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date earlier than today is a late close, and has to say why it was left open. */
|
||||||
|
get isLateClose(): boolean {
|
||||||
|
return !!this.closeTarget && this.closeTarget.businessDate < this.today;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSubmitClose(): boolean {
|
||||||
|
const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
|
||||||
|
if (!this.closeTarget || !hasFigure || this.closing) return false;
|
||||||
|
return !this.isLateClose || !!this.forceReason.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitClose(): void {
|
||||||
|
const day = this.closeTarget;
|
||||||
|
const denominations = this.denominationPayload(this.closeCounts);
|
||||||
|
const figure = denominations.length ? this.closeDenomTotal : this.closingCash;
|
||||||
|
if (!day || figure == null) return;
|
||||||
|
|
||||||
|
this.closing = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.closeBranchDay(
|
||||||
|
day.businessDate,
|
||||||
|
Number(figure),
|
||||||
|
this.closeRemarks.trim() || undefined,
|
||||||
|
this.isLateClose ? this.forceReason.trim() : undefined,
|
||||||
|
undefined,
|
||||||
|
denominations
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.closing = false;
|
||||||
|
if (res) {
|
||||||
|
this.closeTarget = null;
|
||||||
|
const diff = Number(res.cashDifference ?? 0);
|
||||||
|
this.successMsg =
|
||||||
|
diff === 0
|
||||||
|
? `Business day ${res.businessDate} closed — cash tallied.`
|
||||||
|
: `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
absValue(n: number): number {
|
||||||
|
return Math.abs(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
|
||||||
|
differenceLabel(d: number | null): string {
|
||||||
|
if (d == null) return '—';
|
||||||
|
const n = Number(d);
|
||||||
|
if (n === 0) return 'Tallied';
|
||||||
|
return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const [
|
||||||
|
d,
|
||||||
|
t
|
||||||
|
] = iso.split('T');
|
||||||
|
return t ? `${d} ${t.slice(0, 5)}` : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeOnly(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const t = iso.split('T')[1];
|
||||||
|
return t ? t.slice(0, 5) : '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">SUB LEDGER · TRANSACTIONS · {{ scopeName | uppercase }}</div>
|
||||||
|
<h1 class="pg-title">Cash / Clearing / Transfer - Summary Report</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Opening, credit, debit and closing position across Cash, Transfer, Clearing and Digital channels as on
|
||||||
|
{{ asOnDate }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
@if (isHeadOffice) {
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Branch</span>
|
||||||
|
<select class="pg-select" [ngModel]="selectedBranchCode" (ngModelChange)="setBranch($event)">
|
||||||
|
<option value="ALL">All Branches (Consolidated)</option>
|
||||||
|
@for (b of branches; track b.code) {
|
||||||
|
<option [value]="b.code">{{ b.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
|
||||||
|
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="cctr-grand-summary">
|
||||||
|
<div class="cctr-grand-summary__item">
|
||||||
|
<span class="cctr-grand-summary__label">Opening</span>
|
||||||
|
<span class="cctr-grand-summary__value">₹{{ grandOpening | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cctr-grand-summary__item">
|
||||||
|
<span class="cctr-grand-summary__label">Credit</span>
|
||||||
|
<span class="cctr-grand-summary__value col-in">₹{{ grandCredit | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cctr-grand-summary__item">
|
||||||
|
<span class="cctr-grand-summary__label">Debit</span>
|
||||||
|
<span class="cctr-grand-summary__value col-out">₹{{ grandDebit | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cctr-grand-summary__item">
|
||||||
|
<span class="cctr-grand-summary__label">Closing</span>
|
||||||
|
<span class="cctr-grand-summary__value">₹{{ grandClosing | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isHeadOffice && selectedBranchCode === 'ALL') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Branch-wise Break-up — click a branch to drill in</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th class="num">Opening</th>
|
||||||
|
<th class="num col-in">Credit</th>
|
||||||
|
<th class="num col-out">Debit</th>
|
||||||
|
<th class="num col-net">Closing</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (b of branchBreakup; track b.code) {
|
||||||
|
<tr class="cctr-branch-row" (click)="setBranch(b.code)">
|
||||||
|
<td>{{ b.name }}</td>
|
||||||
|
<td class="num">₹{{ b.opening | number: '1.2-2' }}</td>
|
||||||
|
<td class="num col-in">₹{{ b.credit | number: '1.2-2' }}</td>
|
||||||
|
<td class="num col-out">₹{{ b.debit | number: '1.2-2' }}</td>
|
||||||
|
<td class="num col-net">₹{{ b.closing | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td>All Branches</td>
|
||||||
|
<td class="num">₹{{ grandOpening | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ grandCredit | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ grandDebit | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ grandClosing | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="cctr-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeTab === 'cash'"
|
||||||
|
(click)="setTab('cash')"
|
||||||
|
>
|
||||||
|
Cash
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeTab === 'transfer'"
|
||||||
|
(click)="setTab('transfer')"
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeTab === 'clearing'"
|
||||||
|
(click)="setTab('clearing')"
|
||||||
|
>
|
||||||
|
Clearing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeTab === 'digital'"
|
||||||
|
(click)="setTab('digital')"
|
||||||
|
>
|
||||||
|
Digital
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary (Opening | Credit | Debit | Closing) -->
|
||||||
|
@if (!showDetails) {
|
||||||
|
<div class="pg-card">
|
||||||
|
@if (activeTab === 'digital') {
|
||||||
|
<div class="pos-section-label">RTGS · NEFT · UPI · ATM · NACH — Overall Position</div>
|
||||||
|
}
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
@if (activeTab !== 'digital') {
|
||||||
|
<th>GL Code</th>
|
||||||
|
}
|
||||||
|
<th>Particulars</th>
|
||||||
|
<th class="num">Opening</th>
|
||||||
|
<th class="num col-in">Credit</th>
|
||||||
|
<th class="num col-out">Debit</th>
|
||||||
|
<th class="num col-net">Closing</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rowsForActiveTab; track r.code) {
|
||||||
|
<tr>
|
||||||
|
@if (activeTab !== 'digital') {
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.code }}</span>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td class="num">₹{{ r.opening | number: '1.2-2' }}</td>
|
||||||
|
<td class="num col-in">₹{{ rowCredit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num col-out">₹{{ rowDebit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num col-net">₹{{ closing(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td [attr.colspan]="activeTab !== 'digital' ? 2 : 1">Total</td>
|
||||||
|
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'opening') | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'credit') | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'debit') | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ totalClosing(rowsForActiveTab, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Transaction Details (all txns for the selected category, optionally filtered by GL/channel) -->
|
||||||
|
@if (showDetails) {
|
||||||
|
@if (rowsForActiveTab.length > 1) {
|
||||||
|
<div class="cctr-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="glFilter === null"
|
||||||
|
(click)="setGlFilter(null)"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
@for (r of rowsForActiveTab; track r.code) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="glFilter === r.code"
|
||||||
|
(click)="setGlFilter(r.code)"
|
||||||
|
>
|
||||||
|
{{ filterLabel(r) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">
|
||||||
|
{{
|
||||||
|
activeTab === 'cash'
|
||||||
|
? 'Cash'
|
||||||
|
: activeTab === 'transfer'
|
||||||
|
? 'Transfer'
|
||||||
|
: activeTab === 'clearing'
|
||||||
|
? 'Clearing'
|
||||||
|
: 'Digital'
|
||||||
|
}}
|
||||||
|
— Transaction Details (Full Double Entry)
|
||||||
|
@if (glFilter !== null) {
|
||||||
|
· {{ glFilter }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<table class="pg-table cctr-journal">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Voucher / Ref No</th>
|
||||||
|
<th>Dr / Cr</th>
|
||||||
|
<th>GL / Account</th>
|
||||||
|
<th class="num col-out">Debit</th>
|
||||||
|
<th class="num col-in">Credit</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (t of filteredTxns; track t.voucherNo) {
|
||||||
|
<tr class="cctr-journal-row cctr-journal-row--first">
|
||||||
|
<td>{{ asOnDate }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
|
||||||
|
<td>
|
||||||
|
@if (t.debit > 0) {
|
||||||
|
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||||
|
t.accountName
|
||||||
|
}})
|
||||||
|
} @else {
|
||||||
|
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num col-out">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num"></td>
|
||||||
|
<td rowspan="2">
|
||||||
|
<span class="cctr-balanced-chip">✓ Balanced</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="cctr-journal-row cctr-journal-row--second">
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td><span class="cctr-drcr cctr-drcr--cr">Cr</span></td>
|
||||||
|
<td>
|
||||||
|
@if (t.credit > 0) {
|
||||||
|
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||||
|
t.accountName
|
||||||
|
}})
|
||||||
|
} @else {
|
||||||
|
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num"></td>
|
||||||
|
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No transactions for this filter.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
|
||||||
|
<td class="num">
|
||||||
|
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.cctr-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-total-row {
|
||||||
|
td {
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-weight: 800;
|
||||||
|
border-top: 2px solid #cbd5e1;
|
||||||
|
border-bottom: none;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-grand-summary {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
border-right: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-subtext {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-branch-row {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover td {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both journal lines of one voucher share a tint and sit flush against each other (no dotted
|
||||||
|
// divider between them) so the pair reads as a single entry; a hairline separates one voucher's
|
||||||
|
// pair from the next.
|
||||||
|
.cctr-journal-row {
|
||||||
|
&--first td {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--second td {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--first,
|
||||||
|
&--second {
|
||||||
|
&:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-drcr {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
|
||||||
|
&--dr {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--cr {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-contra {
|
||||||
|
color: #64748b;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-balanced-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
+1802
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Date</mat-label>
|
<mat-label>Date</mat-label>
|
||||||
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
|
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
||||||
@@ -59,11 +59,28 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- What the vault actually has, before anyone starts counting a handout it can't cover. -->
|
||||||
|
@if (fromTellerId && !loadingVault) {
|
||||||
|
@if (vaultHasBreakdown) {
|
||||||
|
<div class="ct-vault-banner">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
Vault available: <strong>₹{{ vaultTotalAvailable | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="ct-vault-banner ct-vault-banner--warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
No denomination breakdown recorded for this vault — per-note availability can't be checked, only the total
|
||||||
|
opening balance.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Denomination</th>
|
<th>Denomination</th>
|
||||||
|
<th class="num">Available</th>
|
||||||
<th style="text-align: center">Count</th>
|
<th style="text-align: center">Count</th>
|
||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -71,14 +88,17 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@if (notes.length) {
|
@if (notes.length) {
|
||||||
<tr class="pg-section">
|
<tr class="pg-section">
|
||||||
<td colspan="3">Bank Notes</td>
|
<td colspan="4">Bank Notes</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (d of notes; track d.id) {
|
@for (d of notes; track d.id) {
|
||||||
<tr>
|
<tr [class.ct-row--over]="exceedsAvailable(d)">
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ d.label }}</span>
|
<span class="pg-chip">{{ d.label }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="num" [class.ct-available--over]="exceedsAvailable(d)">
|
||||||
|
{{ vaultHasBreakdown ? availableFor(d) : '—' }}
|
||||||
|
</td>
|
||||||
<td style="display: flex; justify-content: center">
|
<td style="display: flex; justify-content: center">
|
||||||
<div class="pg-step">
|
<div class="pg-step">
|
||||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||||
@@ -91,14 +111,17 @@
|
|||||||
}
|
}
|
||||||
@if (coins.length) {
|
@if (coins.length) {
|
||||||
<tr class="pg-section">
|
<tr class="pg-section">
|
||||||
<td colspan="3">Coins</td>
|
<td colspan="4">Coins</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (d of coins; track d.id) {
|
@for (d of coins; track d.id) {
|
||||||
<tr>
|
<tr [class.ct-row--over]="exceedsAvailable(d)">
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="num" [class.ct-available--over]="exceedsAvailable(d)">
|
||||||
|
{{ vaultHasBreakdown ? availableFor(d) : '—' }}
|
||||||
|
</td>
|
||||||
<td style="display: flex; justify-content: center">
|
<td style="display: flex; justify-content: center">
|
||||||
<div class="pg-step">
|
<div class="pg-step">
|
||||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||||
@@ -111,13 +134,24 @@
|
|||||||
}
|
}
|
||||||
@if (denoms.length === 0) {
|
@if (denoms.length === 0) {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
|
<td colspan="4" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (anyDenomExceedsAvailable) {
|
||||||
|
<div class="ct-limit-warn">
|
||||||
|
One or more notes/coins above are counted higher than the vault currently holds — reduce them before recording the
|
||||||
|
transfer.
|
||||||
|
</div>
|
||||||
|
} @else if (exceedsVaultTotal) {
|
||||||
|
<div class="ct-limit-warn">
|
||||||
|
Total exceeds what the vault has available (₹{{ vaultTotalAvailable | number: '1.2-2' }}).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="pg-total-bar">
|
<div class="pg-total-bar">
|
||||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ct-vault-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1e40af;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-row--over {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-available--over {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-limit-warn {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
@@ -29,7 +29,10 @@ interface Denom {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-cash-transfer',
|
selector: 'mifosx-cash-transfer',
|
||||||
templateUrl: './cash-transfer.component.html',
|
templateUrl: './cash-transfer.component.html',
|
||||||
styleUrls: ['../cashier-shared.scss'],
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./cash-transfer.component.scss'
|
||||||
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -70,6 +73,14 @@ export class CashTransferComponent implements OnInit {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected vault currently holds, denomination-wise — fetched so a clerk can see
|
||||||
|
* before counting whether the vault even has enough of a given note, instead of finding out
|
||||||
|
* only after the backend rejects the transfer. */
|
||||||
|
vaultAvailable: Record<number, number> = {};
|
||||||
|
vaultTotalAvailable = 0;
|
||||||
|
vaultHasBreakdown = true;
|
||||||
|
loadingVault = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
@@ -101,7 +112,8 @@ export class CashTransferComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown. */
|
/** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown, and the
|
||||||
|
* vault's own current denomination-wise stock so it can be shown against every count field. */
|
||||||
onTellerSelect(tellerId: number): void {
|
onTellerSelect(tellerId: number): void {
|
||||||
this.fromTellerId = tellerId;
|
this.fromTellerId = tellerId;
|
||||||
const teller = this.tellers.find((t) => t.id === tellerId);
|
const teller = this.tellers.find((t) => t.id === tellerId);
|
||||||
@@ -109,6 +121,7 @@ export class CashTransferComponent implements OnInit {
|
|||||||
this.toParty = '';
|
this.toParty = '';
|
||||||
this.toCashierId = null;
|
this.toCashierId = null;
|
||||||
this.cashiers = [];
|
this.cashiers = [];
|
||||||
|
this.loadVaultAvailability();
|
||||||
if (!tellerId) {
|
if (!tellerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,6 +138,45 @@ export class CashTransferComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refetched whenever the vault or the transfer date changes — the vault's stock is only ever
|
||||||
|
* meaningful as of a specific business date. */
|
||||||
|
loadVaultAvailability(): void {
|
||||||
|
this.vaultAvailable = {};
|
||||||
|
this.vaultTotalAvailable = 0;
|
||||||
|
this.vaultHasBreakdown = true;
|
||||||
|
if (!this.fromTellerId) return;
|
||||||
|
this.loadingVault = true;
|
||||||
|
this.service
|
||||||
|
.vaultPosition(this.fromTellerId, this.transferDate)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.loadingVault = false;
|
||||||
|
this.vaultHasBreakdown = !!res?.hasBreakdown;
|
||||||
|
this.vaultTotalAvailable = Number(res?.totalAmount ?? 0);
|
||||||
|
const map: Record<number, number> = {};
|
||||||
|
((res?.denominations || []) as any[]).forEach((d: any) => (map[d.denominationId] = d.count));
|
||||||
|
this.vaultAvailable = map;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateChange(value: string): void {
|
||||||
|
this.transferDate = value;
|
||||||
|
this.loadVaultAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many of this denomination the vault currently holds — 0, not undefined, when the vault's
|
||||||
|
* breakdown is known but simply doesn't list this denomination (i.e. it truly has none left). */
|
||||||
|
availableFor(d: Denom): number {
|
||||||
|
return this.vaultAvailable[d.id] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the count entered for this denomination is more than the vault is known to hold —
|
||||||
|
* only flagged once the vault's breakdown has actually loaded, so an empty in-flight state
|
||||||
|
* doesn't flash every row as over-limit. */
|
||||||
|
exceedsAvailable(d: Denom): boolean {
|
||||||
|
return this.vaultHasBreakdown && this.countFor(d) > this.availableFor(d);
|
||||||
|
}
|
||||||
|
|
||||||
/** When a To-cashier is chosen, capture its id and name. */
|
/** When a To-cashier is chosen, capture its id and name. */
|
||||||
onCashierSelect(cashierId: number): void {
|
onCashierSelect(cashierId: number): void {
|
||||||
this.toCashierId = cashierId;
|
this.toCashierId = cashierId;
|
||||||
@@ -152,8 +204,24 @@ export class CashTransferComponent implements OnInit {
|
|||||||
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True once any counted denomination is over what the vault holds — checked client-side purely
|
||||||
|
* so the clerk sees it before submitting; the backend enforces this too regardless. */
|
||||||
|
get anyDenomExceedsAvailable(): boolean {
|
||||||
|
return this.denoms.some((d) => this.exceedsAvailable(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
get exceedsVaultTotal(): boolean {
|
||||||
|
return this.vaultHasBreakdown && this.total > this.vaultTotalAvailable && this.total > 0;
|
||||||
|
}
|
||||||
|
|
||||||
get valid(): boolean {
|
get valid(): boolean {
|
||||||
return this.total > 0 && !!this.fromTellerId && !!this.toCashierId;
|
return (
|
||||||
|
this.total > 0 &&
|
||||||
|
!!this.fromTellerId &&
|
||||||
|
!!this.toCashierId &&
|
||||||
|
!this.anyDenomExceedsAvailable &&
|
||||||
|
!this.exceedsVaultTotal
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts)
|
||||||
|
// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet
|
||||||
|
// names, Blob download) is written once and every cashier report's export behaves the same way.
|
||||||
|
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
export interface ExportSheetSpec {
|
||||||
|
/** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
|
||||||
|
sheetName: string;
|
||||||
|
/** Bold title row, merged across all columns. */
|
||||||
|
title: string;
|
||||||
|
/** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
|
||||||
|
subtitle?: string;
|
||||||
|
headers: string[];
|
||||||
|
rows: (string | number | null)[][];
|
||||||
|
widths?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
|
||||||
|
function safeSheetName(name: string): string {
|
||||||
|
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
|
||||||
|
const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
|
||||||
|
sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
|
||||||
|
|
||||||
|
const title = sheet.addRow([spec.title]);
|
||||||
|
sheet.mergeCells(title.number, 1, title.number, spec.headers.length);
|
||||||
|
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||||
|
title.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
title.height = 24;
|
||||||
|
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
|
||||||
|
|
||||||
|
if (spec.subtitle) {
|
||||||
|
const meta = sheet.addRow([spec.subtitle]);
|
||||||
|
sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
|
||||||
|
meta.font = { italic: true, size: 10 };
|
||||||
|
}
|
||||||
|
sheet.addRow([]);
|
||||||
|
|
||||||
|
const head = sheet.addRow(spec.headers);
|
||||||
|
head.font = { bold: true };
|
||||||
|
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||||
|
head.eachCell((c) => {
|
||||||
|
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||||
|
c.border = { bottom: { style: 'thin' } };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const r of spec.rows) {
|
||||||
|
const row = sheet.addRow(r);
|
||||||
|
r.forEach((v, idx) => {
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
row.getCell(idx + 1).numFmt = '#,##0.00';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
|
||||||
|
export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise<void> {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
wb.creator = 'CBSNexora';
|
||||||
|
wb.created = new Date();
|
||||||
|
sheets.forEach((s) => buildSheet(wb, s));
|
||||||
|
|
||||||
|
const buffer = await wb.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -19,6 +19,13 @@ import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-ca
|
|||||||
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
|
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
|
||||||
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
|
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
|
||||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||||
|
import { DayBookComponent } from './day-book/day-book.component';
|
||||||
|
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
|
||||||
|
import { VoucherViewComponent } from './voucher-view/voucher-view.component';
|
||||||
|
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
||||||
|
import { BranchDayComponent } from './branch-day/branch-day.component';
|
||||||
|
import { VaultPositionComponent } from './vault-position/vault-position.component';
|
||||||
|
import { AccountRegisterComponent } from './account-register/account-register.component';
|
||||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
||||||
@@ -62,6 +69,30 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-request',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||||
|
(m) => m.ShareWithdrawalRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-requests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||||
|
(m) => m.MyShareWithdrawalRequestsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'closure-requests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/my-closure-requests/my-closure-requests.component').then(
|
||||||
|
(m) => m.MyClosureRequestsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'My Closure Requests', breadcrumb: 'My Closure Requests' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account-facilities',
|
path: 'account-facilities',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
@@ -101,6 +132,48 @@ 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' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Branch-level operations, kept out of the cashier tree on purpose: opening and closing the
|
||||||
|
// day governs the whole branch, and is not something a cashier does.
|
||||||
|
path: 'branch-ops',
|
||||||
|
data: { title: 'Branch Operations', breadcrumb: 'Branch Operations' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'day-register',
|
||||||
|
component: BranchDayComponent,
|
||||||
|
data: { title: 'Day Open / Day Close', breadcrumb: 'Day Register' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Same screen, narrowed to the days that were never closed. A separate route rather than
|
||||||
|
// a second link to the register, so choosing it visibly does something.
|
||||||
|
path: 'missed-close',
|
||||||
|
component: BranchDayComponent,
|
||||||
|
data: { title: 'Missed Close Register', breadcrumb: 'Missed Close', missedOnly: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Lives here alongside the day register because both answer "what did this branch look
|
||||||
|
// like on that date"; it is linked from the Reports menu, where people go to find it.
|
||||||
|
path: 'account-register',
|
||||||
|
component: AccountRegisterComponent,
|
||||||
|
data: { title: 'Account Register', breadcrumb: 'Account Register' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Same branch-level scope as the day register: this is the vault's own stock, not any
|
||||||
|
// one cashier's float, so it sits with the other branch-wide screens.
|
||||||
|
path: 'vault-position',
|
||||||
|
component: VaultPositionComponent,
|
||||||
|
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -148,6 +221,26 @@ const routes: Routes = [
|
|||||||
component: CashHistoryComponent,
|
component: CashHistoryComponent,
|
||||||
data: { title: 'Cash History', breadcrumb: 'Cash History' }
|
data: { title: 'Cash History', breadcrumb: 'Cash History' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'day-book',
|
||||||
|
component: DayBookComponent,
|
||||||
|
data: { title: 'Day Book', breadcrumb: 'Day Book' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'voucher-exceptions',
|
||||||
|
component: VoucherExceptionsComponent,
|
||||||
|
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'voucher',
|
||||||
|
component: VoucherViewComponent,
|
||||||
|
data: { title: 'Voucher', breadcrumb: 'Voucher' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'voucher-report',
|
||||||
|
component: VoucherReportComponent,
|
||||||
|
data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'closing-report',
|
path: 'closing-report',
|
||||||
component: ClosingCashReportComponent,
|
component: ClosingCashReportComponent,
|
||||||
@@ -179,6 +272,17 @@ const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
|
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
|
||||||
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
|
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cash-clearing-transfer-summary',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./cash-clearing-transfer-summary/cash-clearing-transfer-summary.component').then(
|
||||||
|
(m) => m.CashClearingTransferSummaryComponent
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
title: 'Cash / Clearing / Transfer - Summary Report',
|
||||||
|
breadcrumb: 'Cash / Clearing / Transfer - Summary Report'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
background: linear-gradient(135deg, #ecfeff, #cffafe);
|
background: linear-gradient(135deg, #ecfeff, #cffafe);
|
||||||
color: #155e75;
|
color: #155e75;
|
||||||
border: 1px solid #a5f3fc;
|
border: 1px solid #a5f3fc;
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ export class CashierService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Tellers / Cashiers ──
|
// ── Tellers / Cashiers ──
|
||||||
getTellers(): Observable<any[]> {
|
getTellers(officeId?: number): Observable<any[]> {
|
||||||
return this.http.get<any[]>('/tellers');
|
let params = new HttpParams();
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
return this.http.get<any[]>('/tellers', { params });
|
||||||
}
|
}
|
||||||
getAllCashiers(): Observable<any[]> {
|
getAllCashiers(): Observable<any[]> {
|
||||||
return this.http.get<any[]>('/cashiers');
|
return this.http.get<any[]>('/cashiers');
|
||||||
@@ -113,6 +115,227 @@ export class CashierService {
|
|||||||
const params = new HttpParams().set('date', date);
|
const params = new HttpParams().set('date', date);
|
||||||
return this.http.get('/cashreports/closing', { params });
|
return this.http.get('/cashreports/closing', { params });
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Every voucher raised on one date — deposits, withdrawals, disbursements, collections and
|
||||||
|
* bank transfers in a single list, with the day's credit and debit totals.
|
||||||
|
*/
|
||||||
|
dayBook(date: string, side?: 'CR' | 'DR', status?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('date', date);
|
||||||
|
if (side) params = params.set('side', side);
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get('/cashreports/day-book', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Voucher physical verification & attachments ──
|
||||||
|
// The voucher number always travels in the body or a query parameter, never as a path segment:
|
||||||
|
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
|
||||||
|
// encoded slashes with a 400 before the request reaches the resource.
|
||||||
|
|
||||||
|
/** Records the outcome of checking a voucher against the paper slip. */
|
||||||
|
verifyVoucher(
|
||||||
|
voucherNo: string,
|
||||||
|
status: 'VERIFIED' | 'EXCEPTION',
|
||||||
|
slipAmount?: number,
|
||||||
|
remarks?: string
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post('/vouchers/verify', {
|
||||||
|
voucherNo,
|
||||||
|
status,
|
||||||
|
slipAmount: slipAmount != null ? String(slipAmount) : null,
|
||||||
|
remarks: remarks ?? null,
|
||||||
|
locale: 'en'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadVoucherAttachment(
|
||||||
|
voucherNo: string,
|
||||||
|
fileName: string,
|
||||||
|
contentType: string,
|
||||||
|
fileData: string,
|
||||||
|
description?: string
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post('/vouchers/attachments', {
|
||||||
|
voucherNo,
|
||||||
|
fileName,
|
||||||
|
contentType,
|
||||||
|
fileData,
|
||||||
|
description: description ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listVoucherAttachments(voucherNo: string): Observable<any> {
|
||||||
|
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||||
|
return this.http.get('/vouchers/attachments', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteVoucherAttachment(id: number): Observable<any> {
|
||||||
|
return this.http.delete(`/vouchers/attachments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
|
||||||
|
downloadVoucherAttachment(id: number): Observable<Blob> {
|
||||||
|
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
|
||||||
|
* raised on Friday still needs answering on Monday.
|
||||||
|
*/
|
||||||
|
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
|
||||||
|
const params = new HttpParams().set('status', status);
|
||||||
|
return this.http.get('/vouchers/exceptions', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vouchers raised over a period, one row each, with what was debited and what credited.
|
||||||
|
*
|
||||||
|
* Who appears is decided by the server from the caller's own roles — a cashier sees only their
|
||||||
|
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
|
||||||
|
* way to choose whose report this is.
|
||||||
|
*/
|
||||||
|
voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('from', from).set('to', to);
|
||||||
|
if (username) {
|
||||||
|
params = params.set('username', username);
|
||||||
|
}
|
||||||
|
if (voucherType) {
|
||||||
|
params = params.set('voucherType', voucherType);
|
||||||
|
}
|
||||||
|
return this.http.get('/vouchers/report', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
|
||||||
|
*
|
||||||
|
* The voucher number travels as a query parameter, not a path segment: numbers are formatted
|
||||||
|
* CR/2708/0001 and the server rejects the encoded slashes in a path.
|
||||||
|
*/
|
||||||
|
voucherDetail(voucherNo: string): Observable<any> {
|
||||||
|
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||||
|
return this.http.get('/vouchers/detail', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
|
||||||
|
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
|
||||||
|
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||||
|
* stood then, and what it holds now.
|
||||||
|
*/
|
||||||
|
accountRegister(date: string, section?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('date', date);
|
||||||
|
if (section && section !== 'ALL') params = params.set('section', section);
|
||||||
|
return this.http.get('/accountregister', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branch day register ──
|
||||||
|
|
||||||
|
/** The branch's day register, plus which day is currently open. Defaults to the caller's office. */
|
||||||
|
branchDayRegister(officeId?: number, limit?: number): Observable<any> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
if (limit) params = params.set('limit', String(limit));
|
||||||
|
return this.http.get('/branchday', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days opened and never closed — today's own open day excluded. */
|
||||||
|
branchDayMissed(officeId?: number): Observable<any> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
return this.http.get('/branchday/missed', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the day should close at, and the arithmetic behind it, so the closing figure does not
|
||||||
|
* have to be worked out by hand.
|
||||||
|
*/
|
||||||
|
branchDayClosePreview(businessDate?: string, officeId?: number): Observable<any> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (businessDate) params = params.set('businessDate', businessDate);
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
return this.http.get('/branchday/close-preview', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** openingDifferenceReason is required when the opening figure differs from yesterday's closing. */
|
||||||
|
openBranchDay(
|
||||||
|
businessDate: string,
|
||||||
|
openingCash?: number,
|
||||||
|
officeId?: number,
|
||||||
|
denominations?: { denominationId: number; denominationValue: number; count: number }[],
|
||||||
|
openingDifferenceReason?: string
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post('/branchday/open', {
|
||||||
|
businessDate,
|
||||||
|
openingCash: openingCash != null ? String(openingCash) : null,
|
||||||
|
officeId: officeId ?? null,
|
||||||
|
denominations: denominations?.length ? denominations : null,
|
||||||
|
openingDifferenceReason: openingDifferenceReason ?? null,
|
||||||
|
locale: 'en'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** forceCloseReason is required when closing a date earlier than today. */
|
||||||
|
closeBranchDay(
|
||||||
|
businessDate: string,
|
||||||
|
closingCash: number,
|
||||||
|
remarks?: string,
|
||||||
|
forceCloseReason?: string,
|
||||||
|
officeId?: number,
|
||||||
|
denominations?: { denominationId: number; denominationValue: number; count: number }[]
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post('/branchday/close', {
|
||||||
|
businessDate,
|
||||||
|
closingCash: String(closingCash),
|
||||||
|
remarks: remarks ?? null,
|
||||||
|
forceCloseReason: forceCloseReason ?? null,
|
||||||
|
officeId: officeId ?? null,
|
||||||
|
denominations: denominations?.length ? denominations : null,
|
||||||
|
locale: 'en'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The opening/closing denomination breakdown recorded for one business day, if any was captured. */
|
||||||
|
branchDayDenominations(officeId?: number, businessDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
if (businessDate) params = params.set('businessDate', businessDate);
|
||||||
|
return this.http.get('/branchday/denominations', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the office's cash-at-vault GL account currently holds — zero means the count itself will
|
||||||
|
* define its opening balance; non-zero means the count must match it exactly. */
|
||||||
|
cashGlStatus(officeId?: number): Observable<any> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
return this.http.get('/branchday/cash-gl-status', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-step new-institution path: defines (or reconciles against) the cash GL's opening balance
|
||||||
|
* from a counted denomination breakdown, then opens the business day with that same count. */
|
||||||
|
migrateCashDenomination(
|
||||||
|
officeId: number,
|
||||||
|
businessDate: string,
|
||||||
|
denominations: { denominationId: number; denominationValue: number; count: number }[],
|
||||||
|
openingDifferenceReason?: string
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post('/branchday/migrate-cash-denomination', {
|
||||||
|
officeId,
|
||||||
|
businessDate,
|
||||||
|
denominations,
|
||||||
|
openingDifferenceReason: openingDifferenceReason ?? null,
|
||||||
|
locale: 'en'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Denomination-wise stock a vault (teller) currently holds — "how much of what does the bank have". */
|
||||||
|
vaultPosition(tellerId?: number, asOfDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (tellerId) params = params.set('tellerId', String(tellerId));
|
||||||
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
||||||
|
return this.http.get('/cashreports/vault-position', { params });
|
||||||
|
}
|
||||||
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
|
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
|
||||||
let params = new HttpParams().set('threshold', String(threshold));
|
let params = new HttpParams().set('threshold', String(threshold));
|
||||||
if (fromDate) params = params.set('fromDate', fromDate);
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
@@ -188,4 +411,54 @@ export class CashierService {
|
|||||||
getShareCollectionStatus(shareAccountId: number): Observable<any> {
|
getShareCollectionStatus(shareAccountId: number): Observable<any> {
|
||||||
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
|
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
|
||||||
|
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
|
||||||
|
// The voucher travels as a query parameter, not a path segment: numbers are formatted
|
||||||
|
// CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes
|
||||||
|
// (%2F) outright with a 400 before the request ever reaches the resource.
|
||||||
|
// clientId lets the backend adopt a cashier deposit made straight to a GL account, which
|
||||||
|
// carries no member of its own. glAccountNo is required — which GL account to search in is
|
||||||
|
// always chosen by hand, never resolved from a share product's accounting setup, so a broken
|
||||||
|
// or missing product mapping can never make a genuine voucher unreachable.
|
||||||
|
let params = new HttpParams().set('voucherNo', voucherNo).set('glAccountNo', glAccountNo);
|
||||||
|
if (clientId) {
|
||||||
|
params = params.set('clientId', String(clientId));
|
||||||
|
}
|
||||||
|
return this.http.get('/shareadvancecollection/by-voucher', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL accounts selectable when searching for a voucher, for the picker on the Apply Collection screen. */
|
||||||
|
getShareAdvanceGlAccounts(): Observable<any> {
|
||||||
|
return this.http.get('/shareadvancecollection/glaccounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share value + any outstanding entry fee for one share account, shown before a voucher search. */
|
||||||
|
getShareAdvanceRequirement(shareAccountId: number): Observable<any> {
|
||||||
|
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receipts still sitting in Share Sundry, optionally for a single member. */
|
||||||
|
getUnappliedShareAdvances(clientId?: number): Observable<any> {
|
||||||
|
const suffix = clientId ? `?clientId=${clientId}` : '';
|
||||||
|
return this.http.get(`/shareadvancecollection${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves a receipt out of Sundry into share capital for an activated account. */
|
||||||
|
applyShareAdvance(collectionId: number, data: any): Observable<any> {
|
||||||
|
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every additional-share tranche awaiting approval, across every share account. */
|
||||||
|
getShareAdditionRequests(): Observable<any> {
|
||||||
|
return this.http.get('/shareadditionrequests');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full detail for one pending tranche — the list row plus route-specific proof of funding: the
|
||||||
|
* voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche.
|
||||||
|
*/
|
||||||
|
getShareAdditionRequest(transactionId: number): Observable<any> {
|
||||||
|
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 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, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||||
|
|
||||||
|
/** HTTP service for the Clerk Closure Request maker-checker workflow (Cash-only savings account
|
||||||
|
* closure). Mirrors ClerkWithdrawalService's shape against the /clerk/closure-requests endpoints. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClerkClosureRequestService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
submit(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/closure-requests', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/closure-requests/my', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequests(
|
||||||
|
status = 'PENDING',
|
||||||
|
offset = 0,
|
||||||
|
limit = 20,
|
||||||
|
fromDate?: string,
|
||||||
|
toDate?: string,
|
||||||
|
search?: string
|
||||||
|
): Observable<any> {
|
||||||
|
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
if (search) params = params.set('search', search);
|
||||||
|
return this.http.get('/clerk/closure-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/closure-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview of what the member would receive on closure. The backend dry-runs the very same
|
||||||
|
* interest posting that closing the account will perform, then rolls it back — so the figure
|
||||||
|
* matches the real payout to the paisa. Nothing is persisted. */
|
||||||
|
preview(savingsAccountId: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/closure-requests/preview/${savingsAccountId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/closure-requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/closure-requests/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silenced: onTokNoBlur() probes this expecting a possible legitimate 404 (token not from this
|
||||||
|
// pool, could be a withdrawal/dividend claim's instead) and shows its own tailored message either way.
|
||||||
|
getByToken(token: string): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/closure-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/closure-requests/${id}/disburse`, {
|
||||||
|
denominationData,
|
||||||
|
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||||
|
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClerkShareWithdrawalService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
submit(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequests(
|
||||||
|
status = 'PENDING',
|
||||||
|
offset = 0,
|
||||||
|
limit = 20,
|
||||||
|
fromDate?: string,
|
||||||
|
toDate?: string,
|
||||||
|
search?: string
|
||||||
|
): Observable<any> {
|
||||||
|
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
if (search) params = params.set('search', search);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { 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' })
|
||||||
@@ -31,10 +32,18 @@ export class ClerkWithdrawalService {
|
|||||||
return this.http.get('/clerk/withdrawal-requests/my', { params });
|
return this.http.get('/clerk/withdrawal-requests/my', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
getPendingRequests(
|
||||||
|
status = 'PENDING',
|
||||||
|
offset = 0,
|
||||||
|
limit = 20,
|
||||||
|
fromDate?: string,
|
||||||
|
toDate?: string,
|
||||||
|
search?: string
|
||||||
|
): Observable<any> {
|
||||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||||
if (fromDate) params = params.set('fromDate', fromDate);
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
if (toDate) params = params.set('toDate', toDate);
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
if (search) params = params.set('search', search);
|
||||||
return this.http.get('/clerk/withdrawal-requests', { params });
|
return this.http.get('/clerk/withdrawal-requests', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,8 +59,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> {
|
||||||
|
|||||||
@@ -227,9 +227,8 @@
|
|||||||
<td class="gl-voucher">
|
<td class="gl-voucher">
|
||||||
@if (r.txnSource === 'GL') {
|
@if (r.txnSource === 'GL') {
|
||||||
<span class="gl-badge">GL</span>
|
<span class="gl-badge">GL</span>
|
||||||
} @else {
|
|
||||||
{{ r.voucherNo }}
|
|
||||||
}
|
}
|
||||||
|
{{ r.voucherNo }}
|
||||||
</td>
|
</td>
|
||||||
<td class="gl-date">
|
<td class="gl-date">
|
||||||
<div>{{ r.txnDate }}</div>
|
<div>{{ r.txnDate }}</div>
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
this.showGl = false;
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.service.closingCashReport(this.date).subscribe({
|
this.service.closingCashReport(this.date).subscribe({
|
||||||
next: (res: any) => {
|
next: (res: any) => {
|
||||||
@@ -106,6 +105,19 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GL Entries is scoped to `this.date` too, but was never re-fetched here -- changing the date and
|
||||||
|
// clicking Load left glLoaded=true from whatever date was open before, so a stale, wrong-date GL
|
||||||
|
// table stayed on screen under a header that had already relabelled itself to the new date. If
|
||||||
|
// the panel was open, refresh it for the date just loaded; otherwise just invalidate it so the
|
||||||
|
// next time it's opened it fetches fresh instead of showing leftover rows.
|
||||||
|
this.glOffset = 0;
|
||||||
|
this.glLoaded = false;
|
||||||
|
if (this.showGl) {
|
||||||
|
this.loadGl();
|
||||||
|
} else {
|
||||||
|
this.glRows = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleGl(): void {
|
toggleGl(): void {
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CASHIER · DAY BOOK</div>
|
||||||
|
<h1 class="pg-title">Day Book</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank
|
||||||
|
transfers, with credit and debit totals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
|
||||||
|
<mat-icon>chevron_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Date</span>
|
||||||
|
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon> Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="db-filters">
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Side</span>
|
||||||
|
<select [(ngModel)]="side" (change)="load()">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="CR">CR · Credit (money in)</option>
|
||||||
|
<option value="DR">DR · Debit (money out)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="status" (change)="load()">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="APPROVED">Approved</option>
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="REJECTED">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="pg-field db-filters__search">
|
||||||
|
<span>Search</span>
|
||||||
|
<input type="text" [(ngModel)]="search" placeholder="Voucher no, account, party, payment type, narration…" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
|
||||||
|
<mat-icon>download</mat-icon> CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMsg) {
|
||||||
|
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
|
||||||
|
flag raised for investigation is useless if someone has to scroll to find it. -->
|
||||||
|
@if (!loading && exceptionRows.length > 0) {
|
||||||
|
<div class="db-exceptions">
|
||||||
|
<div class="db-exceptions__head">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<strong>
|
||||||
|
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
|
||||||
|
</strong>
|
||||||
|
@if (resolvedExceptionCount > 0) {
|
||||||
|
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
|
||||||
|
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@for (e of exceptionRows; track e.voucherNo) {
|
||||||
|
<div class="db-exceptions__item">
|
||||||
|
<span class="pg-chip">{{ e.voucherNo }}</span>
|
||||||
|
<span class="db-exceptions__amt">
|
||||||
|
System ₹{{ e.amount | number: '1.2-2' }}
|
||||||
|
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
|
||||||
|
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
|
||||||
|
<strong class="db-exceptions__diff"
|
||||||
|
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
|
||||||
|
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && loaded && rows.length === 0) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
<mat-icon>receipt_long</mat-icon>
|
||||||
|
<p>No vouchers were raised on {{ date }}.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && filteredRows.length > 0) {
|
||||||
|
<!-- Totals bar. Reflects what is on screen, so a search or side filter still reconciles. -->
|
||||||
|
<div class="closing-summary">
|
||||||
|
<div class="closing-summary__item closing-summary__item--in">
|
||||||
|
<span class="closing-summary__label">Credit · CR (money in)</span>
|
||||||
|
<span class="closing-summary__value">₹{{ shownCredit | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="closing-summary__sep">−</div>
|
||||||
|
<div class="closing-summary__item closing-summary__item--out">
|
||||||
|
<span class="closing-summary__label">Debit · DR (money out)</span>
|
||||||
|
<span class="closing-summary__value">₹{{ shownDebit | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="closing-summary__sep">=</div>
|
||||||
|
<div
|
||||||
|
class="closing-summary__item"
|
||||||
|
[class.closing-summary__item--net-pos]="shownCredit - shownDebit >= 0"
|
||||||
|
[class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
|
||||||
|
>
|
||||||
|
<span class="closing-summary__label">Net</span>
|
||||||
|
<span class="closing-summary__value">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="db-note">
|
||||||
|
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher No</th>
|
||||||
|
<th>Dr/Cr</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Party</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Payment</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>By</th>
|
||||||
|
<th>Approved By</th>
|
||||||
|
<th>Slip Check</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
|
||||||
|
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||||||
|
{{ r.side }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.kind }}</td>
|
||||||
|
<td>
|
||||||
|
{{ r.accountNo ?? '—' }}
|
||||||
|
@if (r.accountType) {
|
||||||
|
<span class="db-muted">· {{ r.accountType }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ r.party ?? '—' }}</td>
|
||||||
|
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.handledBy ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{{ r.approvedBy ?? '—' }}
|
||||||
|
@if (r.approvedBy && r.actionedAt) {
|
||||||
|
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (r.verifyStatus === 'VERIFIED') {
|
||||||
|
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
|
||||||
|
>✓ MATCHED</span
|
||||||
|
>
|
||||||
|
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
|
||||||
|
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
|
||||||
|
<span
|
||||||
|
class="db-badge db-badge--ok"
|
||||||
|
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
|
||||||
|
>
|
||||||
|
✓ RESOLVED
|
||||||
|
</span>
|
||||||
|
} @else if (r.verifyStatus === 'EXCEPTION') {
|
||||||
|
<span
|
||||||
|
class="db-badge db-badge--rejected"
|
||||||
|
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
|
||||||
|
>
|
||||||
|
⚠ EXCEPTION
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="db-muted">Not checked</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ timeOf(r.submittedAt) }}</td>
|
||||||
|
<td class="db-actions">
|
||||||
|
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
|
||||||
|
<mat-icon>fact_check</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
|
||||||
|
<mat-icon>attach_file</mat-icon>
|
||||||
|
@if (r.attachmentCount > 0) {
|
||||||
|
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
|
||||||
|
enough: it cannot be printed, exported, or read without a mouse. -->
|
||||||
|
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
|
||||||
|
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
|
||||||
|
<td colspan="13">
|
||||||
|
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
|
||||||
|
<strong>Exception</strong>
|
||||||
|
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
|
||||||
|
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
|
||||||
|
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
|
||||||
|
}
|
||||||
|
— {{ r.verifyRemarks }}
|
||||||
|
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
|
||||||
|
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
|
||||||
|
@if (r.resolutionStatus) {
|
||||||
|
<div class="db-exc-row__resolution">
|
||||||
|
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
|
||||||
|
{{ r.resolutionNotes }}
|
||||||
|
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
|
||||||
|
next to the row they describe. -->
|
||||||
|
@if (verifyRow === r) {
|
||||||
|
<tr class="db-panel-row">
|
||||||
|
<td colspan="13">
|
||||||
|
<div class="db-panel">
|
||||||
|
<div class="db-panel__head">
|
||||||
|
<strong>Slip check — {{ r.voucherNo }}</strong>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="db-panel__body">
|
||||||
|
<div class="db-cmp">
|
||||||
|
<span class="db-cmp__label">System recorded</span>
|
||||||
|
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Amount written on slip</span>
|
||||||
|
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
|
||||||
|
</label>
|
||||||
|
@if (verifySlipBlank) {
|
||||||
|
<div class="db-cmp">
|
||||||
|
<span class="db-cmp__label">Difference</span>
|
||||||
|
<span class="db-cmp__value">—</span>
|
||||||
|
</div>
|
||||||
|
} @else if (verifyMismatch) {
|
||||||
|
<div class="db-cmp db-cmp--bad">
|
||||||
|
<span class="db-cmp__label">Difference</span>
|
||||||
|
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="db-cmp db-cmp--good">
|
||||||
|
<span class="db-cmp__label">Match</span>
|
||||||
|
<span class="db-cmp__value">Amounts agree</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<label class="pg-field db-panel__remarks">
|
||||||
|
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="verifyRemarks"
|
||||||
|
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
@if (verifyMismatch) {
|
||||||
|
<p class="db-panel__warn">
|
||||||
|
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
|
||||||
|
change the posted amount.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<div class="db-panel__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
(click)="saveVerify()"
|
||||||
|
[disabled]="verifySaving || verifySlipBlank"
|
||||||
|
>
|
||||||
|
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
|
||||||
|
</button>
|
||||||
|
<!-- Raising an exception on an already-matched voucher stays available: a
|
||||||
|
slip cleared in haste, or a difference noticed later, has to be
|
||||||
|
recordable without erasing the earlier verdict. -->
|
||||||
|
@if (!verifyMismatch) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--warn"
|
||||||
|
(click)="raiseException()"
|
||||||
|
[disabled]="verifySaving"
|
||||||
|
>
|
||||||
|
Raise Exception
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Attachment panel for this voucher. -->
|
||||||
|
@if (attachRow === r) {
|
||||||
|
<tr class="db-panel-row">
|
||||||
|
<td colspan="13">
|
||||||
|
<div class="db-panel">
|
||||||
|
<div class="db-panel__head">
|
||||||
|
<strong>Attachments — {{ r.voucherNo }}</strong>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="db-upload">
|
||||||
|
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
|
||||||
|
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
|
||||||
|
</label>
|
||||||
|
@if (attachUploading) {
|
||||||
|
<p class="db-muted">Uploading…</p>
|
||||||
|
}
|
||||||
|
@if (attachLoading) {
|
||||||
|
<p class="db-muted">Loading…</p>
|
||||||
|
}
|
||||||
|
@if (!attachLoading && attachments.length === 0) {
|
||||||
|
<p class="db-muted">Nothing attached to this voucher yet.</p>
|
||||||
|
}
|
||||||
|
@for (a of attachments; track a.id) {
|
||||||
|
<div class="db-file">
|
||||||
|
<mat-icon>description</mat-icon>
|
||||||
|
<span class="db-file__name">{{ a.fileName }}</span>
|
||||||
|
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
|
||||||
|
<mat-icon>download</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
|
||||||
|
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
|
||||||
|
<td colspan="7"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
|
||||||
|
<div class="pg-empty">No voucher matches “{{ search }}”.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book
|
||||||
|
lives here. */
|
||||||
|
|
||||||
|
.db-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
/* The search box takes the slack so the two selects keep their natural width. */
|
||||||
|
&__search {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-note {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
|
||||||
|
.db-side {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--cr {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--dr {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A hairline on the leading edge, tinted by side: readable without colouring the whole row,
|
||||||
|
which would fight the table's own hover state. */
|
||||||
|
.db-row--cr td:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-row--dr td:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rejected {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not defined in cashier-shared.scss, so the day book carries its own. */
|
||||||
|
.pg-alert {
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to
|
||||||
|
hunt for a badge. */
|
||||||
|
.db-exceptions {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-left: 4px solid #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pushed to the right so the count reads first. */
|
||||||
|
.pg-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px dotted #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amt {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__diff {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__why {
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The reason spelled out under its own flagged row. */
|
||||||
|
.db-exc-row td {
|
||||||
|
background: #fef2f2 !important;
|
||||||
|
border-bottom: 1px solid #fecaca !important;
|
||||||
|
color: #7f1d1d;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 14px !important;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
vertical-align: -3px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
|
||||||
|
something anyone has to act on. */
|
||||||
|
.db-exc-row--resolved td {
|
||||||
|
background: #f0fdf4 !important;
|
||||||
|
border-bottom: 1px solid #bbf7d0 !important;
|
||||||
|
color: #14532d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-exc-row__resolution {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px dotted #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Raising an exception is a deliberate, consequential act — styled to read that way without
|
||||||
|
looking destructive, since nothing is deleted. */
|
||||||
|
.pg-btn--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-icon-btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different
|
||||||
|
at a glance, without a separate column. */
|
||||||
|
&__count {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
min-width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline panel, opened under the row it belongs to. */
|
||||||
|
.db-panel-row td {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-left: 3px solid #2563eb;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remarks {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two amounts being compared, side by side. */
|
||||||
|
.db-cmp {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--good {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
|
||||||
|
.db-cmp__value {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
|
||||||
|
.db-cmp__value {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-upload {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-table tfoot td {
|
||||||
|
background: #f8fafc;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #334155;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
/**
|
||||||
|
* 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 { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { catchError, of } from 'rxjs';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
interface DayBookRow {
|
||||||
|
voucherNo: string | null;
|
||||||
|
side: 'CR' | 'DR';
|
||||||
|
kind: string;
|
||||||
|
accountNo: string | null;
|
||||||
|
accountType: string | null;
|
||||||
|
party: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
paymentTypeName: string | null;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
handledBy: string | null;
|
||||||
|
approvedBy: string | null;
|
||||||
|
txnId: string | null;
|
||||||
|
submittedAt: string | null;
|
||||||
|
actionedAt: string | null;
|
||||||
|
/** null until someone has physically checked the slip. */
|
||||||
|
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
|
||||||
|
slipAmount: number | null;
|
||||||
|
verifyRemarks: string | null;
|
||||||
|
verifiedByName: string | null;
|
||||||
|
verifiedAt: string | null;
|
||||||
|
/** Set once a clerk or manager has closed the exception; null while it is still open. */
|
||||||
|
resolutionStatus: string | null;
|
||||||
|
resolutionNotes: string | null;
|
||||||
|
resolvedByName: string | null;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
attachmentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day book — every voucher raised on one date in a single list: cash deposits, withdrawals,
|
||||||
|
* disbursements, collections and bank transfers, with the day's credit and debit totals.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-day-book',
|
||||||
|
templateUrl: './day-book.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./day-book.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class DayBookComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
|
||||||
|
date = '';
|
||||||
|
side: '' | 'CR' | 'DR' = '';
|
||||||
|
status = '';
|
||||||
|
search = '';
|
||||||
|
|
||||||
|
rows: DayBookRow[] = [];
|
||||||
|
creditTotal = 0;
|
||||||
|
debitTotal = 0;
|
||||||
|
netTotal = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
errorMsg = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.date = this.today();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
|
||||||
|
private today(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
if (!this.date) return;
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.dayBook(this.date, this.side || undefined, this.status || undefined)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
this.rows = res?.rows ?? [];
|
||||||
|
this.creditTotal = Number(res?.creditTotal ?? 0);
|
||||||
|
this.debitTotal = Number(res?.debitTotal ?? 0);
|
||||||
|
this.netTotal = Number(res?.netTotal ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */
|
||||||
|
shiftDate(days: number): void {
|
||||||
|
const [
|
||||||
|
y,
|
||||||
|
m,
|
||||||
|
d
|
||||||
|
] = this.date.split('-').map(Number);
|
||||||
|
const dt = new Date(y, m - 1, d + days);
|
||||||
|
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToday(): void {
|
||||||
|
this.date = this.today();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show only the flagged vouchers — set from the exception banner. */
|
||||||
|
exceptionsOnly = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
|
||||||
|
* visible on its own row as history, but it is no longer something anyone has to act on.
|
||||||
|
*/
|
||||||
|
get exceptionRows(): DayBookRow[] {
|
||||||
|
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closed exceptions, kept for the record rather than for action. */
|
||||||
|
get resolvedExceptionCount(): number {
|
||||||
|
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a resolution code. */
|
||||||
|
resolutionLabel(status: string | null): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'RESOLVED_SYSTEM_ERROR':
|
||||||
|
return 'System entry was wrong';
|
||||||
|
case 'RESOLVED_SLIP_ERROR':
|
||||||
|
return 'Slip was written wrong';
|
||||||
|
case 'RESOLVED_NO_ACTION':
|
||||||
|
return 'No action needed';
|
||||||
|
default:
|
||||||
|
return status ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vouchers nobody has checked against a slip yet. */
|
||||||
|
get uncheckedCount(): number {
|
||||||
|
return this.rows.filter((r) => !r.verifyStatus).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExceptionsOnly(): void {
|
||||||
|
this.exceptionsOnly = !this.exceptionsOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
|
||||||
|
get filteredRows(): DayBookRow[] {
|
||||||
|
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
|
||||||
|
const q = this.search.trim().toLowerCase();
|
||||||
|
if (!q) return base;
|
||||||
|
return base.filter((r) =>
|
||||||
|
[
|
||||||
|
r.voucherNo,
|
||||||
|
r.accountNo,
|
||||||
|
r.party,
|
||||||
|
r.kind,
|
||||||
|
r.paymentTypeName,
|
||||||
|
r.handledBy,
|
||||||
|
r.description
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((v) => String(v).toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Totals for what is on screen, so a search or side filter still reconciles. */
|
||||||
|
get shownCredit(): number {
|
||||||
|
return this.filteredRows
|
||||||
|
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
|
||||||
|
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get shownDebit(): number {
|
||||||
|
return this.filteredRows
|
||||||
|
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
|
||||||
|
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only approved money is real money — a pending or rejected voucher must not move the totals.
|
||||||
|
* The three source registers don't share one terminal status string: deposits and disbursements
|
||||||
|
* land on APPROVED, but a clerk withdrawal's own finished state is WITHDRAWN — missing it here
|
||||||
|
* meant a completed withdrawal moved real money out yet the DR total stayed at zero. */
|
||||||
|
private countsAsMoney(r: DayBookRow): boolean {
|
||||||
|
const s = (r.status ?? '').toUpperCase();
|
||||||
|
return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
switch ((status ?? '').toUpperCase()) {
|
||||||
|
case 'APPROVED':
|
||||||
|
case 'COMPLETED':
|
||||||
|
case 'WITHDRAWN':
|
||||||
|
return 'db-badge db-badge--ok';
|
||||||
|
case 'PENDING':
|
||||||
|
return 'db-badge db-badge--pending';
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'db-badge db-badge--rejected';
|
||||||
|
default:
|
||||||
|
return 'db-badge';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time of day only — the date is already the page's subject. */
|
||||||
|
timeOf(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const t = iso.split('T')[1];
|
||||||
|
return t ? t.slice(0, 5) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Physical slip verification ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Row whose verification panel is open, or null when none is. */
|
||||||
|
verifyRow: DayBookRow | null = null;
|
||||||
|
verifySlipAmount: number | null = null;
|
||||||
|
verifyRemarks = '';
|
||||||
|
verifySaving = false;
|
||||||
|
|
||||||
|
openVerify(r: DayBookRow): void {
|
||||||
|
this.verifyRow = r;
|
||||||
|
// Pre-filled with the system's own figure: the common case is that the two agree, and when
|
||||||
|
// they don't the cashier only has to change the digits that actually differ.
|
||||||
|
this.verifySlipAmount = Number(r.amount ?? 0);
|
||||||
|
this.verifyRemarks = r.verifyRemarks ?? '';
|
||||||
|
this.attachRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeVerify(): void {
|
||||||
|
this.verifyRow = null;
|
||||||
|
this.verifySlipAmount = null;
|
||||||
|
this.verifyRemarks = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the amount typed off the slip differs from what the system recorded. */
|
||||||
|
get verifyMismatch(): boolean {
|
||||||
|
if (!this.verifyRow || this.verifySlipAmount == null) return false;
|
||||||
|
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
|
||||||
|
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
|
||||||
|
*/
|
||||||
|
get verifySlipBlank(): boolean {
|
||||||
|
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get verifyDifference(): number {
|
||||||
|
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
|
||||||
|
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the verdict. The status is derived from the figures rather than picked by the user —
|
||||||
|
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
|
||||||
|
*/
|
||||||
|
saveVerify(): void {
|
||||||
|
const row = this.verifyRow;
|
||||||
|
if (!row?.voucherNo) return;
|
||||||
|
|
||||||
|
const isException = this.verifyMismatch;
|
||||||
|
if (isException && !this.verifyRemarks.trim()) {
|
||||||
|
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.verifySaving = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.verifyVoucher(
|
||||||
|
row.voucherNo,
|
||||||
|
isException ? 'EXCEPTION' : 'VERIFIED',
|
||||||
|
isException ? Number(this.verifySlipAmount) : undefined,
|
||||||
|
this.verifyRemarks.trim() || undefined
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.verifySaving = false;
|
||||||
|
if (res) {
|
||||||
|
this.closeVerify();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
|
||||||
|
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
|
||||||
|
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
|
||||||
|
* is exactly what an investigation needs.
|
||||||
|
*/
|
||||||
|
raiseException(): void {
|
||||||
|
const row = this.verifyRow;
|
||||||
|
if (!row?.voucherNo) return;
|
||||||
|
|
||||||
|
if (!this.verifyRemarks.trim()) {
|
||||||
|
this.errorMsg = 'Say what the problem is before raising an exception.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.verifySaving = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
|
||||||
|
// both the slip and the system said at the time the exception was raised.
|
||||||
|
.verifyVoucher(
|
||||||
|
row.voucherNo,
|
||||||
|
'EXCEPTION',
|
||||||
|
Number(this.verifySlipAmount ?? row.amount ?? 0),
|
||||||
|
this.verifyRemarks.trim()
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.verifySaving = false;
|
||||||
|
if (res) {
|
||||||
|
this.closeVerify();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachments ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
attachRow: DayBookRow | null = null;
|
||||||
|
attachments: any[] = [];
|
||||||
|
attachLoading = false;
|
||||||
|
attachUploading = false;
|
||||||
|
|
||||||
|
openAttach(r: DayBookRow): void {
|
||||||
|
this.attachRow = r;
|
||||||
|
this.verifyRow = null;
|
||||||
|
this.attachments = [];
|
||||||
|
if (!r.voucherNo) return;
|
||||||
|
this.attachLoading = true;
|
||||||
|
this.service
|
||||||
|
.listVoucherAttachments(r.voucherNo)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.attachLoading = false;
|
||||||
|
this.attachments = res?.attachments ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAttach(): void {
|
||||||
|
this.attachRow = null;
|
||||||
|
this.attachments = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
const row = this.attachRow;
|
||||||
|
if (!file || !row?.voucherNo) return;
|
||||||
|
|
||||||
|
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
|
||||||
|
// only to have it refused.
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
this.attachUploading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.uploadVoucherAttachment(
|
||||||
|
row.voucherNo!,
|
||||||
|
file.name,
|
||||||
|
file.type || 'application/octet-stream',
|
||||||
|
String(reader.result)
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.attachUploading = false;
|
||||||
|
input.value = '';
|
||||||
|
if (res) {
|
||||||
|
this.openAttach(row);
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
|
||||||
|
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
|
||||||
|
* then handed to the browser as a normal save.
|
||||||
|
*/
|
||||||
|
downloadAttachment(a: any): void {
|
||||||
|
this.service
|
||||||
|
.downloadVoucherAttachment(a.id)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((blob: Blob | null) => {
|
||||||
|
if (!blob) {
|
||||||
|
this.errorMsg = 'Could not download that file.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = a.fileName ?? 'attachment';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAttachment(a: any): void {
|
||||||
|
const row = this.attachRow;
|
||||||
|
this.service
|
||||||
|
.deleteVoucherAttachment(a.id)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe(() => {
|
||||||
|
if (row) this.openAttach(row);
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exportCsv(): void {
|
||||||
|
const header = [
|
||||||
|
'Voucher No',
|
||||||
|
'Side',
|
||||||
|
'Type',
|
||||||
|
'Account',
|
||||||
|
'Party',
|
||||||
|
'Amount',
|
||||||
|
'Payment Type',
|
||||||
|
'Status',
|
||||||
|
'Handled By',
|
||||||
|
'Approved By',
|
||||||
|
'Slip Check',
|
||||||
|
'Slip Amount',
|
||||||
|
'Remarks',
|
||||||
|
'Checked By',
|
||||||
|
'Resolution',
|
||||||
|
'Resolution Notes',
|
||||||
|
'Closed By',
|
||||||
|
'Attachments',
|
||||||
|
'Time'
|
||||||
|
];
|
||||||
|
const lines = this.filteredRows.map((r) =>
|
||||||
|
[
|
||||||
|
r.voucherNo ?? '',
|
||||||
|
r.side,
|
||||||
|
r.kind,
|
||||||
|
r.accountNo ?? '',
|
||||||
|
r.party ?? '',
|
||||||
|
String(r.amount ?? 0),
|
||||||
|
r.paymentTypeName ?? '',
|
||||||
|
r.status,
|
||||||
|
r.handledBy ?? '',
|
||||||
|
r.approvedBy ?? '',
|
||||||
|
r.verifyStatus ?? 'NOT CHECKED',
|
||||||
|
r.slipAmount != null ? String(r.slipAmount) : '',
|
||||||
|
r.verifyRemarks ?? '',
|
||||||
|
r.verifiedByName ?? '',
|
||||||
|
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
|
||||||
|
r.resolutionNotes ?? '',
|
||||||
|
r.resolvedByName ?? '',
|
||||||
|
String(r.attachmentCount ?? 0),
|
||||||
|
this.timeOf(r.submittedAt)
|
||||||
|
]
|
||||||
|
// Quote every field and double any embedded quote: names and narrations routinely
|
||||||
|
// contain commas, which would otherwise split into extra columns.
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
const csv = [
|
||||||
|
header.join(','),
|
||||||
|
...lines
|
||||||
|
].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `day-book-${this.date}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,12 +120,9 @@
|
|||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="onTypChange(row)"
|
(change)="onTypChange(row)"
|
||||||
>
|
>
|
||||||
<option value="~">~</option>
|
@for (t of typOptions; track t.value) {
|
||||||
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
<option [value]="t.value">{{ t.label }}</option>
|
||||||
<option value="Cheque">Cheque</option>
|
}
|
||||||
<option value="Disbursement">Disbursement</option>
|
|
||||||
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
|
|
||||||
<option value="DD">DD (Demand Draft)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
@@ -145,8 +142,7 @@
|
|||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -160,6 +156,7 @@
|
|||||||
(change)="
|
(change)="
|
||||||
row.acTyp === 'GL' &&
|
row.acTyp === 'GL' &&
|
||||||
row.typ !== 'Disbursement' &&
|
row.typ !== 'Disbursement' &&
|
||||||
|
row.typ !== 'Fund Transfer' &&
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
row.typ !== 'Enter Bank Transfer' &&
|
||||||
(row.dbCr = 'Cr')
|
(row.dbCr = 'Cr')
|
||||||
"
|
"
|
||||||
@@ -205,7 +202,10 @@
|
|||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="
|
[disabled]="
|
||||||
row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer'
|
row.acTyp === 'GL' &&
|
||||||
|
row.typ !== 'Disbursement' &&
|
||||||
|
row.typ !== 'Fund Transfer' &&
|
||||||
|
row.typ !== 'Enter Bank Transfer'
|
||||||
"
|
"
|
||||||
(click)="row.dbCr = 'Dr'"
|
(click)="row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
@@ -231,31 +231,62 @@
|
|||||||
<span class="ws-caption">Payment instrument</span>
|
<span class="ws-caption">Payment instrument</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="Transaction Amount">
|
<label
|
||||||
AMOUNT
|
class="ws-f__lbl"
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
>
|
||||||
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
|
@if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) {
|
||||||
|
<span class="ws-amt-locked-badge" title="Amount locked — approved request">🔒</span>
|
||||||
}
|
}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
class="ws-amt-wrap"
|
class="ws-amt-wrap"
|
||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
>
|
>
|
||||||
<span class="ws-amt-prefix">₹</span>
|
@if (row.acTyp !== 'SHR') {
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
|
<span class="ws-f__hint"
|
||||||
|
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||||
|
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||||
|
}}/share</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (row.typ !== 'Withdrawal Slip') {
|
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
|
||||||
|
<div class="ws-f ws-f--cheqno">
|
||||||
|
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
[(ngModel)]="row.resolutionNumber"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="e.g. BR-2026-014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
type="date"
|
||||||
|
[(ngModel)]="row.meetingDate"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
} @else if (isClerk && row.typ !== 'Withdrawal Slip') {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||||
<input
|
<input
|
||||||
@@ -280,8 +311,8 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.tokNo"
|
[(ngModel)]="row.tokNo"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-f__ctl--readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
placeholder="Enter token"
|
placeholder="Enter token"
|
||||||
(blur)="onTokNoBlur(row)"
|
(blur)="onTokNoBlur(row)"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
@@ -405,6 +436,73 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (isClerk && pendingClosure) {
|
||||||
|
<div class="ws-withdrawal-banner ws-withdrawal-banner--closure">
|
||||||
|
<mat-icon class="ws-withdrawal-banner__icon">lock</mat-icon>
|
||||||
|
<div class="ws-withdrawal-banner__content">
|
||||||
|
<div class="ws-withdrawal-banner__title">Closing This Account</div>
|
||||||
|
<div class="ws-withdrawal-banner__details">
|
||||||
|
@if (closurePreview) {
|
||||||
|
<span
|
||||||
|
>Balance: <strong>₹{{ closurePreview.balance | number: '1.2-2' }}</strong></span
|
||||||
|
>
|
||||||
|
<span class="ws-wb-sep">·</span>
|
||||||
|
<span
|
||||||
|
>Interest: <strong>+₹{{ closurePreview.interest | number: '1.2-2' }}</strong></span
|
||||||
|
>
|
||||||
|
<span class="ws-wb-sep">·</span>
|
||||||
|
<span
|
||||||
|
>Payable: <strong>₹{{ closurePreview.total | number: '1.2-2' }}</strong></span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="ws-withdrawal-banner__note">
|
||||||
|
Enter <strong>TOK. NO.</strong> below and press <strong>Save</strong> to send this for the Bank Manager's
|
||||||
|
approval. The account closes when the cashier hands over the cash.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ws-withdrawal-banner__clear" (click)="clearPendingClosure()" title="Cancel closure">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!isClerk && activeClosureRequest) {
|
||||||
|
<div class="ws-withdrawal-banner">
|
||||||
|
<mat-icon class="ws-withdrawal-banner__icon">lock</mat-icon>
|
||||||
|
<div class="ws-withdrawal-banner__content">
|
||||||
|
<div class="ws-withdrawal-banner__title">Approved Account Closure Linked</div>
|
||||||
|
<div class="ws-withdrawal-banner__details">
|
||||||
|
<span
|
||||||
|
>Token: <strong>{{ activeClosureRequest.tok_no }}</strong></span
|
||||||
|
>
|
||||||
|
<span class="ws-wb-sep">·</span>
|
||||||
|
<span
|
||||||
|
>Cash to Hand Over:
|
||||||
|
<strong>₹{{ activeClosureRequest.cashier_disbursed_amount | number: '1.2-2' }}</strong></span
|
||||||
|
>
|
||||||
|
<span class="ws-wb-sep">·</span>
|
||||||
|
<span
|
||||||
|
>Client: <strong>{{ activeClosureRequest.client_name }}</strong></span
|
||||||
|
>
|
||||||
|
<span class="ws-wb-sep">·</span>
|
||||||
|
<span>By Clerk: {{ activeClosureRequest.clerk_name }}</span>
|
||||||
|
<span class="ws-wb-sep">·</span>
|
||||||
|
<span>Approved by: {{ activeClosureRequest.manager_name || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ws-withdrawal-banner__note">
|
||||||
|
@if (activeClosureRequest.rounding_amount && activeClosureRequest.rounding_amount !== 0) {
|
||||||
|
Exact closing balance was ₹{{ activeClosureRequest.closing_amount | number: '1.2-2' }} — rounded
|
||||||
|
{{ activeClosureRequest.rounding_amount > 0 ? 'up' : 'down' }} to whole rupees for cash.
|
||||||
|
} @else {
|
||||||
|
The account is already closed. Hand over the cash to complete the request.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ws-withdrawal-banner__clear" (click)="activeClosureRequest = null" title="Unlink">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- ── STATUS ────────────────────────────────────────────────────────── -->
|
<!-- ── STATUS ────────────────────────────────────────────────────────── -->
|
||||||
@if (loadingAccount) {
|
@if (loadingAccount) {
|
||||||
@@ -712,6 +810,32 @@
|
|||||||
'—'
|
'—'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (selectedRow?.acTyp === 'SHR') {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">No. of Shares Held</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Market Price / Share</span
|
||||||
|
><span class="te-value te-mono"
|
||||||
|
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Resolution No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Savings A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (accountDetails.linkedLoanAccountNo) {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Loan A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="te-panel__header te-panel__header--mt">
|
<div class="te-panel__header te-panel__header--mt">
|
||||||
@@ -724,7 +848,13 @@
|
|||||||
<div class="te-panel__body">
|
<div class="te-panel__body">
|
||||||
<div class="te-balance-row">
|
<div class="te-balance-row">
|
||||||
<span class="te-bal-num">1.</span>
|
<span class="te-bal-num">1.</span>
|
||||||
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
|
<span class="te-bal-label">{{
|
||||||
|
selectedRow?.acTyp === 'SHR'
|
||||||
|
? 'Share Balance'
|
||||||
|
: accountDetails?.glCode
|
||||||
|
? 'GL Balance'
|
||||||
|
: 'Current Balance'
|
||||||
|
}}</span>
|
||||||
<span class="te-bal-value te-bal-value--cr"
|
<span class="te-bal-value te-bal-value--cr"
|
||||||
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
@@ -741,7 +871,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="te-balance-row">
|
<div class="te-balance-row">
|
||||||
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
|
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
|
||||||
<span class="te-bal-value">{{ currencySymbol }}0.00</span>
|
<span class="te-bal-value">{{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-balance-row">
|
<div class="te-balance-row">
|
||||||
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
|
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
|
||||||
@@ -802,7 +932,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="te-bal-item">
|
<div class="te-bal-item">
|
||||||
<span class="te-bal-item__label">Cash Hold</span>
|
<span class="te-bal-item__label">Cash Hold</span>
|
||||||
<span class="te-bal-item__val">0.00</span>
|
<span class="te-bal-item__val">{{ cashHold | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-bal-item">
|
<div class="te-bal-item">
|
||||||
<span class="te-bal-item__label">Transfer Hold</span>
|
<span class="te-bal-item__label">Transfer Hold</span>
|
||||||
@@ -810,7 +940,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="te-bal-item">
|
<div class="te-bal-item">
|
||||||
<span class="te-bal-item__label">Clearing Hold</span>
|
<span class="te-bal-item__label">Clearing Hold</span>
|
||||||
<span class="te-bal-item__val">0.00</span>
|
<span class="te-bal-item__val">{{ clearingHold | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-bal-item">
|
<div class="te-bal-item">
|
||||||
<span class="te-bal-item__label">After Trans.</span>
|
<span class="te-bal-item__label">After Trans.</span>
|
||||||
@@ -826,6 +956,11 @@
|
|||||||
<span class="te-bal-item__label">Currency</span>
|
<span class="te-bal-item__label">Currency</span>
|
||||||
<span class="te-bal-item__val">{{ accountDetails.currency?.code ?? 'INR' }}</span>
|
<span class="te-bal-item__val">{{ accountDetails.currency?.code ?? 'INR' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (canPrintStatement) {
|
||||||
|
<button type="button" class="te-btn" (click)="printStatement()">
|
||||||
|
<mat-icon>print</mat-icon> Statement
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Cashier: all account info in one compact strip -->
|
<!-- Cashier: all account info in one compact strip -->
|
||||||
@@ -862,42 +997,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"
|
||||||
@@ -918,7 +1061,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="te-acs-b">
|
<div class="te-acs-b">
|
||||||
<span class="te-acs-b-l">Cash Hold</span>
|
<span class="te-acs-b-l">Cash Hold</span>
|
||||||
<span class="te-acs-b-v">0.00</span>
|
<span class="te-acs-b-v">{{ cashHold | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-acs-b">
|
<div class="te-acs-b">
|
||||||
<span class="te-acs-b-l">Tr. Hold</span>
|
<span class="te-acs-b-l">Tr. Hold</span>
|
||||||
@@ -926,13 +1069,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="te-acs-b">
|
<div class="te-acs-b">
|
||||||
<span class="te-acs-b-l">Clr. Hold</span>
|
<span class="te-acs-b-l">Clr. Hold</span>
|
||||||
<span class="te-acs-b-v">0.00</span>
|
<span class="te-acs-b-v">{{ clearingHold | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-acs-b te-acs-b--after">
|
<div class="te-acs-b te-acs-b--after">
|
||||||
<span class="te-acs-b-l">After Trans.</span>
|
<span class="te-acs-b-l">After Trans.</span>
|
||||||
<span class="te-acs-b-v te-acs-b-v--cr">{{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}</span>
|
<span class="te-acs-b-v te-acs-b-v--cr">{{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (canPrintStatement) {
|
||||||
|
<div class="te-acs-sep"></div>
|
||||||
|
<button type="button" class="te-btn" (click)="printStatement()">
|
||||||
|
<mat-icon>print</mat-icon> Statement
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<!-- Alerts -->
|
<!-- Alerts -->
|
||||||
@if (accountAlerts.length > 0) {
|
@if (accountAlerts.length > 0) {
|
||||||
<div class="te-acs-sep"></div>
|
<div class="te-acs-sep"></div>
|
||||||
@@ -1256,12 +1405,9 @@
|
|||||||
<div class="ws-f ws-f--type">
|
<div class="ws-f ws-f--type">
|
||||||
<label class="ws-f__lbl">TYPE</label>
|
<label class="ws-f__lbl">TYPE</label>
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
||||||
<option value="~">~</option>
|
@for (t of typOptions; track t.value) {
|
||||||
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
<option [value]="t.value">{{ t.label }}</option>
|
||||||
<option value="Cheque">Cheque</option>
|
}
|
||||||
<option value="Disbursement">Disbursement</option>
|
|
||||||
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
|
|
||||||
<option value="DD">DD (Demand Draft)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
@@ -1280,8 +1426,7 @@
|
|||||||
[(ngModel)]="row.paymentTypeId"
|
[(ngModel)]="row.paymentTypeId"
|
||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -1294,6 +1439,7 @@
|
|||||||
(change)="
|
(change)="
|
||||||
row.acTyp === 'GL' &&
|
row.acTyp === 'GL' &&
|
||||||
row.typ !== 'Disbursement' &&
|
row.typ !== 'Disbursement' &&
|
||||||
|
row.typ !== 'Fund Transfer' &&
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
row.typ !== 'Enter Bank Transfer' &&
|
||||||
(row.dbCr = 'Cr')
|
(row.dbCr = 'Cr')
|
||||||
"
|
"
|
||||||
@@ -1335,7 +1481,10 @@
|
|||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="
|
[disabled]="
|
||||||
row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer'
|
row.acTyp === 'GL' &&
|
||||||
|
row.typ !== 'Disbursement' &&
|
||||||
|
row.typ !== 'Fund Transfer' &&
|
||||||
|
row.typ !== 'Enter Bank Transfer'
|
||||||
"
|
"
|
||||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
@@ -1360,44 +1509,76 @@
|
|||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl">
|
<label class="ws-f__lbl">
|
||||||
AMOUNT
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
@if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) {
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
<span class="ws-amt-locked-badge" title="Amount locked — approved request">🔒</span>
|
||||||
}
|
}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
class="ws-amt-wrap"
|
class="ws-amt-wrap"
|
||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="
|
||||||
|
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<span class="ws-amt-prefix">₹</span>
|
@if (row.acTyp !== 'SHR') {
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-input--locked]="
|
||||||
|
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
|
<span class="ws-f__hint"
|
||||||
|
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||||
|
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||||
|
}}/share</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--cheqno">
|
@if (
|
||||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
|
||||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
) {
|
||||||
</div>
|
<div class="ws-f ws-f--cheqno">
|
||||||
<div class="ws-f ws-f--cheqdt">
|
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
<input
|
||||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
class="ws-f__ctl"
|
||||||
</div>
|
[(ngModel)]="row.resolutionNumber"
|
||||||
|
placeholder="e.g. BR-2026-014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl">MEETING DATE</label>
|
||||||
|
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||||
|
</div>
|
||||||
|
} @else if (isClerk) {
|
||||||
|
<div class="ws-f ws-f--cheqno">
|
||||||
|
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||||
|
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||||
|
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ws-f ws-f--tok">
|
<div class="ws-f ws-f--tok">
|
||||||
<label class="ws-f__lbl">TOK. NO.</label>
|
<label class="ws-f__lbl">TOK. NO.</label>
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.tokNo"
|
[(ngModel)]="row.tokNo"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-f__ctl--readonly]="
|
||||||
|
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||||
|
"
|
||||||
placeholder="Enter token"
|
placeholder="Enter token"
|
||||||
(blur)="onTokNoBlur(row)"
|
(blur)="onTokNoBlur(row)"
|
||||||
/>
|
/>
|
||||||
@@ -1565,6 +1746,19 @@
|
|||||||
My Disbursements
|
My Disbursements
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (canShowAccountActions) {
|
||||||
|
<button
|
||||||
|
class="ws-btn ws-btn--action"
|
||||||
|
type="button"
|
||||||
|
(click)="openAccountActions()"
|
||||||
|
title="Account actions for the loaded account"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||||
|
</svg>
|
||||||
|
Action
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-footer__actions">
|
<div class="ws-footer__actions">
|
||||||
<button
|
<button
|
||||||
@@ -1586,6 +1780,81 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── ACCOUNT ACTIONS (clerk) ──────────────────────────────────────────── -->
|
||||||
|
@if (showAccountActions) {
|
||||||
|
<div class="te-confirm-overlay" (click)="closeAccountActions()">
|
||||||
|
<div class="te-confirm-box te-aa-box" (click)="$event.stopPropagation()">
|
||||||
|
<div class="te-confirm-header te-confirm-header--action">
|
||||||
|
<span class="te-confirm-icon">⚡</span>
|
||||||
|
<span>Account Actions</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-confirm-body">
|
||||||
|
<div class="te-aa-account">
|
||||||
|
<span class="te-aa-account__no">{{ accountDetails?.accountNo }}</span>
|
||||||
|
<span class="te-aa-account__name">{{ clientName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="te-aa-check">
|
||||||
|
<input type="checkbox" [(ngModel)]="closeAccountChecked" (change)="onCloseAccountToggle()" />
|
||||||
|
<span>
|
||||||
|
<strong>Close this account</strong>
|
||||||
|
<small>Pays out the full balance in cash and closes the account, after manager approval.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (closeAccountChecked) {
|
||||||
|
@if (closurePreviewLoading) {
|
||||||
|
<div class="te-aa-calc te-aa-calc--loading">Calculating interest to date…</div>
|
||||||
|
} @else if (closurePreviewError) {
|
||||||
|
<div class="te-aa-calc te-aa-calc--error">{{ closurePreviewError }}</div>
|
||||||
|
} @else if (closurePreview) {
|
||||||
|
<div class="te-aa-calc">
|
||||||
|
<div class="te-aa-calc__row">
|
||||||
|
<span>Current Balance</span>
|
||||||
|
<span>₹{{ closurePreview.balance | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-aa-calc__row">
|
||||||
|
<span>Interest to date</span>
|
||||||
|
<span>+ ₹{{ closurePreview.interest | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-aa-calc__row te-aa-calc__row--total">
|
||||||
|
<span>Payable on Closure</span>
|
||||||
|
<span>₹{{ closurePreview.total | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (!closurePreview.upToDate) {
|
||||||
|
<div class="te-aa-calc__warn">
|
||||||
|
Interest could not be calculated just now, so this shows the current balance only — the actual payout
|
||||||
|
will be higher once interest is added at approval.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="te-aa-calc__note">
|
||||||
|
Interest calculated to {{ closurePreview.asOfDate }} using the same calculation the closure itself
|
||||||
|
performs. Cash is handed over rounded to whole rupees.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="te-aa-next">
|
||||||
|
This amount will be filled into <strong>AMOUNT</strong> on the slip. Enter <strong>TOK. NO.</strong> and
|
||||||
|
press <strong>Save</strong> to send it for the Bank Manager's approval.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="te-confirm-actions">
|
||||||
|
<button class="te-btn" type="button" (click)="closeAccountActions()">Cancel</button>
|
||||||
|
<button
|
||||||
|
class="te-btn te-btn--primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="closeAccountChecked && (!closurePreview || closurePreviewLoading)"
|
||||||
|
(click)="applyCloseAccount()"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- ── REJECT DIALOG (manager review) ───────────────────────────────────── -->
|
<!-- ── REJECT DIALOG (manager review) ───────────────────────────────────── -->
|
||||||
@if (showRejectDialog) {
|
@if (showRejectDialog) {
|
||||||
<div class="te-confirm-overlay" (click)="cancelRejectReview()">
|
<div class="te-confirm-overlay" (click)="cancelRejectReview()">
|
||||||
@@ -1837,6 +2106,15 @@
|
|||||||
@for (line of alertModal.lines; track $index) {
|
@for (line of alertModal.lines; track $index) {
|
||||||
@if ($index === 0) {
|
@if ($index === 0) {
|
||||||
<p class="te-alert-box__desc">{{ line }}</p>
|
<p class="te-alert-box__desc">{{ line }}</p>
|
||||||
|
} @else if (line.startsWith('⚠')) {
|
||||||
|
<div class="te-alert-box__callout">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ line.slice(1).trim() }}</span>
|
||||||
|
</div>
|
||||||
} @else if (line.includes(':') && $index < alertModal.lines.length - 1) {
|
} @else if (line.includes(':') && $index < alertModal.lines.length - 1) {
|
||||||
<div class="te-alert-box__kv">
|
<div class="te-alert-box__kv">
|
||||||
<span class="te-alert-box__kv-k">{{ line.split(':')[0].trim() }}</span>
|
<span class="te-alert-box__kv-k">{{ line.split(':')[0].trim() }}</span>
|
||||||
|
|||||||
@@ -457,6 +457,21 @@ $ws-fld-bd: #c8d8eb;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--action {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 2px 8px rgb(79 70 229 / 30%);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--post {
|
&--post {
|
||||||
background: $ws-accent;
|
background: $ws-accent;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -961,6 +976,14 @@ input.ws-amt-input--locked {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ws-f__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: $ws-muted;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.ws-amt-input {
|
.ws-amt-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1995,12 +2018,12 @@ select.te-cell {
|
|||||||
|
|
||||||
.te-alert-box {
|
.te-alert-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 480px;
|
width: 400px;
|
||||||
max-width: 96vw;
|
max-width: 94vw;
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 32px 80px rgb(0 0 0 / 35%),
|
0 24px 60px rgb(0 0 0 / 32%),
|
||||||
0 0 0 1px rgb(255 255 255 / 8%);
|
0 0 0 1px rgb(255 255 255 / 8%);
|
||||||
animation: te-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
animation: te-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
@@ -2010,32 +2033,35 @@ select.te-cell {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
width: 76px;
|
width: 56px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 24px 0;
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sidebar-icon {
|
&__sidebar-icon {
|
||||||
width: 40px;
|
width: 32px;
|
||||||
height: 40px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgb(255 255 255 / 20%);
|
background: rgb(255 255 255 / 20%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(255 255 255 / 15%),
|
||||||
|
inset 0 1px 2px rgb(255 255 255 / 25%);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 22px;
|
width: 17px;
|
||||||
height: 22px;
|
height: 17px;
|
||||||
stroke: #fff;
|
stroke: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sidebar-type {
|
&__sidebar-type {
|
||||||
font-size: 8px;
|
font-size: 7px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.1em;
|
||||||
color: rgb(255 255 255 / 70%);
|
color: rgb(255 255 255 / 70%);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
writing-mode: vertical-lr;
|
writing-mode: vertical-lr;
|
||||||
@@ -2045,6 +2071,7 @@ select.te-cell {
|
|||||||
// ── Right content ─────────────────────────────────────────────────────────
|
// ── Right content ─────────────────────────────────────────────────────────
|
||||||
&__content {
|
&__content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2054,12 +2081,12 @@ select.te-cell {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 20px 20px 12px;
|
padding: 14px 16px 8px;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 13.5px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
@@ -2083,52 +2110,87 @@ select.te-cell {
|
|||||||
|
|
||||||
&__lines {
|
&__lines {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 14px 20px;
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__desc {
|
&__desc {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 8px;
|
||||||
font-size: 12.5px;
|
font-size: 11.5px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
line-height: 1.55;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__kv {
|
&__kv {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 7px 12px;
|
gap: 10px;
|
||||||
margin: 4px 0;
|
padding: 5px 10px;
|
||||||
|
margin: 3px 0;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 8px;
|
border-radius: 7px;
|
||||||
border-left: 3px solid #e2e8f0;
|
border-left: 3px solid #e2e8f0;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__kv-k {
|
&__kv-k {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__kv-v {
|
&__kv-v {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__foot {
|
&__foot {
|
||||||
margin: 10px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__callout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-left: 3px solid #d97706;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #92400e;
|
||||||
|
line-height: 1.45;
|
||||||
|
animation: te-pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 14px 20px;
|
padding: 10px 16px;
|
||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid #f1f5f9;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
@@ -2196,11 +2258,11 @@ select.te-cell {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 8px 20px;
|
padding: 7px 16px;
|
||||||
min-width: 90px;
|
min-width: 76px;
|
||||||
border-radius: 8px;
|
border-radius: 7px;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 12.5px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -2288,12 +2350,171 @@ select.te-cell {
|
|||||||
&--disbursement {
|
&--disbursement {
|
||||||
background: linear-gradient(135deg, #0284c7, #0369a1);
|
background: linear-gradient(135deg, #0284c7, #0369a1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--action {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.te-confirm-icon {
|
.te-confirm-icon {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Account Actions popup ─────────────────────────────────────────────────────
|
||||||
|
.te-aa-box {
|
||||||
|
width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-aa-account {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e40af;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-aa-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1.5px solid #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin-top: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #dc2626;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #b91c1c;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-aa-calc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&--loading,
|
||||||
|
&--error {
|
||||||
|
display: block;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
padding: 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--loading {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: #166534;
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1.5px dashed #bbf7d0;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #4d7c56;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-aa-next {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: #eef2ff;
|
||||||
|
border: 1px solid #c7d2fe;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #3730a3;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.te-confirm-body {
|
.te-confirm-body {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3060,6 +3281,21 @@ select.te-cell {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 1px 4px rgb(16 185 129 / 12%);
|
box-shadow: 0 1px 4px rgb(16 185 129 / 12%);
|
||||||
|
|
||||||
|
/* A closure is destructive, so it must not read like the ordinary green "all good" banner. */
|
||||||
|
&--closure {
|
||||||
|
background: linear-gradient(135deg, #eef2ff, #f5f3ff);
|
||||||
|
border-color: #a5b4fc;
|
||||||
|
box-shadow: 0 1px 4px rgb(79 70 229 / 14%);
|
||||||
|
|
||||||
|
.ws-withdrawal-banner__icon {
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-withdrawal-banner__title {
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--loading {
|
&--loading {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-color: #cbd5e1;
|
border-color: #cbd5e1;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">BRANCH OPERATIONS · VAULT POSITION</div>
|
||||||
|
<h1 class="pg-title">Vault Position</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Denomination-wise stock a vault currently holds — the branch's own opening breakdown, plus whatever has been
|
||||||
|
settled back into it, minus whatever it has handed out to cashiers since.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
|
@if (tellers.length === 1) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Vault</mat-label>
|
||||||
|
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Vault (optional)</mat-label>
|
||||||
|
<mat-select [value]="selectedTellerId" (selectionChange)="selectedTellerId = $event.value">
|
||||||
|
<mat-option [value]="null">All Vaults</mat-option>
|
||||||
|
@for (t of tellers; track t.id) {
|
||||||
|
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>As of Date</mat-label>
|
||||||
|
<input matInput type="date" [value]="asOfDate" (change)="asOfDate = $any($event.target).value" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>bar_chart</mat-icon> Load Position
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMsg) {
|
||||||
|
<div class="pg-empty" style="color: #dc2626">{{ errorMsg }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (pos of results; track pos.tellerId) {
|
||||||
|
<div class="pg-card" style="margin-bottom: 14px">
|
||||||
|
<div class="pos-header">
|
||||||
|
<span class="pos-cashier">{{ pos.tellerName || labelForTeller(pos.tellerId) }}</span>
|
||||||
|
<span class="pos-total">₹{{ pos.totalAmount | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!pos.hasBreakdown) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
No denomination breakdown was recorded for this vault's opening day on or before {{ pos.asOfDate }}. Open (or
|
||||||
|
re-open) the business day with a note/coin count to start tracking its stock.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
@if (pos.anchorDate && pos.anchorDate !== pos.asOfDate) {
|
||||||
|
<div class="pg-empty" style="padding: 8px 14px; text-align: left">
|
||||||
|
Carried forward from the opening breakdown recorded on {{ pos.anchorDate }}.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (pos.denominations.length === 0) {
|
||||||
|
<div class="pg-empty">No stock — everything opened for this vault has been handed out.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (notes(pos).length > 0) {
|
||||||
|
<div class="pos-section-label">Notes</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Denomination</th>
|
||||||
|
<th style="text-align: center">Current Count</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (d of notes(pos); track d.denominationId) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">₹{{ d.denominationValue }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center; font-weight: 700">{{ d.count }}</td>
|
||||||
|
<td class="num">₹{{ d.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (coins(pos).length > 0) {
|
||||||
|
<div class="pos-section-label pos-section-label--coin">Coins</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Denomination</th>
|
||||||
|
<th style="text-align: center">Current Count</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (d of coins(pos); track d.denominationId) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip pg-chip--coin">₹{{ d.denominationValue }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center; font-weight: 700">{{ d.count }}</td>
|
||||||
|
<td class="num">₹{{ d.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && results.length === 0 && !errorMsg) {
|
||||||
|
<div class="pg-empty">Select filters and click "Load Position".</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 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 { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
interface DenomRow {
|
||||||
|
denominationId: number;
|
||||||
|
denominationValue: number;
|
||||||
|
kind: string;
|
||||||
|
count: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
interface VaultPos {
|
||||||
|
tellerId: number;
|
||||||
|
tellerName?: string;
|
||||||
|
asOfDate: string;
|
||||||
|
anchorDate?: string;
|
||||||
|
hasBreakdown: boolean;
|
||||||
|
totalAmount: number;
|
||||||
|
denominations: DenomRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the bank
|
||||||
|
* have", the same question cashier-position answers for a cashier's own float.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-vault-position',
|
||||||
|
templateUrl: './vault-position.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'../cashier-position/cashier-position.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatInputModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VaultPositionComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
|
||||||
|
tellers: { id: number; name: string }[] = [];
|
||||||
|
selectedTellerId: number | null = null;
|
||||||
|
asOfDate = '';
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
results: VaultPos[] = [];
|
||||||
|
errorMsg = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const d = new Date();
|
||||||
|
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
this.service
|
||||||
|
.getTellers()
|
||||||
|
.pipe(catchError(() => of([] as any[])))
|
||||||
|
.subscribe((list) => {
|
||||||
|
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
|
if (this.tellers.length === 1) {
|
||||||
|
this.selectedTellerId = this.tellers[0].id;
|
||||||
|
}
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.results = [];
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.vaultPosition(this.selectedTellerId ?? undefined, this.asOfDate)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((data: any) => {
|
||||||
|
this.loading = false;
|
||||||
|
if (!data) {
|
||||||
|
this.errorMsg = 'Failed to load vault position.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arr: any[] = Array.isArray(data) ? data : [data];
|
||||||
|
this.results = arr;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
labelForTeller(id: number): string {
|
||||||
|
const t = this.tellers.find((x) => x.id === id);
|
||||||
|
return t ? t.name : `Vault #${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
notes(pos: VaultPos): DenomRow[] {
|
||||||
|
return pos.denominations.filter((d) => d.kind !== 'coin');
|
||||||
|
}
|
||||||
|
|
||||||
|
coins(pos: VaultPos): DenomRow[] {
|
||||||
|
return pos.denominations.filter((d) => d.kind === 'coin');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CASHIER · EXCEPTIONS</div>
|
||||||
|
<h1 class="pg-title">Voucher Exceptions</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
|
||||||
|
wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
|
||||||
|
document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Show</span>
|
||||||
|
<select [(ngModel)]="filter" (change)="load()">
|
||||||
|
<option value="OPEN">Open</option>
|
||||||
|
<option value="RESOLVED">Resolved</option>
|
||||||
|
<option value="ALL">All</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon> Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (errorMsg) {
|
||||||
|
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!canResolve && loaded) {
|
||||||
|
<div class="ve-note">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
|
||||||
|
that whoever raised the flag is not the one who rules on it.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && loaded && exceptions.length === 0) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (e of exceptions; track e.voucherNo) {
|
||||||
|
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
|
||||||
|
<div class="ve-card__head">
|
||||||
|
<span class="pg-chip">{{ e.voucherNo }}</span>
|
||||||
|
@if (e.resolutionStatus) {
|
||||||
|
<span class="db-badge db-badge--ok">RESOLVED</span>
|
||||||
|
} @else {
|
||||||
|
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
|
||||||
|
}
|
||||||
|
<span class="db-muted">
|
||||||
|
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
|
||||||
|
</span>
|
||||||
|
@if (!e.resolutionStatus && canResolve) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
|
||||||
|
Review & Close
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ve-card__amts">
|
||||||
|
<div class="db-cmp">
|
||||||
|
<span class="db-cmp__label">System recorded</span>
|
||||||
|
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (!amountsAgree(e)) {
|
||||||
|
<div class="db-cmp db-cmp--bad">
|
||||||
|
<span class="db-cmp__label">Written on slip</span>
|
||||||
|
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-cmp db-cmp--bad">
|
||||||
|
<span class="db-cmp__label">Difference</span>
|
||||||
|
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="db-cmp">
|
||||||
|
<span class="db-cmp__label">Amounts</span>
|
||||||
|
<span class="db-cmp__value">Agree — flagged for another reason</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
|
||||||
|
|
||||||
|
@if (e.resolutionStatus) {
|
||||||
|
<div class="ve-card__resolution">
|
||||||
|
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
|
||||||
|
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Resolution panel, opened under the exception it closes. -->
|
||||||
|
@if (resolveRow === e) {
|
||||||
|
<div class="db-panel ve-panel">
|
||||||
|
<div class="db-panel__head">
|
||||||
|
<strong>Close exception — {{ e.voucherNo }}</strong>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="pg-field ve-panel__field">
|
||||||
|
<span>What was wrong?</span>
|
||||||
|
<select [(ngModel)]="resolutionStatus">
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
@for (o of resolutionOptions; track o.value) {
|
||||||
|
<option [value]="o.value">{{ o.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="pg-field ve-panel__field">
|
||||||
|
<span>What was found, and what was done about it?</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="resolutionNotes"
|
||||||
|
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="ve-panel__docs">
|
||||||
|
<strong>Supporting document</strong>
|
||||||
|
<p class="db-muted">
|
||||||
|
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
|
||||||
|
whatever the decision rests on.
|
||||||
|
</p>
|
||||||
|
<label class="db-upload">
|
||||||
|
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
|
||||||
|
<span class="db-muted">Max 10 MB</span>
|
||||||
|
</label>
|
||||||
|
@if (attachUploading) {
|
||||||
|
<p class="db-muted">Uploading…</p>
|
||||||
|
}
|
||||||
|
@if (attachLoading) {
|
||||||
|
<p class="db-muted">Loading…</p>
|
||||||
|
}
|
||||||
|
@if (!attachLoading && attachments.length === 0) {
|
||||||
|
<p class="ve-panel__warn">No document attached yet — required before closing.</p>
|
||||||
|
}
|
||||||
|
@for (a of attachments; track a.id) {
|
||||||
|
<div class="db-file">
|
||||||
|
<mat-icon>description</mat-icon>
|
||||||
|
<span class="db-file__name">{{ a.fileName }}</span>
|
||||||
|
<span class="db-muted">{{ a.uploadedByName }}</span>
|
||||||
|
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
|
||||||
|
<mat-icon>download</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-panel__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
(click)="saveResolution()"
|
||||||
|
[disabled]="!canSubmitResolution"
|
||||||
|
>
|
||||||
|
Close Exception
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
|
||||||
|
shared with the day book. Only the exception card itself is defined here. */
|
||||||
|
|
||||||
|
.ve-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
|
||||||
|
reads at a glance without opening anything. */
|
||||||
|
.ve-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 4px solid #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&--resolved {
|
||||||
|
border-left-color: #16a34a;
|
||||||
|
background: #fafffb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pushed right so the identity of the voucher reads first. */
|
||||||
|
&__act {
|
||||||
|
margin-left: auto;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amts {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__why {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__resolution {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #14532d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ve-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__docs {
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* 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 { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { catchError, of } from 'rxjs';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
interface VoucherException {
|
||||||
|
voucherNo: string;
|
||||||
|
systemAmount: number | null;
|
||||||
|
slipAmount: number | null;
|
||||||
|
remarks: string | null;
|
||||||
|
raisedByName: string | null;
|
||||||
|
raisedAt: string | null;
|
||||||
|
accountNo: string | null;
|
||||||
|
party: string | null;
|
||||||
|
raisedAgainst: string | null;
|
||||||
|
attachmentCount: number;
|
||||||
|
resolutionStatus: string | null;
|
||||||
|
resolutionNotes: string | null;
|
||||||
|
resolvedByName: string | null;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
|
||||||
|
*
|
||||||
|
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
|
||||||
|
* or manager sits down with them, records what was actually wrong, and attaches the paper that
|
||||||
|
* supports the decision.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-exceptions',
|
||||||
|
templateUrl: './voucher-exceptions.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'../day-book/day-book.component.scss',
|
||||||
|
'./voucher-exceptions.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherExceptionsComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
|
||||||
|
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
|
||||||
|
exceptions: VoucherException[] = [];
|
||||||
|
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
|
||||||
|
canResolve = false;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
errorMsg = '';
|
||||||
|
|
||||||
|
// Resolution panel
|
||||||
|
resolveRow: VoucherException | null = null;
|
||||||
|
resolutionStatus = '';
|
||||||
|
resolutionNotes = '';
|
||||||
|
resolveSaving = false;
|
||||||
|
|
||||||
|
// Attachments for the voucher being resolved
|
||||||
|
attachments: any[] = [];
|
||||||
|
attachLoading = false;
|
||||||
|
attachUploading = false;
|
||||||
|
|
||||||
|
readonly resolutionOptions = [
|
||||||
|
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
|
||||||
|
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
|
||||||
|
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.voucherExceptions(this.filter)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
this.exceptions = res?.exceptions ?? [];
|
||||||
|
this.canResolve = !!res?.canResolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
difference(e: VoucherException): number {
|
||||||
|
if (e.slipAmount == null) return 0;
|
||||||
|
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the flag was about something other than the amount. */
|
||||||
|
amountsAgree(e: VoucherException): boolean {
|
||||||
|
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
openResolve(e: VoucherException): void {
|
||||||
|
this.resolveRow = e;
|
||||||
|
this.resolutionStatus = '';
|
||||||
|
this.resolutionNotes = '';
|
||||||
|
this.loadAttachments(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeResolve(): void {
|
||||||
|
this.resolveRow = null;
|
||||||
|
this.attachments = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadAttachments(e: VoucherException): void {
|
||||||
|
this.attachments = [];
|
||||||
|
this.attachLoading = true;
|
||||||
|
this.service
|
||||||
|
.listVoucherAttachments(e.voucherNo)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.attachLoading = false;
|
||||||
|
this.attachments = res?.attachments ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
const row = this.resolveRow;
|
||||||
|
if (!file || !row) return;
|
||||||
|
|
||||||
|
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
this.attachUploading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.uploadVoucherAttachment(
|
||||||
|
row.voucherNo,
|
||||||
|
file.name,
|
||||||
|
file.type || 'application/octet-stream',
|
||||||
|
String(reader.result),
|
||||||
|
'Supporting document for exception resolution'
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.attachUploading = false;
|
||||||
|
input.value = '';
|
||||||
|
if (res) {
|
||||||
|
this.loadAttachments(row);
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAttachment(a: any): void {
|
||||||
|
this.service
|
||||||
|
.downloadVoucherAttachment(a.id)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((blob: Blob | null) => {
|
||||||
|
if (!blob) {
|
||||||
|
this.errorMsg = 'Could not download that file.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = a.fileName ?? 'attachment';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
|
||||||
|
get canSubmitResolution(): boolean {
|
||||||
|
return (
|
||||||
|
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveResolution(): void {
|
||||||
|
const row = this.resolveRow;
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
this.resolveSaving = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.service
|
||||||
|
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.errorMsg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.resolveSaving = false;
|
||||||
|
if (res) {
|
||||||
|
this.closeResolve();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolutionLabel(status: string | null): string {
|
||||||
|
if (!status) return '';
|
||||||
|
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date and time — an exception queue spans days, so the day matters here. */
|
||||||
|
stamp(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const [
|
||||||
|
d,
|
||||||
|
t
|
||||||
|
] = iso.split('T');
|
||||||
|
return t ? `${d} ${t.slice(0, 5)}` : d;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head vr-no-print">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CASHIER · REPORT</div>
|
||||||
|
<h1 class="pg-title">Voucher Report</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Period</span>
|
||||||
|
<select [(ngModel)]="period" (change)="load()">
|
||||||
|
<option value="DAY">Day</option>
|
||||||
|
<option value="MONTH">Month</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>{{ period === 'DAY' ? 'Date' : 'Month of' }}</span>
|
||||||
|
<input type="date" [(ngModel)]="anchor" (change)="load()" />
|
||||||
|
</label>
|
||||||
|
@if (report?.canSeeAllUsers) {
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>User</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="usernameFilter"
|
||||||
|
(keyup.enter)="load()"
|
||||||
|
placeholder="All users"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon> Run
|
||||||
|
</button>
|
||||||
|
@if (report && report.count > 0) {
|
||||||
|
<button type="button" class="pg-btn" (click)="print()">
|
||||||
|
<mat-icon>print</mat-icon> Print
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="pg-alert pg-alert--error vr-no-print">{{ error }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty vr-no-print">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (report && !loading) {
|
||||||
|
<div class="vr-period">
|
||||||
|
{{ report.from | date: 'dd MMM yyyy' }}
|
||||||
|
@if (report.from !== report.to) {
|
||||||
|
— {{ report.to | date: 'dd MMM yyyy' }}
|
||||||
|
}
|
||||||
|
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
|
||||||
|
· ₹{{ report.totalAmount | number: '1.2-2' }}
|
||||||
|
@if (report.username) {
|
||||||
|
· {{ report.username }}
|
||||||
|
}
|
||||||
|
<!-- Said plainly rather than left to be inferred from a missing filter: a cashier should know
|
||||||
|
this is their own work and not the branch's. -->
|
||||||
|
@if (!report.canSeeAllUsers) {
|
||||||
|
<span class="vr-scope-note">Your own vouchers</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (report.count === 0) {
|
||||||
|
<div class="pg-empty">No vouchers were raised in this period.</div>
|
||||||
|
} @else {
|
||||||
|
<div class="vr-summaries">
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">By type</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="num">Count</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of byType; track row.type) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.type }}</td>
|
||||||
|
<td class="num">{{ row.count }}</td>
|
||||||
|
<td class="num">{{ row.total | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Only worth showing to someone who can see more than one person's work. -->
|
||||||
|
@if (report.canSeeAllUsers) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">By user</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th class="num">Count</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of byUser; track row.username) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.username }}</td>
|
||||||
|
<td class="num">{{ row.count }}</td>
|
||||||
|
<td class="num">{{ row.total | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Vouchers</div>
|
||||||
|
<table class="pg-table vr-rows">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher No</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Debit</th>
|
||||||
|
<th>Credit</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of report.vouchers; track row.voucherNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
|
||||||
|
{{ row.voucherNo }}
|
||||||
|
</a>
|
||||||
|
@if (row.verificationStatus === 'EXCEPTION') {
|
||||||
|
<mat-icon class="vr-flag" title="An exception was raised against this voucher">error_outline</mat-icon>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ row.voucherType || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||||
|
@if (row.debitAccount) {
|
||||||
|
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||||
|
@if (row.creditAccount) {
|
||||||
|
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||||
|
}
|
||||||
|
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
||||||
|
@if (row.extraLines > 0) {
|
||||||
|
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ row.username || '—' }}</td>
|
||||||
|
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Page chrome, cards and tables come from cashier-shared.scss. Only the register itself is here. */
|
||||||
|
|
||||||
|
.vr-period {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr-scope-note {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two summaries sit side by side where there is room and stack where there is not, so a
|
||||||
|
manager reads the totals before the rows on any screen. */
|
||||||
|
.vr-summaries {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr-rows {
|
||||||
|
.vr-link {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr-flag {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
color: #b91c1c;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr-party {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr-acct,
|
||||||
|
.vr-more {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing a report should put the register on paper — the filters are how you reached it. */
|
||||||
|
@media print {
|
||||||
|
.vr-no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* 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 { RouterLink } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { catchError, of } from 'rxjs';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||||
|
interface VoucherRow {
|
||||||
|
voucherNo: string;
|
||||||
|
voucherType: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
userId: number | null;
|
||||||
|
username: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
verificationStatus: string | null;
|
||||||
|
debitParty: string | null;
|
||||||
|
debitAccount: string | null;
|
||||||
|
creditParty: string | null;
|
||||||
|
creditAccount: string | null;
|
||||||
|
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
|
||||||
|
extraLines: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoucherReport {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
username: string | null;
|
||||||
|
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
|
||||||
|
canSeeAllUsers: boolean;
|
||||||
|
vouchers: VoucherRow[];
|
||||||
|
count: number;
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vouchers raised over a day or a month, one row each.
|
||||||
|
*
|
||||||
|
* A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to
|
||||||
|
* one user. That is decided by the server, not here — this screen only shows or hides the filter.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-report',
|
||||||
|
templateUrl: './voucher-report.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./voucher-report.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatIconModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherReportComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
|
||||||
|
/** Day is the working default; a month is what you ask for at a close. */
|
||||||
|
period: 'DAY' | 'MONTH' = 'DAY';
|
||||||
|
/** Anchor date — the day itself, or any day inside the month wanted. */
|
||||||
|
anchor = this.todayIso();
|
||||||
|
usernameFilter = '';
|
||||||
|
typeFilter = '';
|
||||||
|
|
||||||
|
report: VoucherReport | null = null;
|
||||||
|
loading = false;
|
||||||
|
error: string | null = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
const { from, to } = this.range();
|
||||||
|
this.service
|
||||||
|
.voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.error =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||||
|
err?.error?.developerMessage ??
|
||||||
|
'The voucher report could not be loaded.';
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: VoucherReport | null) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.report = res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The period the anchor date names: that day, or the whole calendar month around it. */
|
||||||
|
private range(): { from: string; to: string } {
|
||||||
|
if (this.period === 'DAY') {
|
||||||
|
return { from: this.anchor, to: this.anchor };
|
||||||
|
}
|
||||||
|
const [
|
||||||
|
year,
|
||||||
|
month
|
||||||
|
] = this.anchor.split('-').map(Number);
|
||||||
|
// Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is
|
||||||
|
// ended correctly without a table of month lengths.
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
const mm = String(month).padStart(2, '0');
|
||||||
|
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private todayIso(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Totals per user, for the summary a manager reads before the rows. */
|
||||||
|
get byUser(): { username: string; count: number; total: number }[] {
|
||||||
|
const rows = this.report?.vouchers ?? [];
|
||||||
|
const tally = new Map<string, { username: string; count: number; total: number }>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.username ?? '—';
|
||||||
|
const entry = tally.get(key) ?? { username: key, count: 0, total: 0 };
|
||||||
|
entry.count += 1;
|
||||||
|
entry.total += row.amount ?? 0;
|
||||||
|
tally.set(key, entry);
|
||||||
|
}
|
||||||
|
return [...tally.values()].sort((a, b) => b.total - a.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Totals per voucher type — deposits against withdrawals against journals. */
|
||||||
|
get byType(): { type: string; count: number; total: number }[] {
|
||||||
|
const rows = this.report?.vouchers ?? [];
|
||||||
|
const tally = new Map<string, { type: string; count: number; total: number }>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.voucherType ?? '—';
|
||||||
|
const entry = tally.get(key) ?? { type: key, count: 0, total: 0 };
|
||||||
|
entry.count += 1;
|
||||||
|
entry.total += row.amount ?? 0;
|
||||||
|
tally.set(key, entry);
|
||||||
|
}
|
||||||
|
return [...tally.values()].sort((a, b) => b.total - a.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
print(): void {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head vv-no-print">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CASHIER · VOUCHER</div>
|
||||||
|
<h1 class="pg-title">Voucher</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
|
||||||
|
and for how much.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Voucher No</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="voucherNo"
|
||||||
|
(keyup.enter)="search()"
|
||||||
|
placeholder="CR/2708/0001"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="search()" [disabled]="loading">
|
||||||
|
<mat-icon>search</mat-icon> Find
|
||||||
|
</button>
|
||||||
|
@if (voucher) {
|
||||||
|
<button type="button" class="pg-btn" (click)="print()">
|
||||||
|
<mat-icon>print</mat-icon> Print
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="pg-alert pg-alert--error vv-no-print">{{ error }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty vv-no-print">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (voucher && !loading) {
|
||||||
|
<div class="pg-card vv-slip">
|
||||||
|
<div class="vv-slip-head">
|
||||||
|
<div>
|
||||||
|
<div class="vv-no">{{ voucher.voucherNo }}</div>
|
||||||
|
<div class="vv-meta">
|
||||||
|
{{ voucher.voucherType || '—' }}
|
||||||
|
@if (voucher.branchName) {
|
||||||
|
· {{ voucher.branchName }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vv-slip-head-right">
|
||||||
|
<div class="vv-amount">₹{{ voucher.amount | number: '1.2-2' }}</div>
|
||||||
|
<div class="vv-meta">
|
||||||
|
@if (voucher.createdAt) {
|
||||||
|
{{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }}
|
||||||
|
}
|
||||||
|
@if (voucher.createdBy) {
|
||||||
|
· {{ voucher.createdBy }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (voucher.verificationStatus) {
|
||||||
|
<div class="vv-status" [class.vv-status--exception]="voucher.verificationStatus === 'EXCEPTION'">
|
||||||
|
<mat-icon>{{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }}</mat-icon>
|
||||||
|
{{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- A voucher raised before its sides were recorded: the number is valid, the posting behind it
|
||||||
|
simply was never captured, so it is shown as an older record rather than as an error. -->
|
||||||
|
@if (hasNoLines) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<table class="pg-table vv-lines">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Particulars</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th class="num">Debit</th>
|
||||||
|
<th class="num">Credit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (line of debitLines; track line.lineNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="vv-party">{{ line.partyName || '—' }}</div>
|
||||||
|
@if (line.description) {
|
||||||
|
<div class="vv-desc">{{ line.description }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ line.accountNo || '—' }}</td>
|
||||||
|
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">—</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@for (line of creditLines; track line.lineNo) {
|
||||||
|
<tr>
|
||||||
|
<td class="vv-indent">
|
||||||
|
<div class="vv-party">{{ line.partyName || '—' }}</div>
|
||||||
|
@if (line.description) {
|
||||||
|
<div class="vv-desc">{{ line.description }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ line.accountNo || '—' }}</td>
|
||||||
|
<td class="num">—</td>
|
||||||
|
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">Total</td>
|
||||||
|
<td class="num">{{ voucher.totalDebit | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ voucher.totalCredit | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Stated rather than assumed: a voucher whose sides were written before the balance check
|
||||||
|
existed, or repaired by hand, should read as unbalanced rather than pass as a valid slip. -->
|
||||||
|
@if (!voucher.balanced) {
|
||||||
|
<div class="pg-alert pg-alert--error">
|
||||||
|
This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Page chrome and the table come from cashier-shared.scss. Only the slip itself is defined here. */
|
||||||
|
|
||||||
|
.vv-slip {
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-slip-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-slip-head-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-no {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-amount {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */
|
||||||
|
&.vv-status--exception {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-lines {
|
||||||
|
/* Credits sit under their debits the way they do on a written slip, so the indent carries the
|
||||||
|
relationship that the DR/CR columns alone would leave to the reader to work out. */
|
||||||
|
.vv-indent {
|
||||||
|
padding-left: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-party {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-desc {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
font-weight: 600;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are
|
||||||
|
how you reached it, not part of it. */
|
||||||
|
@media print {
|
||||||
|
.vv-no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vv-slip {
|
||||||
|
max-width: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* 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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { catchError, of } from 'rxjs';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
/** One side of a voucher: a debit or a credit against a single account. */
|
||||||
|
interface VoucherLine {
|
||||||
|
lineNo: number;
|
||||||
|
entryType: 'DR' | 'CR';
|
||||||
|
glAccountId: number | null;
|
||||||
|
savingsAccountId: number | null;
|
||||||
|
loanAccountId: number | null;
|
||||||
|
partyName: string | null;
|
||||||
|
accountNo: string | null;
|
||||||
|
amount: number;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Voucher {
|
||||||
|
voucherNo: string;
|
||||||
|
voucherType: string | null;
|
||||||
|
referenceType: string | null;
|
||||||
|
referenceId: number | null;
|
||||||
|
amount: number | null;
|
||||||
|
branchOfficeId: number | null;
|
||||||
|
branchName: string | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
verificationStatus: string | null;
|
||||||
|
lines: VoucherLine[];
|
||||||
|
totalDebit: number;
|
||||||
|
totalCredit: number;
|
||||||
|
balanced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One voucher, as a slip: which accounts were debited, which credited, and for how much.
|
||||||
|
*
|
||||||
|
* Looked up by voucher number rather than listed, because that is how a voucher is reached — from a
|
||||||
|
* paper slip in someone's hand, or from a number quoted on a statement.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-view',
|
||||||
|
templateUrl: './voucher-view.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./voucher-view.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherViewComponent {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
voucherNo = '';
|
||||||
|
voucher: Voucher | null = null;
|
||||||
|
loading = false;
|
||||||
|
/** Shown in place of the slip — a bad number and a server error read differently to the user. */
|
||||||
|
error: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// A voucher number in the URL means someone arrived from a link or a statement rather than by
|
||||||
|
// typing, so the slip is fetched straight away instead of showing them an empty search box.
|
||||||
|
const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo');
|
||||||
|
if (fromUrl) {
|
||||||
|
this.voucherNo = fromUrl;
|
||||||
|
this.search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
search(): void {
|
||||||
|
const trimmed = (this.voucherNo || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
this.error = 'Enter a voucher number.';
|
||||||
|
this.voucher = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
// Kept in the URL so the slip can be linked to and survives a refresh.
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { voucherNo: trimmed },
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.service
|
||||||
|
.voucherDetail(trimmed)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.error =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||||
|
err?.error?.developerMessage ??
|
||||||
|
`Voucher ${trimmed} could not be loaded.`;
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: Voucher | null) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.voucher = res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debits first, then credits — the order a slip is read in. */
|
||||||
|
get debitLines(): VoucherLine[] {
|
||||||
|
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR');
|
||||||
|
}
|
||||||
|
|
||||||
|
get creditLines(): VoucherLine[] {
|
||||||
|
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A voucher raised before its sides were recorded. The voucher is real and its number valid — only
|
||||||
|
* the posting behind it was never captured — so this reads as an older record, not a broken one.
|
||||||
|
*/
|
||||||
|
get hasNoLines(): boolean {
|
||||||
|
return !!this.voucher && this.voucher.lines.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
print(): void {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,12 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>{{ 'labels.inputs.Cheque Book' | translate }} #{{ book.id }}</h2>
|
<h2>{{ 'labels.inputs.Cheque Book' | translate }} #{{ book.id }}</h2>
|
||||||
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
|
<div>
|
||||||
|
<button mat-stroked-button (click)="printIssuanceSlip()">
|
||||||
|
<fa-icon icon="print"></fa-icon> {{ 'labels.buttons.Print' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mat-card class="info-card">
|
<mat-card class="info-card">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ChequeBookService } from '../chequebook.service';
|
import { ChequeBookService } from '../chequebook.service';
|
||||||
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
|
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
|
||||||
|
import { buildChequeIssuanceSlipPdf } from './cheque-issuance-slip-pdf';
|
||||||
|
|
||||||
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
|
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
|
||||||
|
|
||||||
@@ -85,6 +86,11 @@ export class ChequeBookDetailComponent implements OnInit {
|
|||||||
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
|
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
printIssuanceSlip(): void {
|
||||||
|
const doc = buildChequeIssuanceSlipPdf(this.book);
|
||||||
|
doc.save(`cheque-book-issuance-${this.book.id}.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
startAction(action: LeafAction): void {
|
startAction(action: LeafAction): void {
|
||||||
this.activeAction = this.activeAction === action ? null : action;
|
this.activeAction = this.activeAction === action ? null : action;
|
||||||
this.actionForm.reset();
|
this.actionForm.reset();
|
||||||
|
|||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-page issuance-slip PDF for a cheque book already issued to an account — the "Cheque Book"
|
||||||
|
* print action was previously a dead menu stub. This is a record of the issuance itself (which
|
||||||
|
* book/series, leaf range, issued-to account, issue date, branch), not a physical cheque leaf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { jsPDF } from 'jspdf';
|
||||||
|
|
||||||
|
const BANK_NAME = 'Nexora Co-operative Bank Ltd.';
|
||||||
|
const C_TEXT: [number, number, number] = [
|
||||||
|
15,
|
||||||
|
23,
|
||||||
|
42
|
||||||
|
];
|
||||||
|
const C_MUTED: [number, number, number] = [
|
||||||
|
100,
|
||||||
|
116,
|
||||||
|
139
|
||||||
|
];
|
||||||
|
const C_BORDER: [number, number, number] = [
|
||||||
|
226,
|
||||||
|
232,
|
||||||
|
240
|
||||||
|
];
|
||||||
|
const ACCENT: [number, number, number] = [
|
||||||
|
30,
|
||||||
|
64,
|
||||||
|
175
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return String(iso);
|
||||||
|
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChequeIssuanceSlipBook {
|
||||||
|
id: number | string;
|
||||||
|
accountNo?: string;
|
||||||
|
clientName?: string;
|
||||||
|
accountType?: string;
|
||||||
|
officeName?: string;
|
||||||
|
seriesCode?: string;
|
||||||
|
rangeStart?: string | number;
|
||||||
|
rangeEnd?: string | number;
|
||||||
|
noOfBooks?: number;
|
||||||
|
status?: string;
|
||||||
|
issueDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChequeIssuanceSlipPdf(book: ChequeIssuanceSlipBook): jsPDF {
|
||||||
|
const doc = new jsPDF({ unit: 'mm', format: 'a5' });
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const margin = 12;
|
||||||
|
let y = 16;
|
||||||
|
|
||||||
|
doc.setFillColor(...ACCENT);
|
||||||
|
doc.rect(0, 0, pageWidth, 4, 'F');
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setTextColor(...C_TEXT);
|
||||||
|
doc.text(BANK_NAME, margin, y);
|
||||||
|
y += 6;
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(...C_MUTED);
|
||||||
|
doc.text('Cheque Book Issuance Slip', margin, y);
|
||||||
|
y += 8;
|
||||||
|
|
||||||
|
doc.setDrawColor(...C_BORDER);
|
||||||
|
doc.line(margin, y, pageWidth - margin, y);
|
||||||
|
y += 8;
|
||||||
|
|
||||||
|
const row = (label: string, value: string) => {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(...C_MUTED);
|
||||||
|
doc.text(label, margin, y);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(10.5);
|
||||||
|
doc.setTextColor(...C_TEXT);
|
||||||
|
doc.text(value, margin + 45, y);
|
||||||
|
y += 7;
|
||||||
|
};
|
||||||
|
|
||||||
|
row('Issuance ID', String(book.id ?? '—'));
|
||||||
|
row('Account No.', book.accountNo ?? '—');
|
||||||
|
row('Account Holder', book.clientName ?? '—');
|
||||||
|
row('Account Type', book.accountType ?? '—');
|
||||||
|
row('Branch', book.officeName ?? '—');
|
||||||
|
row('Series', book.seriesCode ?? '—');
|
||||||
|
row('Leaf Range', book.rangeStart != null && book.rangeEnd != null ? `${book.rangeStart} – ${book.rangeEnd}` : '—');
|
||||||
|
row('No. of Books', String(book.noOfBooks ?? '—'));
|
||||||
|
row('Status', book.status ?? '—');
|
||||||
|
row('Issue Date', formatDate(book.issueDate));
|
||||||
|
|
||||||
|
y += 6;
|
||||||
|
doc.setDrawColor(...C_BORDER);
|
||||||
|
doc.line(margin, y, pageWidth - margin, y);
|
||||||
|
y += 14;
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(...C_MUTED);
|
||||||
|
doc.text('Issued by: ______________________', margin, y);
|
||||||
|
doc.text('Received by: ______________________', margin, y + 16);
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
@@ -12,16 +12,16 @@
|
|||||||
* have meaningful variety out of the box.
|
* have meaningful variety out of the box.
|
||||||
*/
|
*/
|
||||||
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
|
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
|
||||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|4||||||
|
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|4||||||
|
||||||
30|1|C|AAQPS8560J||01|02|||||
|
30|1|C|AAQPS8560J||01|02|||||
|
||||||
70|1|10078_Sign.jpeg|09|01|01||||
|
70|1|10078_Sign.jpeg|09|01|01||||
|
||||||
70|1|10078_Photo.jpeg|02|01|01||||
|
70|1|10078_Photo.jpeg|02|01|01||||
|
||||||
70|1|10078_AP.jpg|04|01|01||||
|
70|1|10078_AP.jpg|04|01|01||||
|
||||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||||
30|2|E|XXXXXXXX2210||01|02|||||
|
30|2|E|XXXXXXXX2210||01|02|||||
|
||||||
70|2|10079_Sign.jpeg|09|01|01||||
|
70|2|10079_Sign.jpeg|09|01|01||||
|
||||||
70|2|10079_Photo.jpeg|02|01|01||||
|
70|2|10079_Photo.jpeg|02|01|01||||
|
||||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||||
30|3|C|AGTPD1144K||01|02|||||
|
30|3|C|AGTPD1144K||01|02|||||
|
||||||
70|3|10080_Sign.jpeg|09|01|01||||
|
70|3|10080_Sign.jpeg|09|01|01||||
|
||||||
70|3|10080_AP.jpg|04|01|01||||
|
70|3|10080_AP.jpg|04|01|01||||
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||||
|
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||||
|
|
||||||
|
.inv-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--matured {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--written_off {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,21 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="dri">
|
<div class="dri" [class.dri--dialog]="isDialog">
|
||||||
<!-- ── NAV BAR ── -->
|
<!-- ── HEADER ── -->
|
||||||
<nav class="cashier-nav cashier-nav--clerk">
|
<nav class="cashier-nav cashier-nav--clerk">
|
||||||
<button type="button" class="cashier-nav__item cashier-nav__item--primary cashier-nav__item--active">
|
<span class="dri-header-title">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
{{
|
||||||
<path d="M12 2v20M2 12h20" />
|
isBankTransferMode
|
||||||
</svg>
|
? 'Bank Transfer Review'
|
||||||
{{ isBankTransferMode ? 'Bank Transfer Review' : isCollectionMode ? 'Collection Review' : 'Disbursement Review' }}
|
: isCollectionMode
|
||||||
</button>
|
? 'Collection Review'
|
||||||
<span class="cashier-nav__esc-hint" (click)="backToInbox()">← Back to Inbox</span>
|
: isFundTransferMode
|
||||||
|
? 'Fund Transfer Review'
|
||||||
|
: 'Disbursement Review'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="cashier-nav__esc-hint" (click)="backToInbox()">{{ isDialog ? '✕ Close' : '← Back to Inbox' }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- ── LOADING ── -->
|
<!-- ── LOADING ── -->
|
||||||
@@ -159,7 +164,7 @@
|
|||||||
<td class="cell-mono">{{ entry.accountNo }}</td>
|
<td class="cell-mono">{{ entry.accountNo }}</td>
|
||||||
<td>{{ entry.accountName }}</td>
|
<td>{{ entry.accountName }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ entry.accountType }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -197,7 +202,7 @@
|
|||||||
<td class="cell-mono">{{ entry.accountNo }}</td>
|
<td class="cell-mono">{{ entry.accountNo }}</td>
|
||||||
<td>{{ entry.accountName }}</td>
|
<td>{{ entry.accountName }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ entry.accountType }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -233,7 +238,7 @@
|
|||||||
<td class="cell-mono">{{ entry.accountNo }}</td>
|
<td class="cell-mono">{{ entry.accountNo }}</td>
|
||||||
<td>{{ entry.accountName }}</td>
|
<td>{{ entry.accountName }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ entry.accountType }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg-page;
|
background: $bg-page;
|
||||||
|
|
||||||
|
/* Opened as a popup rather than a full page — sizes to its own content (capped so a long
|
||||||
|
entries table still scrolls inside the dialog) instead of stretching to the viewport. */
|
||||||
|
&--dialog {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: min(760px, 92vw);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Nav bar ────────────────────────────────────────────────────────────────
|
// ── Nav bar ────────────────────────────────────────────────────────────────
|
||||||
@@ -48,29 +58,6 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
|||||||
border-bottom: 1px solid $border;
|
border-bottom: 1px solid $border;
|
||||||
box-shadow: $shadow-sm;
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
&__item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 7px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
|
|
||||||
&--primary {
|
|
||||||
background: $brand-light;
|
|
||||||
color: $brand;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
background: $brand;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__esc-hint {
|
&__esc-hint {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -88,6 +75,12 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dri-header-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-mid;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Loading / empty ────────────────────────────────────────────────────────
|
// ── Loading / empty ────────────────────────────────────────────────────────
|
||||||
.dri-loading {
|
.dri-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
@@ -33,7 +34,8 @@ interface CreditEntry {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
MatDialogModule
|
||||||
],
|
],
|
||||||
templateUrl: './disbursement-review.component.html',
|
templateUrl: './disbursement-review.component.html',
|
||||||
styleUrl: './disbursement-review.component.scss'
|
styleUrl: './disbursement-review.component.scss'
|
||||||
@@ -43,6 +45,11 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private service = inject(ClerkDisbursementService);
|
private service = inject(ClerkDisbursementService);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
/** Present only when opened as a popup from the requests list; absent when reached by direct
|
||||||
|
* route (e.g. a bookmarked/shared link). Both entry points stay supported — this only changes
|
||||||
|
* how "done" is signalled: closing the dialog vs. navigating back to the inbox route. */
|
||||||
|
private dialogRef = inject(MatDialogRef<DisbursementReviewComponent>, { optional: true });
|
||||||
|
private dialogData = inject<{ requestId: number } | null>(MAT_DIALOG_DATA, { optional: true });
|
||||||
|
|
||||||
requestId: number | null = null;
|
requestId: number | null = null;
|
||||||
request: any = null;
|
request: any = null;
|
||||||
@@ -53,9 +60,17 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
showRejectForm = false;
|
showRejectForm = false;
|
||||||
rejectionReason = '';
|
rejectionReason = '';
|
||||||
|
|
||||||
|
get isDialog(): boolean {
|
||||||
|
return !!this.dialogRef;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const id = this.route.snapshot.params['id'];
|
if (this.dialogData?.requestId) {
|
||||||
this.requestId = id != null ? Number(id) : null;
|
this.requestId = this.dialogData.requestId;
|
||||||
|
} else {
|
||||||
|
const id = this.route.snapshot.params['id'];
|
||||||
|
this.requestId = id != null ? Number(id) : null;
|
||||||
|
}
|
||||||
if (this.requestId) this.loadRequest();
|
if (this.requestId) this.loadRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +108,11 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
} else {
|
} else {
|
||||||
const msg = this.isBankTransferMode
|
const msg = this.isBankTransferMode
|
||||||
? 'Bank transfer approved successfully.'
|
? 'Bank transfer approved successfully.'
|
||||||
: 'Disbursement approved successfully.';
|
: this.isFundTransferMode
|
||||||
|
? 'Fund transfer approved successfully.'
|
||||||
|
: 'Disbursement approved successfully.';
|
||||||
this.snackBar.open(msg, 'Close', { duration: 3000 });
|
this.snackBar.open(msg, 'Close', { duration: 3000 });
|
||||||
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
|
this.finish();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -112,15 +129,31 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
this.snackBar.open(res.error, 'Close', { duration: 5000 });
|
this.snackBar.open(res.error, 'Close', { duration: 5000 });
|
||||||
} else {
|
} else {
|
||||||
this.snackBar.open('Request rejected.', 'Close', { duration: 3000 });
|
this.snackBar.open('Request rejected.', 'Close', { duration: 3000 });
|
||||||
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
|
this.finish();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a
|
||||||
|
* dialog, or returns to the inbox route otherwise. */
|
||||||
|
private finish(): void {
|
||||||
|
if (this.dialogRef) {
|
||||||
|
this.dialogRef.close({ refreshed: true });
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get isBankTransferMode(): boolean {
|
get isBankTransferMode(): boolean {
|
||||||
return this.request?.request_type === 'BANK_TRANSFER';
|
return this.request?.request_type === 'BANK_TRANSFER';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mechanically identical to a Disbursement (same 1 GL debit -> N account credits flow) — only
|
||||||
|
* the label differs, so a checker doesn't read this as a loan payout. */
|
||||||
|
get isFundTransferMode(): boolean {
|
||||||
|
return this.request?.request_type === 'FUND_TRANSFER';
|
||||||
|
}
|
||||||
|
|
||||||
get isCollectionMode(): boolean {
|
get isCollectionMode(): boolean {
|
||||||
if (this.isBankTransferMode) return false;
|
if (this.isBankTransferMode) return false;
|
||||||
return this.creditEntries.some((e) => e.dbCr === 'Dr');
|
return this.creditEntries.some((e) => e.dbCr === 'Dr');
|
||||||
@@ -143,6 +176,24 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
return this.drEntries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
return this.drEntries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable account type for the badge. The stored value is the backend's own code
|
||||||
|
* (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL), so this only
|
||||||
|
* changes presentation -- a checker should read "Fixed Deposit", not "FIXED_DEPOSIT".
|
||||||
|
*/
|
||||||
|
accountTypeLabel(accountType: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
SAVINGS: 'Savings',
|
||||||
|
CURRENT: 'Current',
|
||||||
|
FIXED_DEPOSIT: 'Fixed Deposit',
|
||||||
|
RECURRING_DEPOSIT: 'Recurring Deposit',
|
||||||
|
SHARE: 'Share',
|
||||||
|
LOAN: 'Loan',
|
||||||
|
GL: 'GL'
|
||||||
|
};
|
||||||
|
return labels[(accountType ?? '').toUpperCase()] ?? accountType;
|
||||||
|
}
|
||||||
|
|
||||||
statusClass(status: string): string {
|
statusClass(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
PENDING: 'dri-status--pending',
|
PENDING: 'dri-status--pending',
|
||||||
@@ -171,6 +222,10 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
backToInbox(): void {
|
backToInbox(): void {
|
||||||
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
|
if (this.dialogRef) {
|
||||||
|
this.dialogRef.close();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a claim for an unclaimed dividend, collected as a Demand Draft.
|
||||||
|
*
|
||||||
|
* A dividend is only ever paid out as a draft — the savings-credit, cash and loan-set-off routes
|
||||||
|
* this endpoint once accepted are retired, so the payload carries just what a draft needs: how
|
||||||
|
* much, drawn in whose favour, and payable where.
|
||||||
|
*/
|
||||||
|
submit(
|
||||||
|
lockId: number,
|
||||||
|
claim: {
|
||||||
|
ddApplyAmount: number;
|
||||||
|
ddBeneficiaryName: string;
|
||||||
|
ddPayableAtBranch: string;
|
||||||
|
ddPayableAtCity: string;
|
||||||
|
/**
|
||||||
|
* The leaflet the clerk picked off the shelf. Sent so the draft is drawn on that physical
|
||||||
|
* form — without it the server takes the next available one, and the number the clerk wrote
|
||||||
|
* on the counterfoil would not be the number in the books.
|
||||||
|
*/
|
||||||
|
leafletId?: number;
|
||||||
|
}
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post('/clerk/dividend-claim-requests', {
|
||||||
|
lockId,
|
||||||
|
ddApplyAmount: String(claim.ddApplyAmount),
|
||||||
|
ddBeneficiaryName: claim.ddBeneficiaryName,
|
||||||
|
ddPayableAtBranch: claim.ddPayableAtBranch,
|
||||||
|
ddPayableAtCity: claim.ddPayableAtCity,
|
||||||
|
leafletId: claim.leafletId ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,43 @@
|
|||||||
|
/**
|
||||||
|
* 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,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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
@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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- One list at a time. The three answer unrelated questions and a clerk is only ever in one of
|
||||||
|
them; stacked, the one they came for was usually below the fold. Counts ride on the tabs so a
|
||||||
|
list worth opening — a blocked member, a lock nearing forfeiture — is visible without opening
|
||||||
|
it. -->
|
||||||
|
<div class="dd-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="dd-tab"
|
||||||
|
[class.dd-tab--active]="activeTab === 'declarations'"
|
||||||
|
[attr.aria-selected]="activeTab === 'declarations'"
|
||||||
|
(click)="activeTab = 'declarations'"
|
||||||
|
>
|
||||||
|
Declarations
|
||||||
|
<span class="dd-tab__count">{{ declarations.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="dd-tab"
|
||||||
|
[class.dd-tab--active]="activeTab === 'overdue'"
|
||||||
|
[attr.aria-selected]="activeTab === 'overdue'"
|
||||||
|
(click)="activeTab = 'overdue'"
|
||||||
|
>
|
||||||
|
Overdue EMI
|
||||||
|
<span class="dd-tab__count" [class.dd-tab__count--alert]="rows.length > 0">{{ rows.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="dd-tab"
|
||||||
|
[class.dd-tab--active]="activeTab === 'locks'"
|
||||||
|
[attr.aria-selected]="activeTab === 'locks'"
|
||||||
|
(click)="activeTab = 'locks'"
|
||||||
|
>
|
||||||
|
Lock History
|
||||||
|
<span class="dd-tab__count">{{ locks.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card" [hidden]="activeTab !== 'declarations'">
|
||||||
|
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
|
||||||
|
<div class="dd-toolbar">
|
||||||
|
<div class="dd-search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="dd-search__input"
|
||||||
|
placeholder="Search product, period or status…"
|
||||||
|
[(ngModel)]="declarationSearch"
|
||||||
|
name="declarationSearch"
|
||||||
|
/>
|
||||||
|
@if (declarationSearch) {
|
||||||
|
<button type="button" class="dd-search__clear" (click)="declarationSearch = ''" aria-label="Clear search">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="dd-toolbar__count">
|
||||||
|
{{ visibleDeclarations.length }} of {{ declarations.length }} declaration{{
|
||||||
|
declarations.length === 1 ? '' : 's'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">Refresh</button>
|
||||||
|
</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 if (visibleDeclarations.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="pg-empty">No declaration matches “{{ declarationSearch }}”.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (d of visibleDeclarations; 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" [hidden]="activeTab !== 'overdue'">
|
||||||
|
<div class="pos-section-label">Dividend Dashboard — Locked Dividends for Members with Overdue EMI</div>
|
||||||
|
<div class="dd-toolbar">
|
||||||
|
<div class="dd-search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="dd-search__input"
|
||||||
|
placeholder="Search member name or share account no…"
|
||||||
|
[(ngModel)]="overdueSearch"
|
||||||
|
name="overdueSearch"
|
||||||
|
/>
|
||||||
|
@if (overdueSearch) {
|
||||||
|
<button type="button" class="dd-search__clear" (click)="overdueSearch = ''" aria-label="Clear search">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="dd-toolbar__count">
|
||||||
|
{{ visibleOverdueRows.length }} of {{ rows.length }} member{{ rows.length === 1 ? '' : 's' }}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
|
||||||
|
</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 if (visibleOverdueRows.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="pg-empty">No member matches “{{ overdueSearch }}”.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (row of visibleOverdueRows; track row.lockId) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ row.clientName }}
|
||||||
|
@if (row.shareAccountNo) {
|
||||||
|
<div class="dd-account-no">{{ row.shareAccountNo }}</div>
|
||||||
|
}
|
||||||
|
</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" [hidden]="activeTab !== 'locks'">
|
||||||
|
<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>
|
||||||
|
<div class="dd-search" style="margin-left: auto">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="dd-search__input"
|
||||||
|
placeholder="Search member name or share account no…"
|
||||||
|
[(ngModel)]="lockSearch"
|
||||||
|
name="lockSearch"
|
||||||
|
/>
|
||||||
|
@if (lockSearch) {
|
||||||
|
<button type="button" class="dd-search__clear" (click)="lockSearch = ''" aria-label="Clear search">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Financial-year chips, built from the locks themselves: a new declaration adds its year here
|
||||||
|
on its own, and a year with nothing left to show stops being offered. Shown even for a single
|
||||||
|
year, so the filter is visibly there before a second year exists to use it on. -->
|
||||||
|
@if (lockYears.length > 0) {
|
||||||
|
<div class="dd-year-filter">
|
||||||
|
<span class="dd-year-filter__label">Financial year</span>
|
||||||
|
<div class="dd-filter-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dd-filter-btn"
|
||||||
|
[class.dd-filter-btn--active]="lockYearFilter === 'ALL'"
|
||||||
|
(click)="setLockYearFilter('ALL')"
|
||||||
|
>
|
||||||
|
All years
|
||||||
|
</button>
|
||||||
|
@for (year of lockYears; track year) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dd-filter-btn"
|
||||||
|
[class.dd-filter-btn--active]="lockYearFilter === year"
|
||||||
|
(click)="setLockYearFilter(year)"
|
||||||
|
>
|
||||||
|
FY {{ year }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="dd-year-filter__total">
|
||||||
|
{{ visibleLocks.length }} lock{{ visibleLocks.length === 1 ? '' : 's' }} ·
|
||||||
|
{{ visibleLocksTotal | number: '1.2-2' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client</th>
|
||||||
|
<!-- Which declaration this lock came from. A member can hold several unclaimed dividends at
|
||||||
|
once — this year's plus an earlier year they never collected — and amount alone cannot
|
||||||
|
tell them apart. -->
|
||||||
|
<th>Dividend</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Locked Date</th>
|
||||||
|
<th>Release Due</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Applied Loan</th>
|
||||||
|
<th>Demand Draft</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (locksLoading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (locksLoaded && visibleLocks.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="pg-empty">
|
||||||
|
@if (lockSearch) {
|
||||||
|
No dividend lock matches “{{ lockSearch }}”.
|
||||||
|
} @else {
|
||||||
|
No dividend locks match this filter.
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (l of visibleLocks; track l.lockId) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ l.clientName }}
|
||||||
|
@if (l.shareAccountNo) {
|
||||||
|
<div class="dd-account-no">{{ l.shareAccountNo }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (l.financialYear) {
|
||||||
|
<div class="dd-dividend-year">FY {{ l.financialYear }}</div>
|
||||||
|
<div class="dd-dividend-period">
|
||||||
|
{{ l.productName }} · {{ l.periodStartDate }} → {{ l.periodEndDate }}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<!-- Once a draft has been raised the clerk needs its number, not another Claim button:
|
||||||
|
it is what goes on the counterfoil and what the member asks about when they return.
|
||||||
|
Until then, the button hands off to the DD inventory to raise one. -->
|
||||||
|
<td>
|
||||||
|
@if (l.claimDdNumber && l.claimStatus === 'APPROVED') {
|
||||||
|
<span class="dd-dd-number">{{ l.claimDdNumber }}</span>
|
||||||
|
} @else if (l.claimStatus === 'PENDING') {
|
||||||
|
<span class="dd-badge dd-badge--pending">DD {{ l.claimDdNumber }} — awaiting approval</span>
|
||||||
|
} @else if (l.status === 'UNCLAIMED') {
|
||||||
|
<button type="button" class="pg-btn dd-apply-confirm" (click)="claimAsDemandDraft(l)">
|
||||||
|
Claim by DD
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Which declaration a lock belongs to. The financial year leads because that is how a member asks
|
||||||
|
for it ("my 2026-27 dividend"); the product and exact period sit under it for the clerk who needs
|
||||||
|
to be certain, without crowding the row. */
|
||||||
|
.dd-dividend-year {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e293b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-dividend-period {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Financial-year chips. Sits below the status chips as a second, narrower axis over the same list —
|
||||||
|
status is fetched from the server, year filters what came back. */
|
||||||
|
.dd-year-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #475569;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The DD number just drawn. Approving refreshes the pending list, so the row it came from is gone
|
||||||
|
by the time the clerk reads it — and that number is what goes on the counterfoil. */
|
||||||
|
.dd-issued-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 16px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
font-size: 13.5px;
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #166534;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #14532d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One toolbar per table: search on the left where the eye lands first, the row count next to it so
|
||||||
|
a narrowed table says how much it is hiding, and Refresh pushed to the far right. */
|
||||||
|
.dd-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #475569;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-search {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 260px;
|
||||||
|
max-width: 360px;
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 30px 7px 11px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The browser's own clear affordance would sit under ours. */
|
||||||
|
&::-webkit-search-cancel-button {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only rendered while there is something to clear, so it never reads as a dead control. */
|
||||||
|
&__clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 6px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The share account number under the member's name — the thing a clerk matches against the
|
||||||
|
passbook in front of them, so it has to be readable but must not outweigh the name. */
|
||||||
|
.dd-account-no {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #64748b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal tab strip: one list on screen at a time, chosen here.
|
||||||
|
Scrolls rather than wraps on a narrow window, so the strip stays one line and the tab positions
|
||||||
|
a clerk has learned do not move. */
|
||||||
|
.dd-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border: 0;
|
||||||
|
/* The active marker is this underline, so every tab reserves the space for it — otherwise the
|
||||||
|
strip would shift by 2px each time the selection moved. */
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
color: #1e40af;
|
||||||
|
border-bottom-color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-tab__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
/* A member blocked by an overdue EMI, or a claim waiting on a manager, is work outstanding —
|
||||||
|
coloured so it reads as something to open rather than a neutral tally. */
|
||||||
|
&--alert {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The draft number a member's dividend was paid out on. Monospaced-by-figure so a clerk reading it
|
||||||
|
against the counterfoil can compare digit by digit. */
|
||||||
|
.dd-dd-number {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { DividendDashboardService } from '../dividend-dashboard.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year lock
|
||||||
|
* history, and members whose unclaimed dividend sits alongside an overdue loan EMI (with a
|
||||||
|
* proactive "Apply to Loan" action).
|
||||||
|
*
|
||||||
|
* Claiming a dividend hands off to the DD inventory, because a dividend is only ever collected as a
|
||||||
|
* Demand Draft. The draft is then approved on the DD Issuance Approvals screen alongside every
|
||||||
|
* other draft, so there is no separate claim queue here — the lock history shows the DD number once
|
||||||
|
* it has been drawn.
|
||||||
|
*/
|
||||||
|
@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 router = inject(Router);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which list the clerk is looking at.
|
||||||
|
*
|
||||||
|
* The three lists answer unrelated questions — what has been declared, who is blocked by an
|
||||||
|
* overdue EMI, and the full lock history — and a clerk is only ever in one of them. Stacked on one
|
||||||
|
* page they made the screen long enough that the section a clerk came for was usually below the
|
||||||
|
* fold, so they are tabbed instead.
|
||||||
|
*
|
||||||
|
* Every list is still loaded on init rather than on first view: the counts in the tab strip have
|
||||||
|
* to be right before a tab is opened, or the strip would be advertising numbers it does not know.
|
||||||
|
*/
|
||||||
|
activeTab: 'declarations' | 'overdue' | 'locks' = 'declarations';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free-text search, one box per table.
|
||||||
|
*
|
||||||
|
* Kept separate rather than shared because the three tables answer different questions and a
|
||||||
|
* clerk is usually in one of them: narrowing the lock history to a member should not blank out
|
||||||
|
* the declarations table above it, which has nothing to do with that member.
|
||||||
|
*
|
||||||
|
* Filtering is done here rather than server-side because every row is already loaded — these are
|
||||||
|
* a society's dividends, not a transaction ledger — so a round trip per keystroke would be slower
|
||||||
|
* than the filter itself and would blank the table while it ran.
|
||||||
|
*/
|
||||||
|
declarationSearch = '';
|
||||||
|
overdueSearch = '';
|
||||||
|
lockSearch = '';
|
||||||
|
|
||||||
|
/** Every lock the status filter returned, before the year filter narrows it. */
|
||||||
|
locks: any[] = [];
|
||||||
|
locksLoading = false;
|
||||||
|
locksLoaded = false;
|
||||||
|
lockStatusFilter = 'ALL';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Financial years that actually have locks, newest first — derived from the data rather than
|
||||||
|
* listed anywhere, so a new declaration adds its year on its own and a year whose locks are all
|
||||||
|
* gone stops being offered. No maintenance as years roll over.
|
||||||
|
*/
|
||||||
|
lockYears: string[] = [];
|
||||||
|
lockYearFilter = 'ALL';
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadSummary();
|
||||||
|
this.loadDeclarations();
|
||||||
|
this.load();
|
||||||
|
this.loadLocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the clerk to the DD inventory to draw the draft this dividend is paid out as.
|
||||||
|
*
|
||||||
|
* A dividend is only ever collected as a Demand Draft, so the claim is really a request to issue
|
||||||
|
* one — and the place a draft is issued is the leaflet inventory, where the clerk can see which
|
||||||
|
* numbered leaflet they are about to commit. Gathering the draft's details on this dashboard
|
||||||
|
* instead meant filling a second, thinner version of the DD form and never seeing the DD number
|
||||||
|
* until after approval.
|
||||||
|
*
|
||||||
|
* The dividend travels in the query string rather than in a service, so a reloaded or
|
||||||
|
* bookmarked page still knows which lock it is drawing against instead of silently issuing an
|
||||||
|
* ordinary counter draft.
|
||||||
|
*/
|
||||||
|
claimAsDemandDraft(lock: any): void {
|
||||||
|
this.router.navigate(
|
||||||
|
[
|
||||||
|
'/sponsor-dd',
|
||||||
|
'inventory'
|
||||||
|
],
|
||||||
|
{
|
||||||
|
queryParams: {
|
||||||
|
dividendLockId: lock.lockId,
|
||||||
|
dividendAmount: Number(lock.amount),
|
||||||
|
dividendBeneficiary: lock.clientName ?? '',
|
||||||
|
dividendFinancialYear: lock.financialYear ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any of a row's searchable fields contains the term.
|
||||||
|
*
|
||||||
|
* Substring rather than prefix matching, because a clerk reading an account number off a passbook
|
||||||
|
* often types the distinctive tail of it rather than the branch prefix every account shares. Null
|
||||||
|
* fields are skipped — a lock with no share account still has to be findable by member name.
|
||||||
|
*/
|
||||||
|
private matches(term: string, ...fields: (string | number | null | undefined)[]): boolean {
|
||||||
|
const needle = term.trim().toLowerCase();
|
||||||
|
if (!needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return fields.some((field) => field != null && String(field).toLowerCase().includes(needle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declarations the table renders. Product-level rows, so there is no member to search on. */
|
||||||
|
get visibleDeclarations(): any[] {
|
||||||
|
return this.declarations.filter((d) =>
|
||||||
|
this.matches(this.declarationSearch, d.productName, d.periodStartDate, d.periodEndDate, d.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Members with an overdue EMI, narrowed to the one the clerk is dealing with. */
|
||||||
|
get visibleOverdueRows(): any[] {
|
||||||
|
return this.rows.filter((r) => this.matches(this.overdueSearch, r.clientName, r.shareAccountNo));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.rebuildLockYears();
|
||||||
|
this.locksLoading = false;
|
||||||
|
this.locksLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the year chips from whatever locks came back, newest first.
|
||||||
|
*
|
||||||
|
* A year the current status filter has no locks for disappears from the chips, so the selected
|
||||||
|
* year is reset to ALL when that happens — otherwise the table would show nothing with no
|
||||||
|
* indication why.
|
||||||
|
*/
|
||||||
|
private rebuildLockYears(): void {
|
||||||
|
this.lockYears = [...new Set(this.locks.map((l) => l.financialYear).filter((y) => !!y))].sort((a, b) =>
|
||||||
|
b.localeCompare(a)
|
||||||
|
);
|
||||||
|
if (this.lockYearFilter !== 'ALL' && !this.lockYears.includes(this.lockYearFilter)) {
|
||||||
|
this.lockYearFilter = 'ALL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the table actually renders — the status filter is applied server-side, the year and the
|
||||||
|
* search here. All three narrow together, so a clerk can hold a year and still search within it.
|
||||||
|
*/
|
||||||
|
get visibleLocks(): any[] {
|
||||||
|
return this.locks.filter(
|
||||||
|
(l) =>
|
||||||
|
(this.lockYearFilter === 'ALL' || l.financialYear === this.lockYearFilter) &&
|
||||||
|
this.matches(this.lockSearch, l.clientName, l.shareAccountNo, l.productName, l.appliedLoanAccountNo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total of what is on screen, so a year's worth of dividend can be read off directly. */
|
||||||
|
get visibleLocksTotal(): number {
|
||||||
|
return this.visibleLocks.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLockStatusFilter(status: string): void {
|
||||||
|
this.lockStatusFilter = status;
|
||||||
|
this.loadLocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLockYearFilter(year: string): void {
|
||||||
|
this.lockYearFilter = year;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CLERK · CLOSURE</div>
|
||||||
|
<h1 class="pg-title">My Closure Requests</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Account closure requests you have submitted, and their approval / cashier status. To close an account, use the
|
||||||
|
"Close this account" action on the account's own Transaction Entry screen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Payout Mode</th>
|
||||||
|
<th>Token No</th>
|
||||||
|
<th class="num">Closing Amount</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No closure requests found.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td>{{ r.payment_type_name || 'Cash' }}</td>
|
||||||
|
<td>{{ r.tok_no || '—' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (r.closing_amount != null) {
|
||||||
|
₹{{ r.closing_amount | number: '1.2-2' }}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkClosureRequestService } from '../../cashier/clerk-closure-request.service';
|
||||||
|
|
||||||
|
/** Clerk Dashboard "My Closure Requests" — lists the Cash account-closure requests this clerk has
|
||||||
|
* submitted (from the account's own Transaction Entry screen) and their approval/disbursement
|
||||||
|
* status. Mirrors MyShareWithdrawalRequestsComponent. There is no "New Closure Request" button
|
||||||
|
* here -- submission happens in-context on the account's own screen, not from this list. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-closure-requests',
|
||||||
|
templateUrl: './my-closure-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyClosureRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkClosureRequestService);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = '';
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved — awaiting cashier' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off',
|
||||||
|
CLOSED: 'inv-badge--matured'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending Approval',
|
||||||
|
APPROVED: 'Awaiting Cashier',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
CLOSED: 'Closed'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,7 +170,9 @@
|
|||||||
<td class="cell-mono">{{ ce.accountNo || '—' }}</td>
|
<td class="cell-mono">{{ ce.accountNo || '—' }}</td>
|
||||||
<td>{{ ce.accountName || '—' }}</td>
|
<td>{{ ce.accountName || '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mdr-actype">{{ ce.accountType || '—' }}</span>
|
<span class="mdr-actype">{{
|
||||||
|
ce.accountType ? accountTypeLabel(ce.accountType) : '—'
|
||||||
|
}}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num mdr-ce-amount">{{ ce.amount | number: '1.2-2' }}</td>
|
<td class="num mdr-ce-amount">{{ ce.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -187,6 +187,23 @@ export class MyDisbursementRequestsComponent implements OnInit {
|
|||||||
return this.getCreditEntries(req).length;
|
return this.getCreditEntries(req).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable account type for the badge. The stored value is the backend's own code
|
||||||
|
* (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL); presentation only.
|
||||||
|
*/
|
||||||
|
accountTypeLabel(accountType: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
SAVINGS: 'Savings',
|
||||||
|
CURRENT: 'Current',
|
||||||
|
FIXED_DEPOSIT: 'Fixed Deposit',
|
||||||
|
RECURRING_DEPOSIT: 'Recurring Deposit',
|
||||||
|
SHARE: 'Share',
|
||||||
|
LOAN: 'Loan',
|
||||||
|
GL: 'GL'
|
||||||
|
};
|
||||||
|
return labels[(accountType ?? '').toUpperCase()] ?? accountType;
|
||||||
|
}
|
||||||
|
|
||||||
statusClass(status: string): string {
|
statusClass(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
PENDING: 'mdr-status--pending',
|
PENDING: 'mdr-status--pending',
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CLERK · SHARE WITHDRAWAL</div>
|
||||||
|
<h1 class="pg-title">My Share Requests</h1>
|
||||||
|
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th>Resolution No.</th>
|
||||||
|
<th>Meeting Date</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td class="num">{{ r.requested_shares }}</td>
|
||||||
|
<td>{{ r.resolution_number }}</td>
|
||||||
|
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
|
||||||
|
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||||
|
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-share-withdrawal-requests',
|
||||||
|
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareWithdrawalService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = '';
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
newRequest(): void {
|
||||||
|
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending Approval',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||||
|
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||||
|
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Share Account</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (!isReview) {
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>* Search Account No. / Client Name</span>
|
||||||
|
<mifosx-search-select
|
||||||
|
[options]="accountOptions"
|
||||||
|
[(ngModel)]="selectedAccountId"
|
||||||
|
(ngModelChange)="onAccountSelected($event)"
|
||||||
|
placeholder="Search share account no. or client name…"
|
||||||
|
></mifosx-search-select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (account) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Account No</span>
|
||||||
|
<input type="text" [value]="account.accountNo" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Client</span>
|
||||||
|
<input type="text" [value]="account.clientName" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Shares Held</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Unit Price</span>
|
||||||
|
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (loadingAccount) {
|
||||||
|
<div class="pg-empty">Loading account…</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Client Compliance</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>KYC Status</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||||
|
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_kyc_status_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Risk Category</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_risk_category_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Open AML Alerts</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (account) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Withdrawal Details</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Shares to Withdraw</span>
|
||||||
|
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Requested Date</span>
|
||||||
|
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Withdrawal Value</span>
|
||||||
|
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Remaining Shares After Withdrawal</span>
|
||||||
|
<input type="text" [value]="remainingShares" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Board Resolution Number</span>
|
||||||
|
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Date of Meeting</span>
|
||||||
|
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Settlement Payment Type</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<select [(ngModel)]="settlementPaymentTypeId">
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (pt of paymentTypeOptions; track pt.id) {
|
||||||
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>Description / Remarks</span>
|
||||||
|
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (account && remainingShares === 0) {
|
||||||
|
<div class="swr-close-notice">
|
||||||
|
<mat-icon>lock</mat-icon>
|
||||||
|
<span>
|
||||||
|
This withdraws <strong>all</strong> shares on this account. Approving it will
|
||||||
|
<strong>close the share account automatically</strong> — no separate closure step is needed.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isReview) {
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!isFormValid || submitting"
|
||||||
|
(click)="openConfirmDialog()"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Verification</div>
|
||||||
|
<div class="swrv-trigger">
|
||||||
|
<div class="swrv-trigger__icon">
|
||||||
|
<mat-icon>fact_check</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="swrv-trigger__text">
|
||||||
|
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||||
|
<div class="swrv-trigger__sub">
|
||||||
|
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||||
|
<mat-icon>play_circle</mat-icon>
|
||||||
|
Validate Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showValidateModal) {
|
||||||
|
<div class="swrv-modal">
|
||||||
|
<div class="swrv-modal__card">
|
||||||
|
<div class="swrv-modal__header">
|
||||||
|
<div class="swrv-modal__title">
|
||||||
|
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||||
|
Validate Share Withdrawal Request
|
||||||
|
</div>
|
||||||
|
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__body">
|
||||||
|
<p class="swrv-modal__intro">
|
||||||
|
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||||
|
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||||
|
and GL configuration — not just what was captured at submission time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && !validateAllPassed) {
|
||||||
|
<div class="swrv-modal__warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Some checks need review before approving.</strong>
|
||||||
|
Scroll down to see which ones failed and why.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ol class="swrv-steps">
|
||||||
|
@for (step of validateSteps; track step.label; let i = $index) {
|
||||||
|
<li
|
||||||
|
class="swrv-step"
|
||||||
|
[class.swrv-step--active]="step.status === 'active'"
|
||||||
|
[class.swrv-step--done]="step.status === 'done'"
|
||||||
|
[class.swrv-step--failed]="step.status === 'failed'"
|
||||||
|
>
|
||||||
|
<div class="swrv-step__indicator">
|
||||||
|
@if (step.status === 'done') {
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
} @else if (step.status === 'active') {
|
||||||
|
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||||
|
} @else if (step.status === 'failed') {
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<span>{{ i + 1 }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="swrv-step__content">
|
||||||
|
<div class="swrv-step__label">{{ step.label }}</div>
|
||||||
|
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
@if (validating && validateSteps.length === 0) {
|
||||||
|
<div class="pg-empty">Connecting to server…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && validateAllPassed) {
|
||||||
|
<div class="swrv-modal__result">
|
||||||
|
<mat-icon>verified</mat-icon>
|
||||||
|
<span>All checks passed — safe to approve.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__footer">
|
||||||
|
@if (!validating && backendChecks) {
|
||||||
|
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Re-run
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (!validating && backendChecks && validateAllPassed) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
{{ validating ? 'Validating…' : 'Close' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Settlement</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (reviewRequest?.settlement_amount) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Amount Credited</span>
|
||||||
|
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Credited To (Linked Savings A/C)</span>
|
||||||
|
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Savings Transaction Ref.</span>
|
||||||
|
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||||
|
</label>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-empty" style="grid-column: span 2">
|
||||||
|
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||||
|
already-approved request — check the linked savings account's transaction history to confirm.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && activityLog.length > 0) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Activity Log</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of activityLog; track entry.timestamp) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||||
|
<td>{{ entry.action }}</td>
|
||||||
|
<td>{{ entry.user }}</td>
|
||||||
|
<td>{{ entry.note || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showConfirmDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Confirm Share Withdrawal Request</h3>
|
||||||
|
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Account No / Client</td>
|
||||||
|
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Shares to Withdraw</td>
|
||||||
|
<td>
|
||||||
|
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr [class.swr-close-row]="remainingShares === 0">
|
||||||
|
<td>Remaining Shares After Withdrawal</td>
|
||||||
|
<td>
|
||||||
|
{{ remainingShares }}
|
||||||
|
@if (remainingShares === 0) {
|
||||||
|
<span class="swr-close-tag"><mat-icon>lock</mat-icon> Account will be closed on approval</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Requested Date</td>
|
||||||
|
<td>{{ requestedDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Board Resolution Number</td>
|
||||||
|
<td>{{ resolutionNumber }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Date of Meeting</td>
|
||||||
|
<td>{{ meetingDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Settlement Payment Type</td>
|
||||||
|
<td>{{ settlementPaymentTypeName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Linked Savings Account</td>
|
||||||
|
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
@for (check of verificationChecks; track check.label) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ check.label }}</td>
|
||||||
|
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||||
|
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||||
|
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showRejectDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Reject Share Withdrawal Request</h3>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Rejection Reason</span>
|
||||||
|
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
[disabled]="!rejectionReason.trim()"
|
||||||
|
(click)="confirmRejectReview()"
|
||||||
|
>
|
||||||
|
Confirm Reject
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.swr-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(13 27 42 / 55%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(table) {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-pass {
|
||||||
|
color: #1a7f37;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-fail {
|
||||||
|
color: #b42318;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #eafaf0;
|
||||||
|
color: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fdecea;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Account-will-close notice ---------- */
|
||||||
|
.swr-close-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 4px 16px 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-left: 3px solid #d97706;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #92400e;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #d97706;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #7c2d12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-close-row td {
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-close-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Verification trigger card ---------- */
|
||||||
|
.swrv-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Validate pipeline modal ---------- */
|
||||||
|
@keyframes swrv-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-zoom {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-spin {
|
||||||
|
animation: swrv-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
// No backdrop-filter: it would make this element a containing block for any position:fixed
|
||||||
|
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
|
||||||
|
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
|
||||||
|
// caused. Extra opacity compensates for the loss of the blur look.
|
||||||
|
background: rgb(15 25 45 / 65%);
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: swrv-fade 0.2s ease-out;
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||||
|
animation: swrv-zoom 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.12s;
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
color: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
background: #fdecea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 20px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__intro {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fef3e2;
|
||||||
|
border: 1px solid rgb(217 119 6 / 30%);
|
||||||
|
border-left: 3px solid #d97706;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #633806;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #d97706;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4d2a05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #eafaf0;
|
||||||
|
border: 1px solid rgb(26 127 55 / 25%);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a7f37;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 38px;
|
||||||
|
bottom: -4px;
|
||||||
|
width: 2px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: all 0.25s;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
padding-top: 5px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__msg {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #3457d5;
|
||||||
|
border-color: #3457d5;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #3457d5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #1a7f37;
|
||||||
|
border-color: #1a7f37;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failed {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swrv-spin {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal,
|
||||||
|
.swrv-modal__card {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { SharesService } from '../../shares/shares.service';
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
import { CashierService } from '../../cashier/cashier.service';
|
||||||
|
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||||
|
|
||||||
|
export type ShareWdMode = 'create' | 'review';
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
user: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||||
|
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||||
|
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||||
|
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||||
|
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-withdrawal-request',
|
||||||
|
templateUrl: './share-withdrawal-request.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss',
|
||||||
|
'./share-withdrawal-request.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
SearchSelectComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private wdService = inject(ClerkShareWithdrawalService);
|
||||||
|
private cashierService = inject(CashierService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||||
|
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||||
|
* events instead of navigating away. */
|
||||||
|
@Input() embedded = false;
|
||||||
|
@Input() overrideMode: ShareWdMode | null = null;
|
||||||
|
@Input() overrideRequestId: number | null = null;
|
||||||
|
@Output() closeRequested = new EventEmitter<void>();
|
||||||
|
@Output() actionCompleted = new EventEmitter<void>();
|
||||||
|
|
||||||
|
mode: ShareWdMode = 'create';
|
||||||
|
requestId: number | null = null;
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
loadingAccount = false;
|
||||||
|
|
||||||
|
selectedAccountId = '';
|
||||||
|
accountOptions: SearchSelectOption[] = [];
|
||||||
|
|
||||||
|
account: any = null;
|
||||||
|
|
||||||
|
requestedShares: number | null = null;
|
||||||
|
requestedDate = '';
|
||||||
|
resolutionNumber = '';
|
||||||
|
meetingDate = '';
|
||||||
|
description = '';
|
||||||
|
|
||||||
|
paymentTypes: any[] = [];
|
||||||
|
settlementPaymentTypeId: number | null = null;
|
||||||
|
|
||||||
|
activityLog: ActivityEntry[] = [];
|
||||||
|
reviewRequest: any = null;
|
||||||
|
showRejectDialog = false;
|
||||||
|
rejectionReason = '';
|
||||||
|
showConfirmDialog = false;
|
||||||
|
|
||||||
|
showValidateModal = false;
|
||||||
|
validating = false;
|
||||||
|
validateAllPassed = false;
|
||||||
|
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||||
|
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
get remainingShares(): number {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
return held - (this.requestedShares || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get unitPrice(): number {
|
||||||
|
return Number(this.account?.currentMarketPrice ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get withdrawalValue(): number {
|
||||||
|
return (this.requestedShares || 0) * this.unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
get paymentTypeOptions(): any[] {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedSettlementPaymentType(): any {
|
||||||
|
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get settlementPaymentTypeName(): string {
|
||||||
|
if (this.isReview) {
|
||||||
|
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||||
|
}
|
||||||
|
return this.selectedSettlementPaymentType?.name || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFormValid(): boolean {
|
||||||
|
if (!this.account) return false;
|
||||||
|
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
if (this.requestedShares > held) return false;
|
||||||
|
if (!this.requestedDate) return false;
|
||||||
|
if (!this.resolutionNumber.trim()) return false;
|
||||||
|
if (!this.meetingDate) return false;
|
||||||
|
if (!this.settlementPaymentTypeId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||||
|
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
const shares = this.requestedShares || 0;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Board Resolution Number provided',
|
||||||
|
pass: !!this.resolutionNumber?.trim(),
|
||||||
|
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||||
|
},
|
||||||
|
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||||
|
{
|
||||||
|
label: 'Shares requested within shares held',
|
||||||
|
pass: shares > 0 && shares <= held,
|
||||||
|
detail: `${shares} of ${held} shares held`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Linked savings account confirmed',
|
||||||
|
pass: !!this.account?.savingsAccountNumber,
|
||||||
|
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settlement Payment Type selected (non-cash)',
|
||||||
|
pass: this.settlementPaymentTypeName !== '—',
|
||||||
|
detail: this.settlementPaymentTypeName
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReview(): boolean {
|
||||||
|
return this.mode === 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSubtitle(): string {
|
||||||
|
return this.isReview
|
||||||
|
? "Approve or reject the clerk's share withdrawal request."
|
||||||
|
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||||
|
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||||
|
this.requestId = idParam ? Number(idParam) : null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
this.meetingDate = this.requestedDate;
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getShareAccountSummaries()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((rows: any[]) => {
|
||||||
|
this.accountOptions = (rows || [])
|
||||||
|
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||||
|
.map((r) => ({
|
||||||
|
value: String(r.id),
|
||||||
|
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||||
|
searchText: `${r.accountNo} ${r.clientName}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cashierService
|
||||||
|
.getPaymentTypes()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||||
|
|
||||||
|
if (this.requestId && this.mode === 'review') {
|
||||||
|
this.loadReviewRequest(this.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccountSelected(accountId: string): void {
|
||||||
|
this.selectedAccountId = accountId;
|
||||||
|
if (!accountId) {
|
||||||
|
this.account = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingAccount = true;
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(accountId, false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => {
|
||||||
|
this.account = acc;
|
||||||
|
this.loadingAccount = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadReviewRequest(id: number): void {
|
||||||
|
this.wdService.getById(id).subscribe({
|
||||||
|
next: (req: any) => {
|
||||||
|
this.reviewRequest = req;
|
||||||
|
this.requestedShares = Number(req.requested_shares);
|
||||||
|
this.requestedDate = req.requested_date
|
||||||
|
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||||
|
: this.requestedDate;
|
||||||
|
this.resolutionNumber = req.resolution_number ?? '';
|
||||||
|
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||||
|
this.description = req.description ?? '';
|
||||||
|
|
||||||
|
if (req.activity_log) {
|
||||||
|
try {
|
||||||
|
this.activityLog = JSON.parse(req.activity_log);
|
||||||
|
} catch {
|
||||||
|
this.activityLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(String(req.share_account_id), false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => (this.account = acc));
|
||||||
|
},
|
||||||
|
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||||
|
openConfirmDialog(): void {
|
||||||
|
if (!this.isFormValid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showConfirmDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelConfirmDialog(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSubmit(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
this.submitting = true;
|
||||||
|
const payload = {
|
||||||
|
shareAccountId: Number(this.selectedAccountId),
|
||||||
|
accountNo: this.account?.accountNo,
|
||||||
|
clientId: this.account?.clientId,
|
||||||
|
clientName: this.account?.clientName,
|
||||||
|
requestedShares: this.requestedShares,
|
||||||
|
requestedDate: this.requestedDate,
|
||||||
|
resolutionNumber: this.resolutionNumber.trim(),
|
||||||
|
meetingDate: this.meetingDate,
|
||||||
|
description: this.description,
|
||||||
|
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||||
|
};
|
||||||
|
this.wdService.submit(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openValidateModal(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.showValidateModal = true;
|
||||||
|
this.runBackendValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeValidateModal(): void {
|
||||||
|
if (this.validating) return;
|
||||||
|
this.showValidateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||||
|
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||||
|
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||||
|
runBackendValidation(): void {
|
||||||
|
if (!this.reviewRequest || this.validating) return;
|
||||||
|
this.validating = true;
|
||||||
|
this.backendChecks = null;
|
||||||
|
this.validateSteps = [];
|
||||||
|
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||||
|
this.backendChecks = checks;
|
||||||
|
this.validateAllPassed = !!res?.allPassed;
|
||||||
|
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||||
|
this.revealValidateSteps(0);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.validating = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealValidateSteps(index: number): void {
|
||||||
|
if (index >= this.validateSteps.length) {
|
||||||
|
this.validating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.validateSteps[index].status = 'active';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||||
|
this.revealValidateSteps(index + 1);
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
approveReview(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
let message =
|
||||||
|
res?.settlementAmount != null
|
||||||
|
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||||
|
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||||
|
if (res?.accountClosed) {
|
||||||
|
message += ' All shares withdrawn — the share account has been closed.';
|
||||||
|
}
|
||||||
|
this.snackBar.open(message, 'Close', { duration: 8000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRejectDialog(): void {
|
||||||
|
this.showRejectDialog = true;
|
||||||
|
this.rejectionReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRejectReview(): void {
|
||||||
|
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||||
|
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
if (this.embedded) {
|
||||||
|
this.closeRequested.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="fm-dialog">
|
||||||
|
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||||
|
<span style="font-weight: 500; font-size: 18px">CHILDREN</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content>
|
||||||
|
<form [formGroup]="childrenForm">
|
||||||
|
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px">
|
||||||
|
@for (child of children.controls; track $index) {
|
||||||
|
<div
|
||||||
|
[formGroupName]="$index"
|
||||||
|
style="
|
||||||
|
position: relative;
|
||||||
|
padding: 24px 16px 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="fm-grid">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Child Full Name*</mat-label>
|
||||||
|
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Gender*</mat-label>
|
||||||
|
<mat-select formControlName="genderId" required>
|
||||||
|
@for (g of genderIdOptions; track g.id) {
|
||||||
|
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||||
|
<mat-label>Date of Birth</mat-label>
|
||||||
|
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Nationality</mat-label>
|
||||||
|
<mat-select formControlName="nationality">
|
||||||
|
<mat-option value="INDIAN">Indian</mat-option>
|
||||||
|
<mat-option value="NRI">NRI</mat-option>
|
||||||
|
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||||
|
<mat-option value="OTHER">Other</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="aadhaarNumber"
|
||||||
|
maxlength="12"
|
||||||
|
placeholder="12-digit Aadhaar"
|
||||||
|
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>PAN Number (Optional)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="panNumber"
|
||||||
|
maxlength="10"
|
||||||
|
placeholder="ABCDE1234F"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
(input)="
|
||||||
|
$any(children.at($index))
|
||||||
|
.get('panNumber')
|
||||||
|
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
@if (
|
||||||
|
$any(children.at($index)).get('panNumber')?.hasError('pattern') &&
|
||||||
|
$any(children.at($index)).get('panNumber')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Invalid PAN format</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($index < children.controls.length - 1) {
|
||||||
|
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||||
|
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||||
|
<mat-icon>add</mat-icon> Add Another Child
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||||
|
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||||
|
Add Member
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</div>
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
|
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-client-family-member-child-dialog',
|
||||||
|
templateUrl: './client-family-member-child-dialog.component.html',
|
||||||
|
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatIcon,
|
||||||
|
MatButtonModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatTooltipModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||||
|
dialogRef = inject(MatDialogRef<ClientFamilyMemberChildDialogComponent>);
|
||||||
|
data = inject(MAT_DIALOG_DATA);
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
|
||||||
|
childrenForm: UntypedFormGroup;
|
||||||
|
genderIdOptions: any[];
|
||||||
|
maxDate: Date = new Date();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.genderIdOptions = this.data.genderIdOptions;
|
||||||
|
|
||||||
|
this.childrenForm = this.formBuilder.group({
|
||||||
|
children: this.formBuilder.array([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.data.children && this.data.children.length > 0) {
|
||||||
|
this.data.children.forEach((child: any) => {
|
||||||
|
this.children.push(
|
||||||
|
this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [
|
||||||
|
child.fullName,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
genderId: [
|
||||||
|
child.genderId,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [
|
||||||
|
child.panNumber || '',
|
||||||
|
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.addChild();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): UntypedFormArray {
|
||||||
|
return this.childrenForm.get('children') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFormGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
id: [''],
|
||||||
|
fullName: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
genderId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
dateOfBirth: [''],
|
||||||
|
nationality: ['INDIAN'],
|
||||||
|
aadhaarNumber: [''],
|
||||||
|
panNumber: [
|
||||||
|
'',
|
||||||
|
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(): void {
|
||||||
|
this.children.push(this.createChildFormGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(index: number): void {
|
||||||
|
this.children.removeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (this.childrenForm.valid) {
|
||||||
|
this.dialogRef.close(this.children.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -139,6 +139,21 @@
|
|||||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||||
|
<div class="fm-readonly-remarks">
|
||||||
|
<span class="fm-readonly-label">Children</span>
|
||||||
|
<div class="fm-children-list">
|
||||||
|
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||||
|
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px">
|
||||||
|
<span>{{ child.fullName }}</span>
|
||||||
|
@if (child.dateOfBirth) {
|
||||||
|
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||||
@@ -447,6 +462,25 @@
|
|||||||
<mat-label>Remarks</mat-label>
|
<mat-label>Remarks</mat-label>
|
||||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Children Section -->
|
||||||
|
@if (isSpouse) {
|
||||||
|
<div class="fm-section-label" style="margin-top: 16px">Children</div>
|
||||||
|
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between">
|
||||||
|
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||||
|
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
(click)="openChildrenDialog()"
|
||||||
|
style="margin-left: 16px"
|
||||||
|
>
|
||||||
|
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|||||||
+143
-2
@@ -17,7 +17,7 @@ import {
|
|||||||
MatDialogClose,
|
MatDialogClose,
|
||||||
MatDialogContent
|
MatDialogContent
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
maxDate = new Date();
|
maxDate = new Date();
|
||||||
|
|
||||||
|
hasChildrenChecked = false;
|
||||||
familyMemberForm: UntypedFormGroup;
|
familyMemberForm: UntypedFormGroup;
|
||||||
|
|
||||||
/** Client search */
|
/** Client search */
|
||||||
@@ -197,6 +199,32 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
// Re-fetch linked client details (profile, signature, KYC status)
|
// Re-fetch linked client details (profile, signature, KYC status)
|
||||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||||
}
|
}
|
||||||
|
if (m.children && m.children.length > 0) {
|
||||||
|
this.hasChildrenChecked = true;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||||
|
m.children.forEach((child: any) => {
|
||||||
|
this.children.push(
|
||||||
|
this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [
|
||||||
|
child.fullName,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
genderId: [
|
||||||
|
child.genderId,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [
|
||||||
|
child.panNumber || '',
|
||||||
|
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (this.isViewMode) {
|
if (this.isViewMode) {
|
||||||
this.familyMemberForm.disable();
|
this.familyMemberForm.disable();
|
||||||
}
|
}
|
||||||
@@ -225,8 +253,102 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
isAlive: [true],
|
isAlive: [true],
|
||||||
marriageDate: [''],
|
marriageDate: [''],
|
||||||
marriageAuthority: [''],
|
marriageAuthority: [''],
|
||||||
linkedClientId: ['']
|
linkedClientId: [''],
|
||||||
|
hasChildren: [false],
|
||||||
|
children: this.formBuilder.array([])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => {
|
||||||
|
if (value && !this.hasChildrenChecked) {
|
||||||
|
this.openChildrenDialog();
|
||||||
|
} else if (!value) {
|
||||||
|
this.hasChildrenChecked = false;
|
||||||
|
this.children.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): UntypedFormArray {
|
||||||
|
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openChildrenDialog() {
|
||||||
|
// Dynamically import the dialog to avoid a circular dependency between the two dialogs.
|
||||||
|
const { ClientFamilyMemberChildDialogComponent } =
|
||||||
|
await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||||
|
data: {
|
||||||
|
children: this.children.value,
|
||||||
|
genderIdOptions: this.data.options.genderIdOptions
|
||||||
|
},
|
||||||
|
width: '500px'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||||
|
if (result) {
|
||||||
|
this.children.clear();
|
||||||
|
result.forEach((child) => {
|
||||||
|
this.children.push(
|
||||||
|
this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [
|
||||||
|
child.fullName,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
genderId: [
|
||||||
|
child.genderId,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
dateOfBirth: [child.dateOfBirth],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [
|
||||||
|
child.panNumber || '',
|
||||||
|
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.hasChildrenChecked = true;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||||
|
} else if (this.children.length === 0) {
|
||||||
|
this.hasChildrenChecked = false;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFormGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
id: [''],
|
||||||
|
fullName: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
genderId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
dateOfBirth: [''],
|
||||||
|
nationality: ['INDIAN'],
|
||||||
|
aadhaarNumber: [''],
|
||||||
|
panNumber: [
|
||||||
|
'',
|
||||||
|
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(): void {
|
||||||
|
this.children.push(this.createChildFormGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(index: number): void {
|
||||||
|
this.children.removeAt(index);
|
||||||
|
if (this.children.length === 0) {
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle marriage certificate file selection */
|
/** Handle marriage certificate file selection */
|
||||||
@@ -481,6 +603,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
member._linkedClient = this.selectedClient;
|
member._linkedClient = this.selectedClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format children dates
|
||||||
|
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||||
|
member.children = member.children.map((child: any) => {
|
||||||
|
const c = { ...child };
|
||||||
|
if (c.dateOfBirth instanceof Date) {
|
||||||
|
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||||
|
} else if (!c.dateOfBirth) {
|
||||||
|
delete c.dateOfBirth;
|
||||||
|
}
|
||||||
|
if (c.panNumber) {
|
||||||
|
c.panNumber = c.panNumber.toUpperCase();
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
delete member.children;
|
||||||
|
}
|
||||||
|
delete member.hasChildren;
|
||||||
|
|
||||||
// Attach marriage certificate file if present
|
// Attach marriage certificate file if present
|
||||||
if (this.marriageCertFile) {
|
if (this.marriageCertFile) {
|
||||||
member._marriageCertFile = this.marriageCertFile;
|
member._marriageCertFile = this.marriageCertFile;
|
||||||
|
|||||||
+127
-21
@@ -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,14 @@
|
|||||||
<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" />
|
<input
|
||||||
|
matInput
|
||||||
|
required
|
||||||
|
formControlName="lastname"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('lastname')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
@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 +290,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 +300,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
|
||||||
>
|
>
|
||||||
@@ -365,7 +394,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
<!-- Minor / Guardian Mapping (mandatory for a minor -- see MINOR_AGE_THRESHOLD) -->
|
||||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||||
<div class="guardian-section">
|
<div class="guardian-section">
|
||||||
<!-- Header bar -->
|
<!-- Header bar -->
|
||||||
@@ -694,6 +723,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 +854,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 +1181,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;
|
||||||
|
|||||||
+388
-37
@@ -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 */
|
||||||
@@ -55,6 +56,12 @@ import {
|
|||||||
ContactOtpBlockComponent,
|
ContactOtpBlockComponent,
|
||||||
ContactOtpState
|
ContactOtpState
|
||||||
} from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component';
|
} from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component';
|
||||||
|
import {
|
||||||
|
MINOR_AGE_THRESHOLD,
|
||||||
|
SENIOR_CITIZEN_AGE_THRESHOLD,
|
||||||
|
isMinorAge,
|
||||||
|
isSeniorCitizenAge
|
||||||
|
} from 'app/clients/shared/client-age.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Client Component
|
* Create Client Component
|
||||||
@@ -298,10 +305,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
];
|
];
|
||||||
/** Calculated age detail string (Y/M/D) */
|
/** Calculated age detail string (Y/M/D) */
|
||||||
calculatedAgeDetail: string | null = null;
|
calculatedAgeDetail: string | null = null;
|
||||||
/** Whether client is a minor (age < 18) */
|
/** Whether client is a minor — threshold from the shared age util, derived from Date of Birth */
|
||||||
isMinor = false;
|
isMinor = false;
|
||||||
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
|
/** Whether client is a senior citizen — threshold from the shared age util, derived from DOB */
|
||||||
isSeniorCitizen = false;
|
isSeniorCitizen = false;
|
||||||
|
/** Exposed for template hints so the displayed rule can never drift from the applied rule. */
|
||||||
|
readonly minorAgeThreshold = MINOR_AGE_THRESHOLD;
|
||||||
|
readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD;
|
||||||
/** Age breakdown for DB storage */
|
/** Age breakdown for DB storage */
|
||||||
ageBreakdown = { years: 0, months: 0, days: 0 };
|
ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
|
|
||||||
@@ -333,6 +343,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,10 +549,41 @@ 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(). */
|
||||||
|
private suppressMonthlyAnnualLink = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FormBuilder} formBuilder Form Builder
|
* @param {FormBuilder} formBuilder Form Builder
|
||||||
@@ -652,18 +703,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 = '';
|
||||||
}
|
}
|
||||||
@@ -709,6 +771,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const data = this.clientData;
|
const data = this.clientData;
|
||||||
this.loadExistingSeniorPwdDocuments();
|
this.loadExistingSeniorPwdDocuments();
|
||||||
|
|
||||||
|
// Loading an existing client's independently-saved monthly/annual values must not run
|
||||||
|
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
|
||||||
|
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
|
||||||
|
// instead of keeping the exact historical values as saved.
|
||||||
|
this.suppressMonthlyAnnualLink = true;
|
||||||
|
|
||||||
// Patch all base controls that exist on the form
|
// Patch all base controls that exist on the form
|
||||||
this.createClientForm.patchValue({
|
this.createClientForm.patchValue({
|
||||||
officeId: data.officeId,
|
officeId: data.officeId,
|
||||||
@@ -743,6 +811,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
? new Date(data.timeline.submittedOnDate)
|
? new Date(data.timeline.submittedOnDate)
|
||||||
: this.settingsService.businessDate
|
: this.settingsService.businessDate
|
||||||
});
|
});
|
||||||
|
this.suppressMonthlyAnnualLink = false;
|
||||||
|
|
||||||
// Patch name fields (already created by buildDependencies via legalFormId subscription)
|
// Patch name fields (already created by buildDependencies via legalFormId subscription)
|
||||||
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
|
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
|
||||||
@@ -768,8 +837,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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,6 +1028,86 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.required
|
Validators.required
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
||||||
|
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
||||||
|
this.linkIncomeToTurnover();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||||
|
* and annual income for annual/yearly turnover), but only if the turnover fields
|
||||||
|
* have not been manually modified or cleared.
|
||||||
|
*/
|
||||||
|
private linkIncomeToTurnover(): void {
|
||||||
|
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||||
|
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||||
|
const annualIncome = this.createClientForm.get('annualIncome');
|
||||||
|
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||||
|
|
||||||
|
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
|
||||||
|
|
||||||
|
if (monthlyIncome && monthlyTurnover && annualTurnover) {
|
||||||
|
monthlyIncome.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||||
|
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
||||||
|
if (isPristine || isTurnoverEmpty) {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annualIncome && annualTurnover && monthlyTurnover) {
|
||||||
|
annualIncome.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||||
|
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
||||||
|
if (isPristine || isTurnoverEmpty) {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||||
|
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
||||||
|
* subscriptions don't trigger each other back and forth.
|
||||||
|
*/
|
||||||
|
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
|
||||||
|
private round2(n: number): string {
|
||||||
|
return (Math.round(n * 100) / 100).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
|
||||||
|
const monthly = this.createClientForm.get(monthlyControlName);
|
||||||
|
const annual = this.createClientForm.get(annualControlName);
|
||||||
|
if (!monthly || !annual) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monthly.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
annual.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1044,6 +1198,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: ['']
|
||||||
})
|
})
|
||||||
@@ -1088,8 +1246,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
||||||
this.ageBreakdown = { years, months, days };
|
this.ageBreakdown = { years, months, days };
|
||||||
const wasMinor = this.isMinor;
|
const wasMinor = this.isMinor;
|
||||||
this.isMinor = years < 18;
|
this.isMinor = isMinorAge(years);
|
||||||
this.isSeniorCitizen = years >= 60;
|
this.isSeniorCitizen = isSeniorCitizenAge(years);
|
||||||
// Add/remove guardian form group based on minor status
|
// Add/remove guardian form group based on minor status
|
||||||
if (this.isMinor && !wasMinor) {
|
if (this.isMinor && !wasMinor) {
|
||||||
this.createClientForm.addControl(
|
this.createClientForm.addControl(
|
||||||
@@ -1164,6 +1322,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
|
||||||
@@ -1178,6 +1341,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
|
||||||
@@ -1218,9 +1384,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;
|
||||||
});
|
});
|
||||||
@@ -1238,9 +1406,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;
|
||||||
});
|
});
|
||||||
@@ -1265,6 +1433,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.
|
||||||
@@ -1272,10 +1451,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;
|
||||||
@@ -1284,16 +1470,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 || '';
|
||||||
@@ -1333,9 +1529,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1389,6 +1590,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: () => {
|
||||||
@@ -1402,6 +1608,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 = '-';
|
||||||
@@ -1600,9 +1810,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 */
|
||||||
@@ -1672,6 +1922,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
return salutation ? `${salutation} ${full}` : full;
|
return salutation ? `${salutation} ${full}` : full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name PAN and Aadhaar are actually matched against.
|
||||||
|
*
|
||||||
|
* Deliberately not {@link fullNamePreview}: that prefixes the salutation, while
|
||||||
|
* ClientKycWritePlatformServiceJpaRepositoryImpl builds the verification name from firstname +
|
||||||
|
* middlename + lastname only. Values are passed through untouched rather than capitalised, so what
|
||||||
|
* the KYC step displays is exactly what goes to the provider.
|
||||||
|
*/
|
||||||
|
get verificationName(): string {
|
||||||
|
return [
|
||||||
|
this.createClientForm?.get('firstname')?.value,
|
||||||
|
this.createClientForm?.get('middlename')?.value,
|
||||||
|
this.createClientForm?.get('lastname')?.value
|
||||||
|
]
|
||||||
|
.map((part: any) => (part || '').toString().trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date of birth as captured, sent to the provider formatted dd/MM/yyyy. */
|
||||||
|
get verificationDateOfBirth(): Date | null {
|
||||||
|
const value = this.createClientForm?.get('dateOfBirth')?.value;
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
getSelectedGender(): any {
|
getSelectedGender(): any {
|
||||||
const val = this.createClientForm.get('genderId')?.value;
|
const val = this.createClientForm.get('genderId')?.value;
|
||||||
return this.genderOptions?.find((g: any) => g.id === val) || null;
|
return this.genderOptions?.find((g: any) => g.id === val) || null;
|
||||||
@@ -2054,6 +2336,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);
|
||||||
}
|
}
|
||||||
@@ -2123,15 +2411,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(): {
|
||||||
@@ -2190,26 +2549,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Match monthly income ranges
|
// Match monthly income ranges
|
||||||
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match annual income ranges (evaluated against totalAnnualIncome)
|
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||||
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match turnover ranges
|
// Match turnover ranges
|
||||||
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2228,15 +2587,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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+300
-22
@@ -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';
|
||||||
|
|
||||||
@@ -38,10 +39,17 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="sanctions-msg">
|
<p class="sanctions-msg">
|
||||||
Screening <strong>{{ clientName }}</strong> against<br />
|
Screening <strong>{{ clientName }}</strong> against the<br />
|
||||||
<strong>UN Security Council</strong> &
|
<strong>UN Security Council Consolidated List</strong><br />
|
||||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
<span class="sanctions-lists"
|
||||||
|
>ISIL (Da’esh) & Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
@if (listDataAsOf) {
|
||||||
|
<p class="list-as-of">
|
||||||
|
Sanctions list data as of <strong>{{ listDataAsOf }}</strong>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -54,8 +62,8 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<h3 class="approve-title">No Sanctions Match Found</h3>
|
<h3 class="approve-title">No Sanctions Match Found</h3>
|
||||||
<p class="approve-detail">
|
<p class="approve-detail">
|
||||||
<strong>{{ clientName }}</strong> has been screened against the
|
<strong>{{ clientName }}</strong> has been screened against the
|
||||||
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
|
<strong>UN Security Council Consolidated List</strong> (ISIL & Al-Qaida and Taliban sanctions lists). No
|
||||||
match was found.
|
confirmed match was found.
|
||||||
</p>
|
</p>
|
||||||
<div class="approve-meta">
|
<div class="approve-meta">
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
@@ -111,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>
|
||||||
}
|
}
|
||||||
@@ -134,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>
|
||||||
@@ -167,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 {
|
||||||
@@ -222,6 +320,20 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-as-of {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
text-align: center;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sanctions-lists {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
.regulatory-note {
|
.regulatory-note {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
@@ -468,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,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;
|
||||||
@@ -524,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,
|
||||||
@@ -537,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;
|
||||||
@@ -550,16 +745,37 @@ 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
|
||||||
|
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
|
||||||
|
* the older Master Direction this screen used to name.
|
||||||
|
*/
|
||||||
regulatoryBasis =
|
regulatoryBasis =
|
||||||
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
|
'Screened under Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and Chapter IX of the ' +
|
||||||
|
'RBI Know Your Customer Directions, 2025 (amended as on December 29, 2025).';
|
||||||
|
|
||||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
progressValue = 0;
|
progressValue = 0;
|
||||||
currentStep = 0;
|
currentStep = 0;
|
||||||
|
// Steps describe the screening that actually runs against the UNSC consolidated list; there is no
|
||||||
|
// FIU-IND lookup behind this call, so it is no longer claimed here.
|
||||||
progressSteps = [
|
progressSteps = [
|
||||||
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
|
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
|
||||||
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
|
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
|
||||||
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
|
{ label: 'Cross-checking listed aliases and entities...', icon: 'database' as any },
|
||||||
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
|
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -570,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
|
||||||
@@ -577,6 +795,9 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
next: (result: any) => {
|
next: (result: any) => {
|
||||||
this.apiResult = result;
|
this.apiResult = result;
|
||||||
this.apiDone = true;
|
this.apiDone = true;
|
||||||
|
// Surfaced while the screening is still running, so the operator can see which snapshot of
|
||||||
|
// the UNSC list the check is being made against rather than only learning it afterwards.
|
||||||
|
this.listDataAsOf = this.formatListDate(result?.listDataAsOf);
|
||||||
},
|
},
|
||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
this.apiError = err;
|
this.apiError = err;
|
||||||
@@ -652,13 +873,7 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
'Unable to connect to sanctions screening service.';
|
'Unable to connect to sanctions screening service.';
|
||||||
} else {
|
} else {
|
||||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||||
this.listDataAsOf = this.apiResult?.listDataAsOf
|
this.listDataAsOf = this.formatListDate(this.apiResult?.listDataAsOf);
|
||||||
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric'
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
if (this.apiResult?.matchFound) {
|
if (this.apiResult?.matchFound) {
|
||||||
this.matchFound = true;
|
this.matchFound = true;
|
||||||
this.matchCount = this.apiResult.matchCount || 0;
|
this.matchCount = this.apiResult.matchCount || 0;
|
||||||
@@ -668,10 +883,73 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Formats the date the UNSC snapshot was loaded; null when the backend did not report one. */
|
||||||
|
private formatListDate(value: string | null | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
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({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||||
@@ -304,6 +318,234 @@
|
|||||||
color: #059669;
|
color: #059669;
|
||||||
border: 1px solid #a7f3d0;
|
border: 1px solid #a7f3d0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not verified, but covered by a declaration — deliberately amber rather than green so it never
|
||||||
|
// reads as a passing check.
|
||||||
|
&.declared {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #b45309;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorded but unconfirmed by the registry. Neutral blue rather than green or red: nothing failed
|
||||||
|
// and nothing passed, so it must not read as either.
|
||||||
|
&.unconfirmed {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CIN saved but not confirmed by MCA ─────────────────────────────────────
|
||||||
|
.cin-unconfirmed-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: -4px 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cin-unconfirmed-note__icon {
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PAN declaration summary ────────────────────────────────────────────────
|
||||||
|
.pan-decl-card {
|
||||||
|
margin: -4px 0 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-decl-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-decl-card__icon {
|
||||||
|
color: #b45309;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-decl-card__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-decl-card__date {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-decl-card__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-decl-card__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
.k {
|
||||||
|
color: #92400e;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v {
|
||||||
|
color: #451a03;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
&.strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-decl-card__note {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #fde68a;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pan-decl-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aadhaar enrolment receipt ──────────────────────────────────────────────
|
||||||
|
// Blue rather than the declaration's amber: nothing has failed here, the customer simply does not
|
||||||
|
// hold the document yet.
|
||||||
|
.aad-rcpt-offer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: -4px 0 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #1e40af;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #1d4ed8 !important;
|
||||||
|
border-color: #bfdbfe !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card {
|
||||||
|
margin: -4px 0 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card__icon {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card__date {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
.k {
|
||||||
|
color: #1e40af;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v {
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
&.mono {
|
||||||
|
font-family: monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card__note {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #bfdbfe;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aad-rcpt-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.verify-icon-ok {
|
.verify-icon-ok {
|
||||||
@@ -1025,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 {
|
||||||
@@ -2431,3 +2679,358 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The name and DOB that PAN / Aadhaar are matched against, surfaced from the General tab so a
|
||||||
|
mismatch is visible before the check runs rather than only after it fails. */
|
||||||
|
.identity-basis {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis--incomplete {
|
||||||
|
border-color: #fdba74;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #1d4ed8;
|
||||||
|
|
||||||
|
.identity-basis--incomplete & {
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__legend {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__pairs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__pair {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__k {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__v {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__hint {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-basis__warn {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c2410c;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PAN ↔ Aadhaar premium status card
|
||||||
|
─── Replaces the legacy table-row treatment. Dark gradient with
|
||||||
|
offset glow circles, large gradient icon, and a state-driven
|
||||||
|
accent (green / red / blue / amber).
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
.pal-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 14px 18px 30px;
|
||||||
|
border-radius: 18px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: linear-gradient(135deg, #111827, #0f172a);
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 32px rgb(0 0 0 / 35%),
|
||||||
|
inset 0 1px 1px rgb(255 255 255 / 5%);
|
||||||
|
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
||||||
|
/* Decorative glows — colors swap with the state variant */
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
background: #2563eb;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: -110px;
|
||||||
|
right: -70px;
|
||||||
|
opacity: 0.18;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
background: #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
bottom: -70px;
|
||||||
|
left: -50px;
|
||||||
|
opacity: 0.12;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(145deg, #2563eb, #06b6d4);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__txt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__small {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 11px;
|
||||||
|
border-radius: 50px;
|
||||||
|
background: rgb(34 197 94 / 12%);
|
||||||
|
border: 1px solid rgb(74 222 128 / 25%);
|
||||||
|
color: #4ade80;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
z-index: 2;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgb(255 255 255 / 35%);
|
||||||
|
font-style: italic;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── State variants ─── */
|
||||||
|
&--ok {
|
||||||
|
&::before {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--fail {
|
||||||
|
border-color: rgb(239 68 68 / 22%);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: #ef4444;
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #f87171;
|
||||||
|
opacity: 0.16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pal-card__status {
|
||||||
|
background: rgb(239 68 68 / 14%);
|
||||||
|
border-color: rgb(248 113 113 / 32%);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pal-card__badge {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||||
|
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pal-card__icon {
|
||||||
|
background: linear-gradient(145deg, #ef4444, #f97316);
|
||||||
|
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
border-color: rgb(59 130 246 / 22%);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: #3b82f6;
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #60a5fa;
|
||||||
|
opacity: 0.14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pal-card__status {
|
||||||
|
background: rgb(59 130 246 / 14%);
|
||||||
|
border-color: rgb(96 165 250 / 32%);
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pal-card__badge {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||||
|
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
border-color: rgb(245 158 11 / 22%);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: #f59e0b;
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #fbbf24;
|
||||||
|
opacity: 0.14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pal-card__status {
|
||||||
|
background: rgb(245 158 11 / 14%);
|
||||||
|
border-color: rgb(251 191 36 / 32%);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pal-card__badge {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||||
|
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pal-card__icon {
|
||||||
|
background: linear-gradient(145deg, #f59e0b, #d97706);
|
||||||
|
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 540px) {
|
||||||
|
.pal-card {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 36px;
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -195,7 +195,7 @@
|
|||||||
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
{{ client.isMinor ? 'Yes' : 'No' }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
|
|||||||
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
||||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
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 { 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 = [
|
||||||
@@ -193,6 +195,20 @@ const routes: Routes = [
|
|||||||
component: Form60TabComponent,
|
component: Form60TabComponent,
|
||||||
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'form121',
|
||||||
|
component: Form121TabComponent,
|
||||||
|
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'authorized-signatories',
|
||||||
|
component: AuthorizedSignatoriesTabComponent,
|
||||||
|
data: {
|
||||||
|
title: 'Authorized Signatories',
|
||||||
|
breadcrumb: 'Authorized Signatories',
|
||||||
|
routeParamBreadcrumb: false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'documents',
|
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>
|
||||||
@@ -545,6 +567,31 @@
|
|||||||
>
|
>
|
||||||
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./form121']"
|
||||||
|
routerLinkActive
|
||||||
|
#form121="routerLinkActive"
|
||||||
|
[active]="form121.isActive"
|
||||||
|
>
|
||||||
|
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
||||||
|
</a>
|
||||||
|
<!--
|
||||||
|
Authorized signatories are an entity concept — the persons named in a Board Resolution as
|
||||||
|
authorised to transact for the company. They are captured in the General step at onboarding
|
||||||
|
but have never been readable anywhere in the client view until now.
|
||||||
|
-->
|
||||||
|
@if (clientViewData.legalForm?.id === 2) {
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./authorized-signatories']"
|
||||||
|
routerLinkActive
|
||||||
|
#signatoriesTab="routerLinkActive"
|
||||||
|
[active]="signatoriesTab.isActive"
|
||||||
|
>
|
||||||
|
<i class="fa fa-user-shield cv-tab-icon"></i> Authorized Signatories
|
||||||
|
</a>
|
||||||
|
}
|
||||||
<a
|
<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) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user