Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa6c2d814 | ||
|
|
227949f8b2 | ||
|
|
abc966d507 | ||
|
|
fa2522e678 | ||
|
|
1b2ef5126e | ||
|
|
b953afe2da | ||
|
|
ff781e50e3 | ||
|
|
7ef75c902d | ||
|
|
c18219fce5 | ||
|
|
2d93f80d50 | ||
|
|
1d1e76497c | ||
|
|
352a65cb5d | ||
|
|
8d67a22f18 | ||
|
|
997e8920e4 | ||
|
|
5581d4180d | ||
|
|
75df8865dd | ||
|
|
5a6fb1f9b5 | ||
|
|
f379b2415c | ||
|
|
c34f6a0131 | ||
|
|
7b895a0e2b | ||
|
|
01e86173c1 | ||
|
|
4281a596af | ||
|
|
9f9c9bfab6 | ||
|
|
513e863bba | ||
|
|
13333088d7 | ||
|
|
89bb76455e | ||
|
|
7e69468995 | ||
|
|
63ab9643ce | ||
|
|
8255c8398d | ||
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
b609f19e9f | ||
|
|
7f836f6f71 | ||
|
|
a5124ca3e8 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
8c7d8287f9 | ||
|
|
504d7ee7f6 | ||
|
|
26091c68b0 | ||
|
|
f8c532f74a | ||
|
|
44b41e504a | ||
|
|
e8d91c8f60 | ||
|
|
8dd9767bdf | ||
|
|
c868beb344 | ||
|
|
b43ed87771 | ||
|
|
ebe67cc410 | ||
|
|
d7f5bfe5f5 | ||
|
|
be6103e0b3 | ||
|
|
5cd9998cfc | ||
|
|
0ca6842bde | ||
|
|
3030e2f34e | ||
|
|
b4608cbbe4 | ||
|
|
27cdaf7fc2 | ||
|
|
90beea64cb | ||
|
|
1317a93838 | ||
|
|
0832017508 | ||
|
|
8ac4010967 | ||
|
|
1c125d97c5 | ||
|
|
c935e2a62a | ||
|
|
2a9df828ca | ||
|
|
bd753f4983 | ||
|
|
4261f9659e | ||
|
|
63ac900e42 | ||
|
|
fd28788d23 | ||
|
|
b9b37397fe | ||
|
|
f2525080c8 | ||
|
|
a982083b14 | ||
|
|
43e3c546a7 | ||
|
|
b68b25f789 | ||
|
|
a560cd6533 | ||
|
|
1dc378c2dd | ||
|
|
0bf337b962 | ||
|
|
1daa12a117 | ||
|
|
f687e1808d | ||
|
|
186a9057a1 | ||
|
|
387bd060e1 | ||
|
|
29f1cdc646 | ||
|
|
1efb520009 | ||
|
|
4ea833ff49 | ||
|
|
5dc284bd1c | ||
|
|
56f13ade67 | ||
|
|
dbd160e7e7 | ||
|
|
468eaa96ab | ||
|
|
f682cb17f6 | ||
|
|
79fedb66e5 | ||
|
|
c1fc95d9b7 | ||
|
|
b422ae6600 | ||
|
|
5fd7e1890e | ||
|
|
0389b1f7c3 | ||
|
|
2e4187c3d2 | ||
|
|
cb4260b7a3 | ||
|
|
349dc91951 | ||
|
|
a9c60083bf | ||
|
|
0c99dc3686 | ||
|
|
733d9d22b2 | ||
|
|
d334dd1669 | ||
|
|
d0536dbb11 | ||
|
|
97d0545f07 | ||
|
|
ca85bae4fb | ||
|
|
a22f8e7e45 | ||
|
|
0207cf85a3 | ||
|
|
3cf01e2733 | ||
|
|
6d205c0ed2 | ||
|
|
ed2a40a8bf | ||
|
|
24b1d7c087 | ||
|
|
0cc79ccd57 | ||
|
|
7abfa394df | ||
|
|
1692925d0d | ||
|
|
b9856a1c93 | ||
|
|
d72155a74a | ||
|
|
3d65900fc4 | ||
|
|
93f4b9b03f | ||
|
|
96c4a28c0f | ||
|
|
8a1b7d5d6c | ||
|
|
1ea4cfe8d8 | ||
|
|
d00eac0aa1 | ||
|
|
9afb177c69 | ||
|
|
bc980d2059 | ||
|
|
f3df626483 | ||
|
|
4660a3eb15 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"67d120b4-ff78-435d-be80-f79482a386b4","pid":19832,"acquiredAt":1778067581856}
|
||||
+2
-2
@@ -69,8 +69,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "13mb",
|
||||
"maximumError": "14mb"
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "17mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
Generated
+289
@@ -44,6 +44,7 @@
|
||||
"moment": "^2.29.4",
|
||||
"ngx-image-cropper": "^9.1.6",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"rxjs": "^7.8.2",
|
||||
"signature_pad": "^5.1.3",
|
||||
"tinymce": "^8.2.2",
|
||||
@@ -77,6 +78,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@vendure/ngx-translate-extract": "^9.4.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"cypress": "^13.17.0",
|
||||
"eslint": "^9.39.1",
|
||||
"git-describe": "^4.1.1",
|
||||
@@ -3559,6 +3561,13 @@
|
||||
"url": "https://github.com/sponsors/JounQin"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||
@@ -6126,6 +6135,256 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/nice": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz",
|
||||
@@ -11451,6 +11710,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -19922,6 +20199,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.7.284",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
|
||||
"integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
|
||||
+6
-3
@@ -15,9 +15,10 @@
|
||||
"ng": "ng",
|
||||
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
|
||||
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
|
||||
"start": "npm run env -s && ng serve --proxy-config proxy.localhost.conf.js",
|
||||
"start:demo": "npm run env -s && ng serve --proxy-config proxy.conf.js",
|
||||
"serve:dev": "npm run env -s && ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
||||
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
|
||||
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
|
||||
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
|
||||
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
||||
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
|
||||
"lint": "eslint . && stylelint \"src/**/*.scss\" && prettier . --check && htmlhint \"src\" --config .htmlhintrc",
|
||||
"test": "jest --config jest.config.ts",
|
||||
@@ -72,6 +73,7 @@
|
||||
"moment": "^2.29.4",
|
||||
"ngx-image-cropper": "^9.1.6",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"rxjs": "^7.8.2",
|
||||
"signature_pad": "^5.1.3",
|
||||
"tinymce": "^8.2.2",
|
||||
@@ -105,6 +107,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@vendure/ngx-translate-extract": "^9.4.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"cypress": "^13.17.0",
|
||||
"eslint": "^9.39.1",
|
||||
"git-describe": "^4.1.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 688 KiB |
@@ -32,6 +32,28 @@ export class AccountingService {
|
||||
return this.http.get('/offices', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* Real net profit for a date range, computed live from posted GL journal entries (not the
|
||||
* demo data shown by the GL Reports module).
|
||||
* @param {string} fromDate Period start, yyyy-MM-dd.
|
||||
* @param {string} toDate Period end, yyyy-MM-dd.
|
||||
* @returns {Observable<any>} `{ totalIncome, totalExpense, netProfit }`.
|
||||
*/
|
||||
getProfitAndLoss(fromDate: string, toDate: string): Observable<any> {
|
||||
const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
|
||||
return this.http.get('/accounting/profitandloss', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* Total share dividend actually paid to members in a date range — derived from the Dividend
|
||||
* Payable GL account's own ledger (no separate "Dividend Paid" account needed).
|
||||
* @returns {Observable<any>} `{ totalPaid, payoutCount, entries[], glAccountConfigured }`.
|
||||
*/
|
||||
getDividendPaidReport(fromDate: string, toDate: string): Observable<any> {
|
||||
const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
|
||||
return this.http.get('/accounting/dividend-paid-report', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} useHttpParams True if http params should be used.
|
||||
* @returns {Observable<any>} Accounting rules data.
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="!glAccountForm.valid"
|
||||
[disabled]="!glAccountForm.valid || submitting"
|
||||
*mifosxHasPermission="'CREATE_GLACCOUNT'"
|
||||
>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
|
||||
+34
-14
@@ -14,6 +14,7 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Custom Services */
|
||||
import { AccountingService } from '../../accounting.service';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { PopoverService } from '../../../configuration-wizard/popover/popover.service';
|
||||
import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service';
|
||||
|
||||
@@ -42,6 +43,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class CreateGlAccountComponent implements OnInit, AfterViewInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private accountingService = inject(AccountingService);
|
||||
private alertService = inject(AlertService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
@@ -50,6 +52,8 @@ export class CreateGlAccountComponent implements OnInit, AfterViewInit {
|
||||
|
||||
/** GL account form. */
|
||||
glAccountForm: UntypedFormGroup;
|
||||
/** Submitting state to prevent duplicate submissions. */
|
||||
submitting = false;
|
||||
/** Chart of accounts data. */
|
||||
chartOfAccountsData: any;
|
||||
/** Account type data. */
|
||||
@@ -166,24 +170,40 @@ export class CreateGlAccountComponent implements OnInit, AfterViewInit {
|
||||
|
||||
/**
|
||||
* Submits the gl account form and creates gl account,
|
||||
* if successful redirects to view created account.
|
||||
* if successful redirects to view created account or back to list if awaiting approval.
|
||||
*/
|
||||
submit() {
|
||||
if (this.glAccountForm.invalid) {
|
||||
if (this.glAccountForm.invalid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.accountingService.createGlAccount(this.glAccountForm.value).subscribe((response: any) => {
|
||||
if (this.configurationWizardService.showChartofAccounts) {
|
||||
this.configurationWizardService.showChartofAccounts = false;
|
||||
this.openDialog();
|
||||
} else {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../view',
|
||||
response.resourceId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
this.submitting = true;
|
||||
this.accountingService.createGlAccount(this.glAccountForm.value).subscribe({
|
||||
next: (response: any) => {
|
||||
if (response?.resourceId) {
|
||||
if (this.configurationWizardService.showChartofAccounts) {
|
||||
this.configurationWizardService.showChartofAccounts = false;
|
||||
this.openDialog();
|
||||
} else {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../view',
|
||||
response.resourceId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
}
|
||||
} else if (response?.rollbackTransaction) {
|
||||
this.alertService.alert({
|
||||
type: 'Success',
|
||||
message: 'GL Account submitted successfully for approval.'
|
||||
});
|
||||
this.router.navigate([this.cancelRoute], { relativeTo: this.route });
|
||||
} else {
|
||||
this.router.navigate([this.cancelRoute], { relativeTo: this.route });
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
const STATE_KEY = 'gl_ledger_filter_state';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -89,4 +89,8 @@ export class AccountsService {
|
||||
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
||||
return this.http.get('/recurringdepositaccounts', { params: httpParams });
|
||||
}
|
||||
|
||||
terminateAccount(accountId: string, data: any): Observable<any> {
|
||||
return this.http.post(`/v1/accounts/${accountId}/terminate`, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* AML transaction-monitoring API: live alerts raised by the rule engine, the
|
||||
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
|
||||
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
|
||||
* client's risk-category change history.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AmlService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ═══ Alerts (AML Officer) ═══
|
||||
|
||||
getAlerts(status?: string): Observable<any[]> {
|
||||
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||
return this.http.get<any[]>('/aml/alerts', { params });
|
||||
}
|
||||
|
||||
/** disposition: 'WHITELIST' | 'ESCALATE' */
|
||||
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ Recurring-alert review queue ═══
|
||||
|
||||
/**
|
||||
* Customers whose transactions repeatedly trip the engine. Each row carries the triggering
|
||||
* alerts, so the reviewer can see why the customer keeps surfacing.
|
||||
*/
|
||||
getRecurringAlerts(minAlerts: number, windowDays: number, onlyNeedingReview: boolean): Observable<any[]> {
|
||||
const params = new HttpParams()
|
||||
.set('minAlerts', String(minAlerts))
|
||||
.set('windowDays', String(windowDays))
|
||||
.set('onlyNeedingReview', String(onlyNeedingReview));
|
||||
return this.http.get<any[]>('/aml/recurring-alerts', { params });
|
||||
}
|
||||
|
||||
/** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */
|
||||
reviewRecurringAlert(
|
||||
clientId: number,
|
||||
outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED',
|
||||
notes: string,
|
||||
windowDays: number
|
||||
): Observable<any> {
|
||||
return this.http.post(`/aml/recurring-alerts/${clientId}/review`, { outcome, notes, windowDays });
|
||||
}
|
||||
|
||||
// ═══ Manual referrals from the general ledger ═══
|
||||
|
||||
/**
|
||||
* Ledger entries a user has already marked, keyed on `journalEntryId`.
|
||||
*
|
||||
* Separate from `getAlerts()` because these are not engine alerts — they carry the
|
||||
* journal entry id rather than a savings or loan transaction id, so the ledger
|
||||
* screen can match them to the rows it is showing.
|
||||
*/
|
||||
getManualReferrals(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/alerts/manual-referrals');
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
|
||||
* case for the Principal Officer, the same road an escalated engine alert takes.
|
||||
* The entry must have a customer behind it or the server rejects it.
|
||||
*/
|
||||
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ STR cases (Principal Officer) ═══
|
||||
|
||||
getStrCases(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/str-cases');
|
||||
}
|
||||
|
||||
getStrCase(strCaseId: number): Observable<any> {
|
||||
return this.http.get(`/aml/str-cases/${strCaseId}`);
|
||||
}
|
||||
|
||||
getWeeklyDigest(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
|
||||
}
|
||||
|
||||
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
|
||||
}
|
||||
|
||||
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
|
||||
concludeStrCase(
|
||||
strCaseId: number,
|
||||
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
|
||||
investigationNotes: string,
|
||||
sourceOfFundsNotes: string
|
||||
): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
|
||||
conclusion,
|
||||
investigationNotes,
|
||||
sourceOfFundsNotes
|
||||
});
|
||||
}
|
||||
|
||||
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
|
||||
}
|
||||
|
||||
// ═══ CTR reports (mechanical, threshold-based) ═══
|
||||
|
||||
getCtrReports(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/ctr-reports');
|
||||
}
|
||||
|
||||
/** month format: yyyy-MM */
|
||||
generateCtrReport(month: string): Observable<any> {
|
||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||
}
|
||||
|
||||
// ═══ Client risk-category history ═══
|
||||
|
||||
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ import { ClientsModule } from './clients/clients.module';
|
||||
import { GroupsModule } from './groups/groups.module';
|
||||
import { CentersModule } from './centers/centers.module';
|
||||
import { AccountingModule } from './accounting/accounting.module';
|
||||
import { ChequeBookModule } from './chequebook/chequebook.module';
|
||||
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
|
||||
import { NachClearingModule } from './nachclearing/nachclearing.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { OrganizationModule } from './organization/organization.module';
|
||||
@@ -61,11 +64,18 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||
import { DeafModule } from './deaf/deaf.module';
|
||||
import { DicgcModule } from './dicgc/dicgc.module';
|
||||
import { FiuModule } from './fiu/fiu.module';
|
||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||
import { SftModule } from './sft/sft.module';
|
||||
import { InvestmentModule } from './investment/investment.module';
|
||||
import { NpaModule } from './npa/npa.module';
|
||||
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||
import { DigitalModule } from './digital/digital.module';
|
||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
import { ElectionModule } from './election/election.module';
|
||||
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
||||
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
||||
import { PortalModule } from '@angular/cdk/portal';
|
||||
|
||||
/** Main Routing Module */
|
||||
@@ -142,6 +152,9 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
GroupsModule,
|
||||
CentersModule,
|
||||
AccountingModule,
|
||||
ChequeBookModule,
|
||||
CtsClearingModule,
|
||||
NachClearingModule,
|
||||
SystemModule,
|
||||
ProductsModule,
|
||||
OrganizationModule,
|
||||
@@ -163,11 +176,18 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
DeafModule,
|
||||
DicgcModule,
|
||||
FiuModule,
|
||||
GlReportsModule,
|
||||
SftModule,
|
||||
InvestmentModule,
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
ElectionModule,
|
||||
SponsorDDModule,
|
||||
SponsorDDReconciliationModule,
|
||||
AppRoutingModule,
|
||||
NotFoundComponent,
|
||||
CallbackComponent,
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">SUB LEDGER · TRANSACTIONS · {{ scopeName | uppercase }}</div>
|
||||
<h1 class="pg-title">Cash / Clearing / Transfer - Summary Report</h1>
|
||||
<p class="pg-sub">
|
||||
Opening, credit, debit and closing position across Cash, Transfer, Clearing and Digital channels as on
|
||||
{{ asOnDate }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
@if (isHeadOffice) {
|
||||
<label class="pg-field">
|
||||
<span>Branch</span>
|
||||
<select class="pg-select" [ngModel]="selectedBranchCode" (ngModelChange)="setBranch($event)">
|
||||
<option value="ALL">All Branches (Consolidated)</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
|
||||
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cctr-grand-summary">
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Opening</span>
|
||||
<span class="cctr-grand-summary__value">₹{{ grandOpening | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Credit</span>
|
||||
<span class="cctr-grand-summary__value col-in">₹{{ grandCredit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Debit</span>
|
||||
<span class="cctr-grand-summary__value col-out">₹{{ grandDebit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Closing</span>
|
||||
<span class="cctr-grand-summary__value">₹{{ grandClosing | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isHeadOffice && selectedBranchCode === 'ALL') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Branch-wise Break-up — click a branch to drill in</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Branch</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-net">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of branchBreakup; track b.code) {
|
||||
<tr class="cctr-branch-row" (click)="setBranch(b.code)">
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="num">₹{{ b.opening | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ b.credit | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">₹{{ b.debit | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ b.closing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>All Branches</td>
|
||||
<td class="num">₹{{ grandOpening | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandCredit | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandDebit | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandClosing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'cash'"
|
||||
(click)="setTab('cash')"
|
||||
>
|
||||
Cash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'transfer'"
|
||||
(click)="setTab('transfer')"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'clearing'"
|
||||
(click)="setTab('clearing')"
|
||||
>
|
||||
Clearing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'digital'"
|
||||
(click)="setTab('digital')"
|
||||
>
|
||||
Digital
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary (Opening | Credit | Debit | Closing) -->
|
||||
@if (!showDetails) {
|
||||
<div class="pg-card">
|
||||
@if (activeTab === 'digital') {
|
||||
<div class="pos-section-label">RTGS · NEFT · UPI · ATM · NACH — Overall Position</div>
|
||||
}
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@if (activeTab !== 'digital') {
|
||||
<th>GL Code</th>
|
||||
}
|
||||
<th>Particulars</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-net">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rowsForActiveTab; track r.code) {
|
||||
<tr>
|
||||
@if (activeTab !== 'digital') {
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.code }}</span>
|
||||
</td>
|
||||
}
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="num">₹{{ r.opening | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ rowCredit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">₹{{ rowDebit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ closing(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td [attr.colspan]="activeTab !== 'digital' ? 2 : 1">Total</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'opening') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'credit') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'debit') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ totalClosing(rowsForActiveTab, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Transaction Details (all txns for the selected category, optionally filtered by GL/channel) -->
|
||||
@if (showDetails) {
|
||||
@if (rowsForActiveTab.length > 1) {
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="glFilter === null"
|
||||
(click)="setGlFilter(null)"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@for (r of rowsForActiveTab; track r.code) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="glFilter === r.code"
|
||||
(click)="setGlFilter(r.code)"
|
||||
>
|
||||
{{ filterLabel(r) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
{{
|
||||
activeTab === 'cash'
|
||||
? 'Cash'
|
||||
: activeTab === 'transfer'
|
||||
? 'Transfer'
|
||||
: activeTab === 'clearing'
|
||||
? 'Clearing'
|
||||
: 'Digital'
|
||||
}}
|
||||
— Transaction Details (Full Double Entry)
|
||||
@if (glFilter !== null) {
|
||||
· {{ glFilter }}
|
||||
}
|
||||
</div>
|
||||
<table class="pg-table cctr-journal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher / Ref No</th>
|
||||
<th>Dr / Cr</th>
|
||||
<th>GL / Account</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredTxns; track t.voucherNo) {
|
||||
<tr class="cctr-journal-row cctr-journal-row--first">
|
||||
<td>{{ asOnDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
||||
</td>
|
||||
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
|
||||
<td>
|
||||
@if (t.debit > 0) {
|
||||
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||
t.accountName
|
||||
}})
|
||||
} @else {
|
||||
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num col-out">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||
<td class="num"></td>
|
||||
<td rowspan="2">
|
||||
<span class="cctr-balanced-chip">✓ Balanced</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="cctr-journal-row cctr-journal-row--second">
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td><span class="cctr-drcr cctr-drcr--cr">Cr</span></td>
|
||||
<td>
|
||||
@if (t.credit > 0) {
|
||||
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||
t.accountName
|
||||
}})
|
||||
} @else {
|
||||
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num"></td>
|
||||
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No transactions for this filter.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
|
||||
<td class="num">
|
||||
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="num">
|
||||
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.cctr-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cctr-total-row {
|
||||
td {
|
||||
background: #f1f5f9;
|
||||
font-weight: 800;
|
||||
border-top: 2px solid #cbd5e1;
|
||||
border-bottom: none;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-grand-summary {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
&__item {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-subtext {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cctr-branch-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover td {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
// Both journal lines of one voucher share a tint and sit flush against each other (no dotted
|
||||
// divider between them) so the pair reads as a single entry; a hairline separates one voucher's
|
||||
// pair from the next.
|
||||
.cctr-journal-row {
|
||||
&--first td {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&--second td {
|
||||
border-top: none;
|
||||
padding-top: 4px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
&--first,
|
||||
&--second {
|
||||
&:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-drcr {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&--dr {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&--cr {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-contra {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cctr-balanced-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
+1802
File diff suppressed because it is too large
Load Diff
@@ -45,14 +45,21 @@
|
||||
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
|
||||
@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>Cashier</mat-label>
|
||||
@@ -140,17 +147,24 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller / Vault (To) *</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
@if (tellers.length === 0) {
|
||||
<mat-option [value]="null" disabled>No tellers configured</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller / Vault (To) *</mat-label>
|
||||
<input matInput type="text" [value]="toParty" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller / Vault (To) *</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
@if (tellers.length === 0) {
|
||||
<mat-option [value]="null" disabled>No tellers configured</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Settlement Date</mat-label>
|
||||
|
||||
@@ -97,6 +97,10 @@ export class CashSettlementComponent implements OnInit {
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((list: any[]) => {
|
||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||
if (this.tellers.length === 1) {
|
||||
this.onTellerSelect(this.tellers[0].id);
|
||||
this.onSupervisorTellerSelect(this.tellers[0].id);
|
||||
}
|
||||
});
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
@@ -17,17 +17,24 @@
|
||||
</header>
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>From (Teller / Head)</mat-label>
|
||||
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
@if (tellers.length === 0) {
|
||||
<mat-option [value]="null" disabled>No tellers configured</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>From (Teller / Head)</mat-label>
|
||||
<input matInput type="text" [value]="fromParty" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>From (Teller / Head)</mat-label>
|
||||
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
@if (tellers.length === 0) {
|
||||
<mat-option [value]="null" disabled>No tellers configured</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>To (Cashier)</mat-label>
|
||||
|
||||
@@ -95,6 +95,9 @@ export class CashTransferComponent implements OnInit {
|
||||
.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.onTellerSelect(this.tellers[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,6 +119,9 @@ export class CashTransferComponent implements OnInit {
|
||||
.subscribe((r: any) => {
|
||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||
this.loadingCashiers = false;
|
||||
if (this.cashiers.length === 1) {
|
||||
this.onCashierSelect(this.cashiers[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,15 +20,22 @@
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) -->
|
||||
@if (!isCashierMode) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller (optional)</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
<mat-option [value]="null">All Tellers</mat-option>
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller (optional)</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
<mat-option [value]="null">All Tellers</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>Cashier (optional)</mat-label>
|
||||
|
||||
@@ -85,6 +85,9 @@ export class CashierPositionComponent implements OnInit {
|
||||
.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.onTellerSelect(this.tellers[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -137,6 +140,9 @@ export class CashierPositionComponent implements OnInit {
|
||||
.pipe(catchError(() => of({ cashiers: [] })))
|
||||
.subscribe((r: any) => {
|
||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||
if (this.cashiers.length === 1) {
|
||||
this.selectedCashierId = this.cashiers[0].id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,22 @@ const routes: Routes = [
|
||||
),
|
||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||
},
|
||||
{
|
||||
path: 'share-withdrawal-request',
|
||||
loadComponent: () =>
|
||||
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||
(m) => m.ShareWithdrawalRequestComponent
|
||||
),
|
||||
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||
},
|
||||
{
|
||||
path: 'share-withdrawal-requests',
|
||||
loadComponent: () =>
|
||||
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||
(m) => m.MyShareWithdrawalRequestsComponent
|
||||
),
|
||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities',
|
||||
loadComponent: () =>
|
||||
@@ -70,6 +86,18 @@ const routes: Routes = [
|
||||
),
|
||||
data: { title: 'Account Facilities', breadcrumb: 'Account Facilities' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities/cheque-book',
|
||||
loadComponent: () =>
|
||||
import('../clerk/cheque-book/cheque-book.component').then((m) => m.ClerkChequeBookComponent),
|
||||
data: { title: 'Cheque Book', breadcrumb: 'Cheque Book' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities/cheque-book/:id',
|
||||
loadComponent: () =>
|
||||
import('../clerk/cheque-book/cheque-book-detail.component').then((m) => m.ClerkChequeBookDetailComponent),
|
||||
data: { title: 'Cheque Book Detail', breadcrumb: 'Cheque Book Detail' }
|
||||
},
|
||||
{
|
||||
path: 'photo-signature',
|
||||
loadComponent: () =>
|
||||
@@ -89,6 +117,14 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
|
||||
data: { title: "Today's Activity", breadcrumb: "Today's Activity" }
|
||||
},
|
||||
{
|
||||
path: 'dividend-dashboard',
|
||||
loadComponent: () =>
|
||||
import('../clerk/dividend-dashboard/dividend-dashboard.component').then(
|
||||
(m) => m.DividendDashboardComponent
|
||||
),
|
||||
data: { title: 'Dividend Dashboard', breadcrumb: 'Dividend Dashboard' }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -167,6 +203,17 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
|
||||
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
|
||||
},
|
||||
{
|
||||
path: 'cash-clearing-transfer-summary',
|
||||
loadComponent: () =>
|
||||
import('./cash-clearing-transfer-summary/cash-clearing-transfer-summary.component').then(
|
||||
(m) => m.CashClearingTransferSummaryComponent
|
||||
),
|
||||
data: {
|
||||
title: 'Cash / Clearing / Transfer - Summary Report',
|
||||
breadcrumb: 'Cash / Clearing / Transfer - Summary Report'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkShareWithdrawalService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||
}
|
||||
|
||||
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||
if (status) params = params.set('status', status);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||
}
|
||||
|
||||
validate(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
|
||||
/** HTTP service for Clerk Withdrawal Request maker-checker workflow. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -50,8 +51,12 @@ export class ClerkWithdrawalService {
|
||||
return this.http.post(`/clerk/withdrawal-requests/${id}/reject`, { reason });
|
||||
}
|
||||
|
||||
// Silenced: onTokNoBlur() probes this expecting a possible legitimate 404 (token not from this
|
||||
// pool, could be a dividend claim's instead) and shows its own tailored message either way.
|
||||
getByToken(token: string): Observable<any> {
|
||||
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`);
|
||||
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
|
||||
|
||||
@@ -17,17 +17,24 @@
|
||||
</header>
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
@if (tellers.length === 0) {
|
||||
<mat-option [value]="null" disabled>No tellers configured</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
@if (tellers.length === 0) {
|
||||
<mat-option [value]="null" disabled>No tellers configured</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>From (Cashier)</mat-label>
|
||||
|
||||
@@ -102,6 +102,9 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
.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.onTellerSelect(this.tellers[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,26 @@
|
||||
<span class="mdr-detail__val cell-mono">{{ viewRequest.txn_id }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (viewRequest.dd_face_value != null) {
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">DD Face Value</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_face_value | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">Charges</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_charges | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">GST ({{ viewRequest.dd_gst_percentage }}%)</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_gst_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">Total Collected</span>
|
||||
<span class="mdr-detail__val"
|
||||
><strong>₹{{ viewRequest.amount | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (viewRequest.description) {
|
||||
<div class="mdr-detail mdr-detail--wide">
|
||||
<span class="mdr-detail__lbl">Description</span>
|
||||
|
||||
@@ -20,14 +20,21 @@
|
||||
<mat-tab label="New Reconciliation">
|
||||
<div class="pg-tab-body">
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@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>Cashier</mat-label>
|
||||
|
||||
@@ -92,6 +92,9 @@ export class ReconciliationComponent implements OnInit {
|
||||
.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.onTellerSelect(this.tellers[0].id);
|
||||
}
|
||||
});
|
||||
this.loadHistory();
|
||||
}
|
||||
@@ -108,6 +111,9 @@ export class ReconciliationComponent implements OnInit {
|
||||
.pipe(catchError(() => of({ cashiers: [] })))
|
||||
.subscribe((r: any) => {
|
||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||
if (this.cashiers.length === 1) {
|
||||
this.onCashierSelect(this.cashiers[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
<span class="ws-header__title">Transaction Entry</span>
|
||||
</div>
|
||||
<div class="ws-header__right">
|
||||
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search Customer
|
||||
</button>
|
||||
<span class="ws-header__effdt">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
@@ -139,7 +146,7 @@
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@@ -224,8 +231,11 @@
|
||||
<span class="ws-caption">Payment instrument</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Transaction Amount">
|
||||
AMOUNT
|
||||
<label
|
||||
class="ws-f__lbl"
|
||||
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||
>
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
@@ -235,20 +245,48 @@
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
}
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
<span class="ws-f__hint"
|
||||
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (row.typ !== 'Withdrawal Slip') {
|
||||
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.resolutionNumber"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="e.g. BR-2026-014"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
type="date"
|
||||
[(ngModel)]="row.meetingDate"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
} @else if (row.typ !== 'Withdrawal Slip') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||
<input
|
||||
@@ -300,6 +338,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
GST ({{ ddGstPercentage }}%)
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -652,6 +743,32 @@
|
||||
'—'
|
||||
}}</span>
|
||||
</div>
|
||||
@if (selectedRow?.acTyp === 'SHR') {
|
||||
<div class="te-field">
|
||||
<span class="te-label">No. of Shares Held</span
|
||||
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Market Price / Share</span
|
||||
><span class="te-value te-mono"
|
||||
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Resolution No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Savings A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||
</div>
|
||||
@if (accountDetails.linkedLoanAccountNo) {
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Loan A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="te-panel__header te-panel__header--mt">
|
||||
@@ -664,7 +781,13 @@
|
||||
<div class="te-panel__body">
|
||||
<div class="te-balance-row">
|
||||
<span class="te-bal-num">1.</span>
|
||||
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
|
||||
<span class="te-bal-label">{{
|
||||
selectedRow?.acTyp === 'SHR'
|
||||
? 'Share Balance'
|
||||
: accountDetails?.glCode
|
||||
? 'GL Balance'
|
||||
: 'Current Balance'
|
||||
}}</span>
|
||||
<span class="te-bal-value te-bal-value--cr"
|
||||
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -681,7 +804,7 @@
|
||||
</div>
|
||||
<div class="te-balance-row">
|
||||
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
|
||||
<span class="te-bal-value">{{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}</span>
|
||||
<span class="te-bal-value">{{ currencySymbol }}0.00</span>
|
||||
</div>
|
||||
<div class="te-balance-row">
|
||||
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
|
||||
@@ -746,7 +869,7 @@
|
||||
</div>
|
||||
<div class="te-bal-item">
|
||||
<span class="te-bal-item__label">Transfer Hold</span>
|
||||
<span class="te-bal-item__val">0.00</span>
|
||||
<span class="te-bal-item__val">{{ freezeAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="te-bal-item">
|
||||
<span class="te-bal-item__label">Clearing Hold</span>
|
||||
@@ -802,42 +925,50 @@
|
||||
<div class="te-acs-meta">
|
||||
<span
|
||||
class="te-acs-tag"
|
||||
[class.te-acs-tag--active]="accountDetails?.status?.value === 'Active'"
|
||||
[class.te-acs-tag--active]="activeDividendClaimRequest || accountDetails?.status?.value === 'Active'"
|
||||
[class.te-acs-tag--blocked]="accountDetails?.status?.value === 'Blocked'"
|
||||
[class.te-acs-tag--dormant]="accountDetails?.subStatus?.value === 'Dormant'"
|
||||
>{{
|
||||
accountDetails?.subStatus?.value === 'Dormant' ? 'Dormant' : (accountDetails?.status?.value ?? '—')
|
||||
activeDividendClaimRequest
|
||||
? 'Awaiting Disbursement'
|
||||
: accountDetails?.subStatus?.value === 'Dormant'
|
||||
? 'Dormant'
|
||||
: (accountDetails?.status?.value ?? '—')
|
||||
}}</span
|
||||
>
|
||||
<span class="te-acs-kv"
|
||||
><b>A/c</b>
|
||||
{{
|
||||
accountDetails?.depositType?.value ||
|
||||
(selectedRow?.acTyp === 'CA'
|
||||
? 'Current'
|
||||
: selectedRow?.acTyp === 'SD'
|
||||
? 'Savings'
|
||||
: selectedRow?.acTyp === 'FD'
|
||||
? 'Fixed Dep.'
|
||||
: selectedRow?.acTyp === 'RD'
|
||||
? 'Recurring'
|
||||
: selectedRow?.acTyp === 'GL'
|
||||
? (accountDetails?.type?.value ?? 'GL Account')
|
||||
: selectedRow?.acTyp === 'SHR'
|
||||
? 'Share'
|
||||
: selectedRow?.acTyp === 'LA'
|
||||
? 'Loan'
|
||||
: '—')
|
||||
(activeDividendClaimRequest
|
||||
? 'Dividend Claim'
|
||||
: selectedRow?.acTyp === 'CA'
|
||||
? 'Current'
|
||||
: selectedRow?.acTyp === 'SD'
|
||||
? 'Savings'
|
||||
: selectedRow?.acTyp === 'FD'
|
||||
? 'Fixed Dep.'
|
||||
: selectedRow?.acTyp === 'RD'
|
||||
? 'Recurring'
|
||||
: selectedRow?.acTyp === 'GL'
|
||||
? (accountDetails?.type?.value ?? 'GL Account')
|
||||
: selectedRow?.acTyp === 'SHR'
|
||||
? 'Share'
|
||||
: selectedRow?.acTyp === 'LA'
|
||||
? 'Loan'
|
||||
: '—')
|
||||
}}</span
|
||||
>
|
||||
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
||||
<span class="te-acs-kv"
|
||||
><b>Product</b>
|
||||
{{
|
||||
accountDetails?.savingsProductName ||
|
||||
accountDetails?.depositProductName ||
|
||||
accountDetails?.productName ||
|
||||
'—'
|
||||
activeDividendClaimRequest
|
||||
? 'Dividend Cash Payout'
|
||||
: accountDetails?.savingsProductName ||
|
||||
accountDetails?.depositProductName ||
|
||||
accountDetails?.productName ||
|
||||
'—'
|
||||
}}</span
|
||||
>
|
||||
<span class="te-acs-kv"
|
||||
@@ -862,7 +993,7 @@
|
||||
</div>
|
||||
<div class="te-acs-b">
|
||||
<span class="te-acs-b-l">Tr. Hold</span>
|
||||
<span class="te-acs-b-v">0.00</span>
|
||||
<span class="te-acs-b-v">{{ freezeAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="te-acs-b">
|
||||
<span class="te-acs-b-l">Clr. Hold</span>
|
||||
@@ -957,11 +1088,15 @@
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</svg>
|
||||
Denomination Entry
|
||||
@if (denominationTotal > 0) {
|
||||
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||
}
|
||||
@if (isCashPayment && denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge">⚠ Required for Cash</span>
|
||||
@if (isSelectedRowDdCashGl) {
|
||||
<span class="te-mandate-badge">Target ₹{{ ddTargetTotal | number: '1.2-2' }}</span>
|
||||
} @else {
|
||||
@if (denominationTotal > 0) {
|
||||
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||
}
|
||||
@if (isCashPayment && denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge">⚠ Required for Cash</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="te-panel__body">
|
||||
@@ -1011,7 +1146,26 @@
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@if (denominationTotal > 0) {
|
||||
@if (isSelectedRowDdCashGl) {
|
||||
<div class="te-denom-actions">
|
||||
@if (denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge"
|
||||
>⚠ Count notes/coins totalling ₹{{ ddTargetTotal | number: '1.2-2' }}</span
|
||||
>
|
||||
} @else if (ddDenomMatchesTarget()) {
|
||||
<span class="te-denom-match-badge"
|
||||
>✓ Matches Total To Collect ₹{{ ddTargetTotal | number: '1.2-2' }}</span
|
||||
>
|
||||
} @else {
|
||||
<span class="te-denom-required-badge">
|
||||
⚠ Must total ₹{{ ddTargetTotal | number: '1.2-2' }} (counted ₹{{
|
||||
denominationTotal | number: '1.2-2'
|
||||
}})
|
||||
</span>
|
||||
}
|
||||
<button class="te-btn" type="button" (click)="clearDenominations()">Clear</button>
|
||||
</div>
|
||||
} @else if (denominationTotal > 0) {
|
||||
<div class="te-denom-actions">
|
||||
<button class="te-btn te-btn--primary" type="button" (click)="applyDenominationTotal()">
|
||||
Apply ₹{{ denominationTotal | number: '1.2-2' }} to Amount
|
||||
@@ -1140,6 +1294,13 @@
|
||||
<span class="ws-header__title">Transaction Entry</span>
|
||||
</div>
|
||||
<div class="ws-header__right">
|
||||
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search Customer
|
||||
</button>
|
||||
<span class="ws-header__effdt">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
@@ -1191,7 +1352,7 @@
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@@ -1270,7 +1431,7 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl">
|
||||
AMOUNT
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
@@ -1280,27 +1441,51 @@
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
}
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
<span class="ws-f__hint"
|
||||
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.resolutionNumber"
|
||||
placeholder="e.g. BR-2026-014"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">MEETING DATE</label>
|
||||
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--tok">
|
||||
<label class="ws-f__lbl">TOK. NO.</label>
|
||||
<input
|
||||
@@ -1325,6 +1510,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
GST ({{ ddGstPercentage }}%)
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1539,8 +1776,26 @@
|
||||
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (confirmData.isDdCashGl) {
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">DD Face Value</span
|
||||
><span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.ddFaceValue | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">Charges</span
|
||||
><span class="te-confirm-val">{{ confirmData.symbol }}{{ confirmData.ddCharges | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">GST ({{ confirmData.ddGstPercentage }}%)</span
|
||||
><span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.ddGstAmount | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="te-confirm-row te-confirm-row--amount">
|
||||
<span class="te-confirm-label">Deposit Amount</span>
|
||||
<span class="te-confirm-label">{{ confirmData.isDdCashGl ? 'Total To Collect' : 'Deposit Amount' }}</span>
|
||||
<span class="te-confirm-val te-cr"
|
||||
>+{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -1677,6 +1932,15 @@
|
||||
@for (line of alertModal.lines; track $index) {
|
||||
@if ($index === 0) {
|
||||
<p class="te-alert-box__desc">{{ line }}</p>
|
||||
} @else if (line.startsWith('⚠')) {
|
||||
<div class="te-alert-box__callout">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<span>{{ line.slice(1).trim() }}</span>
|
||||
</div>
|
||||
} @else if (line.includes(':') && $index < alertModal.lines.length - 1) {
|
||||
<div class="te-alert-box__kv">
|
||||
<span class="te-alert-box__kv-k">{{ line.split(':')[0].trim() }}</span>
|
||||
|
||||
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-f__hint {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: $ws-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ws-amt-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
@@ -1928,6 +1936,19 @@ select.te-cell {
|
||||
animation: te-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Denomination matches DD target badge
|
||||
.te-denom-match-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
@keyframes te-pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -1982,12 +2003,12 @@ select.te-cell {
|
||||
|
||||
.te-alert-box {
|
||||
display: flex;
|
||||
width: 480px;
|
||||
max-width: 96vw;
|
||||
border-radius: 16px;
|
||||
width: 400px;
|
||||
max-width: 94vw;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 32px 80px rgb(0 0 0 / 35%),
|
||||
0 24px 60px rgb(0 0 0 / 32%),
|
||||
0 0 0 1px rgb(255 255 255 / 8%);
|
||||
animation: te-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
@@ -1997,32 +2018,35 @@ select.te-cell {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 76px;
|
||||
gap: 8px;
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 0;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
&__sidebar-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(255 255 255 / 20%);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 255 255 / 15%),
|
||||
inset 0 1px 2px rgb(255 255 255 / 25%);
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
stroke: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__sidebar-type {
|
||||
font-size: 8px;
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
text-transform: uppercase;
|
||||
writing-mode: vertical-lr;
|
||||
@@ -2032,6 +2056,7 @@ select.te-cell {
|
||||
// ── Right content ─────────────────────────────────────────────────────────
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2041,12 +2066,12 @@ select.te-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 12px;
|
||||
padding: 14px 16px 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -2070,52 +2095,87 @@ select.te-cell {
|
||||
|
||||
&__lines {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.55;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__kv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
margin: 4px 0;
|
||||
gap: 10px;
|
||||
padding: 5px 10px;
|
||||
margin: 3px 0;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border-radius: 7px;
|
||||
border-left: 3px solid #e2e8f0;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
&__kv-k {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__kv-v {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__foot {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
padding: 8px 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
line-height: 1.45;
|
||||
animation: te-pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
background: #fafafa;
|
||||
}
|
||||
@@ -2183,11 +2243,11 @@ select.te-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 20px;
|
||||
min-width: 90px;
|
||||
border-radius: 8px;
|
||||
padding: 7px 16px;
|
||||
min-width: 76px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.inputs.Cheque Book' | translate }} #{{ book.id }}</h2>
|
||||
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<mat-card class="info-card">
|
||||
<mat-card-content>
|
||||
<div class="info-grid">
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Account' | translate }}</span>
|
||||
<span>
|
||||
{{ book.accountNo || book.accountId }}
|
||||
@if (book.clientName) {
|
||||
— {{ book.clientName }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Account Type' | translate }}</span
|
||||
><span>{{ book.accountType }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
|
||||
><span>{{ book.officeName }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
|
||||
><span>{{ book.seriesCode || '–' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Leaf Range' | translate }}</span>
|
||||
<span>{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '–' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Quantity' | translate }}</span
|
||||
><span>{{ book.noOfBooks }} {{ 'labels.inputs.books' | translate }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
|
||||
><span>{{ book.status }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Issue Date' | translate }}</span
|
||||
><span>{{ book.issueDate ? (book.issueDate | dateFormat) : '–' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@if (book.status === 'ISSUED') {
|
||||
<div class="action-toolbar">
|
||||
<button mat-stroked-button color="warn" (click)="startAction('stoppayment')">
|
||||
{{ 'labels.buttons.Stop Payment' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="startAction('revokestoppayment')">
|
||||
{{ 'labels.buttons.Revoke Stop Payment' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" (click)="startAction('reportlost')">
|
||||
{{ 'labels.buttons.Report Lost' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="startAction('cancelleaf')">
|
||||
{{ 'labels.buttons.Cancel Leaf' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button color="primary" (click)="startAction('markused')">
|
||||
{{ 'labels.buttons.Mark as Used' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" (click)="startAction('return')">
|
||||
{{ 'labels.buttons.Flag Returned' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeAction) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="actionForm" (ngSubmit)="submitAction()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.From Leaf Seq' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="fromLeafSeq" />
|
||||
<mat-hint>{{ 'labels.text.Leave blank for the whole book' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.To Leaf Seq' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="toLeafSeq" />
|
||||
</mat-form-field>
|
||||
@if (activeAction !== 'revokestoppayment') {
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
|
||||
<input matInput formControlName="reason" [required]="activeAction !== 'markused'" />
|
||||
@if (activeAction === 'markused') {
|
||||
<mat-hint>{{
|
||||
'labels.text.Optional — e.g. cleared per bank statement dated ...' | translate
|
||||
}}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="activeAction = null">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" type="submit" [disabled]="actionForm.invalid || busy">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div class="leaf-grid-wrapper mat-elevation-z1">
|
||||
<div class="leaf-grid">
|
||||
@for (l of leaves; track l.leafSerialNo) {
|
||||
<button
|
||||
type="button"
|
||||
class="serial-circle"
|
||||
[ngClass]="
|
||||
l.status === 'ACTIVE'
|
||||
? 'ok'
|
||||
: l.status === 'USED'
|
||||
? 'used'
|
||||
: l.status === 'STOP_PAYMENT'
|
||||
? 'warn'
|
||||
: l.status === 'LOST'
|
||||
? 'bad'
|
||||
: l.status === 'RETURNED'
|
||||
? 'returned'
|
||||
: 'neutral'
|
||||
"
|
||||
(click)="selectLeaf(l)"
|
||||
>
|
||||
{{ l.leafSerialNo }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (leaves.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 24px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.k {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.action-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 20px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.leaf-grid-wrapper {
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.leaf-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.serial-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentcolor;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
box-shadow 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
&.ok {
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.used {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
&.returned {
|
||||
color: #9333ea;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { Observable } from 'rxjs';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { ChequeBookService } from '../chequebook.service';
|
||||
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
|
||||
|
||||
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
|
||||
|
||||
/**
|
||||
* One issued cheque book: its leaf register, and the six leaf-range actions (stop-payment, revoke,
|
||||
* report-lost, cancel-leaf, mark-used, return) — each defaults to the whole book when no range is given.
|
||||
* "Mark as used" is a manual record (this CBS has no clearing/presentment integration to detect it itself)
|
||||
* — a branch sets it once it sees a leaf clear on the bank/clearing statement. "Return" flags a leaf as
|
||||
* presented-and-dishonoured (insufficient funds, signature mismatch, etc.) — terminal, like report-lost.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cheque-book-detail',
|
||||
templateUrl: './cheque-book-detail.component.html',
|
||||
styleUrls: ['./cheque-book-detail.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ChequeBookDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
bookId!: string;
|
||||
book: any = {};
|
||||
leaves: any[] = [];
|
||||
busy = false;
|
||||
|
||||
activeAction: LeafAction | null = null;
|
||||
actionForm: UntypedFormGroup;
|
||||
|
||||
constructor() {
|
||||
this.actionForm = this.formBuilder.group({
|
||||
fromLeafSeq: [''],
|
||||
toLeafSeq: [''],
|
||||
reason: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.bookId = this.route.snapshot.paramMap.get('id') as string;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
loadAll(): void {
|
||||
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => (this.book = data || {}));
|
||||
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
|
||||
this.leaves = data || [];
|
||||
});
|
||||
}
|
||||
|
||||
selectLeaf(leaf: any): void {
|
||||
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
|
||||
}
|
||||
|
||||
startAction(action: LeafAction): void {
|
||||
this.activeAction = this.activeAction === action ? null : action;
|
||||
this.actionForm.reset();
|
||||
const reasonControl = this.actionForm.get('reason');
|
||||
if (action === 'revokestoppayment' || action === 'markused') {
|
||||
reasonControl?.clearValidators();
|
||||
} else {
|
||||
reasonControl?.setValidators(Validators.required);
|
||||
}
|
||||
reasonControl?.updateValueAndValidity();
|
||||
}
|
||||
|
||||
submitAction(): void {
|
||||
if (this.actionForm.invalid || this.busy || !this.activeAction) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.actionForm.value;
|
||||
const payload: any = { locale: 'en' };
|
||||
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
|
||||
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
|
||||
if (raw.reason) payload.reason = raw.reason;
|
||||
|
||||
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
|
||||
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
|
||||
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
|
||||
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
|
||||
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
|
||||
markused: (id, p) => this.chequeBookService.markUsed(id, p),
|
||||
return: (id, p) => this.chequeBookService.returnBook(id, p)
|
||||
};
|
||||
const call = actionCalls[this.activeAction](this.bookId, payload);
|
||||
|
||||
call.subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.activeAction = null;
|
||||
this.notify('Action applied');
|
||||
this.loadAll();
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify(
|
||||
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>
|
||||
<span
|
||||
class="serial-circle"
|
||||
[ngClass]="
|
||||
data.status === 'ACTIVE'
|
||||
? 'ok'
|
||||
: data.status === 'USED'
|
||||
? 'used'
|
||||
: data.status === 'STOP_PAYMENT'
|
||||
? 'warn'
|
||||
: data.status === 'LOST'
|
||||
? 'bad'
|
||||
: data.status === 'RETURNED'
|
||||
? 'returned'
|
||||
: 'neutral'
|
||||
"
|
||||
>{{ data.leafSerialNo }}</span
|
||||
>
|
||||
</h1>
|
||||
<div mat-dialog-content>
|
||||
<div class="info-grid">
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
|
||||
><span>{{ data.leafSerialNo }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
|
||||
><span>{{ data.status }}</span>
|
||||
</div>
|
||||
@if (data.stopReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
|
||||
><span>{{ data.stopReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.lostReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
|
||||
><span>{{ data.lostReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.cancelledReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
|
||||
><span>{{ data.cancelledReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.usedReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
|
||||
><span>{{ data.usedReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.returnReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Return Reason' | translate }}</span
|
||||
><span>{{ data.returnReason }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
|
||||
</mat-dialog-actions>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px 24px;
|
||||
min-width: 280px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.k {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.serial-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentcolor;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.ok {
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.used {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Popup shown when a leaf circle in the cheque-book detail grid is clicked. */
|
||||
@Component({
|
||||
selector: 'mifosx-leaf-detail-dialog',
|
||||
templateUrl: './leaf-detail-dialog.component.html',
|
||||
styleUrls: ['./leaf-detail-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class LeafDetailDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<LeafDetailDialogComponent>>(MatDialogRef);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.inputs.Cheque Books' | translate }}</h2>
|
||||
<div class="header-actions">
|
||||
<button mat-raised-button color="primary" (click)="showRequestForm = !showRequestForm">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Request Cheque Book' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
@for (s of ['REQUESTED', 'ISSUED', 'REJECTED', 'WITHDRAWN']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showRequestForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="requestForm" (ngSubmit)="submitRequest()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Account Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="accountType" required (selectionChange)="onAccountTypeChange()">
|
||||
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
|
||||
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Account Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="accountNo"
|
||||
(input)="onAccountNoInput($any($event.target).value)"
|
||||
required
|
||||
/>
|
||||
@if (searchingAccount) {
|
||||
<mat-hint>{{ 'labels.text.Searching...' | translate }}</mat-hint>
|
||||
} @else if (resolvedAccount) {
|
||||
<mat-hint class="ok-hint">
|
||||
✓ {{ resolvedAccount.accountNo }}
|
||||
@if (resolvedAccount.clientName) {
|
||||
— {{ resolvedAccount.clientName }}
|
||||
}
|
||||
@if (resolvedAccount.status) {
|
||||
({{ resolvedAccount.status }})
|
||||
}
|
||||
</mat-hint>
|
||||
} @else if (accountError) {
|
||||
<mat-hint class="err-hint">{{ accountError | translate }}</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>{{ 'labels.text.Type at least 4 digits of the account number' | translate }}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
|
||||
<mat-select formControlName="officeId" required>
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
|
||||
<mat-select formControlName="seriesId" required>
|
||||
@for (s of requestableSeries; track s.id) {
|
||||
<mat-option [value]="s.id">
|
||||
{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }},
|
||||
{{ availableBooksForRequestSeries(s.id) }} {{ 'labels.inputs.books available' | translate }})
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>{{ 'labels.text.Only series applicable to this account type are shown' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Number of Books' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="noOfBooks" required />
|
||||
<mat-hint>{{ 'labels.text.Leaves handed over = books x the series book size' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="showRequestForm = false">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="requestForm.invalid || busy">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="books">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="accountType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.accountType }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="account">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
{{ b.accountNo || b.accountId }}
|
||||
@if (b.clientName) {
|
||||
<span>({{ b.clientName }})</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.officeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="seriesCode">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.seriesCode }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Quantity' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">
|
||||
{{ b.noOfBooks }} {{ 'labels.inputs.books' | translate }}
|
||||
@if (b.totalLeaves) {
|
||||
<span class="muted">({{ b.totalLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
<span
|
||||
class="pill"
|
||||
[ngClass]="
|
||||
b.status === 'ISSUED' ? 'ok' : b.status === 'REJECTED' || b.status === 'WITHDRAWN' ? 'bad' : 'neutral'
|
||||
"
|
||||
>{{ b.status }}</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="requestedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Requested Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.requestedDate | dateFormat }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
|
||||
@if (b.status === 'REQUESTED') {
|
||||
<button mat-button color="primary" (click)="toggleIssue(b)">
|
||||
{{ 'labels.buttons.Issue' | translate }}
|
||||
</button>
|
||||
<button mat-button color="warn" (click)="toggleReject(b)">{{ 'labels.buttons.Reject' | translate }}</button>
|
||||
}
|
||||
@if (b.status === 'ISSUED') {
|
||||
<button mat-button (click)="openBook(b)">{{ 'labels.buttons.View' | translate }}</button>
|
||||
<button mat-button color="warn" (click)="withdraw(b)">{{ 'labels.buttons.Withdraw' | translate }}</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns" (click)="openBook(row)" class="clickable-row"></tr>
|
||||
</table>
|
||||
@if (books.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (totalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (issueForBookId) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<h3>{{ 'labels.inputs.Issue Cheque Book' | translate }}</h3>
|
||||
<p class="issue-need">
|
||||
{{ 'labels.inputs.Requested' | translate }}: <strong>{{ issueForBook?.noOfBooks }}</strong>
|
||||
{{ 'labels.inputs.books' | translate }} — {{ issueForBook?.seriesCode }}
|
||||
<span [class.err-hint]="issueAvailableBooks < (issueForBook?.noOfBooks || 0)">
|
||||
({{ issueAvailableBooks }} {{ 'labels.inputs.books available' | translate }})
|
||||
</span>
|
||||
</p>
|
||||
<form [formGroup]="issueForm" (ngSubmit)="submitIssue(issueForBookId)">
|
||||
<mat-checkbox formControlName="collectionAcknowledged">
|
||||
{{ 'labels.inputs.Customer collected the book now' | translate }}
|
||||
</mat-checkbox>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="issueForBookId = null; issueForBook = null">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="issueForm.invalid || busy">
|
||||
{{ 'labels.buttons.Issue' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (rejectForBookId) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<h3>{{ 'labels.inputs.Reject Cheque Book Request' | translate }}</h3>
|
||||
<form [formGroup]="rejectForm" (ngSubmit)="submitReject(rejectForBookId)">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Rejection Reason' | translate }}</mat-label>
|
||||
<input matInput formControlName="rejectionReason" required />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="rejectForBookId = null">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" type="submit" [disabled]="rejectForm.invalid || busy">
|
||||
{{ 'labels.buttons.Reject' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
button.active {
|
||||
background: rgb(25 118 210 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.add-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgb(0 0 0 / 3%);
|
||||
}
|
||||
}
|
||||
|
||||
td.num,
|
||||
th.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.issue-need {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.9rem;
|
||||
color: rgb(0 0 0 / 70%);
|
||||
}
|
||||
|
||||
::ng-deep .ok-hint {
|
||||
color: #28c07a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::ng-deep .err-hint {
|
||||
color: #d32f2f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
|
||||
&.ok {
|
||||
background: #dff5e8;
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background: #fde3e3;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background: #e0e0e0;
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { ChequeBookService } from '../chequebook.service';
|
||||
|
||||
/** Cheque book requisitions and issuance — request, issue (draws from available stock), reject, withdraw. */
|
||||
@Component({
|
||||
selector: 'mifosx-cheque-books',
|
||||
templateUrl: './cheque-books.component.html',
|
||||
styleUrls: ['./cheque-books.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ChequeBooksComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private router = inject(Router);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
books: any[] = [];
|
||||
totalRecords = 0;
|
||||
pageSize = 10;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
seriesList: any[] = [];
|
||||
offices: any[] = [];
|
||||
stockLots: any[] = [];
|
||||
busy = false;
|
||||
|
||||
showRequestForm = false;
|
||||
issueForBookId: number | null = null;
|
||||
issueForBook: any = null;
|
||||
rejectForBookId: number | null = null;
|
||||
statusFilter = '';
|
||||
|
||||
requestForm: UntypedFormGroup;
|
||||
issueForm: UntypedFormGroup;
|
||||
rejectForm: UntypedFormGroup;
|
||||
|
||||
// account-number lookup for the request form
|
||||
private accountSearch$ = new Subject<string>();
|
||||
resolvedAccount: any = null;
|
||||
searchingAccount = false;
|
||||
accountError = '';
|
||||
|
||||
columns = [
|
||||
'id',
|
||||
'accountType',
|
||||
'account',
|
||||
'officeName',
|
||||
'seriesCode',
|
||||
'quantity',
|
||||
'status',
|
||||
'requestedDate',
|
||||
'actions'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.requestForm = this.formBuilder.group({
|
||||
accountType: [
|
||||
'SAVINGS',
|
||||
Validators.required
|
||||
],
|
||||
accountNo: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
accountId: [
|
||||
null,
|
||||
Validators.required
|
||||
],
|
||||
officeId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
seriesId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
noOfBooks: [
|
||||
1,
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
this.issueForm = this.formBuilder.group({
|
||||
collectionAcknowledged: [false]
|
||||
});
|
||||
this.rejectForm = this.formBuilder.group({
|
||||
rejectionReason: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBooks();
|
||||
this.loadStock();
|
||||
// populates a dropdown, so fetch every active series in one page rather than paging through it
|
||||
this.chequeBookService
|
||||
.getSeries('ACTIVE', 0, 500)
|
||||
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
|
||||
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
|
||||
|
||||
// account-number lookup: debounce, only fire once at least 4 chars typed (mirrors the cashier module)
|
||||
this.accountSearch$
|
||||
.pipe(debounceTime(400), distinctUntilChanged())
|
||||
.subscribe((accountNo) => this.doAccountSearch(accountNo));
|
||||
}
|
||||
|
||||
/** Called on every keystroke in the account-number box; clears any prior match and (re)triggers the search. */
|
||||
onAccountNoInput(value: string): void {
|
||||
this.resolvedAccount = null;
|
||||
this.accountError = '';
|
||||
this.requestForm.patchValue({ accountId: null });
|
||||
const trimmed = (value || '').trim();
|
||||
if (trimmed.length >= 4) {
|
||||
this.accountSearch$.next(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-run the lookup against the other ledger when the account type toggles; the series choice narrows too. */
|
||||
onAccountTypeChange(): void {
|
||||
this.resolvedAccount = null;
|
||||
this.accountError = '';
|
||||
this.requestForm.patchValue({ accountId: null, seriesId: '' });
|
||||
const accountNo = (this.requestForm.get('accountNo')?.value || '').trim();
|
||||
if (accountNo.length >= 4) {
|
||||
this.accountSearch$.next(accountNo);
|
||||
}
|
||||
}
|
||||
|
||||
/** Series applicable to whichever account type is currently selected in the request form — every series is
|
||||
* tagged SAVINGS-only or CURRENT-only (never both), so this is a direct match. */
|
||||
get requestableSeries(): any[] {
|
||||
const type = this.requestForm.get('accountType')?.value;
|
||||
return this.seriesList.filter((s) => s.applicableAccountType === type);
|
||||
}
|
||||
|
||||
private doAccountSearch(accountNo: string): void {
|
||||
const accountType = this.requestForm.get('accountType')?.value;
|
||||
this.searchingAccount = true;
|
||||
this.accountError = '';
|
||||
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
|
||||
next: (res: any) => {
|
||||
this.searchingAccount = false;
|
||||
const items = res?.pageItems || (Array.isArray(res) ? res : []);
|
||||
const exact = items.find((i: any) => i.accountNo === accountNo);
|
||||
const match = exact || (items.length === 1 ? items[0] : null);
|
||||
if (match) {
|
||||
this.resolvedAccount = {
|
||||
id: match.id,
|
||||
accountNo: match.accountNo,
|
||||
clientName: match.clientName || match.clientDisplayName || match.groupName || '',
|
||||
status: match.status?.value || ''
|
||||
};
|
||||
this.requestForm.patchValue({ accountId: match.id });
|
||||
} else {
|
||||
this.accountError = 'No account found with that number';
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.searchingAccount = false;
|
||||
this.accountError = 'Account lookup failed';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadStock(): void {
|
||||
// feeds availability calculations below, so fetch every available lot in one page rather than paging through it
|
||||
this.chequeBookService
|
||||
.getStockLots(undefined, undefined, 'AVAILABLE', 0, 500)
|
||||
.subscribe((res: any) => (this.stockLots = res?.pageItems ?? []));
|
||||
}
|
||||
|
||||
/** Whole books currently on hand for a series, optionally narrowed to one office. */
|
||||
availableBooksForSeries(seriesId: number, officeId?: number): number {
|
||||
return this.stockLots
|
||||
.filter((l) => l.seriesId === seriesId && (!officeId || l.officeId === officeId) && l.status === 'AVAILABLE')
|
||||
.reduce((sum, l) => sum + (l.remainingBooks || 0), 0);
|
||||
}
|
||||
|
||||
/** Availability at whichever office is currently chosen in the request form. */
|
||||
availableBooksForRequestSeries(seriesId: number): number {
|
||||
return this.availableBooksForSeries(seriesId, this.requestForm.get('officeId')?.value);
|
||||
}
|
||||
|
||||
loadBooks(): void {
|
||||
this.chequeBookService
|
||||
.getBooks(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
this.statusFilter || undefined,
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? this.books.length);
|
||||
});
|
||||
}
|
||||
|
||||
filterByStatus(status: string): void {
|
||||
this.statusFilter = this.statusFilter === status ? '' : status;
|
||||
this.pageIndex = 0;
|
||||
this.loadBooks();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.loadBooks();
|
||||
}
|
||||
|
||||
openBook(book: any): void {
|
||||
this.router.navigate([
|
||||
'/chequebook',
|
||||
'books',
|
||||
book.id
|
||||
]);
|
||||
}
|
||||
|
||||
submitRequest(): void {
|
||||
if (this.requestForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.requestForm.value;
|
||||
const payload = {
|
||||
accountType: raw.accountType,
|
||||
accountId: raw.accountId,
|
||||
officeId: raw.officeId,
|
||||
seriesId: raw.seriesId,
|
||||
noOfBooks: raw.noOfBooks,
|
||||
locale: 'en'
|
||||
};
|
||||
this.chequeBookService.requestBook(payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.showRequestForm = false;
|
||||
this.requestForm.reset({ accountType: 'SAVINGS', noOfBooks: 1 });
|
||||
this.resolvedAccount = null;
|
||||
this.accountError = '';
|
||||
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
||||
this.loadBooks();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
'Request failed — check the account is active and the cheque-book facility is enabled';
|
||||
this.notify(msg, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleIssue(book: any): void {
|
||||
const opening = this.issueForBookId !== book.id;
|
||||
this.issueForBookId = opening ? book.id : null;
|
||||
this.issueForBook = opening ? book : null;
|
||||
this.issueForm.reset({ collectionAcknowledged: false });
|
||||
}
|
||||
|
||||
/** Books available right now for the series this book was already requested against. */
|
||||
get issueAvailableBooks(): number {
|
||||
return this.issueForBook?.seriesId
|
||||
? this.availableBooksForSeries(this.issueForBook.seriesId, this.issueForBook.officeId)
|
||||
: 0;
|
||||
}
|
||||
|
||||
submitIssue(bookId: number): void {
|
||||
if (this.issueForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.issueForm.value;
|
||||
const payload = { collectionAcknowledged: !!raw.collectionAcknowledged, locale: 'en' };
|
||||
this.chequeBookService.issueBook(bookId, payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.issueForBookId = null;
|
||||
this.issueForBook = null;
|
||||
this.notify('Cheque book issued');
|
||||
this.loadBooks();
|
||||
this.loadStock();
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify('Issue failed — check there is enough available stock for this series at the office', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleReject(book: any): void {
|
||||
this.rejectForBookId = this.rejectForBookId === book.id ? null : book.id;
|
||||
this.rejectForm.reset();
|
||||
}
|
||||
|
||||
submitReject(bookId: number): void {
|
||||
if (this.rejectForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const payload = { rejectionReason: this.rejectForm.value.rejectionReason, locale: 'en' };
|
||||
this.chequeBookService.rejectBook(bookId, payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.rejectForBookId = null;
|
||||
this.notify('Cheque book request rejected');
|
||||
this.loadBooks();
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify('Reject failed', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
withdraw(book: any): void {
|
||||
this.chequeBookService.withdrawBook(book.id).subscribe({
|
||||
next: () => {
|
||||
this.notify('Cheque book withdrawn');
|
||||
this.loadBooks();
|
||||
},
|
||||
error: () => this.notify('Withdraw failed', true)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.inputs.Cheque Series' | translate }}</h2>
|
||||
<div class="header-actions">
|
||||
<button mat-raised-button color="primary" (click)="showForm = !showForm">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Add Cheque Series' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="seriesForm" (ngSubmit)="submit()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Series Code' | translate }}</mat-label>
|
||||
<input matInput formControlName="seriesCode" placeholder="CTS-25" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="name" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Leaves Per Book' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="leavesPerBook" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Applicable Account Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="applicableAccountType" required>
|
||||
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings Only' | translate }}</mat-option>
|
||||
<mat-option value="CURRENT">{{ 'labels.inputs.Current Only' | translate }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>{{ 'labels.text.SB and CA cheque stationery differ' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Reorder Level' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="reorderLevel" />
|
||||
<mat-hint>{{ 'labels.text.Low-stock alert threshold (remaining leaves)' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-checkbox formControlName="ctsCompliant">{{
|
||||
'labels.inputs.CTS-2010 Compliant' | translate
|
||||
}}</mat-checkbox>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="showForm = false">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="seriesForm.invalid || busy">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div class="filter-row">
|
||||
@for (s of ['ACTIVE', 'INACTIVE']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="seriesList">
|
||||
<ng-container matColumnDef="seriesCode">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series Code' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let s">{{ s.seriesCode }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let s">{{ s.name }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="leavesPerBook">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaves Per Book' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let s" class="num">{{ s.leavesPerBook }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="applicableAccountType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Applicable Account Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let s">
|
||||
<span class="pill neutral">{{ s.applicableAccountType }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="ctsCompliant">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.CTS-2010 Compliant' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let s">
|
||||
<span class="pill" [ngClass]="s.ctsCompliant ? 'ok' : 'neutral'">{{ s.ctsCompliant ? 'Yes' : 'No' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="reorderLevel">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Reorder Level' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let s" class="num">{{ s.reorderLevel ?? '-' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="active">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let s">
|
||||
<span class="pill" [ngClass]="s.active ? 'ok' : 'neutral'">{{ s.active ? 'Active' : 'Inactive' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let s">
|
||||
@if (s.active) {
|
||||
<button mat-icon-button color="warn" (click)="deactivate(s)">
|
||||
<mat-icon><fa-icon icon="trash"></fa-icon></mat-icon>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
@if (seriesList.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (totalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
button.active {
|
||||
background: rgb(25 118 210 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.add-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td.num,
|
||||
th.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
|
||||
&.ok {
|
||||
background: #dff5e8;
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background: #e0e0e0;
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { ChequeBookService } from '../chequebook.service';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
|
||||
/** Cheque-leaf stationery master — leaves per book, CTS-2010 compliance, low-stock reorder threshold. */
|
||||
@Component({
|
||||
selector: 'mifosx-cheque-series',
|
||||
templateUrl: './cheque-series.component.html',
|
||||
styleUrls: ['./cheque-series.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
RouterLink,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ChequeSeriesComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
seriesList: any[] = [];
|
||||
showForm = false;
|
||||
busy = false;
|
||||
|
||||
statusFilter = '';
|
||||
pageSize = 10;
|
||||
pageIndex = 0;
|
||||
totalRecords = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
|
||||
seriesForm: UntypedFormGroup;
|
||||
|
||||
columns = [
|
||||
'seriesCode',
|
||||
'name',
|
||||
'leavesPerBook',
|
||||
'applicableAccountType',
|
||||
'ctsCompliant',
|
||||
'reorderLevel',
|
||||
'active',
|
||||
'actions'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.seriesForm = this.formBuilder.group({
|
||||
seriesCode: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
name: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
leavesPerBook: [
|
||||
25,
|
||||
Validators.required
|
||||
],
|
||||
applicableAccountType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
ctsCompliant: [true],
|
||||
reorderLevel: [10]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSeries();
|
||||
}
|
||||
|
||||
loadSeries(): void {
|
||||
this.chequeBookService
|
||||
.getSeries(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
|
||||
.subscribe((res: any) => {
|
||||
this.seriesList = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
filterByStatus(status: string): void {
|
||||
this.statusFilter = this.statusFilter === status ? '' : status;
|
||||
this.pageIndex = 0;
|
||||
this.loadSeries();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.loadSeries();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.seriesForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.seriesForm.value;
|
||||
const payload: any = {
|
||||
seriesCode: raw.seriesCode,
|
||||
name: raw.name,
|
||||
leavesPerBook: raw.leavesPerBook,
|
||||
applicableAccountType: raw.applicableAccountType,
|
||||
ctsCompliant: !!raw.ctsCompliant,
|
||||
locale: 'en'
|
||||
};
|
||||
if (raw.reorderLevel !== '' && raw.reorderLevel != null) payload.reorderLevel = raw.reorderLevel;
|
||||
|
||||
this.chequeBookService.createSeries(payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.showForm = false;
|
||||
this.seriesForm.reset({ leavesPerBook: 25, applicableAccountType: '', ctsCompliant: true, reorderLevel: 10 });
|
||||
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
||||
this.loadSeries();
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify('Failed to add cheque series', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deactivate(series: any): void {
|
||||
const dialogRef = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `cheque series ${series.seriesCode}` }
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.delete) {
|
||||
return;
|
||||
}
|
||||
this.chequeBookService.deactivateSeries(series.id).subscribe({
|
||||
next: () => {
|
||||
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
||||
this.loadSeries();
|
||||
},
|
||||
error: () => this.notify('Failed to submit for approval', true)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.menus.Cheque Book Inventory' | translate }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="cards-grid">
|
||||
@for (screen of screens; track screen.label) {
|
||||
<mat-card class="cb-card" [routerLink]="screen.route">
|
||||
<mat-card-content>
|
||||
<div class="card-head">
|
||||
<span class="card-icon">
|
||||
<fa-icon [icon]="screen.icon"></fa-icon>
|
||||
</span>
|
||||
<span class="card-title">{{ screen.label | translate }}</span>
|
||||
</div>
|
||||
<p class="card-desc">{{ screen.description | translate }}</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cb-card {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
flex: 0 0 auto;
|
||||
background: rgb(25 118 210 / 10%);
|
||||
color: #1976d2;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
color: rgb(0 0 0 / 60%);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Cheque Book Inventory landing page — one card per screen (series master, stock, requests/issuance, and
|
||||
* the teller quick-lookup).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-chequebook-dashboard',
|
||||
templateUrl: './chequebook-dashboard.component.html',
|
||||
styleUrls: ['./chequebook-dashboard.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
RouterLink,
|
||||
MatIconModule,
|
||||
MatCardModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ChequeBookDashboardComponent {
|
||||
screens = [
|
||||
{
|
||||
route: [
|
||||
'/chequebook',
|
||||
'series'
|
||||
],
|
||||
label: 'Cheque Series',
|
||||
icon: 'list',
|
||||
description: 'Stationery master — leaves per book, CTS-2010 compliance, low-stock reorder level'
|
||||
},
|
||||
{
|
||||
route: [
|
||||
'/chequebook',
|
||||
'stock'
|
||||
],
|
||||
label: 'Stock Lots & Transfers',
|
||||
icon: 'boxes-stacked',
|
||||
description: 'Receive bulk stock from the press, dispatch to branches, acknowledge receipt'
|
||||
},
|
||||
{
|
||||
route: [
|
||||
'/chequebook',
|
||||
'books'
|
||||
],
|
||||
label: 'Requests & Issuance',
|
||||
icon: 'book',
|
||||
description: 'Customer requisitions, issuance, stop-payment, lost/stolen hotlisting'
|
||||
},
|
||||
{
|
||||
route: [
|
||||
'/chequebook',
|
||||
'lookup'
|
||||
],
|
||||
label: 'Leaf Lookup',
|
||||
icon: 'magnifying-glass',
|
||||
description: 'Counter check — is this cheque number safe to honour?'
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
/** Routing Imports */
|
||||
import { Route } from '../core/route/route.service';
|
||||
|
||||
/** Custom Components */
|
||||
import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component';
|
||||
import { ChequeSeriesComponent } from './cheque-series/cheque-series.component';
|
||||
import { StockManagementComponent } from './stock-management/stock-management.component';
|
||||
import { ChequeBooksComponent } from './cheque-books/cheque-books.component';
|
||||
import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component';
|
||||
import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component';
|
||||
|
||||
/** Cheque Book Inventory Routes */
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'chequebook',
|
||||
data: { title: 'Cheque Book Inventory', breadcrumb: 'Cheque Book Inventory' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: ChequeBookDashboardComponent
|
||||
},
|
||||
{
|
||||
path: 'series',
|
||||
component: ChequeSeriesComponent,
|
||||
data: { title: 'Cheque Series', breadcrumb: 'Cheque Series' }
|
||||
},
|
||||
{
|
||||
path: 'stock',
|
||||
component: StockManagementComponent,
|
||||
data: { title: 'Cheque Stock', breadcrumb: 'Cheque Stock' }
|
||||
},
|
||||
{
|
||||
path: 'books',
|
||||
component: ChequeBooksComponent,
|
||||
data: { title: 'Cheque Books', breadcrumb: 'Cheque Books' }
|
||||
},
|
||||
{
|
||||
path: 'books/:id',
|
||||
component: ChequeBookDetailComponent,
|
||||
data: { title: 'Cheque Book', routeParamBreadcrumb: 'id' }
|
||||
},
|
||||
{
|
||||
path: 'lookup',
|
||||
component: LeafLookupComponent,
|
||||
data: { title: 'Leaf Lookup', breadcrumb: 'Leaf Lookup' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ChequeBookRoutingModule {}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
/** Custom Modules */
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { DirectivesModule } from '../directives/directives.module';
|
||||
import { PipesModule } from 'app/pipes/pipes.module';
|
||||
import { ChequeBookRoutingModule } from './chequebook-routing.module';
|
||||
|
||||
/** Custom Components (standalone) */
|
||||
import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component';
|
||||
import { ChequeSeriesComponent } from './cheque-series/cheque-series.component';
|
||||
import { StockManagementComponent } from './stock-management/stock-management.component';
|
||||
import { ChequeBooksComponent } from './cheque-books/cheque-books.component';
|
||||
import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component';
|
||||
import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component';
|
||||
|
||||
/**
|
||||
* Cheque Book Inventory Module.
|
||||
*
|
||||
* Stationery master, a unified stock ledger (HO receipt / branch transfer-in), account issuance with
|
||||
* per-leaf serial tracking, stop-payment, and lost/stolen hotlisting for both issued and unissued stock.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
DirectivesModule,
|
||||
PipesModule,
|
||||
ChequeBookRoutingModule,
|
||||
ChequeBookDashboardComponent,
|
||||
ChequeSeriesComponent,
|
||||
StockManagementComponent,
|
||||
ChequeBooksComponent,
|
||||
ChequeBookDetailComponent,
|
||||
LeafLookupComponent
|
||||
]
|
||||
})
|
||||
export class ChequeBookModule {}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Cheque Book Inventory — stationery master, a unified stock ledger (HO receipt / branch transfer-in share
|
||||
* one drawdown-cursor concept), account issuance with per-leaf serial tracking, stop-payment and
|
||||
* lost/stolen hotlisting (both issued and unissued stock).
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ChequeBookService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ----- Series -----
|
||||
|
||||
/** status: 'ACTIVE' | 'INACTIVE'. Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
|
||||
getSeries(status?: string, offset = 0, limit = 20): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get('/chequeseries', { params });
|
||||
}
|
||||
|
||||
createSeries(payload: any): Observable<any> {
|
||||
return this.http.post('/chequeseries', payload);
|
||||
}
|
||||
|
||||
updateSeries(id: string | number, payload: any): Observable<any> {
|
||||
return this.http.put(`/chequeseries/${id}`, payload);
|
||||
}
|
||||
|
||||
deactivateSeries(id: string | number): Observable<any> {
|
||||
return this.http.delete(`/chequeseries/${id}`);
|
||||
}
|
||||
|
||||
// ----- Stock lots -----
|
||||
|
||||
/** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
|
||||
getStockLots(
|
||||
officeId?: string | number,
|
||||
seriesId?: string | number,
|
||||
status?: string,
|
||||
offset = 0,
|
||||
limit = 20
|
||||
): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (officeId) params = params.set('officeId', officeId);
|
||||
if (seriesId) params = params.set('seriesId', seriesId);
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get('/chequestocklots', { params });
|
||||
}
|
||||
|
||||
receiveStock(payload: any): Observable<any> {
|
||||
return this.http.post('/chequestocklots', payload);
|
||||
}
|
||||
|
||||
reportStockLotLost(lotId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequestocklots/${lotId}`, payload, {
|
||||
params: new HttpParams().set('command', 'reportlost')
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Stock transfers -----
|
||||
|
||||
/** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
|
||||
getStockTransfers(status?: string, offset = 0, limit = 20): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get('/chequestocktransfers', { params });
|
||||
}
|
||||
|
||||
dispatchStockTransfer(payload: any): Observable<any> {
|
||||
return this.http.post('/chequestocktransfers', payload);
|
||||
}
|
||||
|
||||
receiveStockTransfer(transferId: string | number): Observable<any> {
|
||||
return this.http.post(
|
||||
`/chequestocktransfers/${transferId}`,
|
||||
{},
|
||||
{ params: new HttpParams().set('command', 'receive') }
|
||||
);
|
||||
}
|
||||
|
||||
rejectStockTransfer(transferId: string | number): Observable<any> {
|
||||
return this.http.post(
|
||||
`/chequestocktransfers/${transferId}`,
|
||||
{},
|
||||
{ params: new HttpParams().set('command', 'reject') }
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Cheque books -----
|
||||
|
||||
getBooks(
|
||||
accountType?: string,
|
||||
accountId?: string | number,
|
||||
officeId?: string | number,
|
||||
status?: string,
|
||||
offset = 0,
|
||||
limit = 20
|
||||
): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (accountType) params = params.set('accountType', accountType);
|
||||
if (accountId) params = params.set('accountId', accountId);
|
||||
if (officeId) params = params.set('officeId', officeId);
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get('/chequebooks', { params });
|
||||
}
|
||||
|
||||
getBook(id: string | number): Observable<any> {
|
||||
return this.http.get(`/chequebooks/${id}`);
|
||||
}
|
||||
|
||||
getLeaves(bookId: string | number): Observable<any> {
|
||||
return this.http.get(`/chequebooks/${bookId}/leaves`);
|
||||
}
|
||||
|
||||
requestBook(payload: any): Observable<any> {
|
||||
return this.http.post('/chequebooks', payload);
|
||||
}
|
||||
|
||||
issueBook(bookId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'issue') });
|
||||
}
|
||||
|
||||
rejectBook(bookId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reject') });
|
||||
}
|
||||
|
||||
withdrawBook(bookId: string | number): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, {}, { params: new HttpParams().set('command', 'withdraw') });
|
||||
}
|
||||
|
||||
stopPayment(bookId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, payload, {
|
||||
params: new HttpParams().set('command', 'stoppayment')
|
||||
});
|
||||
}
|
||||
|
||||
revokeStopPayment(bookId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, payload, {
|
||||
params: new HttpParams().set('command', 'revokestoppayment')
|
||||
});
|
||||
}
|
||||
|
||||
reportBookLost(bookId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reportlost') });
|
||||
}
|
||||
|
||||
cancelLeaf(bookId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'cancelleaf') });
|
||||
}
|
||||
|
||||
/** Manually record that leaves cleared, once the branch sees them on the bank/clearing statement. */
|
||||
markUsed(bookId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'markused') });
|
||||
}
|
||||
|
||||
/** Flag leaves as returned/dishonoured (insufficient funds, signature mismatch, etc.) — terminal, like reportlost. */
|
||||
returnBook(bookId: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'return') });
|
||||
}
|
||||
|
||||
// ----- Pending requests (bespoke maker-checker for series/stock/book creation) -----
|
||||
|
||||
/**
|
||||
* A different user than the maker must approve/reject — enforced server-side, not just in this UI.
|
||||
* Server-side paginated and date-filtered, matching the clerk disbursement/withdrawal request queues.
|
||||
*/
|
||||
getPendingRequests(status?: string, fromDate?: string, toDate?: string, offset = 0, limit = 20): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', 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('/chequebook/pending-requests', { params });
|
||||
}
|
||||
|
||||
getPendingRequest(id: string | number): Observable<any> {
|
||||
return this.http.get(`/chequebook/pending-requests/${id}`);
|
||||
}
|
||||
|
||||
approvePendingRequest(id: string | number): Observable<any> {
|
||||
return this.http.post(
|
||||
`/chequebook/pending-requests/${id}`,
|
||||
{},
|
||||
{ params: new HttpParams().set('command', 'approve') }
|
||||
);
|
||||
}
|
||||
|
||||
rejectPendingRequest(id: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/chequebook/pending-requests/${id}`, payload, {
|
||||
params: new HttpParams().set('command', 'reject')
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Leaf lookup -----
|
||||
|
||||
lookupLeaf(serialNo: string | number): Observable<any> {
|
||||
return this.http.get('/chequeleaves/lookup', { params: new HttpParams().set('serialNo', serialNo) });
|
||||
}
|
||||
|
||||
// ----- Account lookup (by account number) -----
|
||||
|
||||
/** Search a Savings or Current account by its account number; returns a paginated list to match against. */
|
||||
searchAccountByNo(accountType: string, accountNo: string): Observable<any> {
|
||||
const base = accountType === 'CURRENT' ? '/currentaccounts' : '/savingsaccounts';
|
||||
const params = new HttpParams().set('accountNo', accountNo).set('offset', '0').set('limit', '20');
|
||||
return this.http.get(base, { params });
|
||||
}
|
||||
|
||||
// ----- Shared template data -----
|
||||
|
||||
getOffices(): Observable<any> {
|
||||
return this.http.get('/offices');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.inputs.Leaf Lookup' | translate }}</h2>
|
||||
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<mat-card class="search-card">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="searchForm" (ngSubmit)="search()" class="search-row">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Cheque Leaf Serial No' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="serialNo" required />
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="searchForm.invalid || busy">
|
||||
<fa-icon icon="magnifying-glass"></fa-icon> {{ 'labels.buttons.Search' | translate }}
|
||||
</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@if (results.length > 1) {
|
||||
<div class="multi-match-note">
|
||||
{{
|
||||
'labels.text.More than one series has a leaf with this serial number — check the series code against the physical cheque.'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (result of results; track result.seriesCode + '-' + result.leafSerialNo) {
|
||||
<mat-card class="result-card">
|
||||
<mat-card-content>
|
||||
<div class="result-header">
|
||||
@if (result.seriesCode) {
|
||||
<span class="series-tag">{{ result.seriesCode }}</span>
|
||||
}
|
||||
<span
|
||||
class="pill big"
|
||||
[ngClass]="
|
||||
result.status === 'ACTIVE'
|
||||
? 'ok'
|
||||
: result.status === 'USED'
|
||||
? 'used'
|
||||
: result.status === 'STOP_PAYMENT'
|
||||
? 'warn'
|
||||
: result.status === 'LOST'
|
||||
? 'bad'
|
||||
: result.status === 'RETURNED'
|
||||
? 'returned'
|
||||
: 'neutral'
|
||||
"
|
||||
>{{ result.status }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
|
||||
><span>{{ result.leafSerialNo }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
|
||||
><span>{{ result.seriesCode || '–' }}</span>
|
||||
</div>
|
||||
@if (result.chequeBookId) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Cheque Book' | translate }}</span
|
||||
><span>#{{ result.chequeBookId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
|
||||
><span>{{ result.accountType }} {{ result.accountNo || result.accountId }}</span>
|
||||
</div>
|
||||
@if (result.clientName) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Client' | translate }}</span
|
||||
><span>{{ result.clientName }}</span>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="warn-note">
|
||||
{{
|
||||
'labels.text.This leaf was never issued to a customer — reported lost/stolen from stock.' | translate
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
|
||||
><span>{{ result.officeName }}</span>
|
||||
</div>
|
||||
@if (result.status === 'STOP_PAYMENT') {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
|
||||
><span>{{ result.stopReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (result.status === 'LOST') {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
|
||||
><span>{{ result.lostReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (result.status === 'CANCELLED') {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
|
||||
><span>{{ result.cancelledReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (result.status === 'USED' && result.usedReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
|
||||
><span>{{ result.usedReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (result.status === 'RETURNED') {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Return Reason' | translate }}</span
|
||||
><span>{{ result.returnReason }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
} @empty {
|
||||
@if (searched && !busy) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
mat-form-field {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.result-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.series-tag {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.multi-match-note {
|
||||
margin-bottom: 16px;
|
||||
color: #a16207;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 24px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.k {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
.warn-note {
|
||||
grid-column: 1 / -1;
|
||||
color: #a16207;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
|
||||
&.big {
|
||||
font-size: 14px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&.ok {
|
||||
background: #dff5e8;
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.used {
|
||||
background: #e3edfd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background: #fde3e3;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: #fff3cd;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background: #e0e0e0;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
&.returned {
|
||||
background: #f3e8fd;
|
||||
color: #9333ea;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { ChequeBookService } from '../chequebook.service';
|
||||
|
||||
/**
|
||||
* The teller/counter quick-lookup: given a bare cheque-leaf serial number, is it active, stopped, lost, or
|
||||
* was it ever issued at all — one code path covers every case (issued or unissued-but-stolen stock).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-leaf-lookup',
|
||||
templateUrl: './leaf-lookup.component.html',
|
||||
styleUrls: ['./leaf-lookup.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class LeafLookupComponent {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
searchForm: UntypedFormGroup;
|
||||
// a bare serial number is only unique within its own series — more than one series can share a number,
|
||||
// so the lookup can legitimately return more than one match
|
||||
results: any[] = [];
|
||||
searched = false;
|
||||
busy = false;
|
||||
|
||||
constructor() {
|
||||
this.searchForm = this.formBuilder.group({
|
||||
serialNo: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
search(): void {
|
||||
if (this.searchForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.searched = true;
|
||||
this.results = [];
|
||||
this.chequeBookService.lookupLeaf(this.searchForm.value.serialNo).subscribe({
|
||||
next: (data: any) => {
|
||||
this.busy = false;
|
||||
this.results = Array.isArray(data) ? data : [data];
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.results = [];
|
||||
this.notify('No cheque leaf found with that serial number', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>{{ 'labels.inputs.Report Remaining Stock Lost/Stolen' | translate }}</h1>
|
||||
<div mat-dialog-content>
|
||||
<div class="rll-context">
|
||||
<div class="rll-field">
|
||||
<span class="k">{{ 'labels.inputs.Series' | translate }}</span>
|
||||
<span>{{ data.seriesCode }}</span>
|
||||
</div>
|
||||
<div class="rll-field">
|
||||
<span class="k">{{ 'labels.inputs.Office' | translate }}</span>
|
||||
<span>{{ data.officeName }}</span>
|
||||
</div>
|
||||
<div class="rll-field">
|
||||
<span class="k">{{ 'labels.inputs.Remaining Range' | translate }}</span>
|
||||
<span>{{ data.nextSerialNo }} – {{ data.rangeEnd }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="reportLostForm" (ngSubmit)="submit()" class="rll-form">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.From Serial No' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="fromSerialNo" required />
|
||||
<mat-hint>{{
|
||||
'labels.text.Only a suffix of the remaining undrawn range can be reported' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
|
||||
<input matInput formControlName="reason" required />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||
<button mat-raised-button color="warn" [disabled]="reportLostForm.invalid || busy" (click)="submit()">
|
||||
{{ 'labels.buttons.Report Lost' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.rll-context {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px 20px;
|
||||
min-width: 420px;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
.rll-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.k {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 20px;
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { ChequeBookService } from '../../chequebook.service';
|
||||
|
||||
/** Popup shown when "Report Lost" is clicked on a stock lot row — reports the undrawn suffix as lost/stolen. */
|
||||
@Component({
|
||||
selector: 'mifosx-report-lot-lost-dialog',
|
||||
templateUrl: './report-lot-lost-dialog.component.html',
|
||||
styleUrls: ['./report-lot-lost-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class ReportLotLostDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<ReportLotLostDialogComponent>>(MatDialogRef);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
busy = false;
|
||||
reportLostForm: UntypedFormGroup;
|
||||
|
||||
constructor() {
|
||||
this.reportLostForm = this.formBuilder.group({
|
||||
fromSerialNo: [
|
||||
this.data.nextSerialNo,
|
||||
Validators.required
|
||||
],
|
||||
reason: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.reportLostForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.reportLostForm.value;
|
||||
const payload = { fromSerialNo: raw.fromSerialNo, reason: raw.reason, locale: 'en' };
|
||||
this.chequeBookService.reportStockLotLost(this.data.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.notify('Remaining stock reported lost/stolen');
|
||||
this.dialogRef.close({ reported: true });
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify(
|
||||
'Failed to report stock lost — check the serial number is within the remaining undrawn range',
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.inputs.Cheque Stock' | translate }}</h2>
|
||||
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<mat-tab-group>
|
||||
<!-- ============ Stock Lots ============ -->
|
||||
<mat-tab label="{{ 'labels.inputs.Stock Lots' | translate }}">
|
||||
<div class="tab-body">
|
||||
<!-- Denomination-wise availability roll-up: how many 25-leaf / 50-leaf books are on hand -->
|
||||
@if (stockSummary.length > 0) {
|
||||
<div class="summary-strip">
|
||||
<div class="summary-title">{{ 'labels.inputs.Available Stock by Book Size' | translate }}</div>
|
||||
<div class="summary-cards">
|
||||
@for (row of stockSummary; track row.seriesCode + row.leavesPerBook) {
|
||||
<div class="summary-card">
|
||||
<div class="summary-books">{{ row.books }}</div>
|
||||
<div class="summary-label">
|
||||
{{ 'labels.inputs.books' | translate }} × {{ row.leavesPerBook }}-{{
|
||||
'labels.inputs.leaf' | translate
|
||||
}}
|
||||
</div>
|
||||
<div class="summary-series">{{ row.seriesCode }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="tab-toolbar">
|
||||
<button mat-raised-button color="primary" (click)="showReceiveForm = !showReceiveForm">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Receive Stock' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showReceiveForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<div class="global-allocation-note">
|
||||
{{ 'labels.text.Highest leaf serial allocated so far, across every series' | translate }}:
|
||||
<strong>{{ globalHighestAllocated }}</strong>
|
||||
— {{ 'labels.text.next available start' | translate }}:
|
||||
<strong>{{ globalHighestAllocated + 1 }}</strong>
|
||||
</div>
|
||||
<form [formGroup]="receiveForm" (ngSubmit)="submitReceive()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
|
||||
<mat-select formControlName="seriesId" required (selectionChange)="onReceiveSeriesChange()">
|
||||
@for (s of seriesList; track s.id) {
|
||||
<mat-option [value]="s.id"
|
||||
>{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{
|
||||
'labels.inputs.leaf' | translate
|
||||
}})</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
|
||||
<mat-select formControlName="officeId" required>
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Number of Books' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="noOfBooks" required />
|
||||
@if (receiveLeavesPerBook) {
|
||||
<mat-hint
|
||||
>{{ receiveTotalLeaves }} {{ 'labels.inputs.leaves' | translate }} ({{
|
||||
'labels.inputs.books' | translate
|
||||
}}
|
||||
× {{ receiveLeavesPerBook }})</mat-hint
|
||||
>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Range Start' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="rangeStart" required />
|
||||
<mat-hint
|
||||
>{{ 'labels.text.Auto-suggested to avoid overlap across every series; editable' | translate }} ({{
|
||||
globalHighestAllocated
|
||||
}}
|
||||
+ 1)</mat-hint
|
||||
>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Range End' | translate }}</mat-label>
|
||||
<input matInput type="number" [value]="receiveRangeEnd ?? ''" readonly />
|
||||
<mat-hint>{{ 'labels.text.Calculated from books x book size' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Batch / PO Reference' | translate }}</mat-label>
|
||||
<input matInput formControlName="batchRef" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="showReceiveForm = false">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="receiveForm.invalid || !receiveRangeEnd || busy"
|
||||
>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div class="filter-row">
|
||||
@for (s of lotStatusOptions; track s) {
|
||||
<button mat-stroked-button [class.active]="lotStatusFilter === s" (click)="filterLotsByStatus(s)">
|
||||
{{ s }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="displayedLots">
|
||||
<ng-container matColumnDef="seriesCode">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l">{{ l.seriesCode }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l">{{ l.officeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="range">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaf Range' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l" class="num">{{ l.rangeStart }} – {{ l.rangeEnd }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="remainingLeaves">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Remaining' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l" class="num">
|
||||
<strong>{{ l.remainingBooks ?? 0 }}</strong> {{ 'labels.inputs.books' | translate }}
|
||||
<span class="muted">× {{ l.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }}</span>
|
||||
<span class="muted">({{ l.remainingLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
|
||||
@if (l.lowStock) {
|
||||
<span class="pill warn">{{ 'labels.text.Low Stock' | translate }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let l">
|
||||
<span
|
||||
class="pill"
|
||||
[ngClass]="l.status === 'AVAILABLE' ? 'ok' : l.status === 'CANCELLED' ? 'bad' : 'neutral'"
|
||||
>{{ l.status }}</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let l">
|
||||
@if (l.status === 'AVAILABLE') {
|
||||
<button mat-button color="warn" (click)="openReportLost(l)">
|
||||
{{ 'labels.buttons.Report Lost' | translate }}
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="lotColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: lotColumns"></tr>
|
||||
</table>
|
||||
@if (displayedLots.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (lotTotalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="lotTotalRecords"
|
||||
[pageSize]="lotPageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="lotPageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onLotPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- ============ Stock Transfers ============ -->
|
||||
<mat-tab label="{{ 'labels.inputs.Stock Transfers' | translate }}">
|
||||
<div class="tab-body">
|
||||
<div class="tab-toolbar">
|
||||
<button mat-raised-button color="primary" (click)="showDispatchForm = !showDispatchForm">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Dispatch Transfer' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showDispatchForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="dispatchForm" (ngSubmit)="submitDispatch()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
|
||||
<mat-select formControlName="seriesId" required>
|
||||
@for (s of seriesList; track s.id) {
|
||||
<mat-option [value]="s.id">{{ s.seriesCode }} — {{ s.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.From Office' | translate }}</mat-label>
|
||||
<mat-select formControlName="fromOfficeId" required>
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.To Office' | translate }}</mat-label>
|
||||
<mat-select formControlName="toOfficeId" required>
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.From Lot Id' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="fromLotId" required />
|
||||
<mat-hint>{{
|
||||
'labels.text.The stock-lot id to draw the transfer from (see Stock Lots tab)' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.No of Leaves' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="noOfLeaves" required />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="showDispatchForm = false">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="dispatchForm.invalid || busy">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div class="filter-row">
|
||||
@for (s of transferStatusOptions; track s) {
|
||||
<button mat-stroked-button [class.active]="transferStatusFilter === s" (click)="filterTransfersByStatus(s)">
|
||||
{{ s }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="stockTransfers">
|
||||
<ng-container matColumnDef="seriesCode">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let t">{{ t.seriesCode }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fromOfficeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.From Office' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let t">{{ t.fromOfficeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="toOfficeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.To Office' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let t">{{ t.toOfficeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="range">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaf Range' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let t" class="num">{{ t.rangeStart }} – {{ t.rangeEnd }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="totalLeaves">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaves' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let t" class="num">{{ t.totalLeaves }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let t">
|
||||
<span
|
||||
class="pill"
|
||||
[ngClass]="t.status === 'RECEIVED' ? 'ok' : t.status === 'REJECTED' ? 'bad' : 'neutral'"
|
||||
>{{ t.status }}</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="dispatchDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Dispatch Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let t">{{ t.dispatchDate | dateFormat }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let t">
|
||||
@if (t.status === 'DISPATCHED') {
|
||||
<button mat-button color="primary" (click)="receiveTransfer(t)">
|
||||
{{ 'labels.buttons.Receive' | translate }}
|
||||
</button>
|
||||
<button mat-button color="warn" (click)="rejectTransfer(t)">
|
||||
{{ 'labels.buttons.Reject' | translate }}
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="transferColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: transferColumns"></tr>
|
||||
</table>
|
||||
@if (stockTransfers.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (transferTotalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="transferTotalRecords"
|
||||
[pageSize]="transferPageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="transferPageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onTransferPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab-body {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button.active {
|
||||
background: rgb(25 118 210 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
min-width: 120px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgb(0 0 0 / 10%);
|
||||
border-radius: 10px;
|
||||
background: rgb(25 118 210 / 6%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-books {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.8rem;
|
||||
color: rgb(0 0 0 / 70%);
|
||||
}
|
||||
|
||||
.summary-series {
|
||||
font-size: 0.72rem;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.global-allocation-note {
|
||||
margin-bottom: 14px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgb(25 118 210 / 8%);
|
||||
color: rgb(0 0 0 / 70%);
|
||||
font-size: 0.85rem;
|
||||
|
||||
strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px 20px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td.num,
|
||||
th.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
|
||||
&.ok {
|
||||
background: #dff5e8;
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background: #fde3e3;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: #fff3cd;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background: #e0e0e0;
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { ChequeBookService } from '../chequebook.service';
|
||||
|
||||
/** Dialog Components */
|
||||
import { ReportLotLostDialogComponent } from './report-lot-lost-dialog/report-lot-lost-dialog.component';
|
||||
|
||||
/**
|
||||
* Cheque stock: the unified stock-lot ledger (HO receipt / branch transfer-in share one drawdown-cursor
|
||||
* concept) and the office-to-office transfer workflow (dispatch → receive-ack / reject) that draws from it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-stock-management',
|
||||
templateUrl: './stock-management.component.html',
|
||||
styleUrls: ['./stock-management.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
RouterLink,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatTabsModule,
|
||||
MatPaginatorModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class StockManagementComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
seriesList: any[] = [];
|
||||
offices: any[] = [];
|
||||
busy = false;
|
||||
|
||||
showReceiveForm = false;
|
||||
showDispatchForm = false;
|
||||
|
||||
receiveForm: UntypedFormGroup;
|
||||
dispatchForm: UntypedFormGroup;
|
||||
|
||||
lotColumns = [
|
||||
'seriesCode',
|
||||
'officeName',
|
||||
'range',
|
||||
'remainingLeaves',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
transferColumns = [
|
||||
'seriesCode',
|
||||
'fromOfficeName',
|
||||
'toOfficeName',
|
||||
'range',
|
||||
'totalLeaves',
|
||||
'status',
|
||||
'dispatchDate',
|
||||
'actions'
|
||||
];
|
||||
|
||||
readonly lotStatusOptions = [
|
||||
'AVAILABLE',
|
||||
'DISPATCHED',
|
||||
'EXHAUSTED',
|
||||
'PARTIALLY_COMPROMISED',
|
||||
'CANCELLED'
|
||||
];
|
||||
readonly transferStatusOptions = [
|
||||
'DISPATCHED',
|
||||
'RECEIVED',
|
||||
'REJECTED'
|
||||
];
|
||||
|
||||
/** Every stock lot, unfiltered — drives the denomination roll-up and the "next free serial" calculation,
|
||||
* so it must never be limited to whatever page/status the display table happens to be showing. */
|
||||
private allStockLots: any[] = [];
|
||||
|
||||
/** The lot table shown on screen — server-side paginated and status-filtered independently of allStockLots. */
|
||||
displayedLots: any[] = [];
|
||||
lotStatusFilter = '';
|
||||
lotPageSize = 10;
|
||||
lotPageIndex = 0;
|
||||
lotTotalRecords = 0;
|
||||
|
||||
stockTransfers: any[] = [];
|
||||
transferStatusFilter = '';
|
||||
transferPageSize = 10;
|
||||
transferPageIndex = 0;
|
||||
transferTotalRecords = 0;
|
||||
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.receiveForm = this.formBuilder.group({
|
||||
seriesId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
officeId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
noOfBooks: [
|
||||
1,
|
||||
Validators.required
|
||||
],
|
||||
rangeStart: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
batchRef: ['']
|
||||
});
|
||||
this.dispatchForm = this.formBuilder.group({
|
||||
seriesId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
fromOfficeId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
toOfficeId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
fromLotId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
noOfLeaves: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAllStockLotsForCalculations();
|
||||
this.loadDisplayedLots();
|
||||
this.loadStockTransfers();
|
||||
// populates dropdowns, so fetch every active series in one page rather than paging through it
|
||||
this.chequeBookService
|
||||
.getSeries('ACTIVE', 0, 500)
|
||||
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
|
||||
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
|
||||
}
|
||||
|
||||
/** Denomination-wise roll-up of available books, e.g. "CTS-25 (25-leaf): 2000 books". */
|
||||
stockSummary: { seriesCode: string; leavesPerBook: number; books: number; leaves: number }[] = [];
|
||||
|
||||
/** Unfiltered, every status, every page — feeds stockSummary and nextFreeSerial only, never the table. */
|
||||
loadAllStockLotsForCalculations(): void {
|
||||
this.chequeBookService.getStockLots(undefined, undefined, undefined, 0, 500).subscribe((res: any) => {
|
||||
this.allStockLots = res?.pageItems ?? [];
|
||||
this.buildSummary();
|
||||
});
|
||||
}
|
||||
|
||||
loadDisplayedLots(): void {
|
||||
this.chequeBookService
|
||||
.getStockLots(
|
||||
undefined,
|
||||
undefined,
|
||||
this.lotStatusFilter || undefined,
|
||||
this.lotPageIndex * this.lotPageSize,
|
||||
this.lotPageSize
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.displayedLots = res?.pageItems ?? [];
|
||||
this.lotTotalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
filterLotsByStatus(status: string): void {
|
||||
this.lotStatusFilter = this.lotStatusFilter === status ? '' : status;
|
||||
this.lotPageIndex = 0;
|
||||
this.loadDisplayedLots();
|
||||
}
|
||||
|
||||
onLotPageChange(event: PageEvent): void {
|
||||
this.lotPageSize = event.pageSize;
|
||||
this.lotPageIndex = event.pageIndex;
|
||||
this.loadDisplayedLots();
|
||||
}
|
||||
|
||||
private buildSummary(): void {
|
||||
const map = new Map<string, { seriesCode: string; leavesPerBook: number; books: number; leaves: number }>();
|
||||
for (const lot of this.allStockLots) {
|
||||
// only stock that can actually be issued/transferred counts toward availability
|
||||
if (lot.status !== 'AVAILABLE') {
|
||||
continue;
|
||||
}
|
||||
const key = `${lot.seriesCode}|${lot.leavesPerBook}`;
|
||||
const row = map.get(key) || { seriesCode: lot.seriesCode, leavesPerBook: lot.leavesPerBook, books: 0, leaves: 0 };
|
||||
row.books += lot.remainingBooks || 0;
|
||||
row.leaves += lot.remainingLeaves || 0;
|
||||
map.set(key, row);
|
||||
}
|
||||
this.stockSummary = Array.from(map.values()).sort((a, b) => (a.leavesPerBook || 0) - (b.leavesPerBook || 0));
|
||||
}
|
||||
|
||||
loadStockTransfers(): void {
|
||||
this.chequeBookService
|
||||
.getStockTransfers(
|
||||
this.transferStatusFilter || undefined,
|
||||
this.transferPageIndex * this.transferPageSize,
|
||||
this.transferPageSize
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.stockTransfers = res?.pageItems ?? [];
|
||||
this.transferTotalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
filterTransfersByStatus(status: string): void {
|
||||
this.transferStatusFilter = this.transferStatusFilter === status ? '' : status;
|
||||
this.transferPageIndex = 0;
|
||||
this.loadStockTransfers();
|
||||
}
|
||||
|
||||
onTransferPageChange(event: PageEvent): void {
|
||||
this.transferPageSize = event.pageSize;
|
||||
this.transferPageIndex = event.pageIndex;
|
||||
this.loadStockTransfers();
|
||||
}
|
||||
|
||||
/** The series currently chosen in the receive form. */
|
||||
get receiveSeries(): any {
|
||||
return this.seriesList.find((s) => s.id === this.receiveForm.get('seriesId')?.value);
|
||||
}
|
||||
|
||||
get receiveLeavesPerBook(): number {
|
||||
return this.receiveSeries?.leavesPerBook || 0;
|
||||
}
|
||||
|
||||
/** Leaves to receive = books x this series' book size. */
|
||||
get receiveTotalLeaves(): number {
|
||||
const books = Number(this.receiveForm.get('noOfBooks')?.value) || 0;
|
||||
return books * this.receiveLeavesPerBook;
|
||||
}
|
||||
|
||||
/** Derived — the last serial covered by this receipt, so the operator never types it. */
|
||||
get receiveRangeEnd(): number | null {
|
||||
const start = Number(this.receiveForm.get('rangeStart')?.value);
|
||||
const leaves = this.receiveTotalLeaves;
|
||||
if (!start || !leaves) {
|
||||
return null;
|
||||
}
|
||||
return start + leaves - 1;
|
||||
}
|
||||
|
||||
/** Highest leaf-serial allocated so far, across every series/office — the actual number the "Range Start"
|
||||
* suggestion is based on. Shown directly in the form (not just silently applied to the field) so the operator
|
||||
* can see and trust the reasoning instead of taking a pre-filled number on faith. */
|
||||
get globalHighestAllocated(): number {
|
||||
return this.allStockLots.reduce((m, l) => Math.max(m, l.rangeEnd || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Next unused serial across EVERY series, not just the one being received — a real bank allocates leaf-serial
|
||||
* numbers from one bank-wide numbering authority, so two different cheque designs never legitimately reuse the
|
||||
* same range. Suggesting a start that only avoids the same series' own lots is exactly what let two series end
|
||||
* up overlapping (and, once both got issued to the same account, an unresolvable serial collision) before the
|
||||
* backend's own overlap check was widened to match (ChequeStockLotRepository.countOverlappingAnySeries).
|
||||
*/
|
||||
private nextFreeSerial(): number {
|
||||
return this.globalHighestAllocated + 1;
|
||||
}
|
||||
|
||||
onReceiveSeriesChange(): void {
|
||||
// pre-fill a non-overlapping start; operator can still override
|
||||
this.receiveForm.patchValue({ rangeStart: this.nextFreeSerial() });
|
||||
}
|
||||
|
||||
private reloadLots(): void {
|
||||
this.loadAllStockLotsForCalculations();
|
||||
this.loadDisplayedLots();
|
||||
}
|
||||
|
||||
submitReceive(): void {
|
||||
if (this.receiveForm.invalid || this.busy || !this.receiveRangeEnd) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.receiveForm.value;
|
||||
const payload: any = {
|
||||
seriesId: raw.seriesId,
|
||||
officeId: raw.officeId,
|
||||
rangeStart: raw.rangeStart,
|
||||
rangeEnd: this.receiveRangeEnd,
|
||||
locale: 'en'
|
||||
};
|
||||
if (raw.batchRef) payload.batchRef = raw.batchRef;
|
||||
|
||||
this.chequeBookService.receiveStock(payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.showReceiveForm = false;
|
||||
this.receiveForm.reset({ noOfBooks: 1 });
|
||||
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
||||
this.reloadLots();
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify('Failed to receive stock — check the leaf range does not overlap existing stock', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openReportLost(lot: any): void {
|
||||
this.dialog
|
||||
.open(ReportLotLostDialogComponent, { data: lot, width: '520px' })
|
||||
.afterClosed()
|
||||
.subscribe((result: any) => {
|
||||
if (result?.reported) {
|
||||
this.reloadLots();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitDispatch(): void {
|
||||
if (this.dispatchForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.dispatchForm.value;
|
||||
const payload = {
|
||||
seriesId: raw.seriesId,
|
||||
fromOfficeId: raw.fromOfficeId,
|
||||
toOfficeId: raw.toOfficeId,
|
||||
fromLotId: raw.fromLotId,
|
||||
noOfLeaves: raw.noOfLeaves,
|
||||
locale: 'en'
|
||||
};
|
||||
this.chequeBookService.dispatchStockTransfer(payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.showDispatchForm = false;
|
||||
this.dispatchForm.reset();
|
||||
this.notify('Stock transfer dispatched');
|
||||
this.reloadLots();
|
||||
this.loadStockTransfers();
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify('Failed to dispatch — check the lot has enough remaining leaves', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
receiveTransfer(transfer: any): void {
|
||||
this.chequeBookService.receiveStockTransfer(transfer.id).subscribe({
|
||||
next: () => {
|
||||
this.notify('Transfer receipt acknowledged');
|
||||
this.reloadLots();
|
||||
this.loadStockTransfers();
|
||||
},
|
||||
error: () => this.notify('Failed to acknowledge receipt', true)
|
||||
});
|
||||
}
|
||||
|
||||
rejectTransfer(transfer: any): void {
|
||||
this.chequeBookService.rejectStockTransfer(transfer.id).subscribe({
|
||||
next: () => {
|
||||
this.notify('Transfer rejected');
|
||||
this.loadStockTransfers();
|
||||
},
|
||||
error: () => this.notify('Failed to reject transfer', true)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,16 @@
|
||||
* have meaningful variety out of the box.
|
||||
*/
|
||||
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
|
||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|4||||||
|
||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|4||||||
|
||||
30|1|C|AAQPS8560J||01|02|||||
|
||||
70|1|10078_Sign.jpeg|09|01|01||||
|
||||
70|1|10078_Photo.jpeg|02|01|01||||
|
||||
70|1|10078_AP.jpg|04|01|01||||
|
||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||
30|2|E|XXXXXXXX2210||01|02|||||
|
||||
70|2|10079_Sign.jpeg|09|01|01||||
|
||||
70|2|10079_Photo.jpeg|02|01|01||||
|
||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||
30|3|C|AGTPD1144K||01|02|||||
|
||||
70|3|10080_Sign.jpeg|09|01|01||||
|
||||
70|3|10080_AP.jpg|04|01|01||||
|
||||
|
||||
@@ -446,8 +446,12 @@ export class RecordMasterComponent implements OnInit {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Signature — client document named 'clientSignature'
|
||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
||||
// 2. Signature — client document named 'clientSignature'. Multiple rows can exist for
|
||||
// clients who re-cropped/replaced their signature before uploads became single-slot, so
|
||||
// pick the highest id (latest) rather than the first unordered match.
|
||||
const signatureDoc = (documents as any[])
|
||||
?.filter((d: any) => d?.name === 'clientSignature')
|
||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||
if (signatureDoc) {
|
||||
this.viewDetails.docs.signature = {
|
||||
present: true,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maps a clearing status/decision string to one of the three shared .pill classes
|
||||
* (ok / bad / neutral). Shared by every CTS, NACH and settlement/reconciliation screen so the
|
||||
* colour language stays consistent across the whole clearing feature.
|
||||
*/
|
||||
export function pillClass(status: string | null | undefined): 'ok' | 'bad' | 'neutral' {
|
||||
const s = (status || '').toUpperCase();
|
||||
const ok = [
|
||||
'PASSED',
|
||||
'PASS',
|
||||
'POSTED',
|
||||
'MATCHED',
|
||||
'SETTLED',
|
||||
'SENT',
|
||||
'SENT_TO_CLEARING_HOUSE',
|
||||
'PROCESSED',
|
||||
'VALIDATED',
|
||||
'ACTIVE',
|
||||
'CLEARED',
|
||||
'HONOURED',
|
||||
'COMPLETED'
|
||||
];
|
||||
const bad = [
|
||||
'RETURNED',
|
||||
'RETURN',
|
||||
'REJECTED',
|
||||
'MISMATCH',
|
||||
'DISHONOURED',
|
||||
'CANCELLED',
|
||||
'FAILED',
|
||||
'FAIL',
|
||||
'UNROUTABLE'
|
||||
];
|
||||
if (ok.includes(s)) {
|
||||
return 'ok';
|
||||
}
|
||||
if (bad.includes(s)) {
|
||||
return 'bad';
|
||||
}
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/** Extracts the most specific backend error message from a Fineract-style error response. */
|
||||
export function clearingError(err: any, fallback = 'Request failed'): string {
|
||||
return err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Shared clearing services used across CTS and NACH — return-reason-code master, settlement-batch posting,
|
||||
* and reconciliation.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ClearingService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ----- Return reason codes -----
|
||||
|
||||
/** appliesTo: 'CTS' | 'NACH'. Returns a raw array of reason codes. */
|
||||
getReturnReasonCodes(appliesTo: string): Observable<any> {
|
||||
return this.http.get('/clearing/return-reason-codes', {
|
||||
params: new HttpParams().set('appliesTo', appliesTo)
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Settlement batches -----
|
||||
|
||||
/** Server-side paginated — returns { pageItems, totalFilteredRecords }. */
|
||||
getSettlementBatches(clearingType?: string, status?: string, offset = 0, limit = 20): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (clearingType) params = params.set('clearingType', clearingType);
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get('/clearing/settlement-batches', { params });
|
||||
}
|
||||
|
||||
getSettlementBatch(id: string | number): Observable<any> {
|
||||
return this.http.get(`/clearing/settlement-batches/${id}`);
|
||||
}
|
||||
|
||||
/** payload: { clearingType, sourceBatchId }. */
|
||||
prepareSettlement(payload: any): Observable<any> {
|
||||
return this.http.post('/clearing/settlement-batches', payload);
|
||||
}
|
||||
|
||||
postSettlement(id: string | number): Observable<any> {
|
||||
return this.http.post(
|
||||
`/clearing/settlement-batches/${id}`,
|
||||
{},
|
||||
{ params: new HttpParams().set('command', 'post') }
|
||||
);
|
||||
}
|
||||
|
||||
rejectSettlement(id: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/clearing/settlement-batches/${id}`, payload, {
|
||||
params: new HttpParams().set('command', 'reject')
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Reconciliations -----
|
||||
|
||||
getReconciliations(clearingType?: string, offset = 0, limit = 20): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (clearingType) params = params.set('clearingType', clearingType);
|
||||
return this.http.get('/clearing/reconciliations', { params });
|
||||
}
|
||||
|
||||
runReconciliation(payload: any): Observable<any> {
|
||||
return this.http.post('/clearing/reconciliations', payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
export interface JointHolder {
|
||||
clientId: number;
|
||||
cifNumber?: string;
|
||||
displayName: string;
|
||||
sharePercentage?: number;
|
||||
signingAuthority?: string;
|
||||
role?: string;
|
||||
isPrimary?: boolean;
|
||||
isMandatory?: boolean;
|
||||
}
|
||||
|
||||
export interface AccountSignatoryInfo {
|
||||
modeOfOperation: string | null;
|
||||
primaryClientId: number | null;
|
||||
primaryName: string | null;
|
||||
jointHolders: JointHolder[];
|
||||
availableBalance: number | null;
|
||||
onHoldAmount: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches account-holder/signatory data needed to verify a cheque's signature — reusing the exact APIs and
|
||||
* conventions already established in the cashier module's joint-holder detail view
|
||||
* (`transaction-entry.component.ts`), so this isn't a second, divergent implementation of the same lookup.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SignatoryService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
/**
|
||||
* Fetches the savings or current account (with joint holders and mode of operation embedded via
|
||||
* `associations=all` — the same Fineract convention the cashier module already relies on).
|
||||
*/
|
||||
fetchAccountSignatories(accountType: string, accountId: number | string): Observable<AccountSignatoryInfo> {
|
||||
const path =
|
||||
accountType?.toUpperCase() === 'CURRENT' ? `/currentaccounts/${accountId}` : `/savingsaccounts/${accountId}`;
|
||||
return this.http.get<any>(path, { params: new HttpParams().set('associations', 'all') }).pipe(
|
||||
map((acc: any) => ({
|
||||
modeOfOperation: acc?.modeOfOperation ?? null,
|
||||
primaryClientId: acc?.clientId ?? null,
|
||||
primaryName: acc?.clientName ?? acc?.name ?? null,
|
||||
jointHolders: Array.isArray(acc?.jointHolders) ? acc.jointHolders : [],
|
||||
availableBalance: acc?.summary?.availableBalance ?? null,
|
||||
onHoldAmount: acc?.summary?.totalSavingsAmountOnHold ?? acc?.onHoldFundsDerived ?? null
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's stored signature (a document tagged name === 'clientSignature'), as a blob object URL, or null if none uploaded. */
|
||||
fetchClientSignature(clientId: number | string): Observable<string | null> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
|
||||
switchMap((docs: any[]) => {
|
||||
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
|
||||
// their signature before uploads became single-slot, so pick the highest id (latest)
|
||||
// rather than the first unordered match.
|
||||
const sigDoc = (docs || [])
|
||||
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||
if (!sigDoc) {
|
||||
return of(null);
|
||||
}
|
||||
return this.http.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' }).pipe(
|
||||
map((blob: Blob) => URL.createObjectURL(blob)),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's photo (already base64/text from this API), or null if none uploaded. */
|
||||
fetchClientPhoto(clientId: number | string): Observable<string | null> {
|
||||
return this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null)));
|
||||
}
|
||||
}
|
||||
@@ -11,116 +11,79 @@
|
||||
}
|
||||
|
||||
.af-page {
|
||||
background: #f8fafc;
|
||||
min-height: calc(100vh - 68px);
|
||||
background: #f0f4f8;
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// ── Hero ──────────────────────────────────────────────────────────────────────
|
||||
// ── Header ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
.af-hero {
|
||||
.af-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 40%, #2563eb 100%);
|
||||
border-radius: 16px;
|
||||
padding: 22px 28px;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 32px rgb(37 99 235 / 35%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -40px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__icon-wrap {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: rgb(255 255 255 / 15%);
|
||||
border: 1px solid rgb(255 255 255 / 25%);
|
||||
border-radius: 14px;
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
backdrop-filter: blur(8px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
&__icon {
|
||||
color: #0f4f3c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 5px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
color: rgb(255 255 255 / 75%);
|
||||
font-weight: 700;
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
&--green {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&--green strong {
|
||||
color: #86efac;
|
||||
&--red {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--red strong {
|
||||
color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
&__sep {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgb(255 255 255 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
.af-back-btn {
|
||||
.af-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 20px;
|
||||
background: rgb(255 255 255 / 12%);
|
||||
border: 1px solid rgb(255 255 255 / 25%);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
gap: 6px;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
color: #0d1b2a;
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 22%);
|
||||
transform: translateX(-2px);
|
||||
background: #f0fdf4;
|
||||
border-color: #0f4f3c;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +95,12 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 60px 20px;
|
||||
gap: 10px;
|
||||
padding: 40px 20px;
|
||||
|
||||
&__icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #e2e8f0;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 50%;
|
||||
@@ -147,80 +110,87 @@
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 6px 0 0;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 13.5px;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
margin-top: 10px;
|
||||
padding: 10px 24px;
|
||||
background: #2563eb;
|
||||
margin-top: 6px;
|
||||
padding: 8px 20px;
|
||||
background: #0f4f3c;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
.af-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 20px 16px;
|
||||
}
|
||||
|
||||
// ── Progress card ──────────────────────────────────────────────────────────────
|
||||
|
||||
.af-progress-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
padding: 18px 22px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
&__fraction {
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #16a34a;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
.af-progress-bar {
|
||||
height: 10px;
|
||||
height: 7px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #16a34a, #22c55e);
|
||||
background: #16a34a;
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s ease;
|
||||
box-shadow: 0 0 8px rgb(34 197 94 / 35%);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,65 +199,53 @@
|
||||
.af-fac-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 22px;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
font-size: 11.5px;
|
||||
padding: 9px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
}
|
||||
|
||||
.af-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 20px 22px;
|
||||
gap: 7px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.af-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 15px;
|
||||
gap: 5px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1.5px solid;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
border: 1px solid;
|
||||
cursor: default;
|
||||
|
||||
&__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--on {
|
||||
background: #f0fdf4;
|
||||
border-color: #22c55e;
|
||||
color: #15803d;
|
||||
border-color: #86efac;
|
||||
color: #166534;
|
||||
|
||||
.af-pill__dot {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 5px rgb(34 197 94 / 50%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgb(34 197 94 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,63 +265,46 @@
|
||||
.af-stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.af-stat-card {
|
||||
border-radius: 14px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
border: 1px solid;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
font-size: 12px;
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__num {
|
||||
font-size: 30px;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
color: #15803d;
|
||||
|
||||
.af-stat-card__icon {
|
||||
background: #bbf7d0;
|
||||
}
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
|
||||
.af-stat-card__icon {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,34 +18,37 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||
styleUrl: './account-facilities.component.scss',
|
||||
template: `
|
||||
<div class="af-page">
|
||||
<!-- Hero -->
|
||||
<div class="af-hero">
|
||||
<div class="af-hero__icon-wrap">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<!-- Header -->
|
||||
<div class="af-header">
|
||||
<div class="af-header__left">
|
||||
<svg
|
||||
class="af-header__icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="af-hero__text">
|
||||
<h1 class="af-hero__title">Account Facilities</h1>
|
||||
@if (accountLoaded) {
|
||||
<div class="af-hero__stats">
|
||||
<span class="af-hero__stat af-hero__stat--green">
|
||||
<strong>{{ activeCount }}</strong> Active
|
||||
</span>
|
||||
<span class="af-hero__sep"></span>
|
||||
<span class="af-hero__stat af-hero__stat--red">
|
||||
<strong>{{ facilities.length - activeCount }}</strong> Inactive
|
||||
</span>
|
||||
<span class="af-hero__sep"></span>
|
||||
<span class="af-hero__stat">
|
||||
<strong>{{ facilities.length }}</strong> Total
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<div class="af-header__title">Account Facilities</div>
|
||||
@if (accountLoaded) {
|
||||
<div class="af-header__sub">
|
||||
<span class="af-header__stat af-header__stat--green">{{ activeCount }} active</span>
|
||||
·
|
||||
<span class="af-header__stat af-header__stat--red">{{ facilities.length - activeCount }} inactive</span>
|
||||
· {{ facilities.length }} total
|
||||
</div>
|
||||
} @else {
|
||||
<div class="af-header__sub">View all facilities enabled for the loaded account.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!embedded) {
|
||||
<button class="af-back-btn" (click)="goBack()">
|
||||
<button type="button" class="af-btn" (click)="goBack()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
@@ -58,7 +61,7 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||
<!-- Empty state -->
|
||||
<div class="af-empty">
|
||||
<div class="af-empty__icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
@@ -70,48 +73,40 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Progress bar -->
|
||||
<div class="af-progress-card">
|
||||
<div class="af-progress-card__top">
|
||||
<span class="af-progress-card__label">Facility Coverage</span>
|
||||
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span>
|
||||
<div class="af-body">
|
||||
<!-- Progress bar -->
|
||||
<div class="af-progress-card">
|
||||
<div class="af-progress-card__top">
|
||||
<span class="af-progress-card__label">Facility Coverage</span>
|
||||
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span>
|
||||
</div>
|
||||
<div class="af-progress-bar">
|
||||
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-progress-bar">
|
||||
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Facilities pills -->
|
||||
<div class="af-fac-card">
|
||||
<div class="af-fac-card__header">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
All Facilities
|
||||
<!-- Facilities pills -->
|
||||
<div class="af-fac-card">
|
||||
<div class="af-fac-card__header">All Facilities</div>
|
||||
<div class="af-pills">
|
||||
@for (f of facilities; track f.label) {
|
||||
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
|
||||
<span class="af-pill__dot"></span>
|
||||
{{ f.label }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-pills">
|
||||
@for (f of facilities; track f.label) {
|
||||
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
|
||||
<span class="af-pill__dot"></span>
|
||||
{{ f.label }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="af-stats-row">
|
||||
<div class="af-stat-card af-stat-card--green">
|
||||
<div class="af-stat-card__icon">✓</div>
|
||||
<div>
|
||||
<!-- Stats row -->
|
||||
<div class="af-stats-row">
|
||||
<div class="af-stat-card af-stat-card--green">
|
||||
<span class="af-stat-card__icon">✓</span>
|
||||
<span class="af-stat-card__num">{{ activeCount }}</span>
|
||||
<span class="af-stat-card__label">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-stat-card af-stat-card--red">
|
||||
<div class="af-stat-card__icon">✗</div>
|
||||
<div>
|
||||
<div class="af-stat-card af-stat-card--red">
|
||||
<span class="af-stat-card__icon">✕</span>
|
||||
<span class="af-stat-card__num">{{ facilities.length - activeCount }}</span>
|
||||
<span class="af-stat-card__label">Inactive</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cb-page {
|
||||
background: #f8fafc;
|
||||
min-height: calc(100vh - 68px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
.cb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #0f4f3c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #0d1b2a;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #0f4f3c;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #0f4f3c;
|
||||
border-color: #0f4f3c;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cb-status-msg {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
// ── Body ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
.cb-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.cb-info-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.cb-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px 24px;
|
||||
|
||||
&__k {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
&__v {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
.cb-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--bad {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&--neutral {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
.cb-no-books {
|
||||
padding: 30px 10px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// ── Action toolbar ─────────────────────────────────────────────────────────────
|
||||
|
||||
.cb-action-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.cb-action-btn {
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
color: #0d1b2a;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-color: #fca5a5;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #0f4f3c;
|
||||
border-color: #0f4f3c;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leaf grid ──────────────────────────────────────────────────────────────────
|
||||
|
||||
.cb-leaf-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cb-leaf-circle {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
transform 0.1s,
|
||||
box-shadow 0.1s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-color: #22c55e;
|
||||
color: #166534;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&--used {
|
||||
border-color: #cbd5e1;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-color: #f59e0b;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
&--bad {
|
||||
border-color: #ef4444;
|
||||
color: #991b1b;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
&--returned {
|
||||
border-color: #a855f7;
|
||||
color: #6b21a8;
|
||||
background: #faf5ff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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 { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ChequeBookService } from '../../chequebook/chequebook.service';
|
||||
import { LeafDetailDialogComponent } from '../../chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component';
|
||||
import { ClerkLeafActionDialogComponent, LeafAction } from './leaf-action-dialog/leaf-action-dialog.component';
|
||||
|
||||
/** Cheque-book detail for the Clerk Dashboard — a fullscreen clerk-styled view of one book, its leaf
|
||||
* register, and the same maker-side leaf actions (stop-payment, revoke, report-lost, cancel-leaf,
|
||||
* mark-used) as the back-office ChequeBookDetailComponent, so a clerk can act on a leaf without leaving
|
||||
* the clerk shell/nav. Every action still goes through the existing maker-checker approval flow server-side.
|
||||
* Each action opens as a popup (ClerkLeafActionDialogComponent) rather than an inline form so it never
|
||||
* pushes the leaf grid around and gets the full dialog width to lay out its fields. */
|
||||
@Component({
|
||||
selector: 'mifosx-clerk-cheque-book-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styleUrl: './cheque-book-detail.component.scss',
|
||||
template: `
|
||||
<div class="cb-page">
|
||||
<!-- Header -->
|
||||
<div class="cb-header">
|
||||
<div class="cb-header__left">
|
||||
<svg
|
||||
class="cb-header__icon"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="cb-header__title">Cheque Book #{{ book.id }}</div>
|
||||
<div class="cb-header__sub">
|
||||
{{ book.accountNo || book.accountId }}
|
||||
@if (book.clientName) {
|
||||
· {{ book.clientName }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="cb-btn" (click)="goBack()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cb-status-msg">Loading cheque book…</div>
|
||||
} @else {
|
||||
<div class="cb-body">
|
||||
<!-- Info card -->
|
||||
<div class="cb-info-card">
|
||||
<div class="cb-info-grid">
|
||||
<div>
|
||||
<span class="cb-info-grid__k">Account Type</span>
|
||||
<span class="cb-info-grid__v">{{ book.accountType }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cb-info-grid__k">Office</span>
|
||||
<span class="cb-info-grid__v">{{ book.officeName }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cb-info-grid__k">Series</span>
|
||||
<span class="cb-info-grid__v">{{ book.seriesCode ?? '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cb-info-grid__k">Leaf Range</span>
|
||||
<span class="cb-info-grid__v">
|
||||
{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cb-info-grid__k">Quantity</span>
|
||||
<span class="cb-info-grid__v">{{ book.noOfBooks }} book{{ book.noOfBooks === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cb-info-grid__k">Status</span>
|
||||
<span
|
||||
class="cb-pill"
|
||||
[class.cb-pill--ok]="book.status === 'ISSUED'"
|
||||
[class.cb-pill--bad]="book.status === 'REJECTED' || book.status === 'WITHDRAWN'"
|
||||
[class.cb-pill--neutral]="
|
||||
book.status !== 'ISSUED' && book.status !== 'REJECTED' && book.status !== 'WITHDRAWN'
|
||||
"
|
||||
>{{ book.status }}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cb-info-grid__k">Issue Date</span>
|
||||
<span class="cb-info-grid__v">{{ book.issueDate ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action toolbar — only for an issued book; each button pops up its own action dialog -->
|
||||
@if (book.status === 'ISSUED') {
|
||||
<div class="cb-action-toolbar">
|
||||
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('stoppayment')">
|
||||
Stop Payment
|
||||
</button>
|
||||
<button type="button" class="cb-action-btn" (click)="openAction('revokestoppayment')">
|
||||
Revoke Stop Payment
|
||||
</button>
|
||||
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('reportlost')">
|
||||
Report Lost
|
||||
</button>
|
||||
<button type="button" class="cb-action-btn" (click)="openAction('cancelleaf')">Cancel Leaf</button>
|
||||
<button type="button" class="cb-action-btn cb-action-btn--primary" (click)="openAction('markused')">
|
||||
Mark as Used
|
||||
</button>
|
||||
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('return')">
|
||||
Flag Returned
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Leaf grid -->
|
||||
<div class="cb-info-card">
|
||||
@if (leaves.length === 0) {
|
||||
<div class="cb-no-books">No leaves to display</div>
|
||||
} @else {
|
||||
<div class="cb-leaf-grid">
|
||||
@for (l of leaves; track l.leafSerialNo) {
|
||||
<button
|
||||
type="button"
|
||||
class="cb-leaf-circle"
|
||||
[class.cb-leaf-circle--ok]="l.status === 'ACTIVE'"
|
||||
[class.cb-leaf-circle--used]="l.status === 'USED'"
|
||||
[class.cb-leaf-circle--warn]="l.status === 'STOP_PAYMENT'"
|
||||
[class.cb-leaf-circle--bad]="l.status === 'LOST'"
|
||||
[class.cb-leaf-circle--returned]="l.status === 'RETURNED'"
|
||||
(click)="selectLeaf(l)"
|
||||
>
|
||||
{{ l.leafSerialNo }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ClerkChequeBookDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
bookId!: string;
|
||||
book: any = {};
|
||||
leaves: any[] = [];
|
||||
loading = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.bookId = this.route.snapshot.paramMap.get('id') as string;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
loadAll(): void {
|
||||
this.loading = true;
|
||||
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => {
|
||||
this.book = data || {};
|
||||
this.loading = false;
|
||||
});
|
||||
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
|
||||
this.leaves = data || [];
|
||||
});
|
||||
}
|
||||
|
||||
selectLeaf(leaf: any): void {
|
||||
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
|
||||
}
|
||||
|
||||
openAction(action: LeafAction): void {
|
||||
const ref = this.dialog.open(ClerkLeafActionDialogComponent, {
|
||||
width: '480px',
|
||||
data: { bookId: this.bookId, action }
|
||||
});
|
||||
ref.afterClosed().subscribe((result) => {
|
||||
if (result?.success) {
|
||||
this.loadAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/clerk/account-facilities/cheque-book']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cb-page {
|
||||
background: #f8fafc;
|
||||
min-height: calc(100vh - 68px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
.cb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #0f4f3c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #0d1b2a;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #0f4f3c;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ────────────────────────────────────────────────────────────────
|
||||
|
||||
.cb-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 60px 20px;
|
||||
|
||||
&__icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
background: #e2e8f0;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 13.5px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
margin-top: 10px;
|
||||
padding: 10px 24px;
|
||||
background: #0f4f3c;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Warning banner ─────────────────────────────────────────────────────────────
|
||||
|
||||
.cb-warn-banner {
|
||||
margin: 0 20px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.cb-status-msg {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 13.5px;
|
||||
|
||||
&--error {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.cb-no-books {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 40px 20px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// ── Table card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
.cb-table-card {
|
||||
margin: 0 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cb-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cb-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cb-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
|
||||
&:hover td {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.cb-cell--num {
|
||||
color: #64748b;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.cb-cell--date {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cb-cell--action {
|
||||
color: #0f4f3c;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cb-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--bad {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&--neutral {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||
import { ChequeBookService } from '../../chequebook/chequebook.service';
|
||||
|
||||
/** Read-only cheque-book view for the account currently loaded on the Clerk Dashboard —
|
||||
* reached via the "Cheque Book" entry in the Account Facilities dropdown. */
|
||||
@Component({
|
||||
selector: 'mifosx-clerk-cheque-book',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styleUrl: './cheque-book.component.scss',
|
||||
template: `
|
||||
<div class="cb-page">
|
||||
<!-- Header -->
|
||||
<div class="cb-header">
|
||||
<div class="cb-header__left">
|
||||
<svg
|
||||
class="cb-header__icon"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="cb-header__title">Cheque Book</div>
|
||||
@if (accountLoaded) {
|
||||
<div class="cb-header__sub">
|
||||
{{ accountNo }}
|
||||
@if (clientName) {
|
||||
· {{ clientName }}
|
||||
}
|
||||
@if (books.length > 0) {
|
||||
· {{ books.length }} book{{ books.length === 1 ? '' : 's' }}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cb-header__sub">View cheque books issued for the loaded account.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!embedded) {
|
||||
<button type="button" class="cb-btn" (click)="goBack()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!accountLoaded) {
|
||||
<div class="cb-empty">
|
||||
<div class="cb-empty__icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="cb-empty__title">No Account Loaded</p>
|
||||
<p class="cb-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
||||
@if (!embedded) {
|
||||
<button class="cb-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (!facilityActive) {
|
||||
<div class="cb-warn-banner">Cheque Book facility is not active for this account.</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="cb-status-msg">Loading cheque books…</div>
|
||||
} @else if (error) {
|
||||
<div class="cb-status-msg cb-status-msg--error">{{ error }}</div>
|
||||
} @else if (books.length === 0) {
|
||||
<div class="cb-no-books">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.3">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
<span>No cheque book issued for this account</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cb-table-card">
|
||||
<div class="cb-table-wrap">
|
||||
<table class="cb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Series</th>
|
||||
<th>Books</th>
|
||||
<th>Leaves</th>
|
||||
<th>Status</th>
|
||||
<th>Requested</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of books; track b.id) {
|
||||
<tr class="cb-row" (click)="viewBook(b)">
|
||||
<td>{{ b.seriesCode }}</td>
|
||||
<td class="cb-cell--num">{{ b.noOfBooks }}</td>
|
||||
<td class="cb-cell--num">{{ b.totalLeaves ?? '—' }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="cb-pill"
|
||||
[class.cb-pill--ok]="b.status === 'ISSUED'"
|
||||
[class.cb-pill--bad]="b.status === 'REJECTED' || b.status === 'WITHDRAWN'"
|
||||
[class.cb-pill--neutral]="
|
||||
b.status !== 'ISSUED' && b.status !== 'REJECTED' && b.status !== 'WITHDRAWN'
|
||||
"
|
||||
>{{ b.status }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="cb-cell--date">{{ b.requestedDate ?? '—' }}</td>
|
||||
<td class="cb-cell--action">View ›</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ClerkChequeBookComponent implements OnInit {
|
||||
private clerkUiState = inject(ClerkUiStateService);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private router = inject(Router);
|
||||
|
||||
@Input() embedded = false;
|
||||
|
||||
accountLoaded = false;
|
||||
accountNo = '';
|
||||
clientName = '';
|
||||
facilityActive = true;
|
||||
books: any[] = [];
|
||||
loading = false;
|
||||
error = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
const s = this.clerkUiState.snapshot;
|
||||
this.accountLoaded = s.accountLoaded;
|
||||
if (!this.accountLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.accountNo = s.savedAccount?.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
|
||||
const clientDetails = s.savedAccount?.clientDetails;
|
||||
this.clientName = clientDetails ? `${clientDetails.firstname ?? ''} ${clientDetails.lastname ?? ''}`.trim() : '';
|
||||
const facility = s.facilities.find((f) => f.label === 'Cheque Book');
|
||||
this.facilityActive = facility ? facility.active : true;
|
||||
|
||||
const accountType = this.toChequeAccountType(s.savedRow?.acTyp);
|
||||
const accountId = s.savedAccount?.accountDetails?.id;
|
||||
if (accountId) {
|
||||
this.loadBooks(accountType, accountId);
|
||||
} else if (this.accountNo) {
|
||||
this.resolveAccountId(accountType, this.accountNo);
|
||||
}
|
||||
}
|
||||
|
||||
/** Cheque-book series/books are only ever SAVINGS or CURRENT — CA/CB is the clerk's UI code for current accounts. */
|
||||
private toChequeAccountType(acTyp?: string): string {
|
||||
const t = (acTyp || '').toUpperCase();
|
||||
return t === 'CA' || t === 'CB' ? 'CURRENT' : 'SAVINGS';
|
||||
}
|
||||
|
||||
private resolveAccountId(accountType: string, accountNo: string): void {
|
||||
this.loading = true;
|
||||
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
|
||||
next: (res: any) => {
|
||||
const items = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const match = items.find((i: any) => i.accountNo === accountNo) ?? (items.length === 1 ? items[0] : null);
|
||||
if (match) {
|
||||
this.loadBooks(accountType, match.id);
|
||||
} else {
|
||||
this.loading = false;
|
||||
this.error = 'Could not resolve this account for cheque book lookup';
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.error = 'Could not resolve this account for cheque book lookup';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadBooks(accountType: string, accountId: string | number): void {
|
||||
this.loading = true;
|
||||
this.chequeBookService.getBooks(accountType, accountId, undefined, undefined, 0, 50).subscribe({
|
||||
next: (res: any) => {
|
||||
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.error = 'Failed to load cheque book details';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
viewBook(book: any): void {
|
||||
this.router.navigate([
|
||||
'/clerk/account-facilities/cheque-book',
|
||||
book.id
|
||||
]);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/clerk/dashboard']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.la-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.la-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&__lbl {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: #0d1b2a;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.la-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #0d1b2a;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #0f4f3c;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #0f4f3c;
|
||||
border-color: #0f4f3c;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ChequeBookService } from '../../../chequebook/chequebook.service';
|
||||
|
||||
export type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
|
||||
|
||||
const ACTION_TITLES: Record<LeafAction, string> = {
|
||||
stoppayment: 'Stop Payment',
|
||||
revokestoppayment: 'Revoke Stop Payment',
|
||||
reportlost: 'Report Lost',
|
||||
cancelleaf: 'Cancel Leaf',
|
||||
markused: 'Mark as Used',
|
||||
return: 'Flag Returned'
|
||||
};
|
||||
|
||||
/** Popup for the five leaf-range actions on a clerk-viewed cheque book — kept as its own dialog so the
|
||||
* action form doesn't push the leaf grid around and always has full width to lay out its fields. */
|
||||
@Component({
|
||||
selector: 'mifosx-clerk-leaf-action-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions
|
||||
],
|
||||
styleUrl: './leaf-action-dialog.component.scss',
|
||||
template: `
|
||||
<h1 mat-dialog-title>{{ title }}</h1>
|
||||
<div mat-dialog-content>
|
||||
<form [formGroup]="actionForm" class="la-form">
|
||||
<div class="la-form-grid">
|
||||
<label class="la-field">
|
||||
<span class="la-field__lbl">From Leaf Seq</span>
|
||||
<input class="la-field__input" type="number" formControlName="fromLeafSeq" />
|
||||
<span class="la-field__hint">Leave blank for the whole book</span>
|
||||
</label>
|
||||
<label class="la-field">
|
||||
<span class="la-field__lbl">To Leaf Seq</span>
|
||||
<input class="la-field__input" type="number" formControlName="toLeafSeq" />
|
||||
</label>
|
||||
@if (data.action !== 'revokestoppayment') {
|
||||
<label class="la-field">
|
||||
<span class="la-field__lbl">Reason</span>
|
||||
<input class="la-field__input" formControlName="reason" />
|
||||
@if (data.action === 'markused') {
|
||||
<span class="la-field__hint">Optional — e.g. cleared per bank statement dated ...</span>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<mat-dialog-actions align="end">
|
||||
<button type="button" class="la-btn" (click)="dialogRef.close()">Cancel</button>
|
||||
<button type="button" class="la-btn la-btn--primary" [disabled]="actionForm.invalid || busy" (click)="submit()">
|
||||
Submit
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`
|
||||
})
|
||||
export class ClerkLeafActionDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<ClerkLeafActionDialogComponent>>(MatDialogRef);
|
||||
data: { bookId: string; action: LeafAction } = inject(MAT_DIALOG_DATA);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private chequeBookService = inject(ChequeBookService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
busy = false;
|
||||
actionForm: UntypedFormGroup;
|
||||
|
||||
get title(): string {
|
||||
return ACTION_TITLES[this.data.action];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.actionForm = this.formBuilder.group({
|
||||
fromLeafSeq: [''],
|
||||
toLeafSeq: [''],
|
||||
reason: [
|
||||
'',
|
||||
this.data.action === 'revokestoppayment' || this.data.action === 'markused' ? [] : Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.actionForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.actionForm.value;
|
||||
const payload: any = { locale: 'en' };
|
||||
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
|
||||
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
|
||||
if (raw.reason) payload.reason = raw.reason;
|
||||
|
||||
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
|
||||
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
|
||||
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
|
||||
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
|
||||
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
|
||||
markused: (id, p) => this.chequeBookService.markUsed(id, p),
|
||||
return: (id, p) => this.chequeBookService.returnBook(id, p)
|
||||
};
|
||||
|
||||
actionCalls[this.data.action](this.data.bookId, payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.notify('Action applied');
|
||||
this.dialogRef.close({ success: true });
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify(
|
||||
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||
|
||||
.inv-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inv-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: auto;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.inv-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--active {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--matured {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--written_off {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpContext } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
|
||||
/**
|
||||
* HTTP service for the maker-checker "claim my dividend early" workflow — a clerk submits a claim
|
||||
* against a still-locked dividend on the member's behalf, a Bank Manager approves (only possible
|
||||
* when the client has no overdue loan) or rejects it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DividendClaimRequestService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(
|
||||
lockId: number,
|
||||
breakdown: {
|
||||
loanId?: number;
|
||||
loanApplyAmount?: number;
|
||||
savingsAccountId?: number;
|
||||
savingsApplyAmount?: number;
|
||||
cashApplyAmount?: number;
|
||||
tokNo?: string;
|
||||
}
|
||||
): Observable<any> {
|
||||
const body: any = { lockId };
|
||||
if (breakdown.loanId != null) {
|
||||
body.loanId = breakdown.loanId;
|
||||
}
|
||||
if (breakdown.loanApplyAmount != null) {
|
||||
body.loanApplyAmount = String(breakdown.loanApplyAmount);
|
||||
}
|
||||
if (breakdown.savingsAccountId != null) {
|
||||
body.savingsAccountId = breakdown.savingsAccountId;
|
||||
}
|
||||
if (breakdown.savingsApplyAmount != null) {
|
||||
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
|
||||
}
|
||||
if (breakdown.cashApplyAmount != null) {
|
||||
body.cashApplyAmount = String(breakdown.cashApplyAmount);
|
||||
}
|
||||
if (breakdown.tokNo != null) {
|
||||
body.tokNo = breakdown.tokNo;
|
||||
}
|
||||
return this.http.post('/clerk/dividend-claim-requests', body);
|
||||
}
|
||||
|
||||
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
|
||||
// token pool 404s, and shows its own tailored message either way.
|
||||
getByToken(token: string): Observable<any> {
|
||||
return this.http.get(`/clerk/dividend-claim-requests/by-token/${encodeURIComponent(token)}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
|
||||
return this.http.post(`/clerk/dividend-claim-requests/${id}/disburse`, {
|
||||
denominationData,
|
||||
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
|
||||
});
|
||||
}
|
||||
|
||||
getOverdueLoans(clientId: number): Observable<any> {
|
||||
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
|
||||
}
|
||||
|
||||
list(status = 'PENDING'): Observable<any> {
|
||||
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/dividend-claim-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/dividend-claim-requests/${id}/reject`, { reason });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* HTTP service for the Bank Manager's Dividend Dashboard — members with a locked (3-year)
|
||||
* share dividend who also have an overdue loan EMI, with an action to apply the locked dividend
|
||||
* towards that EMI.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DividendDashboardService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getDashboard(): Observable<any> {
|
||||
return this.http.get('/clerk/dividend-dashboard');
|
||||
}
|
||||
|
||||
applyToLoan(lockId: number, loanId: number, amount: number): Observable<any> {
|
||||
return this.http.post(`/clerk/dividend-dashboard/${lockId}/apply-to-loan`, { loanId, amount: String(amount) });
|
||||
}
|
||||
|
||||
getSummary(): Observable<any> {
|
||||
return this.http.get('/clerk/dividend-dashboard/summary');
|
||||
}
|
||||
|
||||
getDeclarations(): Observable<any> {
|
||||
return this.http.get('/clerk/dividend-dashboard/declarations');
|
||||
}
|
||||
|
||||
getLocks(status?: string): Observable<any> {
|
||||
const params = status && status !== 'ALL' ? { status } : {};
|
||||
return this.http.get('/clerk/dividend-dashboard/locks', { params });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
@if (summary) {
|
||||
<div class="dd-summary-grid">
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Declarations</div>
|
||||
<div class="dd-stat-value">{{ summary.totalDeclarations }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.pendingApprovalDeclarations }} pending approval</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Members Affected</div>
|
||||
<div class="dd-stat-value">{{ summary.membersAffected }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.totalMemberDividends }} member-dividends total</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Total Amount</div>
|
||||
<div class="dd-stat-value">{{ summary.totalAmount | number: '1.2-2' }}</div>
|
||||
<div class="dd-stat-sub">across all declared dividends</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Currently Locked</div>
|
||||
<div class="dd-stat-value">{{ summary.lockedCount }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.lockedAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Applied to Loans</div>
|
||||
<div class="dd-stat-value">{{ summary.appliedToLoanCount }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.appliedToLoanAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="dd-stat-card">
|
||||
<div class="dd-stat-label">Claimed by Member</div>
|
||||
<div class="dd-stat-value">{{ summary.claimedCount }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.claimedAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="dd-stat-card dd-stat-card--forfeited">
|
||||
<div class="dd-stat-label">Forfeited to Profit</div>
|
||||
<div class="dd-stat-value">{{ summary.forfeitedCount }}</div>
|
||||
<div class="dd-stat-sub">{{ summary.forfeitedAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">
|
||||
Refresh
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Period</th>
|
||||
<th class="num">Declared Amount</th>
|
||||
<th class="num">Members</th>
|
||||
<th>Status</th>
|
||||
<th>Approved On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (declarationsLoading) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (declarationsLoaded && declarations.length === 0) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (d of declarations; track d.declarationId) {
|
||||
<tr>
|
||||
<td>{{ d.productName }}</td>
|
||||
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
|
||||
<td class="num">{{ d.declaredAmount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ d.totalMembers }}</td>
|
||||
<td>
|
||||
<span class="dd-badge" [ngClass]="declarationStatusClass(d.status)">{{
|
||||
declarationStatusLabel(d.status)
|
||||
}}</span>
|
||||
</td>
|
||||
<td>{{ d.approvedOn ? (d.approvedOn | date: 'medium') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pos-section-label">Dividend Dashboard — Locked Dividends for Members with Overdue EMI</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th class="num">Locked Dividend</th>
|
||||
<th>Locked Date</th>
|
||||
<th>Release Due</th>
|
||||
<th class="num">Total Overdue EMI</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (loaded && rows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No members with a locked dividend and an overdue EMI right now.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (row of rows; track row.lockId) {
|
||||
<tr>
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
|
||||
<td>{{ row.lockedDate }}</td>
|
||||
<td>{{ row.releaseDueDate }}</td>
|
||||
<td class="num">{{ totalOverdue(row) | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<button type="button" class="pg-btn" (click)="toggleApply(row)">
|
||||
{{ expandedLockId === row.lockId ? 'Cancel' : 'Apply to Loan' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedLockId === row.lockId) {
|
||||
<tr class="dd-apply-row">
|
||||
<td colspan="6">
|
||||
<div class="dd-apply-panel">
|
||||
<label class="inv-form-field">
|
||||
<span>Loan</span>
|
||||
<select [(ngModel)]="selectedLoanId[row.lockId]" (change)="onLoanChange(row)">
|
||||
@for (loan of row.overdueLoans; track loan.loanId) {
|
||||
<option [ngValue]="loan.loanId">
|
||||
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Amount to Apply (max {{ maxAmountFor(row) | number: '1.2-2' }})</span>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="applyAmount[row.lockId]"
|
||||
[max]="maxAmountFor(row)"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
/>
|
||||
</label>
|
||||
@if (applyError) {
|
||||
<div class="dd-apply-error">{{ applyError }}</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dd-apply-confirm"
|
||||
[disabled]="applying"
|
||||
(click)="submitApply(row)"
|
||||
>
|
||||
{{ applying ? 'Applying…' : 'Confirm' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pos-section-label">All Dividend Locks — Full 3-Year Lock History</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px; gap: 8px">
|
||||
<div class="dd-filter-group">
|
||||
@for (opt of ['ALL', 'UNCLAIMED', 'CLAIMED', 'FORFEITED']; track opt) {
|
||||
<button
|
||||
type="button"
|
||||
class="dd-filter-btn"
|
||||
[class.dd-filter-btn--active]="lockStatusFilter === opt"
|
||||
(click)="setLockStatusFilter(opt)"
|
||||
>
|
||||
{{ opt === 'ALL' ? 'All' : (opt | titlecase) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<label class="inv-form-field" style="align-self: end; margin-left: auto">
|
||||
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Savings Account</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Locked Date</th>
|
||||
<th>Release Due</th>
|
||||
<th>Status</th>
|
||||
<th>Applied Loan</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (locksLoading) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (locksLoaded && locks.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (l of locks; track l.lockId) {
|
||||
<tr>
|
||||
<td>{{ l.clientName }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ l.savingsAccountNo }}</span>
|
||||
</td>
|
||||
<td class="num">{{ l.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ l.lockedDate }}</td>
|
||||
<td>{{ l.releaseDueDate }}</td>
|
||||
<td>
|
||||
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
|
||||
</td>
|
||||
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
|
||||
<td>
|
||||
@if (l.status === 'UNCLAIMED') {
|
||||
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
|
||||
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedClaimLockId === l.lockId) {
|
||||
<tr class="dd-apply-row">
|
||||
<td colspan="8">
|
||||
<div class="dd-apply-panel dd-apply-panel--wrap">
|
||||
<div class="dd-apply-panel-title">
|
||||
Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash)
|
||||
</div>
|
||||
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Apply to Overdue Loan</span>
|
||||
<select [(ngModel)]="claimLoanId[l.lockId]">
|
||||
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
|
||||
<option [ngValue]="loan.loanId">
|
||||
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Loan Amount</span>
|
||||
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" />
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pg-empty-inline">No overdue loan for this client.</div>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>Credit to Savings</span>
|
||||
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" />
|
||||
</label>
|
||||
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Savings Account ID (no linked account — enter one to credit)</span>
|
||||
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" />
|
||||
</label>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>Cash</span>
|
||||
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
|
||||
</label>
|
||||
@if (claimCashAmount[l.lockId] > 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Token No. (for cashier hand-over)</span>
|
||||
<input type="text" [(ngModel)]="claimTokNo[l.lockId]" />
|
||||
</label>
|
||||
}
|
||||
<div class="dd-apply-panel-total">
|
||||
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
|
||||
</div>
|
||||
@if (claimError) {
|
||||
<div class="dd-apply-error">{{ claimError }}</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dd-apply-confirm"
|
||||
[disabled]="claimSubmitting"
|
||||
(click)="submitClaim(l)"
|
||||
>
|
||||
{{ claimSubmitting ? 'Submitting…' : 'Submit Claim Request' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
|
||||
Refresh
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Savings Account</th>
|
||||
<th class="num">To Loan</th>
|
||||
<th class="num">To Savings</th>
|
||||
<th class="num">Cash</th>
|
||||
<th class="num">Unclaimed Balance</th>
|
||||
<th>Client Loan Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (claimRequestsLoading) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">No dividend claim requests pending review.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (req of claimRequests; track req.id) {
|
||||
<tr>
|
||||
<td>{{ req.client_name }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ req.savings_account_no }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
{{
|
||||
req.loan_apply_amount > 0
|
||||
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
|
||||
: '—'
|
||||
}}
|
||||
</td>
|
||||
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
|
||||
<td class="num">
|
||||
{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
|
||||
@if (req.cash_apply_amount > 0 && req.tok_no) {
|
||||
<div class="pg-empty-inline">Tok: {{ req.tok_no }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
@if (req.has_overdue_loan) {
|
||||
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
|
||||
} @else {
|
||||
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
|
||||
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="toggleRejectClaim(req)">
|
||||
{{ expandedRejectClaimId === req.id ? 'Cancel' : 'Reject' }}
|
||||
</button>
|
||||
@if (claimActionError[req.id]) {
|
||||
<div class="dd-retry-error">{{ claimActionError[req.id] }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedRejectClaimId === req.id) {
|
||||
<tr class="dd-apply-row">
|
||||
<td colspan="8">
|
||||
<div class="dd-apply-panel">
|
||||
<label class="inv-form-field">
|
||||
<span>Rejection Reason</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="rejectReason[req.id]"
|
||||
placeholder="Why is this claim being rejected?"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dd-apply-confirm"
|
||||
[disabled]="claimActioningId === req.id"
|
||||
(click)="rejectClaim(req)"
|
||||
>
|
||||
{{ claimActioningId === req.id ? 'Rejecting…' : 'Confirm Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.dd-apply-row td {
|
||||
background: #f8fafc;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dd-apply-panel {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 16px;
|
||||
|
||||
.inv-form-field {
|
||||
min-width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-apply-error {
|
||||
color: #b42318;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dd-apply-panel--wrap {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.dd-apply-panel-title {
|
||||
width: 100%;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dd-apply-panel-total {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dd-apply-confirm {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #2563eb;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-retry-error {
|
||||
margin-top: 4px;
|
||||
color: #b42318;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.dd-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dd-badge--pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pg-empty-inline {
|
||||
color: #94a3b8;
|
||||
font-size: 12.5px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dd-badge--failed {
|
||||
background: #fee2e2;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.dd-badge--posted {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.dd-badge--partial {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* ─── Summary stat cards ─── */
|
||||
.dd-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dd-stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
border-left: 3px solid #94a3b8;
|
||||
|
||||
&--forfeited {
|
||||
border-left-color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-stat-label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dd-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-top: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dd-stat-sub {
|
||||
font-size: 11.5px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ─── Lock status filter buttons ─── */
|
||||
.dd-filter-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dd-filter-btn {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #1e293b;
|
||||
border-color: #1e293b;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DividendDashboardService } from '../dividend-dashboard.service';
|
||||
import { DividendClaimRequestService } from '../dividend-claim-request.service';
|
||||
|
||||
/**
|
||||
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year
|
||||
* lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a
|
||||
* proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to
|
||||
* claim their dividend directly (split across loan, savings, or cash).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-dividend-dashboard',
|
||||
templateUrl: './dividend-dashboard.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss',
|
||||
'./dividend-dashboard.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class DividendDashboardComponent implements OnInit {
|
||||
private service = inject(DividendDashboardService);
|
||||
private claimRequestService = inject(DividendClaimRequestService);
|
||||
|
||||
rows: any[] = [];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
expandedLockId: number | null = null;
|
||||
selectedLoanId: Record<number, number | null> = {};
|
||||
applyAmount: Record<number, number | null> = {};
|
||||
applying = false;
|
||||
applyError: string | null = null;
|
||||
|
||||
summary: any = null;
|
||||
summaryLoading = false;
|
||||
|
||||
declarations: any[] = [];
|
||||
declarationsLoading = false;
|
||||
declarationsLoaded = false;
|
||||
|
||||
locks: any[] = [];
|
||||
locksLoading = false;
|
||||
locksLoaded = false;
|
||||
lockStatusFilter = 'ALL';
|
||||
|
||||
expandedClaimLockId: number | null = null;
|
||||
claimOverdueLoans: Record<number, any[]> = {};
|
||||
claimLoanId: Record<number, number | null> = {};
|
||||
claimLoanAmount: Record<number, number | null> = {};
|
||||
claimSavingsAmount: Record<number, number | null> = {};
|
||||
claimSavingsAccountOverride: Record<number, number | null> = {};
|
||||
claimCashAmount: Record<number, number | null> = {};
|
||||
claimTokNo: Record<number, string> = {};
|
||||
claimSubmitting = false;
|
||||
claimError: string | null = null;
|
||||
|
||||
claimRequests: any[] = [];
|
||||
claimRequestsLoading = false;
|
||||
claimRequestsLoaded = false;
|
||||
claimActioningId: number | null = null;
|
||||
claimActionError: Record<number, string> = {};
|
||||
expandedRejectClaimId: number | null = null;
|
||||
rejectReason: Record<number, string> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSummary();
|
||||
this.loadDeclarations();
|
||||
this.load();
|
||||
this.loadLocks();
|
||||
this.loadClaimRequests();
|
||||
}
|
||||
|
||||
toggleClaim(lock: any): void {
|
||||
if (this.expandedClaimLockId === lock.lockId) {
|
||||
this.expandedClaimLockId = null;
|
||||
return;
|
||||
}
|
||||
this.expandedClaimLockId = lock.lockId;
|
||||
this.claimError = null;
|
||||
this.claimLoanId[lock.lockId] = null;
|
||||
this.claimLoanAmount[lock.lockId] = null;
|
||||
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
|
||||
this.claimSavingsAccountOverride[lock.lockId] = null;
|
||||
this.claimCashAmount[lock.lockId] = null;
|
||||
this.claimTokNo[lock.lockId] = '';
|
||||
this.claimRequestService
|
||||
.getOverdueLoans(lock.clientId)
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((loans: any[]) => {
|
||||
this.claimOverdueLoans[lock.lockId] = loans ?? [];
|
||||
if (loans && loans.length > 0) {
|
||||
this.claimLoanId[lock.lockId] = loans[0].loanId;
|
||||
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
|
||||
this.claimLoanAmount[lock.lockId] = loanAmt;
|
||||
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
claimTotal(lock: any): number {
|
||||
return (
|
||||
Number(this.claimLoanAmount[lock.lockId] || 0) +
|
||||
Number(this.claimSavingsAmount[lock.lockId] || 0) +
|
||||
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
|
||||
);
|
||||
}
|
||||
|
||||
submitClaim(lock: any): void {
|
||||
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
|
||||
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
|
||||
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
|
||||
// dividend share often works out to paise — floor to the nearest rupee so the denomination
|
||||
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
|
||||
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
|
||||
const total = loanAmount + savingsAmount + cashAmount;
|
||||
if (total <= 0) {
|
||||
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
|
||||
return;
|
||||
}
|
||||
if (total > Number(lock.amount)) {
|
||||
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
|
||||
return;
|
||||
}
|
||||
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
|
||||
this.claimError = 'Select a loan to apply the loan amount towards.';
|
||||
return;
|
||||
}
|
||||
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
|
||||
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
|
||||
return;
|
||||
}
|
||||
const tokNo = this.claimTokNo[lock.lockId]?.trim();
|
||||
if (cashAmount > 0 && !tokNo) {
|
||||
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
|
||||
return;
|
||||
}
|
||||
this.claimSubmitting = true;
|
||||
this.claimError = null;
|
||||
this.claimRequestService
|
||||
.submit(lock.lockId, {
|
||||
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
|
||||
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
|
||||
savingsAccountId:
|
||||
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
|
||||
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
|
||||
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
|
||||
tokNo: cashAmount > 0 ? tokNo : undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.claimSubmitting = false;
|
||||
this.expandedClaimLockId = null;
|
||||
this.loadClaimRequests();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimSubmitting = false;
|
||||
this.claimError = err?.error?.message || 'Failed to submit claim request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadClaimRequests(): void {
|
||||
this.claimRequestsLoading = true;
|
||||
this.claimRequestService
|
||||
.list('PENDING')
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.claimRequests = res ?? [];
|
||||
this.claimRequestsLoading = false;
|
||||
this.claimRequestsLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
approveClaim(req: any): void {
|
||||
this.claimActioningId = req.id;
|
||||
delete this.claimActionError[req.id];
|
||||
this.claimRequestService.approve(req.id).subscribe({
|
||||
next: () => {
|
||||
this.claimActioningId = null;
|
||||
this.loadClaimRequests();
|
||||
this.loadLocks();
|
||||
this.loadSummary();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimActioningId = null;
|
||||
this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleRejectClaim(req: any): void {
|
||||
this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id;
|
||||
}
|
||||
|
||||
rejectClaim(req: any): void {
|
||||
const reason = this.rejectReason[req.id];
|
||||
if (!reason || !reason.trim()) {
|
||||
this.claimActionError[req.id] = 'Rejection reason is required.';
|
||||
return;
|
||||
}
|
||||
this.claimActioningId = req.id;
|
||||
delete this.claimActionError[req.id];
|
||||
this.claimRequestService.reject(req.id, reason).subscribe({
|
||||
next: () => {
|
||||
this.claimActioningId = null;
|
||||
this.expandedRejectClaimId = null;
|
||||
this.loadClaimRequests();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimActioningId = null;
|
||||
this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadSummary(): void {
|
||||
this.summaryLoading = true;
|
||||
this.service
|
||||
.getSummary()
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.summary = res;
|
||||
this.summaryLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
loadDeclarations(): void {
|
||||
this.declarationsLoading = true;
|
||||
this.service
|
||||
.getDeclarations()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.declarations = res ?? [];
|
||||
this.declarationsLoading = false;
|
||||
this.declarationsLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
declarationStatusLabel(status: string): string {
|
||||
return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved';
|
||||
}
|
||||
|
||||
declarationStatusClass(status: string): string {
|
||||
return status === 'PENDING_APPROVAL' ? 'dd-badge--pending' : 'dd-badge--posted';
|
||||
}
|
||||
|
||||
loadLocks(): void {
|
||||
this.locksLoading = true;
|
||||
this.service
|
||||
.getLocks(this.lockStatusFilter)
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.locks = res ?? [];
|
||||
this.locksLoading = false;
|
||||
this.locksLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
setLockStatusFilter(status: string): void {
|
||||
this.lockStatusFilter = status;
|
||||
this.loadLocks();
|
||||
}
|
||||
|
||||
lockStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'UNCLAIMED':
|
||||
return 'dd-badge--pending';
|
||||
case 'CLAIMED':
|
||||
return 'dd-badge--posted';
|
||||
case 'FORFEITED':
|
||||
return 'dd-badge--failed';
|
||||
default:
|
||||
return 'dd-badge--pending';
|
||||
}
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.expandedLockId = null;
|
||||
this.service
|
||||
.getDashboard()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.rows = res ?? [];
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
totalOverdue(row: any): number {
|
||||
return (row.overdueLoans || []).reduce((sum: number, l: any) => sum + Number(l.overdueAmount || 0), 0);
|
||||
}
|
||||
|
||||
toggleApply(row: any): void {
|
||||
if (this.expandedLockId === row.lockId) {
|
||||
this.expandedLockId = null;
|
||||
return;
|
||||
}
|
||||
this.expandedLockId = row.lockId;
|
||||
this.applyError = null;
|
||||
const firstLoan = (row.overdueLoans || [])[0];
|
||||
this.selectedLoanId[row.lockId] = firstLoan ? firstLoan.loanId : null;
|
||||
this.applyAmount[row.lockId] = firstLoan
|
||||
? Math.min(Number(row.lockedAmount), Number(firstLoan.overdueAmount))
|
||||
: Number(row.lockedAmount);
|
||||
}
|
||||
|
||||
maxAmountFor(row: any): number {
|
||||
const loanId = this.selectedLoanId[row.lockId];
|
||||
const loan = (row.overdueLoans || []).find((l: any) => l.loanId === loanId);
|
||||
const overdue = loan ? Number(loan.overdueAmount) : Number(row.lockedAmount);
|
||||
return Math.min(Number(row.lockedAmount), overdue);
|
||||
}
|
||||
|
||||
onLoanChange(row: any): void {
|
||||
this.applyAmount[row.lockId] = this.maxAmountFor(row);
|
||||
}
|
||||
|
||||
submitApply(row: any): void {
|
||||
const loanId = this.selectedLoanId[row.lockId];
|
||||
const amount = this.applyAmount[row.lockId];
|
||||
if (!loanId || !amount || amount <= 0) {
|
||||
this.applyError = 'Select a loan and enter a valid amount.';
|
||||
return;
|
||||
}
|
||||
this.applying = true;
|
||||
this.applyError = null;
|
||||
this.service.applyToLoan(row.lockId, loanId, amount).subscribe({
|
||||
next: () => {
|
||||
this.applying = false;
|
||||
this.load();
|
||||
this.loadLocks();
|
||||
this.loadSummary();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.applying = false;
|
||||
this.applyError = err?.error?.message || 'Failed to apply dividend to loan.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CLERK · SHARE WITHDRAWAL</div>
|
||||
<h1 class="pg-title">My Share Requests</h1>
|
||||
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Client</th>
|
||||
<th class="num">Shares</th>
|
||||
<th>Resolution No.</th>
|
||||
<th>Meeting Date</th>
|
||||
<th>Submitted At</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (r of requests; track r.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.account_no }}</span>
|
||||
</td>
|
||||
<td>{{ r.client_name }}</td>
|
||||
<td class="num">{{ r.requested_shares }}</td>
|
||||
<td>{{ r.resolution_number }}</td>
|
||||
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||
|
||||
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||
@Component({
|
||||
selector: 'mifosx-my-share-withdrawal-requests',
|
||||
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkShareWithdrawalService);
|
||||
private router = inject(Router);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = '';
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||
.subscribe((res: any) => {
|
||||
this.requests = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
newRequest(): void {
|
||||
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'inv-badge--active',
|
||||
APPROVED: 'inv-badge--matured',
|
||||
REJECTED: 'inv-badge--written_off'
|
||||
};
|
||||
return `inv-badge ${map[status] ?? ''}`;
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending Approval',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected'
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
formatDateTime(val: any): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
return d.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn" (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Account</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (!isReview) {
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>* Search Account No. / Client Name</span>
|
||||
<mifosx-search-select
|
||||
[options]="accountOptions"
|
||||
[(ngModel)]="selectedAccountId"
|
||||
(ngModelChange)="onAccountSelected($event)"
|
||||
placeholder="Search share account no. or client name…"
|
||||
></mifosx-search-select>
|
||||
</label>
|
||||
}
|
||||
@if (account) {
|
||||
<label class="inv-form-field">
|
||||
<span>Account No</span>
|
||||
<input type="text" [value]="account.accountNo" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Client</span>
|
||||
<input type="text" [value]="account.clientName" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Shares Held</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Unit Price</span>
|
||||
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
}
|
||||
@if (loadingAccount) {
|
||||
<div class="pg-empty">Loading account…</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isReview && reviewRequest) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Client Compliance</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<div class="inv-form-field">
|
||||
<span>KYC Status</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||
>
|
||||
{{ reviewRequest.client_kyc_status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Risk Category</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||
>
|
||||
{{ reviewRequest.client_risk_category_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Open AML Alerts</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||
>
|
||||
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (account) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Withdrawal Details</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>* Shares to Withdraw</span>
|
||||
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Requested Date</span>
|
||||
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Withdrawal Value</span>
|
||||
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Remaining Shares After Withdrawal</span>
|
||||
<input type="text" [value]="remainingShares" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Board Resolution Number</span>
|
||||
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Date of Meeting</span>
|
||||
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Settlement Payment Type</span>
|
||||
@if (!isReview) {
|
||||
<select [(ngModel)]="settlementPaymentTypeId">
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (pt of paymentTypeOptions; track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||
}
|
||||
</label>
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>Description / Remarks</span>
|
||||
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (account && remainingShares === 0) {
|
||||
<div class="swr-close-notice">
|
||||
<mat-icon>lock</mat-icon>
|
||||
<span>
|
||||
This withdraws <strong>all</strong> shares on this account. Approving it will
|
||||
<strong>close the share account automatically</strong> — no separate closure step is needed.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isReview) {
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!isFormValid || submitting"
|
||||
(click)="openConfirmDialog()"
|
||||
>
|
||||
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Verification</div>
|
||||
<div class="swrv-trigger">
|
||||
<div class="swrv-trigger__icon">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
</div>
|
||||
<div class="swrv-trigger__text">
|
||||
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||
<div class="swrv-trigger__sub">
|
||||
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
Validate Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Process Withdrawal
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showValidateModal) {
|
||||
<div class="swrv-modal">
|
||||
<div class="swrv-modal__card">
|
||||
<div class="swrv-modal__header">
|
||||
<div class="swrv-modal__title">
|
||||
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||
Validate Share Withdrawal Request
|
||||
</div>
|
||||
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__body">
|
||||
<p class="swrv-modal__intro">
|
||||
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||
and GL configuration — not just what was captured at submission time.
|
||||
</p>
|
||||
|
||||
@if (backendChecks && !validating && !validateAllPassed) {
|
||||
<div class="swrv-modal__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div>
|
||||
<strong>Some checks need review before approving.</strong>
|
||||
Scroll down to see which ones failed and why.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ol class="swrv-steps">
|
||||
@for (step of validateSteps; track step.label; let i = $index) {
|
||||
<li
|
||||
class="swrv-step"
|
||||
[class.swrv-step--active]="step.status === 'active'"
|
||||
[class.swrv-step--done]="step.status === 'done'"
|
||||
[class.swrv-step--failed]="step.status === 'failed'"
|
||||
>
|
||||
<div class="swrv-step__indicator">
|
||||
@if (step.status === 'done') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (step.status === 'active') {
|
||||
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||
} @else if (step.status === 'failed') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else {
|
||||
<span>{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="swrv-step__content">
|
||||
<div class="swrv-step__label">{{ step.label }}</div>
|
||||
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@if (validating && validateSteps.length === 0) {
|
||||
<div class="pg-empty">Connecting to server…</div>
|
||||
}
|
||||
|
||||
@if (backendChecks && !validating && validateAllPassed) {
|
||||
<div class="swrv-modal__result">
|
||||
<mat-icon>verified</mat-icon>
|
||||
<span>All checks passed — safe to approve.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__footer">
|
||||
@if (!validating && backendChecks) {
|
||||
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Re-run
|
||||
</button>
|
||||
}
|
||||
@if (!validating && backendChecks && validateAllPassed) {
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Process Withdrawal
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||
{{ validating ? 'Validating…' : 'Close' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Settlement</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (reviewRequest?.settlement_amount) {
|
||||
<label class="inv-form-field">
|
||||
<span>Amount Credited</span>
|
||||
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Credited To (Linked Savings A/C)</span>
|
||||
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Savings Transaction Ref.</span>
|
||||
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pg-empty" style="grid-column: span 2">
|
||||
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||
already-approved request — check the linked savings account's transaction history to confirm.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && activityLog.length > 0) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Activity Log</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of activityLog; track entry.timestamp) {
|
||||
<tr>
|
||||
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||
<td>{{ entry.action }}</td>
|
||||
<td>{{ entry.user }}</td>
|
||||
<td>{{ entry.note || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showConfirmDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Confirm Share Withdrawal Request</h3>
|
||||
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Account No / Client</td>
|
||||
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shares to Withdraw</td>
|
||||
<td>
|
||||
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr [class.swr-close-row]="remainingShares === 0">
|
||||
<td>Remaining Shares After Withdrawal</td>
|
||||
<td>
|
||||
{{ remainingShares }}
|
||||
@if (remainingShares === 0) {
|
||||
<span class="swr-close-tag"><mat-icon>lock</mat-icon> Account will be closed on approval</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Requested Date</td>
|
||||
<td>{{ requestedDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Board Resolution Number</td>
|
||||
<td>{{ resolutionNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date of Meeting</td>
|
||||
<td>{{ meetingDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Settlement Payment Type</td>
|
||||
<td>{{ settlementPaymentTypeName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linked Savings Account</td>
|
||||
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
@for (check of verificationChecks; track check.label) {
|
||||
<tr>
|
||||
<td>{{ check.label }}</td>
|
||||
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showRejectDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Reject Share Withdrawal Request</h3>
|
||||
<label class="inv-form-field">
|
||||
<span>* Rejection Reason</span>
|
||||
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||
</label>
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
[disabled]="!rejectionReason.trim()"
|
||||
(click)="confirmRejectReview()"
|
||||
>
|
||||
Confirm Reject
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.swr-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(13 27 42 / 55%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.swr-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&:has(table) {
|
||||
width: 560px;
|
||||
}
|
||||
}
|
||||
|
||||
.swr-check-pass {
|
||||
color: #1a7f37;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swr-check-fail {
|
||||
color: #b42318;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swr-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&--ok {
|
||||
background: #eafaf0;
|
||||
color: #1a7f37;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: #fdecea;
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Account-will-close notice ---------- */
|
||||
.swr-close-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 16px 16px;
|
||||
padding: 10px 16px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #7c2d12;
|
||||
}
|
||||
}
|
||||
|
||||
.swr-close-row td {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.swr-close-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Verification trigger card ---------- */
|
||||
.swrv-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Validate pipeline modal ---------- */
|
||||
@keyframes swrv-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swrv-zoom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swrv-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.swrv-spin {
|
||||
animation: swrv-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.swrv-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
// No backdrop-filter: it would make this element a containing block for any position:fixed
|
||||
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
|
||||
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
|
||||
// caused. Extra opacity compensates for the loss of the blur look.
|
||||
background: rgb(15 25 45 / 65%);
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
animation: swrv-fade 0.2s ease-out;
|
||||
|
||||
&__card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||
animation: swrv-zoom 0.25s ease-out;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid #eef1f5;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__title-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: #b42318;
|
||||
border-color: #b42318;
|
||||
background: #fdecea;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 20px 22px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__intro {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 16px;
|
||||
|
||||
strong {
|
||||
color: #0d1b2a;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__warn {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #fef3e2;
|
||||
border: 1px solid rgb(217 119 6 / 30%);
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: #633806;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: #4d2a05;
|
||||
}
|
||||
}
|
||||
|
||||
&__result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 12px 14px;
|
||||
background: #eafaf0;
|
||||
border: 1px solid rgb(26 127 55 / 25%);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a7f37;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid #eef1f5;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.swrv-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.swrv-step {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 38px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.25s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 5px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&__msg {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
.swrv-step__indicator {
|
||||
background: #3457d5;
|
||||
border-color: #3457d5;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #3457d5;
|
||||
}
|
||||
}
|
||||
|
||||
&--done {
|
||||
.swrv-step__indicator {
|
||||
background: #1a7f37;
|
||||
border-color: #1a7f37;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #1a7f37;
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
&--failed {
|
||||
.swrv-step__indicator {
|
||||
background: #b42318;
|
||||
border-color: #b42318;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.swrv-spin {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.swrv-modal,
|
||||
.swrv-modal__card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||
import { CashierService } from '../../cashier/cashier.service';
|
||||
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||
|
||||
export type ShareWdMode = 'create' | 'review';
|
||||
|
||||
interface ActivityEntry {
|
||||
timestamp: string;
|
||||
action: string;
|
||||
user: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-withdrawal-request',
|
||||
templateUrl: './share-withdrawal-request.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss',
|
||||
'./share-withdrawal-request.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
SearchSelectComponent
|
||||
]
|
||||
})
|
||||
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private sharesService = inject(SharesService);
|
||||
private wdService = inject(ClerkShareWithdrawalService);
|
||||
private cashierService = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||
* events instead of navigating away. */
|
||||
@Input() embedded = false;
|
||||
@Input() overrideMode: ShareWdMode | null = null;
|
||||
@Input() overrideRequestId: number | null = null;
|
||||
@Output() closeRequested = new EventEmitter<void>();
|
||||
@Output() actionCompleted = new EventEmitter<void>();
|
||||
|
||||
mode: ShareWdMode = 'create';
|
||||
requestId: number | null = null;
|
||||
|
||||
submitting = false;
|
||||
loadingAccount = false;
|
||||
|
||||
selectedAccountId = '';
|
||||
accountOptions: SearchSelectOption[] = [];
|
||||
|
||||
account: any = null;
|
||||
|
||||
requestedShares: number | null = null;
|
||||
requestedDate = '';
|
||||
resolutionNumber = '';
|
||||
meetingDate = '';
|
||||
description = '';
|
||||
|
||||
paymentTypes: any[] = [];
|
||||
settlementPaymentTypeId: number | null = null;
|
||||
|
||||
activityLog: ActivityEntry[] = [];
|
||||
reviewRequest: any = null;
|
||||
showRejectDialog = false;
|
||||
rejectionReason = '';
|
||||
showConfirmDialog = false;
|
||||
|
||||
showValidateModal = false;
|
||||
validating = false;
|
||||
validateAllPassed = false;
|
||||
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||
[];
|
||||
|
||||
get remainingShares(): number {
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
return held - (this.requestedShares || 0);
|
||||
}
|
||||
|
||||
get unitPrice(): number {
|
||||
return Number(this.account?.currentMarketPrice ?? 0);
|
||||
}
|
||||
|
||||
get withdrawalValue(): number {
|
||||
return (this.requestedShares || 0) * this.unitPrice;
|
||||
}
|
||||
|
||||
get paymentTypeOptions(): any[] {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
|
||||
get selectedSettlementPaymentType(): any {
|
||||
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||
}
|
||||
|
||||
get settlementPaymentTypeName(): string {
|
||||
if (this.isReview) {
|
||||
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||
}
|
||||
return this.selectedSettlementPaymentType?.name || '—';
|
||||
}
|
||||
|
||||
get isFormValid(): boolean {
|
||||
if (!this.account) return false;
|
||||
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
if (this.requestedShares > held) return false;
|
||||
if (!this.requestedDate) return false;
|
||||
if (!this.resolutionNumber.trim()) return false;
|
||||
if (!this.meetingDate) return false;
|
||||
if (!this.settlementPaymentTypeId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
const shares = this.requestedShares || 0;
|
||||
return [
|
||||
{
|
||||
label: 'Board Resolution Number provided',
|
||||
pass: !!this.resolutionNumber?.trim(),
|
||||
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||
},
|
||||
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||
{
|
||||
label: 'Shares requested within shares held',
|
||||
pass: shares > 0 && shares <= held,
|
||||
detail: `${shares} of ${held} shares held`
|
||||
},
|
||||
{
|
||||
label: 'Linked savings account confirmed',
|
||||
pass: !!this.account?.savingsAccountNumber,
|
||||
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||
},
|
||||
{
|
||||
label: 'Settlement Payment Type selected (non-cash)',
|
||||
pass: this.settlementPaymentTypeName !== '—',
|
||||
detail: this.settlementPaymentTypeName
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get isReview(): boolean {
|
||||
return this.mode === 'review';
|
||||
}
|
||||
|
||||
get pageTitle(): string {
|
||||
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||
}
|
||||
|
||||
get pageSubtitle(): string {
|
||||
return this.isReview
|
||||
? "Approve or reject the clerk's share withdrawal request."
|
||||
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||
this.requestId = idParam ? Number(idParam) : null;
|
||||
|
||||
const today = new Date();
|
||||
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
this.meetingDate = this.requestedDate;
|
||||
|
||||
this.sharesService
|
||||
.getShareAccountSummaries()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((rows: any[]) => {
|
||||
this.accountOptions = (rows || [])
|
||||
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||
.map((r) => ({
|
||||
value: String(r.id),
|
||||
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||
searchText: `${r.accountNo} ${r.clientName}`
|
||||
}));
|
||||
});
|
||||
|
||||
this.cashierService
|
||||
.getPaymentTypes()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||
|
||||
if (this.requestId && this.mode === 'review') {
|
||||
this.loadReviewRequest(this.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
onAccountSelected(accountId: string): void {
|
||||
this.selectedAccountId = accountId;
|
||||
if (!accountId) {
|
||||
this.account = null;
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = true;
|
||||
this.sharesService
|
||||
.getSharesAccountData(accountId, false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => {
|
||||
this.account = acc;
|
||||
this.loadingAccount = false;
|
||||
});
|
||||
}
|
||||
|
||||
private loadReviewRequest(id: number): void {
|
||||
this.wdService.getById(id).subscribe({
|
||||
next: (req: any) => {
|
||||
this.reviewRequest = req;
|
||||
this.requestedShares = Number(req.requested_shares);
|
||||
this.requestedDate = req.requested_date
|
||||
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||
: this.requestedDate;
|
||||
this.resolutionNumber = req.resolution_number ?? '';
|
||||
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||
this.description = req.description ?? '';
|
||||
|
||||
if (req.activity_log) {
|
||||
try {
|
||||
this.activityLog = JSON.parse(req.activity_log);
|
||||
} catch {
|
||||
this.activityLog = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.sharesService
|
||||
.getSharesAccountData(String(req.share_account_id), false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => (this.account = acc));
|
||||
},
|
||||
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||
});
|
||||
}
|
||||
|
||||
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||
openConfirmDialog(): void {
|
||||
if (!this.isFormValid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.showConfirmDialog = true;
|
||||
}
|
||||
|
||||
cancelConfirmDialog(): void {
|
||||
this.showConfirmDialog = false;
|
||||
}
|
||||
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
this.submitting = true;
|
||||
const payload = {
|
||||
shareAccountId: Number(this.selectedAccountId),
|
||||
accountNo: this.account?.accountNo,
|
||||
clientId: this.account?.clientId,
|
||||
clientName: this.account?.clientName,
|
||||
requestedShares: this.requestedShares,
|
||||
requestedDate: this.requestedDate,
|
||||
resolutionNumber: this.resolutionNumber.trim(),
|
||||
meetingDate: this.meetingDate,
|
||||
description: this.description,
|
||||
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||
};
|
||||
this.wdService.submit(payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/clerk/dashboard']);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openValidateModal(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.showValidateModal = true;
|
||||
this.runBackendValidation();
|
||||
}
|
||||
|
||||
closeValidateModal(): void {
|
||||
if (this.validating) return;
|
||||
this.showValidateModal = false;
|
||||
}
|
||||
|
||||
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||
runBackendValidation(): void {
|
||||
if (!this.reviewRequest || this.validating) return;
|
||||
this.validating = true;
|
||||
this.backendChecks = null;
|
||||
this.validateSteps = [];
|
||||
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||
this.backendChecks = checks;
|
||||
this.validateAllPassed = !!res?.allPassed;
|
||||
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||
this.revealValidateSteps(0);
|
||||
},
|
||||
error: (err) => {
|
||||
this.validating = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private revealValidateSteps(index: number): void {
|
||||
if (index >= this.validateSteps.length) {
|
||||
this.validating = false;
|
||||
return;
|
||||
}
|
||||
this.validateSteps[index].status = 'active';
|
||||
setTimeout(() => {
|
||||
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||
this.revealValidateSteps(index + 1);
|
||||
}, 320);
|
||||
}
|
||||
|
||||
approveReview(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
let message =
|
||||
res?.settlementAmount != null
|
||||
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||
if (res?.accountClosed) {
|
||||
message += ' All shares withdrawn — the share account has been closed.';
|
||||
}
|
||||
this.snackBar.open(message, 'Close', { duration: 8000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
openRejectDialog(): void {
|
||||
this.showRejectDialog = true;
|
||||
this.rejectionReason = '';
|
||||
}
|
||||
|
||||
confirmRejectReview(): void {
|
||||
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.showRejectDialog = false;
|
||||
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (this.embedded) {
|
||||
this.closeRequested.emit();
|
||||
return;
|
||||
}
|
||||
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="fm-dialog">
|
||||
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px">
|
||||
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||
<span style="font-weight: 500; font-size: 18px">CHILDREN</span>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="childrenForm">
|
||||
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px">
|
||||
@for (child of children.controls; track $index) {
|
||||
<div
|
||||
[formGroupName]="$index"
|
||||
style="
|
||||
position: relative;
|
||||
padding: 24px 16px 16px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
"
|
||||
>
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Child Full Name*</mat-label>
|
||||
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Gender*</mat-label>
|
||||
<mat-select formControlName="genderId" required>
|
||||
@for (g of genderIdOptions; track g.id) {
|
||||
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Nationality</mat-label>
|
||||
<mat-select formControlName="nationality">
|
||||
<mat-option value="INDIAN">Indian</mat-option>
|
||||
<mat-option value="NRI">NRI</mat-option>
|
||||
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
placeholder="12-digit Aadhaar"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>PAN Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
maxlength="10"
|
||||
placeholder="ABCDE1234F"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
$any(children.at($index))
|
||||
.get('panNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (
|
||||
$any(children.at($index)).get('panNumber')?.hasError('pattern') &&
|
||||
$any(children.at($index)).get('panNumber')?.touched
|
||||
) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@if ($index < children.controls.length - 1) {
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
}
|
||||
}
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||
<mat-icon>add</mat-icon> Add Another Child
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||
Add Member
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-family-member-child-dialog',
|
||||
templateUrl: './client-family-member-child-dialog.component.html',
|
||||
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
dialogRef = inject(MatDialogRef<ClientFamilyMemberChildDialogComponent>);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
childrenForm: UntypedFormGroup;
|
||||
genderIdOptions: any[];
|
||||
maxDate: Date = new Date();
|
||||
|
||||
constructor() {
|
||||
this.genderIdOptions = this.data.genderIdOptions;
|
||||
|
||||
this.childrenForm = this.formBuilder.group({
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.data.children && this.data.children.length > 0) {
|
||||
this.data.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.addChild();
|
||||
}
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.childrenForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.childrenForm.valid) {
|
||||
this.dialogRef.close(this.children.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -139,6 +139,21 @@
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||
<div class="fm-readonly-remarks">
|
||||
<span class="fm-readonly-label">Children</span>
|
||||
<div class="fm-children-list">
|
||||
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px">
|
||||
<span>{{ child.fullName }}</span>
|
||||
@if (child.dateOfBirth) {
|
||||
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||
@@ -447,6 +462,25 @@
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Children Section -->
|
||||
@if (isSpouse) {
|
||||
<div class="fm-section-label" style="margin-top: 16px">Children</div>
|
||||
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between">
|
||||
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
color="primary"
|
||||
(click)="openChildrenDialog()"
|
||||
style="margin-left: 16px"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
+143
-2
@@ -17,7 +17,7 @@ import {
|
||||
MatDialogClose,
|
||||
MatDialogContent
|
||||
} from '@angular/material/dialog';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
maxDate = new Date();
|
||||
|
||||
hasChildrenChecked = false;
|
||||
familyMemberForm: UntypedFormGroup;
|
||||
|
||||
/** Client search */
|
||||
@@ -197,6 +199,32 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
// Re-fetch linked client details (profile, signature, KYC status)
|
||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||
}
|
||||
if (m.children && m.children.length > 0) {
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||
m.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
if (this.isViewMode) {
|
||||
this.familyMemberForm.disable();
|
||||
}
|
||||
@@ -225,8 +253,102 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
isAlive: [true],
|
||||
marriageDate: [''],
|
||||
marriageAuthority: [''],
|
||||
linkedClientId: ['']
|
||||
linkedClientId: [''],
|
||||
hasChildren: [false],
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
|
||||
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => {
|
||||
if (value && !this.hasChildrenChecked) {
|
||||
this.openChildrenDialog();
|
||||
} else if (!value) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.children.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
async openChildrenDialog() {
|
||||
// Dynamically import the dialog to avoid a circular dependency between the two dialogs.
|
||||
const { ClientFamilyMemberChildDialogComponent } =
|
||||
await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||
|
||||
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||
data: {
|
||||
children: this.children.value,
|
||||
genderIdOptions: this.data.options.genderIdOptions
|
||||
},
|
||||
width: '500px'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||
if (result) {
|
||||
this.children.clear();
|
||||
result.forEach((child) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||
} else if (this.children.length === 0) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
if (this.children.length === 0) {
|
||||
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle marriage certificate file selection */
|
||||
@@ -481,6 +603,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
member._linkedClient = this.selectedClient;
|
||||
}
|
||||
|
||||
// Format children dates
|
||||
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||
member.children = member.children.map((child: any) => {
|
||||
const c = { ...child };
|
||||
if (c.dateOfBirth instanceof Date) {
|
||||
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||
} else if (!c.dateOfBirth) {
|
||||
delete c.dateOfBirth;
|
||||
}
|
||||
if (c.panNumber) {
|
||||
c.panNumber = c.panNumber.toUpperCase();
|
||||
}
|
||||
return c;
|
||||
});
|
||||
} else {
|
||||
delete member.children;
|
||||
}
|
||||
delete member.hasChildren;
|
||||
|
||||
// Attach marriage certificate file if present
|
||||
if (this.marriageCertFile) {
|
||||
member._marriageCertFile = this.marriageCertFile;
|
||||
|
||||
+16
@@ -62,6 +62,22 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Mother requirement — shown independently of the Father/Caretaker check below -->
|
||||
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasMother) {
|
||||
<div class="fm-step__empty fm-step__empty--required">
|
||||
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||
<span class="fm-step__empty-required">Mother is required.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Father-or-Caretaker requirement — shown independently of the Mother check above -->
|
||||
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasFather && !hasCaretaker) {
|
||||
<div class="fm-step__empty fm-step__empty--required">
|
||||
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||
<span class="fm-step__empty-required">Either Father or a Caretaker is required.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Spouse hint for married clients -->
|
||||
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
||||
<div class="fm-spouse-hint">
|
||||
|
||||
+44
@@ -12,6 +12,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { FamilyMemberAddedDialogComponent } from './family-member-added-dialog.component';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.member) {
|
||||
this.clientFamilyMembers.push(response.member);
|
||||
this.showFamilyMemberAddedConfirmation(response.member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
|
||||
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
|
||||
private showFamilyMemberAddedConfirmation(member: any): void {
|
||||
this.dialog.open(FamilyMemberAddedDialogComponent, {
|
||||
width: '360px',
|
||||
data: {
|
||||
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
|
||||
name: member._linkedClient?.displayName || member.firstName,
|
||||
relationship: this.getRelName(member.relationshipId),
|
||||
dateOfBirth: member.dateOfBirth,
|
||||
isDependent: member.isDependent
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -223,6 +240,33 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
return this.clientFamilyMembers.length > 0;
|
||||
}
|
||||
|
||||
/** True if a family member with the given relationship (case-insensitive) has been added */
|
||||
private hasRelationship(name: string): boolean {
|
||||
const target = name.toLowerCase();
|
||||
return this.clientFamilyMembers.some((m: any) => {
|
||||
const resolved = this.getRelName(m.relationshipId ?? m.relationship).toLowerCase();
|
||||
if (resolved === target) return true;
|
||||
return typeof m.relationship === 'string' && m.relationship.toLowerCase() === target;
|
||||
});
|
||||
}
|
||||
|
||||
get hasMother(): boolean {
|
||||
return this.hasRelationship('mother');
|
||||
}
|
||||
|
||||
get hasFather(): boolean {
|
||||
return this.hasRelationship('father');
|
||||
}
|
||||
|
||||
get hasCaretaker(): boolean {
|
||||
return this.hasRelationship('caretaker');
|
||||
}
|
||||
|
||||
/** Mother is always required; Father is required unless a Caretaker has been added instead. */
|
||||
get familyRelationshipRulesMet(): boolean {
|
||||
return this.hasMother && (this.hasFather || this.hasCaretaker);
|
||||
}
|
||||
|
||||
/** Check if client's marital status is "Married" */
|
||||
get isMarried(): boolean {
|
||||
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
|
||||
/**
|
||||
* Confirmation popup shown right after a family member is added — surfaces the linked
|
||||
* client's own CBS Client ID (when the member was linked to an existing client rather than
|
||||
* entered manually) alongside name/relationship, since the "Add Member" dialog itself just
|
||||
* closed silently before this with no acknowledgement of what got recorded.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-family-member-added-dialog',
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogActions,
|
||||
MatIcon,
|
||||
MatButton
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title class="fma-title">
|
||||
<mat-icon class="fma-title__icon">check_circle</mat-icon>
|
||||
Family Member Added
|
||||
</h2>
|
||||
<div class="fma-body">
|
||||
@if (data.clientId) {
|
||||
<div class="fma-row fma-row--id">
|
||||
<span class="fma-row__label">Client ID</span>
|
||||
<span class="fma-row__value fma-row__value--id">{{ data.clientId }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="fma-row">
|
||||
<span class="fma-row__label">Name</span>
|
||||
<span class="fma-row__value">{{ data.name }}</span>
|
||||
</div>
|
||||
@if (data.relationship) {
|
||||
<div class="fma-row">
|
||||
<span class="fma-row__label">Relationship</span>
|
||||
<span class="fma-row__value">{{ data.relationship }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.dateOfBirth) {
|
||||
<div class="fma-row">
|
||||
<span class="fma-row__label">Date of Birth</span>
|
||||
<span class="fma-row__value">{{ data.dateOfBirth }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.isDependent) {
|
||||
<div class="fma-row">
|
||||
<span class="fma-row__label">Dependent</span>
|
||||
<span class="fma-row__value">Yes</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-flat-button color="primary" (click)="dialogRef.close()">Done</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.fma-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
padding: 20px 24px 4px;
|
||||
}
|
||||
|
||||
.fma-title__icon {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.fma-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 24px 8px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.fma-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fma-row__label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fma-row__value {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fma-row--id {
|
||||
background: #eff6ff;
|
||||
margin: 0 -24px;
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.fma-row__value--id {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
color: #1353a0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 8px 24px 16px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class FamilyMemberAddedDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<FamilyMemberAddedDialogComponent>>(MatDialogRef);
|
||||
data: {
|
||||
clientId?: string | number | null;
|
||||
name: string;
|
||||
relationship?: string;
|
||||
dateOfBirth?: string;
|
||||
isDependent?: boolean;
|
||||
} = inject(MAT_DIALOG_DATA);
|
||||
}
|
||||
+345
-86
@@ -136,7 +136,14 @@
|
||||
| translate
|
||||
}}
|
||||
</mat-label>
|
||||
<input matInput required formControlName="fullname" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="fullname"
|
||||
autocomplete="off"
|
||||
(blur)="capitalizeControl('fullname')"
|
||||
style="text-transform: capitalize"
|
||||
/>
|
||||
@if (createClientForm.controls.fullname.hasError('required')) {
|
||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||
@@ -151,10 +158,12 @@
|
||||
<div class="full-name-row full-width">
|
||||
<div class="full-name-preview">
|
||||
<span class="full-name-label">Entity Name:</span>
|
||||
<span class="full-name-value">{{ createClientForm.get('fullname')?.value }}</span>
|
||||
<span class="full-name-value" style="text-transform: capitalize">{{
|
||||
createClientForm.get('fullname')?.value
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="verify-inline">
|
||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
||||
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
@@ -164,27 +173,27 @@
|
||||
(click)="verifySanctions()"
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
||||
Verify Entity
|
||||
{{ sanctionsNeedsRescreen ? 'Re-verify Entity' : 'Verify Entity' }}
|
||||
</button>
|
||||
}
|
||||
@if (sanctionsVerified) {
|
||||
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 & PMLA 2002</span>
|
||||
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
|
||||
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||
<mat-icon
|
||||
class="sanctions-info-icon"
|
||||
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
|
||||
[matTooltip]="sanctionsTooltip"
|
||||
matTooltipClass="sanctions-tooltip"
|
||||
>info</mat-icon
|
||||
>
|
||||
}
|
||||
@if (sanctionsBlocked) {
|
||||
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 & PMLA 2002</span>
|
||||
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
|
||||
<span class="verify-status blocked">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
|
||||
</span>
|
||||
<mat-icon
|
||||
class="sanctions-info-icon blocked-info"
|
||||
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
|
||||
[matTooltip]="sanctionsTooltip"
|
||||
matTooltipClass="sanctions-tooltip"
|
||||
>info</mat-icon
|
||||
>
|
||||
@@ -214,7 +223,14 @@
|
||||
<mat-form-field appearance="outline" class="name-field first-name">
|
||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
||||
<input matInput required formControlName="firstname" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="firstname"
|
||||
autocomplete="off"
|
||||
(blur)="capitalizeControl('firstname')"
|
||||
style="text-transform: capitalize"
|
||||
/>
|
||||
@if (createClientForm.controls.firstname.hasError('required')) {
|
||||
<mat-error>First name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||
@@ -228,7 +244,13 @@
|
||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
||||
<input matInput formControlName="middlename" />
|
||||
<input
|
||||
matInput
|
||||
formControlName="middlename"
|
||||
autocomplete="off"
|
||||
(blur)="capitalizeControl('middlename')"
|
||||
style="text-transform: capitalize"
|
||||
/>
|
||||
@if (
|
||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||
createClientForm.controls.middlename?.touched
|
||||
@@ -241,7 +263,14 @@
|
||||
<mat-form-field appearance="outline" class="name-field last-name">
|
||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
||||
<input matInput required formControlName="lastname" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="lastname"
|
||||
autocomplete="off"
|
||||
(blur)="capitalizeControl('lastname')"
|
||||
style="text-transform: capitalize"
|
||||
/>
|
||||
@if (createClientForm.controls.lastname.hasError('required')) {
|
||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||
@@ -261,7 +290,7 @@
|
||||
<span class="full-name-value">{{ fullNamePreview }}</span>
|
||||
</div>
|
||||
<div class="verify-inline">
|
||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
||||
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
@@ -271,27 +300,27 @@
|
||||
(click)="verifySanctions()"
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
||||
Verify Client
|
||||
{{ sanctionsNeedsRescreen ? 'Re-verify Client' : 'Verify Client' }}
|
||||
</button>
|
||||
}
|
||||
@if (sanctionsVerified) {
|
||||
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 & PMLA 2002</span>
|
||||
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
|
||||
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||
<mat-icon
|
||||
class="sanctions-info-icon"
|
||||
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
|
||||
[matTooltip]="sanctionsTooltip"
|
||||
matTooltipClass="sanctions-tooltip"
|
||||
>info</mat-icon
|
||||
>
|
||||
}
|
||||
@if (sanctionsBlocked) {
|
||||
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 & PMLA 2002</span>
|
||||
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
|
||||
<span class="verify-status blocked">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
|
||||
</span>
|
||||
<mat-icon
|
||||
class="sanctions-info-icon blocked-info"
|
||||
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
|
||||
[matTooltip]="sanctionsTooltip"
|
||||
matTooltipClass="sanctions-tooltip"
|
||||
>info</mat-icon
|
||||
>
|
||||
@@ -358,11 +387,14 @@
|
||||
@if (isMinor) {
|
||||
<span class="minor-badge">Minor</span>
|
||||
}
|
||||
@if (isSeniorCitizen) {
|
||||
<span class="senior-citizen-badge">Senior Citizen</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||
<div class="guardian-section">
|
||||
<!-- Header bar -->
|
||||
@@ -691,6 +723,9 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Date of Incorporation is captured on the KYC step, next to the entity PAN it is
|
||||
verified against — see client-kyc-step's `incorporationDate` control. -->
|
||||
|
||||
<!-- Authorized Signatories (multiple, registered clients) -->
|
||||
<div class="form-field full-width signatory-section">
|
||||
<h4 class="section-title signatory-title">
|
||||
@@ -819,7 +854,8 @@
|
||||
<div class="guardian-result-item__info">
|
||||
<span class="guardian-result-item__name">{{ result.displayName }}</span>
|
||||
<span class="guardian-result-item__meta"
|
||||
>ID: {{ result.id }} · A/C: {{ result.accountNumber }} ·
|
||||
>ID: {{ result.id }} · A/C: {{ result.accountNumber }}
|
||||
·
|
||||
{{ result.officeName }}
|
||||
@if (result.dateOfBirth) {
|
||||
· DOB: {{ result.dateOfBirth | dateFormat }}
|
||||
@@ -995,6 +1031,32 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- CLIENT CREATION contact-OTP verification — create mode only. Edit mode already has
|
||||
its own dual old/new OTP panel in EditClientComponent's template; this single
|
||||
"new value" block only makes sense for a brand-new client (no "old" destination to
|
||||
verify against). Each block only appears once its own field passes validation, so
|
||||
staff enter a plausible-looking number/email before being asked to verify it. -->
|
||||
@if (!clientData) {
|
||||
<div class="contact-otp-row">
|
||||
@if (createClientForm.get('mobileNo')?.valid) {
|
||||
<mifosx-client-contact-otp-block
|
||||
channel="MOBILE"
|
||||
[destination]="createClientForm.get('mobileNo')?.value"
|
||||
label="Verify Mobile Number"
|
||||
(stateChange)="onMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
}
|
||||
@if (createClientForm.get('emailAddress')?.valid) {
|
||||
<mifosx-client-contact-otp-block
|
||||
channel="EMAIL"
|
||||
[destination]="createClientForm.get('emailAddress')?.value"
|
||||
label="Verify Email Address"
|
||||
(stateChange)="onEmailOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1006,69 +1068,67 @@
|
||||
Education & Social Details
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<!-- Education Level (non-minor) -->
|
||||
@if (!isMinor) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Education Level -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Religion -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
@@ -1121,6 +1181,79 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
Section: Turnover & Risk (Non-Individual only)
|
||||
|
||||
An entity has no salary to declare, so the individual Financial & Classification section below
|
||||
never applied to it — which left a company with no turnover on file at all, and its risk stuck
|
||||
on the flat entity default. Turnover is the size signal the branch actually has at onboarding,
|
||||
so it is captured here and the risk band it produces is shown right beside it: the officer sees
|
||||
the grade move as they type, instead of first meeting it on the KYC tab.
|
||||
-->
|
||||
@if (createClientForm.get('legalFormId')?.value === LegalFormId.ENTITY) {
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="chart-line" class="section-icon"></fa-icon>
|
||||
Turnover & Risk Profile
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Monthly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
|
||||
<mat-hint>Yearly turnover fills in automatically (× 12)</mat-hint>
|
||||
@if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Annual Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
<mat-hint>Drives the risk grade below</mat-hint>
|
||||
@if (
|
||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div
|
||||
class="full-width entity-risk-card"
|
||||
[class.entity-risk-card--low]="entityTurnoverRisk.category === 1"
|
||||
[class.entity-risk-card--medium]="entityTurnoverRisk.category === 2"
|
||||
[class.entity-risk-card--high]="entityTurnoverRisk.category === 3"
|
||||
>
|
||||
<div class="entity-risk-card__head">
|
||||
<fa-icon icon="shield-alt" class="entity-risk-card__icon"></fa-icon>
|
||||
<span class="entity-risk-card__title">Auto-calculated Risk Category</span>
|
||||
<span class="entity-risk-card__badge">{{ riskCategoryLabel(entityTurnoverRisk.category) }}</span>
|
||||
</div>
|
||||
<p class="entity-risk-card__reason">{{ entityTurnoverRisk.reason }}</p>
|
||||
<p class="entity-risk-card__scale">
|
||||
Up to ₹1 crore — Low · ₹1 crore to ₹10 crore — Medium · Above ₹10 crore — High
|
||||
</p>
|
||||
<p class="entity-risk-card__note">
|
||||
Carried into the KYC tab, where it can still be overridden with a recorded reason.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Section: Financial & Classification (Individual only) -->
|
||||
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||
<div class="section-block">
|
||||
@@ -1191,21 +1324,25 @@
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Annual Turnover (Last FY)</mat-label>
|
||||
<mat-label>Annual Income</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
<input matInput type="number" formControlName="annualIncome" min="0" />
|
||||
@if (
|
||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('annualTurnover')?.touched
|
||||
createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||
createClientForm.get('annualIncome')?.hasError('min') && createClientForm.get('annualIncome')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width subsection-title">
|
||||
<fa-icon icon="coins" class="subsection-icon"></fa-icon>
|
||||
Other Income
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Agriculture Income</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
@@ -1239,6 +1376,128 @@
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Monthly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
|
||||
@if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Yearly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
@if (
|
||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width subsection-title">
|
||||
<fa-icon icon="user-shield" class="subsection-icon"></fa-icon>
|
||||
Senior Citizen / Person with Disability
|
||||
</div>
|
||||
|
||||
@if (isSeniorCitizen) {
|
||||
<div class="full-width senior-citizen-auto-note">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Senior Citizen (60+ years) — detected automatically from Date of Birth
|
||||
</div>
|
||||
|
||||
<div class="full-width doc-upload-field">
|
||||
<label class="doc-upload-label">
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onSeniorCitizenProofSelected($event)" />
|
||||
<fa-icon icon="file-upload"></fa-icon>
|
||||
@if (seniorCitizenProofFile) {
|
||||
<span>{{ seniorCitizenProofFile.name }}</span>
|
||||
} @else if (existingSeniorCitizenProofDoc) {
|
||||
<span>{{ existingSeniorCitizenProofDoc.name }} (uploaded)</span>
|
||||
} @else {
|
||||
<span>Upload Senior Citizen Proof (Age / ID Proof) — optional</span>
|
||||
}
|
||||
</label>
|
||||
@if (seniorCitizenProofFile) {
|
||||
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else if (existingSeniorCitizenProofDoc) {
|
||||
<button type="button" mat-icon-button (click)="viewExistingSeniorCitizenProof()" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button (click)="removeExistingSeniorCitizenProof()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="checkbox-field full-width">
|
||||
<mat-checkbox formControlName="isPwd">Person with Disability (PWD)</mat-checkbox>
|
||||
</div>
|
||||
|
||||
@if (createClientForm.get('isPwd')?.value) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Disability Type</mat-label>
|
||||
<mat-select formControlName="pwdCategoryId">
|
||||
@for (category of pwdCategoryOptions; track category.id) {
|
||||
<mat-option [value]="category.id">{{ category.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (seniorPwdDocsTouched && !createClientForm.get('pwdCategoryId')?.value) {
|
||||
<mat-error>Disability type is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width doc-upload-field">
|
||||
<label
|
||||
class="doc-upload-label"
|
||||
[class.doc-upload-label--missing]="
|
||||
seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc
|
||||
"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
|
||||
<fa-icon icon="file-upload"></fa-icon>
|
||||
@if (pwdCertificateFile) {
|
||||
<span>{{ pwdCertificateFile.name }}</span>
|
||||
} @else if (existingPwdCertificateDoc) {
|
||||
<span>{{ existingPwdCertificateDoc.name }} (uploaded)</span>
|
||||
} @else {
|
||||
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
|
||||
}
|
||||
</label>
|
||||
@if (pwdCertificateFile) {
|
||||
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else if (existingPwdCertificateDoc) {
|
||||
<button type="button" mat-icon-button (click)="viewExistingPwdCertificate()" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button (click)="removeExistingPwdCertificate()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc) {
|
||||
<span class="doc-upload-error">Required when PWD is checked</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+200
@@ -56,6 +56,175 @@ form {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Live risk grade beside the entity's turnover fields. Neutral by default; the accent tracks the
|
||||
band so a jump from Medium to High is visible without reading the sentence. */
|
||||
.entity-risk-card {
|
||||
margin: 4px 0 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #94a3b8;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
margin-left: auto;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__reason {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__scale,
|
||||
&__note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
border-left-color: #16a34a;
|
||||
|
||||
.entity-risk-card__icon {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.entity-risk-card__badge {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
border-left-color: #d97706;
|
||||
|
||||
.entity-risk-card__icon {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.entity-risk-card__badge {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
border-left-color: #dc2626;
|
||||
|
||||
.entity-risk-card__icon {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.entity-risk-card__badge {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.senior-citizen-auto-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #1074b9;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.doc-upload-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.doc-upload-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
&.doc-upload-label--missing {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-upload-req {
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doc-upload-error {
|
||||
font-size: 11px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6b7280;
|
||||
margin: 20px 0 4px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
.subsection-icon {
|
||||
color: var(--md-sys-color-primary, #1074b9);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -234,6 +403,18 @@ form {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.senior-citizen-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
color: #1074b9;
|
||||
margin-left: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ═══ Guardian Section (Redesigned) ═══ */
|
||||
.guardian-section {
|
||||
margin-top: 12px;
|
||||
@@ -1493,6 +1674,21 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
/* Create-time mobile/email OTP verification — spans both `.form-grid` columns so the two
|
||||
* blocks sit on their own full-width row instead of disrupting the field/verify-button pairing
|
||||
* above them. */
|
||||
.contact-otp-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 4px;
|
||||
|
||||
mifosx-client-contact-otp-block {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1516,6 +1712,10 @@ form {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-otp-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.guardian-card__avatar.has-photo {
|
||||
|
||||
+821
-25
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
@@ -31,13 +40,18 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="warning-text">This may take a few seconds. Do not close or refresh this window.</span>
|
||||
</div>
|
||||
}
|
||||
<!--
|
||||
An unreachable DoT service means the number was NOT checked. Reporting that as a green
|
||||
"verification completed successfully — number is CLEAN" claimed a check that never ran and
|
||||
stored the client as verified, so the outage is now shown for what it is.
|
||||
-->
|
||||
@if (!verifying && serviceUnavailable) {
|
||||
<div class="verify-result">
|
||||
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
|
||||
<p class="result-msg">DIP & MNRL verification completed successfully.</p>
|
||||
<div class="success-detail">
|
||||
<fa-icon icon="shield-alt" class="success-detail-icon"></fa-icon>
|
||||
<span>Mobile number is <strong>CLEAN</strong> — not found in the revocation list.</span>
|
||||
<div class="verify-result unavailable">
|
||||
<fa-icon icon="exclamation-triangle" class="result-icon unavailable-icon"></fa-icon>
|
||||
<p class="result-msg unavailable-msg">Could not verify — DIP/MNRL service unavailable</p>
|
||||
<div class="unavailable-detail">
|
||||
This number has <strong>not</strong> been checked against the DoT Digital Intelligence Platform. Retry once
|
||||
the service is reachable, or proceed with a recorded reason.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -66,11 +80,63 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<p class="result-msg error-msg">{{ error }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
A number turned away before is a standing signal. Without this the next officer screens
|
||||
it as if it had no history and can wave through what a colleague already refused.
|
||||
-->
|
||||
@if (!verifying && priorRejection) {
|
||||
<div class="prior-rejection">
|
||||
<fa-icon icon="history" class="prior-icon"></fa-icon>
|
||||
<div>
|
||||
<strong>This number was rejected before.</strong>
|
||||
<div class="prior-detail">
|
||||
Rejected by user {{ priorRejection.decidedBy }} on {{ priorRejection.decidedOn | date: 'medium' }} —
|
||||
status was {{ priorRejection.lookupStatus }}.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!verifying && needsDecision && showReasonForm) {
|
||||
<div class="reason-form">
|
||||
<label class="reason-label">Why is this number acceptable despite the DIP/MNRL result?</label>
|
||||
<textarea
|
||||
class="reason-input"
|
||||
rows="3"
|
||||
[(ngModel)]="decisionReason"
|
||||
placeholder="e.g. Number re-issued to the applicant on 02/2026; ownership confirmed against the operator's CAF and a fresh OTP."
|
||||
></textarea>
|
||||
<div class="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
|
||||
Minimum 10 characters — this reason is kept on the lookup record.
|
||||
</div>
|
||||
@if (decisionError) {
|
||||
<div class="reason-hint reason-error">{{ decisionError }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
@if (!verifying) {
|
||||
@if (!verifying && !needsDecision) {
|
||||
<button mat-raised-button color="primary" (click)="close()">OK</button>
|
||||
}
|
||||
<!--
|
||||
A flagged number is a decision, not a notification: the officer must either reject it or
|
||||
say in writing why it is acceptable. Previously a single OK dismissed the warning and the
|
||||
number was onboarded with nothing recorded.
|
||||
-->
|
||||
@if (!verifying && needsDecision && !showReasonForm) {
|
||||
<button mat-stroked-button color="warn" (click)="reject()" [disabled]="saving">Reject this Number</button>
|
||||
<button mat-raised-button color="primary" (click)="startProceed()" [disabled]="saving">
|
||||
Proceed with Reason
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && needsDecision && showReasonForm) {
|
||||
<button mat-stroked-button (click)="cancelProceed()" [disabled]="saving">Back</button>
|
||||
<button mat-raised-button color="primary" (click)="submitProceed()" [disabled]="saving">
|
||||
{{ saving ? 'Saving…' : 'Save Decision & Proceed' }}
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
@@ -228,13 +294,100 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.unavailable-icon {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.unavailable-msg {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.unavailable-detail {
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prior-rejection {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #7f1d1d;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prior-icon {
|
||||
color: #dc2626;
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.prior-detail {
|
||||
font-size: 12px;
|
||||
color: #991b1b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.reason-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.reason-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.reason-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.reason-input:focus {
|
||||
outline: none;
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
.reason-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.reason-error {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 8px 24px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
`
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatProgressBarModule,
|
||||
MatButtonModule,
|
||||
@@ -255,6 +408,25 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
suspectedStatus = '';
|
||||
suspectedReason = '';
|
||||
suspectedReportedBy = '';
|
||||
|
||||
/** Set when this number was rejected on an earlier client — history the officer must see. */
|
||||
priorRejection: any = null;
|
||||
|
||||
/** Officer decision capture for a flagged or unverifiable number. */
|
||||
showReasonForm = false;
|
||||
decisionReason = '';
|
||||
reasonTouched = false;
|
||||
saving = false;
|
||||
decisionError = '';
|
||||
|
||||
/** A hit or an unverified number cannot simply be dismissed with OK. */
|
||||
get needsDecision(): boolean {
|
||||
return this.suspected || this.serviceUnavailable;
|
||||
}
|
||||
|
||||
get reasonValid(): boolean {
|
||||
return this.decisionReason.trim().length >= 10;
|
||||
}
|
||||
private subscription: any;
|
||||
private intervalId: any;
|
||||
private apiResponse: any = null;
|
||||
@@ -305,6 +477,7 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const res = this.apiResponse;
|
||||
this.priorRejection = res?.priorRejection || null;
|
||||
if (res?.data?.status === 'SERVICE_UNAVAILABLE') {
|
||||
this.serviceUnavailable = true;
|
||||
this.resultMessage = res.message || 'DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.';
|
||||
@@ -329,11 +502,68 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
startProceed() {
|
||||
this.showReasonForm = true;
|
||||
this.reasonTouched = false;
|
||||
this.decisionError = '';
|
||||
}
|
||||
|
||||
cancelProceed() {
|
||||
this.showReasonForm = false;
|
||||
this.decisionReason = '';
|
||||
this.decisionError = '';
|
||||
}
|
||||
|
||||
submitProceed() {
|
||||
this.reasonTouched = true;
|
||||
if (!this.reasonValid) {
|
||||
return;
|
||||
}
|
||||
this.recordDecision('PROCEEDED', this.decisionReason.trim(), true);
|
||||
}
|
||||
|
||||
reject() {
|
||||
this.recordDecision('REJECTED', 'Number rejected at onboarding following DIP/MNRL result.', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the decision before closing. The number is only usable downstream once the backend
|
||||
* has stored PROCEEDED — the client-create gate re-checks this, so closing without saving
|
||||
* would simply fail at save time with no explanation.
|
||||
*/
|
||||
private recordDecision(decision: string, reason: string, verified: boolean) {
|
||||
const logId = this.apiResponse?.lookupLogId;
|
||||
if (!logId) {
|
||||
this.decisionError = 'Lookup reference missing — re-run the check before recording a decision.';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.decisionError = '';
|
||||
this.clientsService.recordDipDecision(logId, decision, reason).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.dialogRef.close({
|
||||
verified,
|
||||
decision,
|
||||
lookupLogId: logId,
|
||||
status: this.apiResponse?.data?.status || null,
|
||||
timestamp: this.apiResponse?.timestamp || null
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
this.decisionError =
|
||||
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not save the decision.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
const verified = !this.suspected && !this.error;
|
||||
const verified = !this.suspected && !this.error && !this.serviceUnavailable;
|
||||
this.dialogRef.close({
|
||||
verified,
|
||||
lookupLogId: this.apiResponse?.lookupLogId || null,
|
||||
status: this.apiResponse?.data?.status || null,
|
||||
timestamp: this.apiResponse?.timestamp || null
|
||||
});
|
||||
}
|
||||
|
||||
+327
-14
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
@@ -38,10 +39,18 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
}
|
||||
</div>
|
||||
<p class="sanctions-msg">
|
||||
Screening <strong>{{ clientName }}</strong> against<br />
|
||||
<strong>UN Security Council</strong> &
|
||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||
Screening <strong>{{ clientName }}</strong> against the<br />
|
||||
<strong>UN Security Council Consolidated List</strong><br />
|
||||
<span class="sanctions-lists"
|
||||
>ISIL (Da’esh) & Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists</span
|
||||
>
|
||||
</p>
|
||||
@if (listDataAsOf) {
|
||||
<p class="list-as-of">
|
||||
Sanctions list data as of <strong>{{ listDataAsOf }}</strong>
|
||||
</p>
|
||||
}
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && !matchFound && !errorOccurred) {
|
||||
@@ -53,8 +62,8 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<h3 class="approve-title">No Sanctions Match Found</h3>
|
||||
<p class="approve-detail">
|
||||
<strong>{{ clientName }}</strong> has been screened against the
|
||||
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
|
||||
match was found.
|
||||
<strong>UN Security Council Consolidated List</strong> (ISIL & Al-Qaida and Taliban sanctions lists). No
|
||||
confirmed match was found.
|
||||
</p>
|
||||
<div class="approve-meta">
|
||||
<div class="meta-row">
|
||||
@@ -69,7 +78,14 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
@if (listDataAsOf) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">List Data As Of</span>
|
||||
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
@@ -96,11 +112,86 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
@if (listDataAsOf) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">List Data As Of</span>
|
||||
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!--
|
||||
The list matches on name alone, so a hit is a "stop and verify", not a verdict. The
|
||||
officer has to be able to compare the listed particulars against the applicant's own
|
||||
before deciding — showing only a red banner made that impossible.
|
||||
-->
|
||||
<div class="compare-header">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
Compare the applicant against the listed particulars
|
||||
</div>
|
||||
<div class="applicant-card">
|
||||
<div class="applicant-label">Applicant</div>
|
||||
<div class="applicant-values">
|
||||
<span
|
||||
><strong>{{ clientName }}</strong></span
|
||||
>
|
||||
<span>DOB: {{ applicantDob || '—' }}</span>
|
||||
<span>Nationality: {{ applicantNationality || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="match-list">
|
||||
@for (m of matches; track m.id) {
|
||||
<div class="match-item">
|
||||
<div class="match-name">{{ m.fullName }}</div>
|
||||
<div class="match-meta">
|
||||
<span class="match-type">{{ m.entryType }}</span>
|
||||
@if (m.unListType) {
|
||||
<span class="match-list-type">{{ m.unListType }}</span>
|
||||
}
|
||||
@if (m.referenceNumber) {
|
||||
<span class="match-ref">{{ m.referenceNumber }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="match-aliases">
|
||||
DOB: {{ m.dateOfBirth || '—' }} · Nationality: {{ m.nationality || '—' }} · Place of
|
||||
birth: {{ m.placeOfBirth || '—' }}
|
||||
</div>
|
||||
@if (m.aliases?.length) {
|
||||
<div class="match-aliases">Aliases: {{ m.aliases.join('; ') }}</div>
|
||||
}
|
||||
@if (m.listedOn) {
|
||||
<div class="match-aliases">Listed on: {{ m.listedOn }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="escalate-banner">
|
||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||
</div>
|
||||
@if (showReasonForm) {
|
||||
<div class="reason-form">
|
||||
<label class="reason-label">
|
||||
{{
|
||||
pendingDisposition === 'FALSE_POSITIVE'
|
||||
? 'Why is this NOT the same person? State the identifiers you compared.'
|
||||
: 'Record the basis on which this match is confirmed.'
|
||||
}}
|
||||
</label>
|
||||
<textarea
|
||||
class="reason-input"
|
||||
rows="3"
|
||||
[(ngModel)]="dispositionReason"
|
||||
placeholder="e.g. DOB 12/03/1988 vs listed 1965; nationality IN vs listed AF; passport number does not match."
|
||||
></textarea>
|
||||
<div class="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
|
||||
Minimum 10 characters — this text is the compliance record.
|
||||
</div>
|
||||
@if (dispositionError) {
|
||||
<div class="reason-hint reason-error">{{ dispositionError }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
@@ -119,9 +210,26 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
<button mat-raised-button color="warn" (click)="close(false)" class="reject-btn">
|
||||
<fa-icon icon="times"></fa-icon> Flag & Escalate
|
||||
</button>
|
||||
@if (!showReasonForm) {
|
||||
<button mat-stroked-button (click)="startDisposition('FALSE_POSITIVE')" class="approve-btn-secondary">
|
||||
Not the Same Person
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="startDisposition('CONFIRMED_MATCH')" class="reject-btn">
|
||||
<fa-icon icon="times"></fa-icon> Confirm Match & Escalate
|
||||
</button>
|
||||
}
|
||||
@if (showReasonForm) {
|
||||
<button mat-stroked-button (click)="cancelDisposition()" [disabled]="savingDisposition">Back</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
[color]="pendingDisposition === 'FALSE_POSITIVE' ? 'primary' : 'warn'"
|
||||
(click)="submitDisposition()"
|
||||
[disabled]="savingDisposition"
|
||||
class="reject-btn"
|
||||
>
|
||||
{{ savingDisposition ? 'Saving…' : 'Submit for Principal Officer Approval' }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
<button mat-raised-button (click)="close(false)">Close</button>
|
||||
@@ -152,8 +260,13 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
mat-dialog-content {
|
||||
padding: 16px 24px;
|
||||
max-height: unset;
|
||||
overflow: visible;
|
||||
/*
|
||||
The match card lists every hit with its aliases, so this content is now tall enough to
|
||||
push the action row off-screen if the dialog is allowed to grow unbounded — which left
|
||||
"Not the Same Person" unreachable. Scroll the body instead and keep the buttons pinned.
|
||||
*/
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
@@ -207,6 +320,29 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-as-of {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
text-align: center;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.sanctions-lists {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.regulatory-note {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
margin: 8px 0 0;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.approve-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -444,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
.match-list {
|
||||
width: 100%;
|
||||
max-height: unset;
|
||||
overflow: visible;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
@@ -486,6 +620,84 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.compare-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.applicant-card {
|
||||
width: 100%;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 4px solid #1074b9;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.applicant-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: #1074b9;
|
||||
}
|
||||
|
||||
.applicant-values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.reason-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reason-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.reason-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.reason-input:focus {
|
||||
outline: none;
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
.reason-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.reason-error {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block-msg {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -500,11 +712,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 8px 24px 16px;
|
||||
/* Sits below a scrolling body, so mark it off clearly as the fixed action row. */
|
||||
border-top: 1px solid #e5e7eb;
|
||||
gap: 8px;
|
||||
}
|
||||
`
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatProgressBarModule,
|
||||
MatButtonModule,
|
||||
@@ -513,10 +729,13 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
})
|
||||
export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
private dialogRef = inject(MatDialogRef<SanctionsVerifyDialogComponent>);
|
||||
private data: { name: string } = inject(MAT_DIALOG_DATA);
|
||||
private data: { name: string; dateOfBirth?: string; nationality?: string } = inject(MAT_DIALOG_DATA);
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
clientName = '';
|
||||
/** Applicant's own identifiers, shown beside the listed particulars for comparison. */
|
||||
applicantDob = '';
|
||||
applicantNationality = '';
|
||||
verifying = true;
|
||||
matchFound = false;
|
||||
matchCount = 0;
|
||||
@@ -524,14 +743,39 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
errorOccurred = false;
|
||||
errorMessage = '';
|
||||
screeningLogId: number | null = null;
|
||||
listDataAsOf: string | null = null;
|
||||
|
||||
/** Disposition capture — a hit must be closed out, not merely acknowledged. */
|
||||
showReasonForm = false;
|
||||
pendingDisposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH' | null = null;
|
||||
dispositionReason = '';
|
||||
reasonTouched = false;
|
||||
savingDisposition = false;
|
||||
dispositionError = '';
|
||||
|
||||
/** Mirrors the backend's minimum: an unexplained assessment is not a review. */
|
||||
get reasonValid(): boolean {
|
||||
return this.dispositionReason.trim().length >= 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
|
||||
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
|
||||
* the older Master Direction this screen used to name.
|
||||
*/
|
||||
regulatoryBasis =
|
||||
'Screened under Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and Chapter IX of the ' +
|
||||
'RBI Know Your Customer Directions, 2025 (amended as on December 29, 2025).';
|
||||
|
||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
progressValue = 0;
|
||||
currentStep = 0;
|
||||
// Steps describe the screening that actually runs against the UNSC consolidated list; there is no
|
||||
// FIU-IND lookup behind this call, so it is no longer claimed here.
|
||||
progressSteps = [
|
||||
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
|
||||
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
|
||||
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
|
||||
{ label: 'Cross-checking listed aliases and entities...', icon: 'database' as any },
|
||||
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
|
||||
];
|
||||
|
||||
@@ -542,6 +786,8 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit() {
|
||||
this.clientName = this.data?.name || '';
|
||||
this.applicantDob = this.data?.dateOfBirth || '';
|
||||
this.applicantNationality = this.data?.nationality || '';
|
||||
this.dialogRef.disableClose = true;
|
||||
|
||||
// Start API call immediately
|
||||
@@ -549,6 +795,9 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
next: (result: any) => {
|
||||
this.apiResult = result;
|
||||
this.apiDone = true;
|
||||
// Surfaced while the screening is still running, so the operator can see which snapshot of
|
||||
// the UNSC list the check is being made against rather than only learning it afterwards.
|
||||
this.listDataAsOf = this.formatListDate(result?.listDataAsOf);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.apiError = err;
|
||||
@@ -624,6 +873,7 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
'Unable to connect to sanctions screening service.';
|
||||
} else {
|
||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||
this.listDataAsOf = this.formatListDate(this.apiResult?.listDataAsOf);
|
||||
if (this.apiResult?.matchFound) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = this.apiResult.matchCount || 0;
|
||||
@@ -633,10 +883,73 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** Formats the date the UNSC snapshot was loaded; null when the backend did not report one. */
|
||||
private formatListDate(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
clearInterval(this.progressTimer);
|
||||
}
|
||||
|
||||
startDisposition(disposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH') {
|
||||
this.pendingDisposition = disposition;
|
||||
this.showReasonForm = true;
|
||||
this.reasonTouched = false;
|
||||
this.dispositionError = '';
|
||||
}
|
||||
|
||||
cancelDisposition() {
|
||||
this.showReasonForm = false;
|
||||
this.pendingDisposition = null;
|
||||
this.dispositionReason = '';
|
||||
this.dispositionError = '';
|
||||
}
|
||||
|
||||
submitDisposition() {
|
||||
this.reasonTouched = true;
|
||||
if (!this.reasonValid || !this.pendingDisposition) {
|
||||
return;
|
||||
}
|
||||
// Without a log id there is nothing to attach the assessment to — close as a plain block
|
||||
// rather than silently discarding the officer's reasoning.
|
||||
if (!this.screeningLogId) {
|
||||
this.dispositionError = 'Screening reference missing — re-run the check before recording an outcome.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.savingDisposition = true;
|
||||
this.clientsService
|
||||
.recordSanctionsDisposition(this.screeningLogId, this.pendingDisposition, this.dispositionReason.trim())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.savingDisposition = false;
|
||||
// Both outcomes keep the client blocked. A false positive only takes effect once the
|
||||
// Principal Officer approves it from the Sanctions Logs screen.
|
||||
this.dialogRef.close({
|
||||
verified: false,
|
||||
screeningLogId: this.screeningLogId,
|
||||
disposition: this.pendingDisposition
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.savingDisposition = false;
|
||||
this.dispositionError =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
'Could not save the review outcome.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(verified: boolean) {
|
||||
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
|
||||
}
|
||||
|
||||
+53
-9
@@ -122,8 +122,41 @@
|
||||
}
|
||||
|
||||
<div class="addr-proof-field">
|
||||
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
|
||||
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly">
|
||||
<div
|
||||
class="addr-proof-label-row"
|
||||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px"
|
||||
>
|
||||
<label class="addr-proof-label" style="margin-bottom: 0"
|
||||
><mat-icon>description</mat-icon> Document type</label
|
||||
>
|
||||
@if (isAddressProofLinkedToKyc && !readOnly) {
|
||||
<div
|
||||
class="addr-proof-linked-note"
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #059669;
|
||||
font-size: 0.85rem;
|
||||
background: #ecfdf5;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px">link</mat-icon>
|
||||
Automatically linked from KYC step
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="unlinkAddressProof()"
|
||||
matTooltip="Provide a different address proof"
|
||||
style="width: 24px; height: 24px; padding: 0; line-height: 24px"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; color: #047857">edit</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly || isAddressProofLinkedToKyc">
|
||||
@for (group of addressGroups; track group.label; let last = $last) {
|
||||
<div class="addr-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
|
||||
<div class="addr-chip-grid">
|
||||
@@ -131,11 +164,11 @@
|
||||
<span
|
||||
class="addr-chip"
|
||||
[class.addr-chip--sel]="isAddrProofSelected(p)"
|
||||
[class.addr-chip--locked]="readOnly && !isAddrProofSelected(p)"
|
||||
[attr.aria-disabled]="readOnly ? 'true' : null"
|
||||
(click)="readOnly ? null : selectAddrProof(p)"
|
||||
(keydown.enter)="readOnly ? null : selectAddrProof(p)"
|
||||
[attr.tabindex]="readOnly ? -1 : 0"
|
||||
[class.addr-chip--locked]="(readOnly || isAddressProofLinkedToKyc) && !isAddrProofSelected(p)"
|
||||
[attr.aria-disabled]="readOnly || isAddressProofLinkedToKyc ? 'true' : null"
|
||||
(click)="readOnly || isAddressProofLinkedToKyc ? null : selectAddrProof(p)"
|
||||
(keydown.enter)="readOnly || isAddressProofLinkedToKyc ? null : selectAddrProof(p)"
|
||||
[attr.tabindex]="readOnly || isAddressProofLinkedToKyc ? -1 : 0"
|
||||
role="button"
|
||||
>
|
||||
<mat-icon class="addr-chip__icon">
|
||||
@@ -167,14 +200,25 @@
|
||||
<input
|
||||
matInput
|
||||
formControlName="documentKey"
|
||||
[readonly]="readOnly || isAddressProofLinkedToKyc"
|
||||
[placeholder]="selectedAddrProof.documentKeyPlaceholder || ''"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (selectedAddrProof.requiresDate) {
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Issue / bill date</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
|
||||
<input matInput type="date" formControlName="issueDate" />
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="addrIssueDatePicker"
|
||||
formControlName="issueDate"
|
||||
[readonly]="readOnly || isAddressProofLinkedToKyc"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="addrIssueDatePicker"
|
||||
[disabled]="readOnly || isAddressProofLinkedToKyc"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
+225
-35
@@ -15,9 +15,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-india-address-step',
|
||||
@@ -38,14 +41,36 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
private http = inject(HttpClient);
|
||||
private clientsService = inject(ClientsService);
|
||||
private kycUpload = inject(KycUploadHelperService);
|
||||
private dialog = inject(MatDialog);
|
||||
private dates = inject(Dates);
|
||||
|
||||
/** Client ID — when set, loads existing addresses for edit mode */
|
||||
@Input() clientId: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
/** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own
|
||||
* name — restricts the address-proof picker to Aadhaar/Passport when true. */
|
||||
@Input() isMinor = false;
|
||||
/** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/
|
||||
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
|
||||
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
|
||||
@Input() isNonIndividual = false;
|
||||
/** KYC form data passed down from the parent wizard so we can pre-fill address proof */
|
||||
@Input() kycData: any;
|
||||
/** KYC document code for robust matching */
|
||||
@Input() kycDocCode: string | undefined;
|
||||
/** KYC document files passed down to avoid re-uploading */
|
||||
@Input() kycDocFiles: { front: File | null; back: File | null } | null = null;
|
||||
/** Combined preview string for KYC document */
|
||||
@Input() kycDocMergedPreview: string | null = null;
|
||||
/** Existing server documents from KYC step (used in Edit mode) */
|
||||
@Input() existingKycDocSides: { front: any | null; back: any | null } | null = null;
|
||||
/** Identifier ID for existing server documents */
|
||||
@Input() existingKycIdentifierId: string | null = null;
|
||||
|
||||
activeTab = 0;
|
||||
sameAsPermanent = false;
|
||||
isAddressProofLinkedToKyc = false;
|
||||
|
||||
permanentForm: UntypedFormGroup = this.fb.group({
|
||||
addressLine1: [
|
||||
@@ -228,6 +253,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
/** Address type name → tab mapping for loading existing data */
|
||||
private typeToTab: Record<string, string> = {};
|
||||
|
||||
private existingAddressProofCheckComplete = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadProofTypes();
|
||||
if (this.clientId) {
|
||||
@@ -235,14 +262,121 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
}
|
||||
|
||||
private findMatchingProof(targetName: string | undefined): any {
|
||||
if (!targetName) return undefined;
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const n = cleanStr(targetName);
|
||||
return this.addressProofs.find((p) => {
|
||||
const pname = cleanStr(p.name);
|
||||
return pname === n || n.includes(pname) || pname.includes(n);
|
||||
});
|
||||
}
|
||||
|
||||
private tryLinkKycData(): void {
|
||||
if (!this.kycData || !this.kycData.kycDocumentName) return;
|
||||
|
||||
if (this.clientId && !this.existingAddressProofCheckComplete) return;
|
||||
|
||||
if (this.isAddressProofLinkedToKyc || !this.permanentForm.get('documentKey')?.value) {
|
||||
let matchedProof = undefined;
|
||||
if (this.kycDocCode) {
|
||||
matchedProof = this.addressProofs.find((p) => p.code === this.kycDocCode);
|
||||
}
|
||||
if (!matchedProof) {
|
||||
matchedProof = this.findMatchingProof(this.kycData.kycDocumentName);
|
||||
}
|
||||
if (matchedProof) {
|
||||
this.isAddressProofLinkedToKyc = true;
|
||||
this.selectAddrProof(matchedProof);
|
||||
this.permanentForm.patchValue({
|
||||
documentKey: this.kycData.kycDocumentNumber,
|
||||
issueDate: this.kycData.kycDocIssueDate
|
||||
});
|
||||
this.currentForm.patchValue({
|
||||
documentKey: this.kycData.kycDocumentNumber,
|
||||
issueDate: this.kycData.kycDocIssueDate
|
||||
});
|
||||
|
||||
// Sync uploaded images if they exist in KYC step
|
||||
if (this.kycDocFiles && (this.kycDocFiles.front || this.kycDocFiles.back)) {
|
||||
this.addrFiles = { front: this.kycDocFiles.front || null, back: this.kycDocFiles.back || null };
|
||||
if (this.kycDocMergedPreview) {
|
||||
this.addrMergedPreview = this.kycDocMergedPreview;
|
||||
}
|
||||
} else if (this.existingKycDocSides && (this.existingKycDocSides.front || this.existingKycDocSides.back)) {
|
||||
this.existingAddressProofDocuments = [];
|
||||
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
|
||||
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
|
||||
if (this.existingKycDocSides.front?.previewUrl) {
|
||||
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
|
||||
} else if (this.existingKycDocSides.back?.previewUrl) {
|
||||
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
|
||||
}
|
||||
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
|
||||
}
|
||||
} else if (this.isAddressProofLinkedToKyc) {
|
||||
// If it was linked but the KYC document changed to something not allowed for address (e.g. PAN for non-individuals), unlink.
|
||||
this.unlinkAddressProof();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unlinkAddressProof(): void {
|
||||
this.isAddressProofLinkedToKyc = false;
|
||||
this.permanentForm.patchValue({ documentKey: '', issueDate: null });
|
||||
this.currentForm.patchValue({ documentKey: '', issueDate: null });
|
||||
this.addrFiles = { front: null, back: null };
|
||||
this.addrMergedPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets transient address-tab state for a brand-new client.
|
||||
*
|
||||
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
|
||||
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
|
||||
* re-runs for a second "create new client" session — the previous client's address forms,
|
||||
* selected proof-of-address chip and uploaded files would otherwise leak into the new one.
|
||||
* Called by CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
|
||||
*/
|
||||
resetForNewClient(): void {
|
||||
this.permanentForm.reset({ country: 'India' });
|
||||
this.currentForm.reset({ country: 'India' });
|
||||
this.officeForm.reset({ country: 'India' });
|
||||
this.correspondenceForm.reset({ country: 'India' });
|
||||
this.activeTab = 0;
|
||||
this.sameAsPermanent = false;
|
||||
this.pinStatus = {
|
||||
permanent: { loading: false, found: false, error: false, message: '' },
|
||||
current: { loading: false, found: false, error: false, message: '' },
|
||||
office: { loading: false, found: false, error: false, message: '' },
|
||||
correspondence: { loading: false, found: false, error: false, message: '' }
|
||||
};
|
||||
this.localityOptions = { permanent: [], current: [], office: [], correspondence: [] };
|
||||
this.selectedAddrProof = null;
|
||||
this.addrFiles = { front: null, back: null };
|
||||
this.addrMergedFile = null;
|
||||
this.addrMergedPreview = null;
|
||||
this.existingAddressProofIdentifierId = null;
|
||||
this.existingAddressProofDocuments = [];
|
||||
this.addressLoaded = false;
|
||||
this.existingAddressProofCheckComplete = false;
|
||||
// Re-fetch rather than re-filter `addressProofs` in place — if the previous session's
|
||||
// isMinor filtering already reduced it (e.g. down to Aadhaar/Passport only), filtering
|
||||
// that reduced list again can never recover the items it already removed.
|
||||
this.loadProofTypes();
|
||||
}
|
||||
|
||||
private loadProofTypes(): void {
|
||||
this.loadingProofs = true;
|
||||
this.clientsService.getKycProofTypes().subscribe({
|
||||
next: (res: any) => {
|
||||
const activeFilter = (p: any) => p?.active !== false;
|
||||
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
|
||||
this.addressProofs = this.filterAddressProofsForNonIndividual(
|
||||
this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType))
|
||||
);
|
||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||
this.loadingProofs = false;
|
||||
this.tryLinkKycData();
|
||||
},
|
||||
error: () => {
|
||||
this.loadingProofs = false;
|
||||
@@ -250,6 +384,41 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
/** Minors don't hold utility bills, bank statements, rent agreements, property tax receipts,
|
||||
* employer letters or pension orders in their own name, and Voter ID/Driving Licence both
|
||||
* require adult age — Aadhaar and Passport are the only OVDs left for proof of address. */
|
||||
private static readonly MINOR_ELIGIBLE_ADDRESS_PROOF_CODES = new Set([
|
||||
'AADHAAR',
|
||||
'PASSPORT'
|
||||
]);
|
||||
|
||||
private filterAddressProofsForMinor(proofs: ProofType[]): ProofType[] {
|
||||
if (!this.isMinor) return proofs;
|
||||
return proofs.filter((p) =>
|
||||
ClientIndiaAddressStepComponent.MINOR_ELIGIBLE_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/** Personal OVDs — not valid proof of a registered office/entity address. */
|
||||
private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([
|
||||
'AADHAAR',
|
||||
'PASSPORT',
|
||||
'VOTER_ID',
|
||||
'DRIVING_LICENSE',
|
||||
'NREGA_JOB_CARD',
|
||||
'NPR_LETTER',
|
||||
'BIRTH_CERTIFICATE',
|
||||
'SCHOOL_ID_CARD'
|
||||
]);
|
||||
|
||||
private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] {
|
||||
if (!this.isNonIndividual) return proofs;
|
||||
return proofs.filter(
|
||||
(p) =>
|
||||
!ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
selectAddrProof(proof: ProofType): void {
|
||||
this.selectedAddrProof = proof;
|
||||
this.permanentForm.get('proofId')?.setValue(proof.id);
|
||||
@@ -330,16 +499,28 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
files.push({ slot: 'Back', file: this.addrFiles.back });
|
||||
}
|
||||
}
|
||||
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||
return {
|
||||
proof: this.selectedAddrProof,
|
||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||
issueDate: this.permanentForm.get('issueDate')?.value || '',
|
||||
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||
files
|
||||
};
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['kycData'] || changes['existingKycDocSides']) {
|
||||
this.tryLinkKycData();
|
||||
}
|
||||
if (changes['existingKycIdentifierId']) {
|
||||
if (
|
||||
this.existingAddressProofIdentifierId &&
|
||||
this.existingAddressProofIdentifierId === this.existingKycIdentifierId
|
||||
) {
|
||||
this.isAddressProofLinkedToKyc = true;
|
||||
}
|
||||
}
|
||||
if (changes['readOnly']) {
|
||||
const forms = [
|
||||
this.permanentForm,
|
||||
@@ -355,6 +536,25 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changes['isMinor'] && !changes['isMinor'].firstChange && this.addressProofs.length) {
|
||||
const filtered = this.filterAddressProofsForMinor(this.addressProofs);
|
||||
if (filtered.length !== this.addressProofs.length) {
|
||||
this.addressProofs = filtered;
|
||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||
if (this.selectedAddrProof && !filtered.some((p) => p.id === this.selectedAddrProof?.id)) {
|
||||
this.selectedAddrProof = null;
|
||||
this.permanentForm.get('proofId')?.setValue('');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legal form toggled on an already-initialized instance — re-fetch rather than filter
|
||||
// `addressProofs` in place, since the non-individual filter only ever removes entries and
|
||||
// toggling back to individual could never recover what a prior pass had stripped out.
|
||||
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) {
|
||||
this.selectedAddrProof = null;
|
||||
this.permanentForm.get('proofId')?.setValue('');
|
||||
this.loadProofTypes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,32 +639,38 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
||||
next: (identifiers: any[]) => {
|
||||
if (!identifiers || identifiers.length === 0) return;
|
||||
const proofNames = new Set(this.addressProofs.map((p) => p.name?.toLowerCase()).filter(Boolean));
|
||||
const identifier = identifiers.find((i: any) => {
|
||||
const n = i?.documentType?.name?.toLowerCase();
|
||||
return n && proofNames.has(n);
|
||||
});
|
||||
if (!identifier?.id) return;
|
||||
if (!identifiers || identifiers.length === 0) {
|
||||
this.existingAddressProofCheckComplete = true;
|
||||
this.tryLinkKycData();
|
||||
return;
|
||||
}
|
||||
const identifier = identifiers.find((i: any) => this.findMatchingProof(i?.documentType?.name));
|
||||
if (!identifier?.id) {
|
||||
this.existingAddressProofCheckComplete = true;
|
||||
this.tryLinkKycData();
|
||||
return;
|
||||
}
|
||||
|
||||
this.existingAddressProofCheckComplete = true;
|
||||
this.existingAddressProofIdentifierId = identifier.id.toString();
|
||||
if (this.existingKycIdentifierId && this.existingKycIdentifierId === this.existingAddressProofIdentifierId) {
|
||||
this.isAddressProofLinkedToKyc = true;
|
||||
}
|
||||
// Restore selected chip + form values
|
||||
const matchedProof = this.addressProofs.find(
|
||||
(p) => p.name?.toLowerCase() === identifier.documentType?.name?.toLowerCase()
|
||||
);
|
||||
const matchedProof = this.findMatchingProof(identifier.documentType?.name);
|
||||
if (matchedProof) {
|
||||
this.selectedAddrProof = matchedProof;
|
||||
this.permanentForm.patchValue({
|
||||
proofId: matchedProof.id,
|
||||
documentKey: identifier.documentKey || ''
|
||||
});
|
||||
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
|
||||
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
|
||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
||||
// shows what was previously entered.
|
||||
const desc: string = identifier.description || '';
|
||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||
if (issueMatch) {
|
||||
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
|
||||
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,33 +772,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved address proof in a new browser tab.
|
||||
*
|
||||
* For images we already hold a base64 data URL in `previewUrl` from
|
||||
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
|
||||
* For PDFs / other types we need to fetch the binary blob and create a
|
||||
* temporary object URL so the browser's PDF viewer can render it.
|
||||
*/
|
||||
/** Open the saved address proof in an in-app preview dialog. */
|
||||
viewExistingAddressProof(): void {
|
||||
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingAddressProofDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
// Revoke after the tab has had time to load — browsers vary, so
|
||||
// err on the side of a generous wait.
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
next: (blob: Blob) =>
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||
}),
|
||||
error: () => {
|
||||
// Silent fail — UI shows the saved name so user knows it exists.
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,20 @@
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Same chip, muted — says "you may" where the red one says "you must". */
|
||||
.section-optional-tag {
|
||||
margin-left: 6px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-badge {
|
||||
@@ -266,6 +280,23 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.change-number-btn {
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 14px !important;
|
||||
height: 36px !important;
|
||||
border-radius: 6px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.verify-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -287,6 +318,234 @@
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
// Not verified, but covered by a declaration — deliberately amber rather than green so it never
|
||||
// reads as a passing check.
|
||||
&.declared {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
// Recorded but unconfirmed by the registry. Neutral blue rather than green or red: nothing failed
|
||||
// and nothing passed, so it must not read as either.
|
||||
&.unconfirmed {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CIN saved but not confirmed by MCA ─────────────────────────────────────
|
||||
.cin-unconfirmed-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: -4px 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e40af;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cin-unconfirmed-note__icon {
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ── PAN declaration summary ────────────────────────────────────────────────
|
||||
.pan-decl-card {
|
||||
margin: -4px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pan-decl-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pan-decl-card__icon {
|
||||
color: #b45309;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.pan-decl-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pan-decl-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pan-decl-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pan-decl-card__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #451a03;
|
||||
word-break: break-word;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl-card__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #fde68a;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pan-decl-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aadhaar enrolment receipt ──────────────────────────────────────────────
|
||||
// Blue rather than the declaration's amber: nothing has failed here, the customer simply does not
|
||||
// hold the document yet.
|
||||
.aad-rcpt-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: -4px 0 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
font-size: 12.5px;
|
||||
color: #1e40af;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
color: #1d4ed8 !important;
|
||||
border-color: #bfdbfe !important;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt-card {
|
||||
margin: -4px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt-card__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.verify-icon-ok {
|
||||
@@ -297,8 +556,24 @@
|
||||
.toggle-vis-btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 13px !important;
|
||||
color: #64748b;
|
||||
|
||||
/* Material reserves a 48x48 invisible touch-target + ripple around every
|
||||
mat-icon-button by default. Shrinking the button to 28px above doesn't
|
||||
shrink that touch-target/ripple with it, so — since the suffix
|
||||
container doesn't clip overflow — it visually spills out past the
|
||||
field's own border, making the icon look misplaced/half-outside the box. */
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-button-persistent-ripple,
|
||||
::ng-deep .mat-ripple {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Verification Info Banner */
|
||||
@@ -992,6 +1267,12 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
|
||||
/* Entity clients get the photograph box only, so it keeps a card's width instead of
|
||||
stretching across the whole step where the signature box used to sit beside it. */
|
||||
&--single {
|
||||
grid-template-columns: minmax(0, 420px);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
@@ -1057,9 +1338,17 @@
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&__btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d5dd;
|
||||
@@ -1074,6 +1363,10 @@
|
||||
border-color: #1353a0;
|
||||
color: #1353a0;
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
@@ -2228,3 +2521,516 @@
|
||||
width: 95px;
|
||||
}
|
||||
}
|
||||
|
||||
.override-checkbox-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.risk-auto-calc-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-top: -8px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.info-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-text strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-color: #a7f3d0;
|
||||
|
||||
.info-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border-color: #fde68a;
|
||||
|
||||
.info-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-color: #fecaca;
|
||||
|
||||
.info-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.risk-rules-toggle-wrapper {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.rules-toggle-btn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e6bb8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
background: #f1f5f9;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.risk-rules-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.risk-rules-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
transition: background 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
|
||||
&--high {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #ecfdf5;
|
||||
color: #16a34a;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The name and DOB that PAN / Aadhaar are matched against, surfaced from the General tab so a
|
||||
mismatch is visible before the check runs rather than only after it fails. */
|
||||
.identity-basis {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.identity-basis--incomplete {
|
||||
border-color: #fdba74;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.identity-basis__icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #1d4ed8;
|
||||
|
||||
.identity-basis--incomplete & {
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.identity-basis__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-basis__legend {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.identity-basis__pairs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 24px;
|
||||
}
|
||||
|
||||
.identity-basis__pair {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.identity-basis__k {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.identity-basis__v {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.identity-basis__hint {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.identity-basis__warn {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #c2410c;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
PAN ↔ Aadhaar premium status card
|
||||
─── Replaces the legacy table-row treatment. Dark gradient with
|
||||
offset glow circles, large gradient icon, and a state-driven
|
||||
accent (green / red / blue / amber).
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.pal-card {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: 14px 18px 30px;
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #111827, #0f172a);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
box-shadow:
|
||||
0 12px 32px rgb(0 0 0 / 35%),
|
||||
inset 0 1px 1px rgb(255 255 255 / 5%);
|
||||
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Decorative glows — colors swap with the state variant */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
top: -110px;
|
||||
right: -70px;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
bottom: -70px;
|
||||
left: -50px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #2563eb, #06b6d4);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&__txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__small {
|
||||
font-size: 10.5px;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 50px;
|
||||
background: rgb(34 197 94 / 12%);
|
||||
border: 1px solid rgb(74 222 128 / 25%);
|
||||
color: #4ade80;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(5px);
|
||||
width: fit-content;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
z-index: 2;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 16px;
|
||||
font-size: 10px;
|
||||
color: rgb(255 255 255 / 35%);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ─── State variants ─── */
|
||||
&--ok {
|
||||
&::before {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-color: rgb(239 68 68 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #ef4444;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #f87171;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(239 68 68 / 14%);
|
||||
border-color: rgb(248 113 113 / 32%);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #ef4444, #f97316);
|
||||
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-color: rgb(59 130 246 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #3b82f6;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #60a5fa;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(59 130 246 / 14%);
|
||||
border-color: rgb(96 165 250 / 32%);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #f59e0b;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #fbbf24;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(245 158 11 / 14%);
|
||||
border-color: rgb(251 191 36 / 32%);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #f59e0b, #d97706);
|
||||
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 540px) {
|
||||
.pal-card {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
|
||||
&__hint {
|
||||
position: static;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user