Merge pull request 'Implemented data export' (#98) from finishline into main

Reviewed-on: #98
This commit was merged in pull request #98.
This commit is contained in:
2026-09-26 13:46:36 +02:00
12 changed files with 10939 additions and 359 deletions
+72 -3
View File
@@ -9,11 +9,14 @@
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.102.8", "@tanstack/react-query": "^5.102.8",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"axios": "^1.20.0", "axios": "^1.20.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"hashids": "^2.3.0", "hashids": "^2.3.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"oidc-client-ts": "^3.5.0", "oidc-client-ts": "^3.5.0",
@@ -23,12 +26,12 @@
"react-router-dom": "^7.18.3", "react-router-dom": "^7.18.3",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"vite": "^6.2.3", "vite": "^6.2.3",
"zod": "^4.5.4", "xlsx": "^0.18.5",
"zod": "^3.25.76",
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
@@ -69,6 +72,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
@@ -273,8 +278,12 @@
"@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
@@ -283,12 +292,16 @@
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="], "@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
@@ -299,6 +312,8 @@
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="],
@@ -319,6 +334,12 @@
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
@@ -331,6 +352,12 @@
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -343,6 +370,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -377,10 +406,14 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
@@ -391,6 +424,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="], "framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="],
@@ -427,6 +462,8 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
@@ -435,6 +472,8 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
@@ -447,6 +486,10 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
"jspdf-autotable": ["jspdf-autotable@5.0.8", "", { "peerDependencies": { "jspdf": "^2 || ^3 || ^4" } }, "sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
@@ -525,10 +568,14 @@
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
@@ -545,6 +592,8 @@
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
@@ -561,8 +610,12 @@
"react-router-dom": ["react-router-dom@7.18.3", "", { "dependencies": { "react-router": "7.18.3" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg=="], "react-router-dom": ["react-router-dom@7.18.3", "", { "dependencies": { "react-router": "7.18.3" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="], "rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -591,12 +644,20 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -617,17 +678,25 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,8 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"hashids": "^2.3.0", "hashids": "^2.3.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"oidc-client-ts": "^3.5.0", "oidc-client-ts": "^3.5.0",
@@ -32,6 +34,7 @@
"react-router-dom": "^7.18.3", "react-router-dom": "^7.18.3",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"vite": "^6.2.3", "vite": "^6.2.3",
"xlsx": "^0.18.5",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -17,19 +17,23 @@ import {
Tag, Tag,
CheckCircle2, CheckCircle2,
AlertCircle, AlertCircle,
Coins,
} from 'lucide-react'; } from 'lucide-react';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { import {
Instrument,
Instruction, Instruction,
Programme, Programme,
ActivityStatus, ActivityStatus,
} from '../../types'; } from '../../types';
import { import {
getAllInstructions, getAllInstruments,
createInstruction, createInstrument,
updateInstruction, updateInstrument,
} from '../../services/api'; } from '../../services/api';
import { import {
getActivityStatusBadge, getActivityStatusBadge,
@@ -38,17 +42,17 @@ import {
ACTIVITY_STATUS_OPTIONS, ACTIVITY_STATUS_OPTIONS,
} from '../../lib/formatters'; } from '../../lib/formatters';
const instructionSchema = z.object({ const instrumentSchema = z.object({
programmeId: z.coerce.number().min(1, 'Please select an associated programme'), programmeId: z.coerce.number().min(1, 'Please select an associated programme'),
name: z.string().min(3, 'Instruction title must be at least 3 characters'), name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
code: z.string().optional(), code: z.string().optional(),
description: z.string().min(5, 'Description is required (minimum 5 characters)'), description: z.string().min(5, 'Description is required (minimum 5 characters)'),
status: z.coerce.number().default(ActivityStatus.Active), status: z.coerce.number().default(ActivityStatus.Active),
}); });
type InstructionFormValues = z.infer<typeof instructionSchema>; type InstrumentFormValues = z.infer<typeof instrumentSchema>;
interface InstructionsSectionProps { interface FundingInstrumentsSectionProps {
programmes: Programme[]; programmes: Programme[];
currentUserId: string; currentUserId: string;
preselectedProgrammeId?: string | number | null; preselectedProgrammeId?: string | number | null;
@@ -57,7 +61,7 @@ interface InstructionsSectionProps {
onCloseCreateModalExternal?: () => void; onCloseCreateModalExternal?: () => void;
} }
export const InstructionsSection: React.FC<InstructionsSectionProps> = ({ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps> = ({
programmes, programmes,
currentUserId, currentUserId,
preselectedProgrammeId, preselectedProgrammeId,
@@ -76,27 +80,12 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
// Modals // Modals
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingInstruction, setEditingInstruction] = useState<Instruction | null>(null); const [editingInstrument, setEditingInstrument] = useState<Instrument | null>(null);
const [inspectingInstruction, setInspectingInstruction] = useState<Instruction | null>(null); const [inspectingInstrument, setInspectingInstrument] = useState<Instrument | null>(null);
// Sync external create modal trigger if provided
React.useEffect(() => {
if (isCreateModalOpenExternal) {
setEditingInstruction(null);
instructionForm.reset({
programmeId: preselectedProgrammeId ? Number(preselectedProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
name: '',
code: '',
description: '',
status: ActivityStatus.Active,
});
setIsModalOpen(true);
}
}, [isCreateModalOpenExternal, preselectedProgrammeId, programmes]);
// Form setup // Form setup
const instructionForm = useForm<InstructionFormValues>({ const instrumentForm = useForm<InstrumentFormValues>({
resolver: zodResolver(instructionSchema), resolver: zodResolver(instrumentSchema),
defaultValues: { defaultValues: {
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0, programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
name: '', name: '',
@@ -106,15 +95,30 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
}, },
}); });
// Query: Get all instructions // Sync external create modal trigger if provided
React.useEffect(() => {
if (isCreateModalOpenExternal) {
setEditingInstrument(null);
instrumentForm.reset({
programmeId: preselectedProgrammeId ? Number(preselectedProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
name: '',
code: '',
description: '',
status: ActivityStatus.Active,
});
setIsModalOpen(true);
}
}, [isCreateModalOpenExternal, preselectedProgrammeId, programmes, instrumentForm]);
// Query: Get all funding instruments primarily from API
const { const {
data: instructions = [], data: instruments = [],
isLoading, isLoading,
isFetching, isFetching,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ['all-instructions', programmes], queryKey: ['all-instruments', programmes],
queryFn: () => getAllInstructions(programmes), queryFn: () => getAllInstruments(programmes),
}); });
// Programme lookup helper // Programme lookup helper
@@ -124,12 +128,14 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
return map; return map;
}, [programmes]); }, [programmes]);
// Mutation: Create or Update Instruction // Mutation: Create or Update Funding Instrument
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: InstructionFormValues) => { mutationFn: async (data: InstrumentFormValues) => {
if (editingInstruction) { if (editingInstrument) {
await updateInstruction({ await updateInstrument({
instructionId: editingInstruction.id, instructionId: editingInstrument.id,
instrumentId: editingInstrument.id,
id: editingInstrument.id,
programmeId: data.programmeId, programmeId: data.programmeId,
code: data.code, code: data.code,
name: data.name, name: data.name,
@@ -138,7 +144,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
userId: currentUserId, userId: currentUserId,
}); });
} else { } else {
await createInstruction({ await createInstrument({
programmeId: data.programmeId, programmeId: data.programmeId,
code: data.code, code: data.code,
name: data.name, name: data.name,
@@ -149,27 +155,28 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
} }
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-instruments'] });
queryClient.invalidateQueries({ queryKey: ['all-instructions'] }); queryClient.invalidateQueries({ queryKey: ['all-instructions'] });
queryClient.invalidateQueries({ queryKey: ['instruments'] }); queryClient.invalidateQueries({ queryKey: ['instruments'] });
success( success(
editingInstruction ? 'Instruction Updated' : 'Instruction Created', editingInstrument ? 'Funding Instrument Updated' : 'Funding Instrument Created',
editingInstruction editingInstrument
? 'Funding instruction record updated successfully.' ? 'Funding instrument record updated successfully via the API.'
: 'New funding instruction attached to programme successfully.' : 'New funding instrument created and linked to strategic programme successfully.'
); );
setIsModalOpen(false); setIsModalOpen(false);
setEditingInstruction(null); setEditingInstrument(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal(); if (onCloseCreateModalExternal) onCloseCreateModalExternal();
}, },
onError: (err: any) => { onError: (err: any) => {
toastError('Save Failed', err?.message || 'Failed to save instruction'); toastError('Save Failed', err?.message || 'Failed to save funding instrument');
}, },
}); });
// Handlers // Handlers
const openCreateModal = (programmeId?: number | string) => { const openCreateModal = (programmeId?: number | string) => {
setEditingInstruction(null); setEditingInstrument(null);
instructionForm.reset({ instrumentForm.reset({
programmeId: programmeId programmeId: programmeId
? Number(programmeId) ? Number(programmeId)
: selectedProgrammeFilter !== 'all' : selectedProgrammeFilter !== 'all'
@@ -185,9 +192,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
setIsModalOpen(true); setIsModalOpen(true);
}; };
const openEditModal = (inst: Instruction) => { const openEditModal = (inst: Instrument) => {
setEditingInstruction(inst); setEditingInstrument(inst);
instructionForm.reset({ instrumentForm.reset({
programmeId: Number(inst.programmeId), programmeId: Number(inst.programmeId),
name: inst.name || '', name: inst.name || '',
code: inst.code || '', code: inst.code || '',
@@ -199,13 +206,13 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
const handleCloseModal = () => { const handleCloseModal = () => {
setIsModalOpen(false); setIsModalOpen(false);
setEditingInstruction(null); setEditingInstrument(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal(); if (onCloseCreateModalExternal) onCloseCreateModalExternal();
}; };
// Filter instructions // Filter funding instruments
const filteredInstructions = useMemo(() => { const filteredInstruments = useMemo(() => {
return instructions.filter((inst) => { return instruments.filter((inst) => {
// Programme filter // Programme filter
if (selectedProgrammeFilter !== 'all' && String(inst.programmeId) !== String(selectedProgrammeFilter)) { if (selectedProgrammeFilter !== 'all' && String(inst.programmeId) !== String(selectedProgrammeFilter)) {
return false; return false;
@@ -228,7 +235,29 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
} }
return true; return true;
}); });
}, [instructions, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]); }, [instruments, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
// Export columns definition
const instrumentExportColumns: ExportColumn<Instrument>[] = useMemo(
() => [
{ header: 'Instrument ID', key: 'id' },
{ header: 'Instrument Code', key: 'code', format: (val) => val || '—' },
{ header: 'Title / Name', key: 'name', format: (val) => val || 'Untitled' },
{
header: 'Strategic Programme',
key: 'programmeId',
format: (val) => programmeMap.get(String(val))?.name || `Programme #${val}`,
},
{
header: 'Status',
key: 'status',
format: (val) => (Number(val) === ActivityStatus.Active ? 'Active' : 'Inactive'),
},
{ header: 'Description', key: 'description', format: (val) => val || '—' },
{ header: 'Created Date', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
],
[programmeMap]
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -240,7 +269,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input <input
type="text" type="text"
placeholder="Search instructions by title, code, description, or programme..." placeholder="Search funding instruments by title, code, description, or programme..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
@@ -290,60 +319,72 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</div> </div>
</div> </div>
{/* Action Buttons */} {/* Action Buttons: Refresh, Export & Add */}
<div className="flex items-center gap-2 self-end lg:self-center"> <div className="flex items-center gap-2 self-end lg:self-center">
<button <button
type="button" type="button"
onClick={() => refetch()} onClick={() => {
refetch();
success('Refreshed', 'Funding instruments updated from API.');
}}
disabled={isFetching} disabled={isFetching}
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl border border-slate-700 transition cursor-pointer" className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh Instructions" title="Refresh Funding Instruments from API"
> >
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetching ? 'animate-spin' : ''}`} />
Refresh <span>Refresh</span>
</button> </button>
<ExportButton
title="Funding Instruments Registry"
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
columns={instrumentExportColumns}
data={filteredInstruments}
/>
<button <button
type="button" type="button"
onClick={() => openCreateModal()} onClick={() => openCreateModal()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer" className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Instruction <span>Add Instrument</span>
</button> </button>
</div> </div>
</div> </div>
{/* Instructions Table & Cards */} {/* Funding Instruments Table & Cards */}
<div className="rounded-2xl border border-slate-800/80 bg-[#0d121f]/90 overflow-hidden shadow-xl"> <div className="rounded-2xl border border-slate-800/80 bg-[#0d121f]/90 overflow-hidden shadow-xl">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between"> <div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-400" /> <Coins className="w-4 h-4 text-blue-400" />
<h3 className="text-xs font-bold text-slate-200 uppercase tracking-wider"> <h3 className="text-xs font-bold text-slate-200 uppercase tracking-wider">
Funding Instructions & Instrument Guidelines ({filteredInstructions.length}) Funding Instruments & Financing Directives ({filteredInstruments.length})
</h3> </h3>
<HelpTooltip <HelpTooltip
title="Funding Instructions" title="Funding Instruments"
content="Binding operational instructions and instrument guidelines tied to strategic funding programmes. Defines implementation directives and compliance standards." content="Binding funding instruments and financing directives tied to strategic funding programmes. Defines capital allocation mechanisms, rules, and statutory guidelines."
placement="right" placement="right"
size="sm" size="sm"
/> />
</div> </div>
<span className="text-[11px] text-slate-400 font-mono"> <span className="text-[11px] text-slate-400 font-mono">
{filteredInstructions.length} of {instructions.length} recorded {filteredInstruments.length} of {instruments.length} recorded
</span> </span>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="p-12 text-center text-xs text-slate-400 flex flex-col items-center justify-center gap-2"> <div className="p-12 text-center text-xs text-slate-400 flex flex-col items-center justify-center gap-2">
<RefreshCw className="w-5 h-5 animate-spin text-blue-400" /> <RefreshCw className="w-5 h-5 animate-spin text-blue-400" />
<span>Loading instructions directory...</span> <span>Loading funding instruments directory...</span>
</div> </div>
) : filteredInstructions.length === 0 ? ( ) : filteredInstruments.length === 0 ? (
<div className="p-12 text-center text-xs text-slate-400 space-y-3"> <div className="p-12 text-center text-xs text-slate-400 space-y-3">
<div className="text-slate-500"> <div className="text-slate-500">
{searchTerm || selectedProgrammeFilter !== 'all' || statusFilter !== 'all' {searchTerm || selectedProgrammeFilter !== 'all' || statusFilter !== 'all'
? 'No funding instructions match the active filters.' ? 'No funding instruments match the active filters.'
: 'No funding instructions registered yet. Link instructions to programmes using the button below.'} : 'No funding instruments registered yet. Link funding instruments to programmes using the button below.'}
</div> </div>
<button <button
type="button" type="button"
@@ -351,7 +392,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer" className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add First Instruction Add First Funding Instrument
</button> </button>
</div> </div>
) : ( ) : (
@@ -360,7 +401,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<thead> <thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider"> <tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<th className="py-3 px-4">Code / Ref</th> <th className="py-3 px-4">Code / Ref</th>
<th className="py-3 px-4">Instruction Title</th> <th className="py-3 px-4">Instrument Title</th>
<th className="py-3 px-4">Associated Programme</th> <th className="py-3 px-4">Associated Programme</th>
<th className="py-3 px-4">Status</th> <th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Created Info</th> <th className="py-3 px-4">Created Info</th>
@@ -368,7 +409,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300"> <tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
{filteredInstructions.map((inst) => { {filteredInstruments.map((inst) => {
const statusBadge = getActivityStatusBadge(inst.status); const statusBadge = getActivityStatusBadge(inst.status);
const parentProg = programmeMap.get(String(inst.programmeId)); const parentProg = programmeMap.get(String(inst.programmeId));
@@ -416,9 +457,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div className="flex items-center justify-end gap-1.5"> <div className="flex items-center justify-end gap-1.5">
<button <button
type="button" type="button"
onClick={() => setInspectingInstruction(inst)} onClick={() => setInspectingInstrument(inst)}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer" className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Instruction" title="Inspect Instrument"
> >
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
Inspect Inspect
@@ -427,7 +468,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
type="button" type="button"
onClick={() => openEditModal(inst)} onClick={() => openEditModal(inst)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer" className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Edit / Mutate Instruction" title="Edit / Update Instrument"
> >
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
Edit Edit
@@ -443,20 +484,20 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
)} )}
</div> </div>
{/* Modal: Create / Edit Instruction */} {/* Modal: Create / Edit Funding Instrument */}
<Modal <Modal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={handleCloseModal} onClose={handleCloseModal}
title={editingInstruction ? 'Edit Funding Instruction' : 'Add New Funding Instruction'} title={editingInstrument ? 'Edit Funding Instrument' : 'Add New Funding Instrument'}
subtitle={ subtitle={
editingInstruction editingInstrument
? `Mutating Instruction ${editingInstruction.code || `#${editingInstruction.id}`}` ? `Updating Instrument ${editingInstrument.code || `#${editingInstrument.id}`}`
: 'Authoritative operational instruction attached to a strategic programme' : 'Authoritative funding instrument attached to a strategic programme'
} }
helpText="Instructions operationalize funding programmes into concrete binding directives. Choose an existing programme from the dropdown to link data." helpText="Funding instruments operationalize funding programmes into concrete financing mechanisms. Select a strategic programme from the dropdown."
> >
<form <form
onSubmit={instructionForm.handleSubmit((data) => mutation.mutate(data))} onSubmit={instrumentForm.handleSubmit((data) => mutation.mutate(data))}
className="space-y-4" className="space-y-4"
> >
{/* Associated Programme Dropdown */} {/* Associated Programme Dropdown */}
@@ -467,12 +508,12 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</label> </label>
<HelpTooltip <HelpTooltip
title="Associated Programme" title="Associated Programme"
content="Select the strategic funding programme under which this operational instruction is established." content="Select the strategic funding programme under which this operational funding instrument is established."
size="sm" size="sm"
/> />
</div> </div>
<select <select
{...instructionForm.register('programmeId')} {...instrumentForm.register('programmeId')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500" className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
> >
<option value="">Select an associated programme...</option> <option value="">Select an associated programme...</option>
@@ -482,9 +523,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</option> </option>
))} ))}
</select> </select>
{instructionForm.formState.errors.programmeId && ( {instrumentForm.formState.errors.programmeId && (
<p className="text-red-400 text-[11px] mt-1"> <p className="text-red-400 text-[11px] mt-1">
{instructionForm.formState.errors.programmeId.message} {instrumentForm.formState.errors.programmeId.message}
</p> </p>
)} )}
</div> </div>
@@ -493,23 +534,23 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div> <div>
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300"> <label className="block text-xs font-semibold text-slate-300">
Instruction Title * Instrument Title *
</label> </label>
<HelpTooltip <HelpTooltip
title="Instruction Title" title="Instrument Title"
content="Formal title of the funding instruction or instrument (e.g., SME Technology Transfer Subsidy Guideline)." content="Formal title of the funding instrument (e.g., SME Technology Transfer Subsidy Guideline)."
size="sm" size="sm"
/> />
</div> </div>
<input <input
type="text" type="text"
placeholder="e.g. Clean Energy Grant Directive 2026" placeholder="e.g. Clean Energy Grant Directive 2026"
{...instructionForm.register('name')} {...instrumentForm.register('name')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500" className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
{instructionForm.formState.errors.name && ( {instrumentForm.formState.errors.name && (
<p className="text-red-400 text-[11px] mt-1"> <p className="text-red-400 text-[11px] mt-1">
{instructionForm.formState.errors.name.message} {instrumentForm.formState.errors.name.message}
</p> </p>
)} )}
</div> </div>
@@ -518,10 +559,10 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div> <div>
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300"> <label className="block text-xs font-semibold text-slate-300">
Instruction Code (Optional) Instrument Code (Optional)
</label> </label>
<HelpTooltip <HelpTooltip
title="Instruction Code" title="Instrument Code"
content="Unique operational reference code (e.g., INST-CE-001). If left blank, one is generated automatically." content="Unique operational reference code (e.g., INST-CE-001). If left blank, one is generated automatically."
size="sm" size="sm"
/> />
@@ -529,7 +570,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<input <input
type="text" type="text"
placeholder="e.g. INST-CE-001" placeholder="e.g. INST-CE-001"
{...instructionForm.register('code')} {...instrumentForm.register('code')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono" className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
/> />
</div> </div>
@@ -538,23 +579,23 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div> <div>
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300"> <label className="block text-xs font-semibold text-slate-300">
Description & Implementation Directives * Description & Financing Directives *
</label> </label>
<HelpTooltip <HelpTooltip
title="Instruction Description" title="Instrument Description"
content="Comprehensive directives, statutory mandate, eligibility scope, and operational guidelines." content="Comprehensive directives, statutory mandate, eligibility scope, and financing guidelines."
size="sm" size="sm"
/> />
</div> </div>
<textarea <textarea
rows={4} rows={4}
placeholder="Detail the operational mandate, eligibility scope, disbursement triggers, and statutory compliance requirements..." placeholder="Detail the financing mandate, eligibility scope, disbursement triggers, and statutory compliance requirements..."
{...instructionForm.register('description')} {...instrumentForm.register('description')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500" className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
{instructionForm.formState.errors.description && ( {instrumentForm.formState.errors.description && (
<p className="text-red-400 text-[11px] mt-1"> <p className="text-red-400 text-[11px] mt-1">
{instructionForm.formState.errors.description.message} {instrumentForm.formState.errors.description.message}
</p> </p>
)} )}
</div> </div>
@@ -564,13 +605,13 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Status</label> <label className="block text-xs font-semibold text-slate-300">Status</label>
<HelpTooltip <HelpTooltip
title="Instruction Lifecycle Status" title="Instrument Lifecycle Status"
content="Indicates whether this instruction is actively enforceable or archived/inactive." content="Indicates whether this funding instrument is actively enforceable or archived/inactive."
size="sm" size="sm"
/> />
</div> </div>
<select <select
{...instructionForm.register('status')} {...instrumentForm.register('status')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500" className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
> >
{ACTIVITY_STATUS_OPTIONS.map((opt) => ( {ACTIVITY_STATUS_OPTIONS.map((opt) => (
@@ -596,29 +637,29 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer" className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
> >
{mutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} {mutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
{editingInstruction ? 'Save Instruction Changes' : 'Create Instruction'} {editingInstrument ? 'Save Instrument Changes' : 'Create Funding Instrument'}
</button> </button>
</div> </div>
</form> </form>
</Modal> </Modal>
{/* Modal: Inspect Instruction Detail */} {/* Modal: Inspect Funding Instrument Detail */}
{inspectingInstruction && ( {inspectingInstrument && (
<Modal <Modal
isOpen={!!inspectingInstruction} isOpen={!!inspectingInstrument}
onClose={() => setInspectingInstruction(null)} onClose={() => setInspectingInstrument(null)}
title={`Instruction: ${inspectingInstruction.name || ''}`} title={`Funding Instrument: ${inspectingInstrument.name || ''}`}
subtitle={`Reference: ${inspectingInstruction.code || `INST-${inspectingInstruction.id}`}`} subtitle={`Reference: ${inspectingInstrument.code || `INST-${inspectingInstrument.id}`}`}
maxWidth="lg" maxWidth="lg"
> >
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
<div className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] space-y-3"> <div className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-mono text-xs font-bold text-blue-400 bg-blue-500/10 px-2.5 py-1 rounded border border-blue-500/20"> <span className="font-mono text-xs font-bold text-blue-400 bg-blue-500/10 px-2.5 py-1 rounded border border-blue-500/20">
{inspectingInstruction.code || `INST-${inspectingInstruction.id}`} {inspectingInstrument.code || `INST-${inspectingInstrument.id}`}
</span> </span>
{(() => { {(() => {
const badge = getActivityStatusBadge(inspectingInstruction.status); const badge = getActivityStatusBadge(inspectingInstrument.status);
return ( return (
<span <span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${badge.className}`} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${badge.className}`}
@@ -630,9 +671,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</div> </div>
<div> <div>
<h4 className="text-sm font-bold text-white mb-1">{inspectingInstruction.name}</h4> <h4 className="text-sm font-bold text-white mb-1">{inspectingInstrument.name}</h4>
<p className="text-slate-300 whitespace-pre-wrap leading-relaxed"> <p className="text-slate-300 whitespace-pre-wrap leading-relaxed">
{inspectingInstruction.description || 'No detailed description provided.'} {inspectingInstrument.description || 'No detailed description provided.'}
</p> </p>
</div> </div>
@@ -640,27 +681,27 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div> <div>
<span className="text-slate-500 block">Associated Programme</span> <span className="text-slate-500 block">Associated Programme</span>
<span className="text-slate-200 font-semibold"> <span className="text-slate-200 font-semibold">
{programmeMap.get(String(inspectingInstruction.programmeId))?.name || {programmeMap.get(String(inspectingInstrument.programmeId))?.name ||
`Programme #${inspectingInstruction.programmeId}`} `Programme #${inspectingInstrument.programmeId}`}
</span> </span>
</div> </div>
<div> <div>
<span className="text-slate-500 block">Created On</span> <span className="text-slate-500 block">Created On</span>
<span className="text-slate-200 font-mono"> <span className="text-slate-200 font-mono">
{formatDate(inspectingInstruction.createdAt)} {formatDate(inspectingInstrument.createdAt)}
</span> </span>
</div> </div>
<div> <div>
<span className="text-slate-500 block">Created By</span> <span className="text-slate-500 block">Created By</span>
<span className="text-slate-200 font-mono"> <span className="text-slate-200 font-mono">
{inspectingInstruction.createdBy ? getUserEmail(inspectingInstruction.createdBy) : '—'} {inspectingInstrument.createdBy ? getUserEmail(inspectingInstrument.createdBy) : '—'}
</span> </span>
</div> </div>
{inspectingInstruction.updatedAt && ( {inspectingInstrument.updatedAt && (
<div> <div>
<span className="text-slate-500 block">Last Mutated</span> <span className="text-slate-500 block">Last Mutated</span>
<span className="text-slate-200 font-mono"> <span className="text-slate-200 font-mono">
{formatDate(inspectingInstruction.updatedAt)} {formatDate(inspectingInstrument.updatedAt)}
</span> </span>
</div> </div>
)} )}
@@ -671,18 +712,18 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
const inst = inspectingInstruction; const inst = inspectingInstrument;
setInspectingInstruction(null); setInspectingInstrument(null);
openEditModal(inst); openEditModal(inst);
}} }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-xl border border-slate-700 cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-xl border border-slate-700 cursor-pointer"
> >
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
Edit Instruction Edit Funding Instrument
</button> </button>
<button <button
type="button" type="button"
onClick={() => setInspectingInstruction(null)} onClick={() => setInspectingInstrument(null)}
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer" className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
> >
Close Close
@@ -694,3 +735,6 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</div> </div>
); );
}; };
export const InstructionsSection = FundingInstrumentsSection;
@@ -22,6 +22,8 @@ import {
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip'; import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { import {
Rule, Rule,
Programme, Programme,
@@ -231,6 +233,28 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
}); });
}, [rules, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]); }, [rules, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
// Export columns definition
const ruleExportColumns: ExportColumn<Rule>[] = useMemo(
() => [
{ header: 'Rule ID', key: 'id' },
{ header: 'Rule Name', key: 'name', format: (val) => val || 'Untitled Rule' },
{
header: 'Strategic Programme',
key: 'programmeId',
format: (val) => programmeMap.get(String(val))?.name || `Programme #${val}`,
},
{ header: 'Version', key: 'version', format: (val) => `v${val || 1}` },
{
header: 'Status',
key: 'status',
format: (val) => (Number(val) === ActivityStatus.Active ? 'Active' : 'Inactive'),
},
{ header: 'Rule Description', key: 'description', format: (val) => val || '—' },
{ header: 'Created Date', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
],
[programmeMap]
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Top Filter and Actions Bar */} {/* Top Filter and Actions Bar */}
@@ -291,25 +315,37 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
</div> </div>
</div> </div>
{/* Action Buttons */} {/* Action Buttons: Refresh, Export & Add */}
<div className="flex items-center gap-2 self-end lg:self-center"> <div className="flex items-center gap-2 self-end lg:self-center">
<button <button
type="button" type="button"
onClick={() => refetch()} onClick={() => {
refetch();
success('Refreshed', 'Programme rules updated from API.');
}}
disabled={isFetching} disabled={isFetching}
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl border border-slate-700 transition cursor-pointer" className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh Rules" title="Refresh Programme Rules from API"
> >
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetching ? 'animate-spin' : ''}`} />
Refresh <span>Refresh</span>
</button> </button>
<ExportButton
title="Programme Rules Registry"
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
columns={ruleExportColumns}
data={filteredRules}
/>
<button <button
type="button" type="button"
onClick={() => openCreateModal()} onClick={() => openCreateModal()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer" className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Rule <span>Add Rule</span>
</button> </button>
</div> </div>
</div> </div>
@@ -0,0 +1,139 @@
import React, { useState, useRef, useEffect } from 'react';
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
import { useAuth } from '../../auth/AuthProvider';
export interface ExportButtonProps<T = any> {
title: string;
subtitle?: string;
filename: string;
columns: ExportColumn<T>[];
data: T[];
label?: string;
size?: 'sm' | 'md';
className?: string;
}
export function ExportButton<T>({
title,
subtitle,
filename,
columns,
data,
label = 'Export',
size = 'md',
className = '',
}: ExportButtonProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { user, claims, tenantId } = useAuth();
const isDisabled = !data || data.length === 0;
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleExportSpreadsheet = () => {
if (isDisabled) return;
setIsOpen(false);
exportToSpreadsheet({
title,
subtitle,
filename,
columns,
data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
userEmail: user?.profile?.email || (claims as any)?.email || '',
});
};
const handleExportPdf = () => {
if (isDisabled) return;
setIsOpen(false);
exportToPdf({
title,
subtitle,
filename,
columns,
data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
userEmail: user?.profile?.email || (claims as any)?.email || '',
});
};
return (
<div className={`relative inline-block ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => !isDisabled && setIsOpen(!isOpen)}
disabled={isDisabled}
title={
isDisabled
? 'No data available to export in current view'
: `Export ${data.length} records to Spreadsheet or PDF`
}
className={`inline-flex items-center justify-center gap-1.5 font-semibold rounded-xl border transition cursor-pointer shadow-xs ${
size === 'sm' ? 'px-3 py-1.5 text-xs' : 'px-3.5 py-2 text-xs'
} ${
isDisabled
? 'text-slate-600 bg-slate-900/40 border-slate-800/80 cursor-not-allowed opacity-60'
: 'text-slate-300 bg-[#0d121f] hover:bg-slate-800 border-slate-700/80 hover:text-white'
}`}
>
<Download className={`w-3.5 h-3.5 ${isDisabled ? 'text-slate-600' : 'text-emerald-400'}`} />
<span>{label}</span>
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isDisabled && (
<div className="absolute right-0 mt-1.5 w-52 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95">
<div className="p-2 border-b border-slate-800/80">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
Export Format ({data.length} items)
</span>
</div>
<div className="p-1 space-y-0.5">
<button
type="button"
onClick={handleExportSpreadsheet}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-emerald-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0 group-hover:bg-emerald-500/20">
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" />
</div>
<div>
<span className="font-semibold block leading-tight">Spreadsheet (.xlsx)</span>
<span className="text-[10px] text-slate-400">Excel / Google Sheets</span>
</div>
</button>
<button
type="button"
onClick={handleExportPdf}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-blue-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0 group-hover:bg-blue-500/20">
<FileText className="w-3.5 h-3.5 text-blue-400" />
</div>
<div>
<span className="font-semibold block leading-tight">PDF Document (.pdf)</span>
<span className="text-[10px] text-slate-400">Landscape with PFM Logo</span>
</div>
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,302 @@
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
export interface ExportColumn {
header: string;
key: string;
format?: (value: any, row: any) => string;
}
export interface ExportDataOptions {
title: string;
subtitle?: string;
filename: string;
columns: ExportColumn[];
data: any[];
}
/**
* Converts dataset into standard CSV string and triggers browser download
*/
export function exportToSpreadsheet({ filename, columns, data }: ExportDataOptions): void {
if (!data || data.length === 0) return;
const headers = columns.map((col) => `"${(col.header || '').replace(/"/g, '""')}"`).join(',');
const rows = data.map((row) =>
columns
.map((col) => {
let val: any;
if (col.format) {
val = col.format(row[col.key], row);
} else {
val = row[col.key];
}
if (val === null || val === undefined) val = '';
const strVal = String(val).replace(/"/g, '""');
return `"${strVal}"`;
})
.join(',')
);
const csvContent = '\uFEFF' + [headers, ...rows].join('\r\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `${filename.replace(/\.csv$/i, '')}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Draws the high-visibility official PFM vector brand insignia onto the jsPDF document.
*/
function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void {
const scale = size / 64;
doc.saveGraphicsState();
// Dark foundation disc
doc.setFillColor(9, 13, 22);
doc.circle(x + 32 * scale, y + 32 * scale, 31 * scale, 'F');
doc.setDrawColor(30, 41, 59);
doc.setLineWidth(1 * scale);
doc.circle(x + 32 * scale, y + 32 * scale, 30 * scale, 'S');
// Cyan/Blue Outer hexagonal shield ring
doc.setDrawColor(59, 130, 246);
doc.setLineWidth(1.2 * scale);
doc.polygon(
[
{ x: x + 32 * scale, y: y + 6.5 * scale },
{ x: x + 53.5 * scale, y: y + 19 * scale },
{ x: x + 56 * scale, y: y + 23.2 * scale },
{ x: x + 56 * scale, y: y + 40.8 * scale },
{ x: x + 53.5 * scale, y: y + 45 * scale },
{ x: x + 32 * scale, y: y + 57.5 * scale },
{ x: x + 27 * scale, y: y + 57.5 * scale },
{ x: x + 5.5 * scale, y: y + 45 * scale },
{ x: x + 3 * scale, y: y + 40.8 * scale },
{ x: x + 3 * scale, y: y + 23.2 * scale },
{ x: x + 5.5 * scale, y: y + 19 * scale },
{ x: x + 27 * scale, y: y + 6.5 * scale },
],
'S'
);
// Top Facet - Indigo/Violet Governance Canopy
doc.setFillColor(99, 102, 241);
doc.triangle(
x + 32 * scale,
y + 9 * scale,
x + 51.5 * scale,
y + 20.2 * scale,
x + 43 * scale,
y + 25.2 * scale,
'F'
);
doc.triangle(
x + 32 * scale,
y + 9 * scale,
x + 43 * scale,
y + 25.2 * scale,
x + 21 * scale,
y + 25.2 * scale,
'F'
);
doc.triangle(
x + 32 * scale,
y + 9 * scale,
x + 21 * scale,
y + 25.2 * scale,
x + 12.5 * scale,
y + 20.2 * scale,
'F'
);
// Left Facet - Electric Sapphire Capital Vault
doc.setFillColor(37, 99, 235);
doc.triangle(
x + 10 * scale,
y + 23 * scale,
x + 19.2 * scale,
y + 28.4 * scale,
x + 19.2 * scale,
y + 44.5 * scale,
'F'
);
doc.triangle(
x + 10 * scale,
y + 23 * scale,
x + 19.2 * scale,
y + 44.5 * scale,
x + 30 * scale,
y + 50.8 * scale,
'F'
);
// Right Facet - Cyan/Emerald Performance Wing
doc.setFillColor(6, 182, 212);
doc.triangle(
x + 54 * scale,
y + 23 * scale,
x + 44.8 * scale,
y + 28.4 * scale,
x + 44.8 * scale,
y + 44.5 * scale,
'F'
);
doc.triangle(
x + 54 * scale,
y + 23 * scale,
x + 44.8 * scale,
y + 44.5 * scale,
x + 34 * scale,
y + 50.8 * scale,
'F'
);
// Center Plate
doc.setFillColor(15, 23, 42);
doc.setDrawColor(51, 65, 85);
doc.polygon(
[
{ x: x + 22 * scale, y: y + 28.5 * scale },
{ x: x + 32 * scale, y: y + 22.8 * scale },
{ x: x + 42 * scale, y: y + 28.5 * scale },
{ x: x + 42 * scale, y: y + 35.5 * scale },
{ x: x + 32 * scale, y: y + 41.2 * scale },
{ x: x + 22 * scale, y: y + 35.5 * scale },
],
'FD'
);
// Center Diamond Spark
doc.setFillColor(255, 255, 255);
doc.circle(x + 32 * scale, y + 32 * scale, 2.5 * scale, 'F');
doc.restoreGraphicsState();
}
/**
* Generates an executive-grade PDF document in LANDSCAPE mode with clear PFM logo visibility,
* metadata header, and structured autoTable data.
*/
export function exportToPdf({ title, subtitle, filename, columns, data }: ExportDataOptions): void {
if (!data || data.length === 0) return;
// Enforce Landscape orientation
const doc = new jsPDF({
orientation: 'landscape',
unit: 'pt',
format: 'a4',
});
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 40;
// Header background banner
doc.setFillColor(13, 18, 31);
doc.rect(0, 0, pageWidth, 72, 'F');
doc.setDrawColor(30, 41, 59);
doc.setLineWidth(1);
doc.line(0, 72, pageWidth, 72);
// Draw PFM Logo with clear visibility
const logoSize = 44;
drawPfmLogo(doc, margin, 14, logoSize);
// PFM Branding Text next to Logo
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(14);
doc.text('PFM Console', margin + logoSize + 12, 33);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(56, 189, 248); // Cyan
doc.text('POST-FUND MANAGEMENT SYSTEM • OFFICIAL REPORT', margin + logoSize + 12, 46);
// Document Title & Subtitle in Header (Right aligned or center)
doc.setFont('helvetica', 'bold');
doc.setFontSize(13);
doc.setTextColor(248, 250, 252);
doc.text(title, pageWidth - margin, 32, { align: 'right' });
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(148, 163, 184);
const nowStr = new Date().toLocaleString('en-GB', {
dateStyle: 'medium',
timeStyle: 'short',
});
doc.text(
`${subtitle || 'Authoritative Register Export'} • Generated: ${nowStr}`,
pageWidth - margin,
46,
{ align: 'right' }
);
// Prepare table headers and rows
const tableHeaders = [columns.map((c) => c.header)];
const tableRows = data.map((row) =>
columns.map((col) => {
let val: any;
if (col.format) {
val = col.format(row[col.key], row);
} else {
val = row[col.key];
}
if (val === null || val === undefined) return '—';
return String(val);
})
);
// Render autoTable in landscape
(autoTable as any)(doc, {
head: tableHeaders,
body: tableRows,
startY: 86,
margin: { left: margin, right: margin, bottom: 45 },
theme: 'grid',
styles: {
fontSize: 8,
cellPadding: 6,
textColor: [30, 41, 59],
lineColor: [226, 232, 240],
lineWidth: 0.5,
overflow: 'linebreak',
},
headStyles: {
fillColor: [15, 23, 42],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 8,
},
alternateRowStyles: {
fillColor: [248, 250, 252],
},
didDrawPage: (hookData: any) => {
// Footer on every page
const currentPg = hookData.pageNumber;
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
doc.text(
`Post Fund Management (PFM) • Confidential & Authoritative • Page ${currentPg}`,
margin,
pageHeight - 20
);
doc.text(
`Total Records: ${data.length} • Landscape Master Report`,
pageWidth - margin,
pageHeight - 20,
{ align: 'right' }
);
},
});
doc.save(`${filename.replace(/\.pdf$/i, '')}.pdf`);
}
@@ -29,6 +29,7 @@ import {
updateOrganisation, updateOrganisation,
getAllPortfolios, getAllPortfolios,
getPortfolios, getPortfolios,
getOrganisationPortfolios,
getPortfolio, getPortfolio,
createPortfolio, createPortfolio,
updatePortfolio, updatePortfolio,
@@ -39,8 +40,8 @@ import {
updateProgramme, updateProgramme,
getInstruments, getInstruments,
getInstructions, getInstructions,
createInstruction, createInstrument,
updateInstruction, updateInstrument,
getRules, getRules,
createRule, createRule,
updateRule, updateRule,
@@ -51,8 +52,10 @@ import {
import { Modal } from '../components/ui/Modal'; import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip'; import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider'; import { useAuth } from '../auth/AuthProvider';
import { InstructionsSection } from '../components/governance/InstructionsSection'; import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection'; import { RulesSection } from '../components/governance/RulesSection';
import { import {
Organisation, Organisation,
@@ -133,26 +136,27 @@ export const GovernancePage: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab'); const tabParam = searchParams.get('tab');
const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' | 'instructions' | 'rules' => { const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules' => {
if (val === 'portfolios' || val === 'programmes' || val === 'organisations' || val === 'instructions' || val === 'rules') { if (val === 'portfolios' || val === 'programmes' || val === 'organisations' || val === 'instruments' || val === 'instructions' || val === 'rules') {
return val; return val === 'instructions' ? 'instruments' : val;
} }
return 'organisations'; return 'organisations';
}; };
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes' | 'instructions' | 'rules'>(() => getValidTab(tabParam)); const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules'>(() => getValidTab(tabParam));
useEffect(() => { useEffect(() => {
if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes' || tabParam === 'instructions' || tabParam === 'rules')) { if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes' || tabParam === 'instruments' || tabParam === 'instructions' || tabParam === 'rules')) {
setActiveTab(tabParam); setActiveTab(tabParam === 'instructions' ? 'instruments' : tabParam);
} }
}, [tabParam]); }, [tabParam]);
const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes' | 'instructions' | 'rules') => { const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules') => {
setActiveTab(tab); const canonicalTab = tab === 'instructions' ? 'instruments' : tab;
setActiveTab(canonicalTab);
setSearchParams((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.set('tab', tab); next.set('tab', canonicalTab);
return next; return next;
}); });
}; };
@@ -219,17 +223,32 @@ export const GovernancePage: React.FC = () => {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({ const {
data: organisations = [],
isLoading: loadingOrgs,
isFetching: isFetchingOrgs,
refetch: refetchOrgs,
} = useQuery({
queryKey: ['organisations'], queryKey: ['organisations'],
queryFn: () => getOrganisations(), queryFn: () => getOrganisations(),
}); });
const { data: portfolios = [], isLoading: loadingPortfolios } = useQuery({ const {
data: portfolios = [],
isLoading: loadingPortfolios,
isFetching: isFetchingPortfolios,
refetch: refetchPortfolios,
} = useQuery({
queryKey: ['portfolios'], queryKey: ['portfolios'],
queryFn: () => getAllPortfolios(), queryFn: () => getAllPortfolios(),
}); });
const { data: programmes = [], isLoading: loadingProgrammes } = useQuery({ const {
data: programmes = [],
isLoading: loadingProgrammes,
isFetching: isFetchingProgrammes,
refetch: refetchProgrammes,
} = useQuery({
queryKey: ['programmes'], queryKey: ['programmes'],
queryFn: () => getAllProgrammes(), queryFn: () => getAllProgrammes(),
}); });
@@ -278,7 +297,7 @@ export const GovernancePage: React.FC = () => {
const { data: orgPortfolios = [], isLoading: loadingOrgPortfolios } = useQuery({ const { data: orgPortfolios = [], isLoading: loadingOrgPortfolios } = useQuery({
queryKey: ['organisation-portfolios', selectedOrgForDetail?.id], queryKey: ['organisation-portfolios', selectedOrgForDetail?.id],
queryFn: () => (selectedOrgForDetail ? getPortfolios(selectedOrgForDetail.id) : Promise.resolve([])), queryFn: () => (selectedOrgForDetail ? getOrganisationPortfolios(selectedOrgForDetail.id) : Promise.resolve([])),
enabled: !!selectedOrgForDetail && isOrgDetailModalOpen, enabled: !!selectedOrgForDetail && isOrgDetailModalOpen,
}); });
@@ -575,9 +594,81 @@ export const GovernancePage: React.FC = () => {
(o) => (o) =>
o.name?.toLowerCase().includes(searchTerm.toLowerCase()) || o.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
o.registrationNo?.toLowerCase().includes(searchTerm.toLowerCase()) || o.registrationNo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
o.email?.toLowerCase().includes(searchTerm.toLowerCase()) o.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
String(o.id).includes(searchTerm)
); );
const filteredPortfolios = portfolios.filter((p) => {
if (!searchTerm.trim()) return true;
const q = searchTerm.toLowerCase();
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const orgName = org?.name?.toLowerCase() || '';
const name = (p.name || '').toLowerCase();
const desc = (p.description || '').toLowerCase();
const id = String(p.id);
const owner = getUserEmail(p.ownedBy).toLowerCase();
return name.includes(q) || desc.includes(q) || id.includes(q) || orgName.includes(q) || owner.includes(q);
});
const filteredProgrammes = programmes.filter((pr) => {
if (!searchTerm.trim()) return true;
const q = searchTerm.toLowerCase();
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const portName = port?.name?.toLowerCase() || '';
const name = (pr.name || '').toLowerCase();
const id = String(pr.id);
const owner = getUserEmail(pr.ownedBy).toLowerCase();
return name.includes(q) || id.includes(q) || portName.includes(q) || owner.includes(q);
});
// Export Columns Definitions
const orgExportColumns: ExportColumn<Organisation>[] = [
{ header: 'Organisation ID', key: 'id' },
{ header: 'Legal Entity Name', key: 'name' },
{ header: 'Registration / CIPC No', key: 'registrationNo', format: (val) => val || '—' },
{ header: 'Entity Classification', key: 'type', format: (val) => getOrganisationTypeLabel(val) },
{ header: 'Official Email', key: 'email', format: (val) => val || '—' },
{ header: 'Status', key: 'status', format: (val) => getOrganisationStatusBadge({ status: val }).label },
{ header: 'Registration Date', key: 'createdAt', format: (val) => formatDate(val) },
];
const portfolioExportColumns: ExportColumn<Portfolio>[] = [
{ header: 'Portfolio ID', key: 'id' },
{ header: 'Portfolio Name', key: 'name' },
{
header: 'Organisation',
key: 'organisationId',
format: (val) => {
const org = organisations.find((o) => String(o.id) === String(val));
return org ? org.name : `Org #${val}`;
},
},
{
header: 'Programmes Count',
key: 'id',
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
},
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Description', key: 'description', format: (val) => val || '—' },
];
const programmeExportColumns: ExportColumn<Programme>[] = [
{ header: 'Programme ID', key: 'id' },
{ header: 'Programme Name', key: 'name' },
{
header: 'Parent Portfolio',
key: 'portfolioId',
format: (val) => {
const port = portfolios.find((p) => String(p.id) === String(val));
return port ? port.name : `Portfolio #${val}`;
},
},
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
];
return ( return (
<div className="space-y-6 max-w-7xl mx-auto"> <div className="space-y-6 max-w-7xl mx-auto">
{/* Header */} {/* Header */}
@@ -597,7 +688,7 @@ export const GovernancePage: React.FC = () => {
/> />
</div> </div>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
Maintain Master Organisation Registry, Funder Portfolios, Strategic Programmes, Funding Instructions, and Programme Rules. Maintain Master Organisation Registry, Funder Portfolios, Strategic Programmes, Funding Instruments, and Programme Rules.
</p> </p>
</div> </div>
@@ -630,13 +721,13 @@ export const GovernancePage: React.FC = () => {
Create Programme Create Programme
</button> </button>
)} )}
{activeTab === 'instructions' && ( {(activeTab === 'instruments' || activeTab === 'instructions') && (
<button <button
onClick={() => setIsInstructionCreateTriggered(true)} onClick={() => setIsInstructionCreateTriggered(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer" className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Instruction Add Funding Instrument
</button> </button>
)} )}
{activeTab === 'rules' && ( {activeTab === 'rules' && (
@@ -705,18 +796,18 @@ export const GovernancePage: React.FC = () => {
/> />
</button> </button>
<button <button
onClick={() => handleTabChange('instructions')} onClick={() => handleTabChange('instruments')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${ className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
activeTab === 'instructions' activeTab === 'instruments' || activeTab === 'instructions'
? 'border-blue-500 text-blue-400' ? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200' : 'border-transparent text-slate-400 hover:text-slate-200'
}`} }`}
> >
<FileText className="w-4 h-4" /> <Coins className="w-4 h-4" />
<span>Funding Instructions</span> <span>Funding Instruments</span>
<HelpTooltip <HelpTooltip
title="Funding Instructions" title="Funding Instruments"
content="Operational directives and funding instrument guidelines attached to specific strategic programmes." content="Binding funding instruments and financing directives attached to specific strategic funding programmes."
size="sm" size="sm"
placement="top" placement="top"
/> />
@@ -740,31 +831,41 @@ export const GovernancePage: React.FC = () => {
</button> </button>
</div> </div>
{/* Search Bar & Actions */} {/* Search Bar & Actions for Organisations */}
{activeTab === 'organisations' && ( {activeTab === 'organisations' && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative max-w-md flex-1"> <div className="relative max-w-md flex-1">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input <input
type="text" type="text"
placeholder="Search by organisation name, reg no, or email..." placeholder="Search by organisation name, reg no, email, or ID..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/> />
</div> </div>
<button <div className="flex items-center gap-2 self-end sm:self-center">
type="button" <button
onClick={() => { type="button"
queryClient.invalidateQueries({ queryKey: ['organisations'] }); onClick={() => {
success('Refreshed', 'Organisation directory refreshed from database.'); refetchOrgs();
}} success('Refreshed', 'Organisation directory refreshed from API.');
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer" }}
title="Force refresh organisation directory from database" disabled={isFetchingOrgs}
> className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
<RefreshCw className="w-3.5 h-3.5 text-blue-400" /> title="Force refresh organisation directory from API"
Refresh Directory >
</button> <RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingOrgs ? 'animate-spin' : ''}`} />
<span>Refresh Directory</span>
</button>
<ExportButton
title="Organisation Master Registry"
subtitle="Official directory of verified recipient and funder entities"
filename={`pfm-organisations-${new Date().toISOString().split('T')[0]}`}
columns={orgExportColumns}
data={filteredOrgs}
/>
</div>
</div> </div>
)} )}
@@ -855,13 +956,57 @@ export const GovernancePage: React.FC = () => {
</div> </div>
)} )}
{/* Search Bar & Actions for Portfolios */}
{activeTab === 'portfolios' && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative max-w-md flex-1">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search portfolios by name, ID, organisation, or owner..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchPortfolios();
success('Refreshed', 'Funder portfolios refreshed from API.');
}}
disabled={isFetchingPortfolios}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh portfolios from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingPortfolios ? 'animate-spin' : ''}`} />
<span>Refresh Portfolios</span>
</button>
<ExportButton
title="Funder Portfolios Registry"
subtitle="High-level funding groupings and thematic capital allocations"
filename={`pfm-portfolios-${new Date().toISOString().split('T')[0]}`}
columns={portfolioExportColumns}
data={filteredPortfolios}
/>
</div>
</div>
)}
{/* Tab 2: Portfolios List */} {/* Tab 2: Portfolios List */}
{activeTab === 'portfolios' && ( {activeTab === 'portfolios' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{loadingPortfolios ? ( {loadingPortfolios ? (
<p className="text-xs text-slate-500 col-span-3">Loading portfolios...</p> <p className="text-xs text-slate-500 col-span-3 text-center py-8">Loading portfolios...</p>
) : filteredPortfolios.length === 0 ? (
<div className="col-span-3 py-12 text-center bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 p-8">
<FolderKanban className="w-8 h-8 text-slate-600 mx-auto mb-2" />
<p className="text-sm font-semibold text-slate-300">No portfolios found matching query</p>
<p className="text-xs text-slate-500 mt-1">Try refining your search keywords or create a new portfolio.</p>
</div>
) : ( ) : (
portfolios.map((p) => { filteredPortfolios.map((p) => {
const org = organisations.find((o) => String(o.id) === String(p.organisationId)); const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length; const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length;
const isHighlighted = String(highlightedPortfolioId) === String(p.id); const isHighlighted = String(highlightedPortfolioId) === String(p.id);
@@ -942,6 +1087,44 @@ export const GovernancePage: React.FC = () => {
</div> </div>
)} )}
{/* Search Bar & Actions for Programmes */}
{activeTab === 'programmes' && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative max-w-md flex-1">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search programmes by name, ID, parent portfolio, or owner..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchProgrammes();
success('Refreshed', 'Programmes hierarchy refreshed from API.');
}}
disabled={isFetchingProgrammes}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh programmes from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingProgrammes ? 'animate-spin' : ''}`} />
<span>Refresh Programmes</span>
</button>
<ExportButton
title="Strategic Programmes Hierarchy"
subtitle="Operational funding vehicles linked to funder portfolios"
filename={`pfm-programmes-${new Date().toISOString().split('T')[0]}`}
columns={programmeExportColumns}
data={filteredProgrammes}
/>
</div>
</div>
)}
{/* Tab 3: Programmes Hierarchy */} {/* Tab 3: Programmes Hierarchy */}
{activeTab === 'programmes' && ( {activeTab === 'programmes' && (
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden"> <div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
@@ -964,8 +1147,14 @@ export const GovernancePage: React.FC = () => {
Loading programmes... Loading programmes...
</td> </td>
</tr> </tr>
) : filteredProgrammes.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
No programmes found matching query.
</td>
</tr>
) : ( ) : (
programmes.map((pr) => { filteredProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId)); const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status); const statusBadge = getApprovalStatusBadge(pr.status);
return ( return (
@@ -1007,10 +1196,10 @@ export const GovernancePage: React.FC = () => {
setIsInstrumentsModalOpen(true); setIsInstrumentsModalOpen(true);
}} }}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer" className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="View Funding Instructions" title="View Funding Instruments"
> >
<FileText className="w-3 h-3" /> <Coins className="w-3 h-3" />
Instructions Instruments
</button> </button>
<button <button
type="button" type="button"
@@ -1044,9 +1233,9 @@ export const GovernancePage: React.FC = () => {
</div> </div>
)} )}
{/* Tab 4: Funding Instructions */} {/* Tab 4: Funding Instruments */}
{activeTab === 'instructions' && ( {(activeTab === 'instruments' || activeTab === 'instructions') && (
<InstructionsSection <FundingInstrumentsSection
programmes={programmes} programmes={programmes}
currentUserId={currentUserId} currentUserId={currentUserId}
preselectedProgrammeId={preselectedProgForInstruction} preselectedProgrammeId={preselectedProgForInstruction}
@@ -1476,26 +1665,26 @@ export const GovernancePage: React.FC = () => {
</form> </form>
</Modal> </Modal>
{/* Modal: View Funding Instructions */} {/* Modal: View Funding Instruments */}
<Modal <Modal
isOpen={isInstrumentsModalOpen} isOpen={isInstrumentsModalOpen}
onClose={() => { onClose={() => {
setIsInstrumentsModalOpen(false); setIsInstrumentsModalOpen(false);
setSelectedProgrammeForInstruments(null); setSelectedProgrammeForInstruments(null);
}} }}
title={`Funding Instructions: ${selectedProgrammeForInstruments?.name || ''}`} title={`Funding Instruments: ${selectedProgrammeForInstruments?.name || ''}`}
subtitle={`Authoritative funding instructions linked to Programme #${selectedProgrammeForInstruments?.id || ''}`} subtitle={`Authoritative funding instruments linked to Programme #${selectedProgrammeForInstruments?.id || ''}`}
> >
<div className="space-y-4"> <div className="space-y-4">
{loadingInstruments ? ( {loadingInstruments ? (
<div className="py-8 text-center text-xs text-slate-400 flex items-center justify-center gap-2"> <div className="py-8 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-blue-400" /> <RefreshCw className="w-4 h-4 animate-spin text-blue-400" />
<span>Loading funding instructions...</span> <span>Loading funding instruments...</span>
</div> </div>
) : programmeInstruments.length === 0 ? ( ) : programmeInstruments.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400 space-y-3"> <div className="py-8 text-center text-xs text-slate-400 space-y-3">
<div className="text-slate-500"> <div className="text-slate-500">
No funding instructions registered under this programme yet. No funding instruments registered under this programme yet.
</div> </div>
<button <button
type="button" type="button"
@@ -1505,12 +1694,12 @@ export const GovernancePage: React.FC = () => {
setSelectedProgrammeForInstruments(null); setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null); setPreselectedProgForInstruction(pId || null);
setIsInstructionCreateTriggered(true); setIsInstructionCreateTriggered(true);
handleTabChange('instructions'); handleTabChange('instruments');
}} }}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-md shadow-blue-500/20" className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-md shadow-blue-500/20"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Add Instruction to this Programme Add Instrument to this Programme
</button> </button>
</div> </div>
) : ( ) : (
@@ -1540,10 +1729,10 @@ export const GovernancePage: React.FC = () => {
setIsInstrumentsModalOpen(false); setIsInstrumentsModalOpen(false);
setSelectedProgrammeForInstruments(null); setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null); setPreselectedProgForInstruction(pId || null);
handleTabChange('instructions'); handleTabChange('instruments');
}} }}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 px-2 py-0.5 rounded cursor-pointer" className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 px-2 py-0.5 rounded cursor-pointer"
title="Manage in Instructions Tab" title="Manage in Funding Instruments Tab"
> >
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
Manage Manage
@@ -1571,12 +1760,12 @@ export const GovernancePage: React.FC = () => {
setSelectedProgrammeForInstruments(null); setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null); setPreselectedProgForInstruction(pId || null);
setIsInstructionCreateTriggered(true); setIsInstructionCreateTriggered(true);
handleTabChange('instructions'); handleTabChange('instruments');
}} }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Add Instruction Add Funding Instrument
</button> </button>
<button <button
type="button" type="button"
@@ -1765,10 +1954,10 @@ export const GovernancePage: React.FC = () => {
setIsInstrumentsModalOpen(true); setIsInstrumentsModalOpen(true);
}} }}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2 py-1 rounded-lg cursor-pointer" className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2 py-1 rounded-lg cursor-pointer"
title="View Funding Instructions" title="View Funding Instruments"
> >
<FileText className="w-3 h-3" /> <Coins className="w-3 h-3" />
Instructions Instruments
</button> </button>
<button <button
type="button" type="button"
@@ -2067,12 +2256,12 @@ export const GovernancePage: React.FC = () => {
</button> </button>
</div> </div>
{/* Total Instructions */} {/* Total Funding Instruments */}
<div className="p-3 bg-gradient-to-br from-blue-500/10 to-blue-500/5 rounded-xl border border-blue-500/20 flex flex-col justify-between"> <div className="p-3 bg-gradient-to-br from-blue-500/10 to-blue-500/5 rounded-xl border border-blue-500/20 flex flex-col justify-between">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-semibold text-blue-300 flex items-center gap-1.5"> <span className="text-[11px] font-semibold text-blue-300 flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-blue-400" /> <Coins className="w-3.5 h-3.5 text-blue-400" />
Total Instructions Total Funding Instruments
</span> </span>
<span className="text-lg font-bold font-mono text-blue-400"> <span className="text-lg font-bold font-mono text-blue-400">
{loadingDetailInstructions ? ( {loadingDetailInstructions ? (
@@ -2089,11 +2278,11 @@ export const GovernancePage: React.FC = () => {
setIsProgrammeDetailModalOpen(false); setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null); setSelectedProgrammeForDetail(null);
setPreselectedProgForInstruction(pId || null); setPreselectedProgForInstruction(pId || null);
handleTabChange('instructions'); handleTabChange('instruments');
}} }}
className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start" className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
> >
View in Instructions Tab &rarr; View in Funding Instruments Tab &rarr;
</button> </button>
</div> </div>
</div> </div>
+299 -144
View File
@@ -20,6 +20,8 @@ import {
CreateProgrammeRequest, CreateProgrammeRequest,
UpdateProgrammeRequest, UpdateProgrammeRequest,
Instrument, Instrument,
CreateInstrumentRequest,
UpdateInstrumentRequest,
Instruction, Instruction,
CreateInstructionRequest, CreateInstructionRequest,
UpdateInstructionRequest, UpdateInstructionRequest,
@@ -225,7 +227,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
return res.data; return res.data;
}; };
export const getPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => { export const getOrganisationPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
const targetOrgId = const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null organisationId !== undefined && organisationId !== '' && organisationId !== null
? organisationId ? organisationId
@@ -235,8 +237,31 @@ export const getPortfolios = async (organisationId?: string | number): Promise<P
return []; return [];
} }
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId); const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`); try {
return res.data || []; const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
return res.data || [];
} catch (err) {
// If hashed ID fails or is not recognized, attempt with raw targetOrgId
if (String(targetOrgId) !== String(hashedOrgId)) {
try {
const fallbackRes = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${targetOrgId}/portfolio`);
return fallbackRes.data || [];
} catch {
// ignore
}
}
console.warn('[getOrganisationPortfolios] Failed to retrieve portfolios for organisation:', err);
return [];
}
};
export const getPortfolios = async (
arg?: string | number | Pagination
): Promise<Portfolio[]> => {
if (arg === undefined || arg === null || typeof arg === 'object') {
return getAllPortfolios(typeof arg === 'object' ? arg : undefined);
}
return getOrganisationPortfolios(arg);
}; };
export const getPortfolio = async (portfolioId: string | number): Promise<Portfolio> => { export const getPortfolio = async (portfolioId: string | number): Promise<Portfolio> => {
@@ -245,23 +270,42 @@ export const getPortfolio = async (portfolioId: string | number): Promise<Portfo
return Array.isArray(res.data) ? res.data[0] : res.data; return Array.isArray(res.data) ? res.data[0] : res.data;
}; };
export const getAllPortfolios = async (): Promise<Portfolio[]> => { export const getAllPortfolios = async (
pagination: Pagination = { page: 1, pageSize: 100, index: 0 }
): Promise<Portfolio[]> => {
try { try {
const orgs = await getOrganisations(1, 10); const res = await apiClient.post<Portfolio[]>('/v1/api/governance/organisations/portfolios', {
if (orgs.length > 0) { index: pagination.index ? toIntegerId(pagination.index) : 0,
const results = await Promise.allSettled( page: pagination.page || 1,
orgs.map((org) => getPortfolios(org.id)) pageSize: pagination.pageSize || 100,
); });
const combined = results if (Array.isArray(res.data)) {
.filter((r): r is PromiseFulfilledResult<Portfolio[]> => r.status === 'fulfilled') return res.data;
.flatMap((r) => r.value); }
if (combined.length > 0) return combined; } catch (err) {
console.warn('[getAllPortfolios] POST /v1/api/governance/organisations/portfolios failed, attempting fallback:', err);
try {
const getRes = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolios', {
params: { page: pagination.page || 1, pageSize: pagination.pageSize || 100 },
});
if (Array.isArray(getRes.data)) {
return getRes.data;
}
} catch {
// Fallback to iterating organisations if bulk endpoint is unavailable
const orgs = await getOrganisations(1, 10);
if (orgs.length > 0) {
const results = await Promise.allSettled(
orgs.map((org) => getOrganisationPortfolios(org.id))
);
const combined = results
.filter((r): r is PromiseFulfilledResult<Portfolio[]> => r.status === 'fulfilled')
.flatMap((r) => r.value);
if (combined.length > 0) return combined;
}
} }
const res = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolio');
return res.data || [];
} catch {
return [];
} }
return [];
}; };
export const getPortfolioProgrammes = async (portfolioId: string | number): Promise<Programme[]> => { export const getPortfolioProgrammes = async (portfolioId: string | number): Promise<Programme[]> => {
@@ -314,7 +358,30 @@ export const getProgrammes = async (portfolioId: string | number): Promise<Progr
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions'; const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
export const getStoredInstructions = (): Instruction[] => { export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
if (!raw) return;
const local: Instrument[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
const apiIds = new Set(apiItems.map((i) => String(i.id)));
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored instruction duplicates', e);
}
};
export const getStoredInstructions = (): Instrument[] => {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
try { try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY); const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
@@ -324,11 +391,14 @@ export const getStoredInstructions = (): Instruction[] => {
} }
}; };
export const saveStoredInstruction = (inst: Instruction): void => { export const saveStoredInstruction = (inst: Instrument): void => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
const current = getStoredInstructions(); const current = getStoredInstructions();
const idx = current.findIndex((i) => String(i.id) === String(inst.id)); const idx = current.findIndex(
(i) => String(i.id) === String(inst.id) ||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
);
if (idx >= 0) { if (idx >= 0) {
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() }; current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
} else { } else {
@@ -340,31 +410,35 @@ export const saveStoredInstruction = (inst: Instruction): void => {
} }
}; };
export const getInstructions = async (programmeId: string | number): Promise<Instruction[]> => { export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
let apiItems: Instruction[] = []; let apiItems: Instrument[] = [];
let apiSucceeded = false;
try { try {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId); const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instruction[]>(`/v1/api/governance/instruments/${hashedProgId}`); const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
apiItems = res.data || []; if (Array.isArray(res.data)) {
apiItems = res.data;
apiSucceeded = true;
}
} catch (err) { } catch (err) {
console.warn(`[getInstructions] Could not fetch instruments from API for prog ${programmeId}:`, err); console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
} }
if (apiSucceeded) {
purgeStoredInstructionDuplicates(apiItems);
return apiItems;
}
// Fallback to local items only when API failed
const localItems = getStoredInstructions().filter( const localItems = getStoredInstructions().filter(
(item) => String(item.programmeId) === String(programmeId) (item) => String(item.programmeId) === String(programmeId)
); );
return localItems;
const map = new Map<string, Instruction>();
apiItems.forEach((item) => map.set(String(item.id), item));
localItems.forEach((item) => map.set(String(item.id), item));
return Array.from(map.values());
}; };
export const getInstruments = getInstructions; export const getInstructions = getInstruments;
export const getAllInstructions = async (programmesList?: Programme[]): Promise<Instruction[]> => { export const getAllInstruments = async (programmesList?: Programme[]): Promise<Instrument[]> => {
const localItems = getStoredInstructions();
let progList = programmesList; let progList = programmesList;
if (!progList || progList.length === 0) { if (!progList || progList.length === 0) {
try { try {
@@ -374,66 +448,103 @@ export const getAllInstructions = async (programmesList?: Programme[]): Promise<
} }
} }
const map = new Map<string, Instruction>(); const map = new Map<string, Instrument>();
localItems.forEach((item) => map.set(String(item.id), item)); let hasApiData = false;
if (progList && progList.length > 0) { if (progList && progList.length > 0) {
const results = await Promise.allSettled( const results = await Promise.allSettled(
progList.map((pr) => getInstructions(pr.id)) progList.map((pr) => getInstruments(pr.id))
); );
results.forEach((res) => { results.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) { if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
hasApiData = true;
res.value.forEach((inst) => map.set(String(inst.id), inst)); res.value.forEach((inst) => map.set(String(inst.id), inst));
} }
}); });
} }
return Array.from(map.values()); if (hasApiData || map.size > 0) {
const allApiInstruments = Array.from(map.values());
purgeStoredInstructionDuplicates(allApiInstruments);
return allApiInstruments;
}
return getStoredInstructions();
}; };
export const createInstruction = async (payload: CreateInstructionRequest): Promise<Instruction> => { export const getAllInstructions = getAllInstruments;
const newId = Date.now();
const newInstruction: Instruction = { export const createInstrument = async (payload: CreateInstrumentRequest): Promise<Instrument> => {
id: newId, const code = payload.code || `INST-${Date.now().toString().slice(-4)}`;
const body = {
programmeId: toIntegerId(payload.programmeId), programmeId: toIntegerId(payload.programmeId),
code: payload.code || `INST-${String(newId).slice(-4)}`, name: payload.name,
code: code,
description: payload.description,
};
try {
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
purgeStoredInstructionDuplicates([res.data]);
return res.data;
}
} catch (err: any) {
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackInstruction: Instrument = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
saveStoredInstruction(fallbackInstruction);
return fallbackInstruction;
}
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name, name: payload.name,
description: payload.description, description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active, status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system', createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
try {
await apiClient.post('/v1/api/governance/instruments', {
programmeId: toIntegerId(payload.programmeId),
code: newInstruction.code,
name: newInstruction.name,
description: newInstruction.description,
status: newInstruction.status,
});
} catch (err: any) {
try {
await apiClient.post('/v1/api/governance/instructions', {
programmeId: toIntegerId(payload.programmeId),
code: newInstruction.code,
name: newInstruction.name,
description: newInstruction.description,
status: newInstruction.status,
});
} catch (e) {
console.warn('[createInstruction] API create failed, persisted locally:', e);
}
}
saveStoredInstruction(newInstruction);
return newInstruction;
}; };
export const updateInstruction = async (payload: UpdateInstructionRequest): Promise<Instruction> => { export const createInstruction = createInstrument;
const existing = getStoredInstructions().find((i) => String(i.id) === String(payload.instructionId));
const updatedInstruction: Instruction = { export const updateInstrument = async (payload: UpdateInstrumentRequest): Promise<Instrument> => {
id: payload.instructionId, const targetId = payload.instructionId ?? payload.instrumentId ?? payload.id;
const numId = toIntegerId(targetId);
const body = {
instructionId: numId,
code: payload.code || '',
name: payload.name,
description: payload.description,
status: Number(payload.status),
};
try {
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
return res.data;
}
} catch (err) {
console.warn('[updateInstrument] API update failed:', err);
}
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
const updatedInstrument: Instrument = {
id: targetId!,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0), programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
code: payload.code || existing?.code || null, code: payload.code || existing?.code || null,
name: payload.name, name: payload.name,
@@ -445,35 +556,14 @@ export const updateInstruction = async (payload: UpdateInstructionRequest): Prom
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
try { if (existing) {
await apiClient.put('/v1/api/governance/instruments', { saveStoredInstruction(updatedInstrument);
instrumentId: toIntegerId(payload.instructionId),
instructionId: toIntegerId(payload.instructionId),
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : undefined,
code: payload.code,
name: payload.name,
description: payload.description,
status: Number(payload.status),
});
} catch (err) {
try {
await apiClient.put('/v1/api/governance/instructions', {
instructionId: toIntegerId(payload.instructionId),
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : undefined,
code: payload.code,
name: payload.name,
description: payload.description,
status: Number(payload.status),
});
} catch (e) {
console.warn('[updateInstruction] API update failed, updated locally:', e);
}
} }
return updatedInstrument;
saveStoredInstruction(updatedInstruction);
return updatedInstruction;
}; };
export const updateInstruction = updateInstrument;
// ========================================== // ==========================================
// 3. Project & Milestone Management APIs // 3. Project & Milestone Management APIs
// ========================================== // ==========================================
@@ -1610,6 +1700,29 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
const LOCAL_RULES_KEY = 'pfm_local_rules'; const LOCAL_RULES_KEY = 'pfm_local_rules';
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
if (!raw) return;
const local: Rule[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
const apiIds = new Set(apiRules.map((r) => String(r.id)));
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored rule duplicates', e);
}
};
export const getStoredRules = (): Rule[] => { export const getStoredRules = (): Rule[] => {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
try { try {
@@ -1624,7 +1737,10 @@ export const saveStoredRule = (rule: Rule): void => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
const current = getStoredRules(); const current = getStoredRules();
const idx = current.findIndex((r) => String(r.id) === String(rule.id)); const idx = current.findIndex(
(r) => String(r.id) === String(rule.id) ||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
);
if (idx >= 0) { if (idx >= 0) {
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() }; current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
} else { } else {
@@ -1640,15 +1756,28 @@ export const saveStoredRule = (rule: Rule): void => {
// 15. Execution: Programme Rules APIs // 15. Execution: Programme Rules APIs
// ========================================== // ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => { export const getRule = async (ruleId: string | number): Promise<Rule> => {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
try { try {
const hashedRuleId = await toolsService.encodeId(ruleId); const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`); const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
return res.data || localRule!; if (res.data) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch { } catch {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
if (localRule) return localRule; if (localRule) return localRule;
throw new Error('Rule not found'); throw new Error('Rule not found');
} }
throw new Error('Rule not found');
};
const toValidUuid = (userId?: string | null): string => {
if (!userId) return '00000000-0000-0000-0000-000000000000';
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (uuidPattern.test(userId)) return userId;
const userSub = getCurrentUserSub();
if (userSub && uuidPattern.test(userSub)) return userSub;
return '00000000-0000-0000-0000-000000000000';
}; };
export const getRules = async ( export const getRules = async (
@@ -1656,23 +1785,33 @@ export const getRules = async (
programmeId?: string | number programmeId?: string | number
): Promise<Rule[]> => { ): Promise<Rule[]> => {
let apiRules: Rule[] = []; let apiRules: Rule[] = [];
let apiSucceeded = false;
try { try {
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', { const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0, index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1, page: pagination.page || 1,
pageSize: pagination.pageSize || 100, pageSize: pagination.pageSize || 100,
}); });
apiRules = res.data || []; if (Array.isArray(res.data)) {
apiRules = res.data;
apiSucceeded = true;
}
} catch (err) { } catch (err) {
console.warn('[getRules] Error fetching from backend API:', err); console.warn('[getRules] Error fetching from backend API:', err);
} }
const localRules = getStoredRules(); if (apiSucceeded) {
const map = new Map<string, Rule>(); purgeStoredRuleDuplicates(apiRules);
apiRules.forEach((r) => map.set(String(r.id), r)); let result = apiRules;
localRules.forEach((r) => map.set(String(r.id), r)); if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return result;
}
let combined = Array.from(map.values()); // Fallback to local storage only if API call failed
const localRules = getStoredRules();
let combined = localRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') { if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
combined = combined.filter((r) => String(r.programmeId) === String(programmeId)); combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
} }
@@ -1680,60 +1819,76 @@ export const getRules = async (
}; };
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => { export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
const newId = Date.now(); const validUserId = toValidUuid(payload.userId);
const newRule: Rule = { const body = {
id: newId, programmeId: toIntegerId(payload.programmeId),
userId: validUserId,
name: payload.name,
description: payload.description,
};
try {
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
if (res.data && res.data.id) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch (err) {
console.warn('[createRule] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackRule: Rule = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
saveStoredRule(fallbackRule);
return fallbackRule;
}
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId), programmeId: toIntegerId(payload.programmeId),
name: payload.name, name: payload.name,
description: payload.description, description: payload.description,
status: ActivityStatus.Active, status: ActivityStatus.Active,
version: 1, version: 1,
createdBy: payload.userId, createdBy: validUserId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
try {
await apiClient.post('/v1/api/execution/rules', {
programmeId: toIntegerId(payload.programmeId),
userId: payload.userId,
name: payload.name,
description: payload.description,
});
} catch (err) {
console.warn('[createRule] API create failed, persisted locally:', err);
}
saveStoredRule(newRule);
return newRule;
}; };
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => { export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
const validUserId = toValidUuid(payload.userId);
try { try {
await apiClient.put('/v1/api/execution/rules', { await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId), ruleId: toIntegerId(payload.ruleId),
userId: payload.userId, userId: validUserId,
name: payload.name, name: payload.name,
description: payload.description, description: payload.description,
status: Number(payload.status), status: Number(payload.status),
version: Number(payload.version), version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}), ...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
}); });
} catch (err) { } catch (err) {
console.warn('[updateRule] API update failed, updated locally:', err); console.warn('[updateRule] API update failed:', err);
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
createdBy: existing?.createdBy || validUserId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: validUserId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
} }
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version),
createdBy: existing?.createdBy || payload.userId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
}; };
+9 -5
View File
@@ -385,9 +385,7 @@ export interface Instrument {
updatedAt?: string | null; updatedAt?: string | null;
} }
export type Instruction = Instrument; export interface CreateInstrumentRequest {
export interface CreateInstructionRequest {
programmeId: number | string; programmeId: number | string;
code?: string; code?: string;
name: string; name: string;
@@ -396,8 +394,10 @@ export interface CreateInstructionRequest {
userId?: string; userId?: string;
} }
export interface UpdateInstructionRequest { export interface UpdateInstrumentRequest {
instructionId: number | string; instructionId?: number | string;
instrumentId?: number | string;
id?: number | string;
programmeId?: number | string; programmeId?: number | string;
code?: string; code?: string;
name: string; name: string;
@@ -406,6 +406,10 @@ export interface UpdateInstructionRequest {
userId?: string; userId?: string;
} }
export type Instruction = Instrument;
export type CreateInstructionRequest = CreateInstrumentRequest;
export type UpdateInstructionRequest = UpdateInstrumentRequest;
export interface Project { export interface Project {
id: number | string; id: number | string;
programmeId: number | string; programmeId: number | string;
@@ -0,0 +1,247 @@
import * as XLSX from 'xlsx';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
export interface ExportColumn<T = any> {
header: string;
key: keyof T | string;
format?: (value: any, row: T) => string | number;
}
export interface ExportOptions<T = any> {
title: string;
subtitle?: string;
filename: string;
columns: ExportColumn<T>[];
data: T[];
tenantName?: string;
userEmail?: string;
}
/**
* Draws the official PFM Shield and Brand Header on jsPDF in landscape mode
*/
function drawPfmPdfHeader(
doc: jsPDF,
title: string,
subtitle?: string,
tenantName?: string,
userEmail?: string
): number {
const pageWidth = doc.internal.pageSize.getWidth();
// Dark header banner
doc.setFillColor(13, 18, 31); // #0d121f
doc.rect(0, 0, pageWidth, 68, 'F');
// Cyan-to-indigo accent bottom border line
doc.setFillColor(37, 99, 235); // #2563eb
doc.rect(0, 66, pageWidth, 2, 'F');
// Draw PFM Logo Shield Icon (Vector art)
const logoX = 24;
const logoY = 16;
// Background circle for logo
doc.setFillColor(15, 23, 42); // slate-900
doc.circle(logoX + 16, logoY + 16, 16, 'F');
doc.setDrawColor(56, 189, 248); // sky-400
doc.setLineWidth(1);
doc.circle(logoX + 16, logoY + 16, 16, 'S');
// Top facet (Violet/Indigo)
doc.setFillColor(99, 102, 241);
doc.triangle(
logoX + 16, logoY + 6,
logoX + 8, logoY + 14,
logoX + 24, logoY + 14,
'F'
);
// Left facet (Sky/Sapphire)
doc.setFillColor(14, 165, 233);
doc.triangle(
logoX + 8, logoY + 14,
logoX + 16, logoY + 26,
logoX + 16, logoY + 14,
'F'
);
// Right facet (Emerald/Cyan)
doc.setFillColor(16, 185, 129);
doc.triangle(
logoX + 24, logoY + 14,
logoX + 16, logoY + 26,
logoX + 16, logoY + 14,
'F'
);
// Core Diamond
doc.setFillColor(255, 255, 255);
doc.circle(logoX + 16, logoY + 14, 2.5, 'F');
// Brand Name & Subtitle
doc.setTextColor(255, 255, 255);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('POST FUND MANAGEMENT (PFM)', logoX + 40, logoY + 13);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(148, 163, 184); // slate-400
doc.text('National Grants & Institutional Governance Platform', logoX + 40, logoY + 24);
// Right-aligned Metadata (Date, Tenant, User)
doc.setFontSize(8);
doc.setTextColor(203, 213, 225);
const nowStr = new Date().toLocaleString();
doc.text(`Generated: ${nowStr}`, pageWidth - 24, logoY + 8, { align: 'right' });
if (tenantName) {
doc.text(`Tenant: ${tenantName}`, pageWidth - 24, logoY + 18, { align: 'right' });
}
if (userEmail) {
doc.text(`Exported by: ${userEmail}`, pageWidth - 24, logoY + 28, { align: 'right' });
}
// Section Title on page
doc.setFontSize(13);
doc.setFont('helvetica', 'bold');
doc.setTextColor(15, 23, 42); // slate-900
doc.text(title, 24, 90);
let currentY = 94;
if (subtitle) {
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(100, 116, 139); // slate-500
doc.text(subtitle, 24, 104);
currentY = 110;
} else {
currentY = 100;
}
return currentY;
}
/**
* Export tabular data to an Excel / Spreadsheet file (.xlsx)
*/
export function exportToSpreadsheet<T>(options: ExportOptions<T>): void {
const { title, filename, columns, data } = options;
if (!data || data.length === 0) {
console.warn('No data available to export to spreadsheet.');
return;
}
// Format table rows based on columns config
const rows = data.map((item) => {
const rowObj: Record<string, any> = {};
columns.forEach((col) => {
const rawVal = (item as any)[col.key];
const formatted = col.format ? col.format(rawVal, item) : (rawVal ?? '');
rowObj[col.header] = formatted;
});
return rowObj;
});
const worksheet = XLSX.utils.json_to_sheet(rows);
// Set column widths based on longest cell content
const colWidths = columns.map((col) => {
const headerLen = col.header.length;
const maxDataLen = data.reduce((max, item) => {
const rawVal = (item as any)[col.key];
const formatted = col.format ? String(col.format(rawVal, item)) : String(rawVal ?? '');
return Math.max(max, formatted.length);
}, headerLen);
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
});
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
const safeSheetName = title.replace(/[:\\/?*[\]]/g, ' ').substring(0, 31).trim() || 'Data';
XLSX.utils.book_append_sheet(workbook, worksheet, safeSheetName);
const cleanFilename = filename.endsWith('.xlsx') ? filename : `${filename}.xlsx`;
XLSX.writeFile(workbook, cleanFilename);
}
/**
* Export tabular data to a landscape PDF file with PFM Logo and branding
*/
export function exportToPdf<T>(options: ExportOptions<T>): void {
const { title, subtitle, filename, columns, data, tenantName, userEmail } = options;
if (!data || data.length === 0) {
console.warn('No data available to export to PDF.');
return;
}
// Landscape A4 doc: width: 841.89 pt, height: 595.28 pt
const doc = new jsPDF({
orientation: 'landscape',
unit: 'pt',
format: 'a4',
});
const startY = drawPfmPdfHeader(doc, title, subtitle, tenantName, userEmail);
// Build table headers and rows
const headers = [columns.map((c) => c.header)];
const body = data.map((item) => {
return columns.map((col) => {
const rawVal = (item as any)[col.key];
return col.format ? String(col.format(rawVal, item)) : String(rawVal ?? '');
});
});
autoTable(doc, {
head: headers,
body: body,
startY: startY + 6,
margin: { left: 24, right: 24, bottom: 32 },
styles: {
font: 'helvetica',
fontSize: 8,
cellPadding: 6,
textColor: [30, 41, 59], // slate-800
lineColor: [226, 232, 240], // slate-200
lineWidth: 0.5,
overflow: 'linebreak',
},
headStyles: {
fillColor: [15, 23, 42], // slate-900
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 8.5,
},
alternateRowStyles: {
fillColor: [248, 250, 252], // slate-50
},
didDrawPage: (dataArg) => {
// Footer on all pages
const pageCount = (doc.internal as any).getNumberOfPages();
const currentPage = dataArg.pageNumber;
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
doc.setFontSize(7.5);
doc.setTextColor(148, 163, 184); // slate-400
doc.text(
'CONFIDENTIAL & PROPRIETARY — POST FUND MANAGEMENT (PFM) PLATFORM',
24,
pageHeight - 14
);
doc.text(
`Page ${currentPage} of ${pageCount}`,
pageWidth - 24,
pageHeight - 14,
{ align: 'right' }
);
},
});
const cleanFilename = filename.endsWith('.pdf') ? filename : `${filename}.pdf`;
doc.save(cleanFilename);
}
+222 -4
View File
@@ -130,6 +130,11 @@
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.29.7" "@babel/helper-plugin-utils" "^7.29.7"
"@babel/runtime@^7.12.5", "@babel/runtime@^7.28.6":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768"
integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==
"@babel/template@^7.29.7": "@babel/template@^7.29.7":
version "7.29.7" version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
@@ -903,11 +908,21 @@
dependencies: dependencies:
undici-types "~6.21.0" undici-types "~6.21.0"
"@types/pako@^2.0.3":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/pako/-/pako-2.0.4.tgz#c3575ef8125e176c345fa0e7b301c1db41170c15"
integrity sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==
"@types/qs@*": "@types/qs@*":
version "6.15.1" version "6.15.1"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.15.1.tgz#8606884272c63f0db96986bd3548650d8a9388bf" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.15.1.tgz#8606884272c63f0db96986bd3548650d8a9388bf"
integrity sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw== integrity sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==
"@types/raf@^3.4.0":
version "3.4.3"
resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.3.tgz#85f1d1d17569b28b8db45e16e996407a56b0ab04"
integrity sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==
"@types/range-parser@*": "@types/range-parser@*":
version "1.2.7" version "1.2.7"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
@@ -942,6 +957,16 @@
"@types/node" "*" "@types/node" "*"
"@types/send" "<1" "@types/send" "<1"
"@types/trusted-types@^2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
"@vitejs/plugin-basic-ssl@^2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz#f505b1c197d8cb4fd6e213a78847574ecc51e2f9"
integrity sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==
"@vitejs/plugin-react@^5.0.4": "@vitejs/plugin-react@^5.0.4":
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz#108bd0f566f288ce3566982df4eff137ded7b15f" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz#108bd0f566f288ce3566982df4eff137ded7b15f"
@@ -962,6 +987,11 @@ accepts@~1.3.8:
mime-types "~2.1.34" mime-types "~2.1.34"
negotiator "0.6.3" negotiator "0.6.3"
adler-32@~1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/adler-32/-/adler-32-1.3.1.tgz#1dbf0b36dda0012189a32b3679061932df1821e2"
integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==
agent-base@6: agent-base@6:
version "6.0.2" version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
@@ -1005,6 +1035,11 @@ axios@^1.20.0:
https-proxy-agent "^5.0.1" https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0" proxy-from-env "^2.1.0"
base64-arraybuffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
base64-js@^1.3.0: base64-js@^1.3.0:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -1080,6 +1115,33 @@ caniuse-lite@^1.0.30001810:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2"
integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg== integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==
canvg@^3.0.11:
version "3.0.11"
resolved "https://registry.yarnpkg.com/canvg/-/canvg-3.0.11.tgz#4b4290a6c7fa36871fac2b14e432eff33b33cf2b"
integrity sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==
dependencies:
"@babel/runtime" "^7.12.5"
"@types/raf" "^3.4.0"
core-js "^3.8.3"
raf "^3.4.1"
regenerator-runtime "^0.13.7"
rgbcolor "^1.0.1"
stackblur-canvas "^2.0.0"
svg-pathdata "^6.0.3"
cfb@~1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/cfb/-/cfb-1.2.2.tgz#94e687628c700e5155436dac05f74e08df23bc44"
integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==
dependencies:
adler-32 "~1.3.0"
crc-32 "~1.2.0"
codepage@~1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/codepage/-/codepage-1.15.0.tgz#2e00519024b39424ec66eeb3ec07227e692618ab"
integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
combined-stream@^1.0.8: combined-stream@^1.0.8:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@@ -1119,6 +1181,23 @@ cookie@~0.7.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
core-js@^3.6.0, core-js@^3.8.3:
version "3.50.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.50.0.tgz#a18646fcb0097650189b4504c803e887bcae4c0a"
integrity sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==
crc-32@~1.2.0, crc-32@~1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff"
integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==
css-line-break@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
dependencies:
utrie "^1.0.2"
data-uri-to-buffer@^4.0.0: data-uri-to-buffer@^4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
@@ -1158,6 +1237,13 @@ detect-libc@^2.0.3:
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
dompurify@^3.3.1:
version "3.4.16"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.16.tgz#c51b3709ea15ee9abb07e804f2ef30ea9b953128"
integrity sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
dotenv@^17.2.3: dotenv@^17.2.3:
version "17.4.2" version "17.4.2"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.4.2.tgz#c07e54a746e11eba021dd9e1047ced5afdc1c034" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.4.2.tgz#c07e54a746e11eba021dd9e1047ced5afdc1c034"
@@ -1350,6 +1436,15 @@ extend@^3.0.2:
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
fast-png@^6.2.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/fast-png/-/fast-png-6.4.0.tgz#807fc353ccab060d09151b7d082786e02d8e92d6"
integrity sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==
dependencies:
"@types/pako" "^2.0.3"
iobuffer "^5.3.2"
pako "^2.1.0"
fdir@^6.4.4, fdir@^6.5.0: fdir@^6.4.4, fdir@^6.5.0:
version "6.5.0" version "6.5.0"
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
@@ -1363,6 +1458,11 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4:
node-domexception "^1.0.0" node-domexception "^1.0.0"
web-streams-polyfill "^3.0.3" web-streams-polyfill "^3.0.3"
fflate@^0.8.1:
version "0.8.3"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc"
integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==
finalhandler@~1.3.1: finalhandler@~1.3.1:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88"
@@ -1404,6 +1504,11 @@ forwarded@0.2.0:
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
frac@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b"
integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
fraction.js@^5.3.4: fraction.js@^5.3.4:
version "5.3.4" version "5.3.4"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a"
@@ -1536,6 +1641,14 @@ hasown@^2.0.2, hasown@^2.0.4:
dependencies: dependencies:
function-bind "^1.1.2" function-bind "^1.1.2"
html2canvas@^1.0.0-rc.5:
version "1.4.1"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
dependencies:
css-line-break "^2.1.0"
text-segmentation "^1.0.3"
http-errors@~2.0.0, http-errors@~2.0.1: http-errors@~2.0.0, http-errors@~2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
@@ -1575,6 +1688,11 @@ inherits@~2.0.4:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
iobuffer@^5.3.2:
version "5.4.0"
resolved "https://registry.yarnpkg.com/iobuffer/-/iobuffer-5.4.0.tgz#f85dff957fd0579257472f0a4cfe5ed3430e63e1"
integrity sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
version "1.9.1" version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -1607,6 +1725,25 @@ json5@^2.2.3:
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jspdf-autotable@^5.0.8:
version "5.0.8"
resolved "https://registry.yarnpkg.com/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz#b010dab34caf5eff60bbcd09a36d0608dc0a84ae"
integrity sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==
jspdf@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/jspdf/-/jspdf-4.2.1.tgz#6ba0d263999313f91f369ee80ecf235046b2acd8"
integrity sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==
dependencies:
"@babel/runtime" "^7.28.6"
fast-png "^6.2.0"
fflate "^0.8.1"
optionalDependencies:
canvg "^3.0.11"
core-js "^3.6.0"
dompurify "^3.3.1"
html2canvas "^1.0.0-rc.5"
jwa@^2.0.1: jwa@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804"
@@ -1850,6 +1987,11 @@ p-retry@^4.6.2:
"@types/retry" "0.12.0" "@types/retry" "0.12.0"
retry "^0.13.1" retry "^0.13.1"
pako@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/pako/-/pako-2.2.0.tgz#246b9d4c841a8d308e484c0d03786651b143e63a"
integrity sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==
parseurl@~1.3.3: parseurl@~1.3.3:
version "1.3.3" version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -1860,6 +2002,11 @@ path-to-regexp@~0.1.12:
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d"
integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
picocolors@^1.1.1: picocolors@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@@ -1930,6 +2077,13 @@ qs@~6.16.0:
es-define-property "^1.0.1" es-define-property "^1.0.1"
side-channel "^1.1.1" side-channel "^1.1.1"
raf@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==
dependencies:
performance-now "^2.1.0"
range-parser@~1.2.1: range-parser@~1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
@@ -1982,11 +2136,21 @@ react@^19.0.1:
resolved "https://registry.yarnpkg.com/react/-/react-19.3.0.tgz#e20aa3a14687075ce863351b5878b0608c3fbfb2" resolved "https://registry.yarnpkg.com/react/-/react-19.3.0.tgz#e20aa3a14687075ce863351b5878b0608c3fbfb2"
integrity sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog== integrity sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==
regenerator-runtime@^0.13.7:
version "0.13.11"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
retry@^0.13.1: retry@^0.13.1:
version "0.13.1" version "0.13.1"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658"
integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==
rgbcolor@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/rgbcolor/-/rgbcolor-1.0.1.tgz#d6505ecdb304a6595da26fa4b43307306775945d"
integrity sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==
rollup@^4.34.9: rollup@^4.34.9:
version "4.63.1" version "4.63.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.63.1.tgz#a9b96d5b2558d034babb12ad8b67a043bc870ac4" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.63.1.tgz#a9b96d5b2558d034babb12ad8b67a043bc870ac4"
@@ -2126,11 +2290,28 @@ source-map-js@^1.2.1:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
ssf@~0.11.2:
version "0.11.2"
resolved "https://registry.yarnpkg.com/ssf/-/ssf-0.11.2.tgz#0b99698b237548d088fc43cdf2b70c1a7512c06c"
integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
dependencies:
frac "~1.1.2"
stackblur-canvas@^2.0.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz#af931277d0b5096df55e1f91c530043e066989b6"
integrity sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==
statuses@~2.0.1, statuses@~2.0.2: statuses@~2.0.1, statuses@~2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
svg-pathdata@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz#80b0e0283b652ccbafb69ad4f8f73e8d3fbf2cac"
integrity sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==
tailwindcss@4.3.3, tailwindcss@^4.1.14: tailwindcss@4.3.3, tailwindcss@^4.1.14:
version "4.3.3" version "4.3.3"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55"
@@ -2141,6 +2322,13 @@ tapable@^2.3.3:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A== integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
text-segmentation@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
dependencies:
utrie "^1.0.2"
tinyglobby@^0.2.13: tinyglobby@^0.2.13:
version "0.2.17" version "0.2.17"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
@@ -2204,6 +2392,13 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
utrie@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
dependencies:
base64-arraybuffer "^1.0.2"
vary@~1.1.2: vary@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
@@ -2228,17 +2423,40 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
wmf@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wmf/-/wmf-1.0.2.tgz#7d19d621071a08c2bdc6b7e688a9c435298cc2da"
integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
word@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/word/-/word-0.3.0.tgz#8542157e4f8e849f4a363a288992d47612db9961"
integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
ws@^8.18.0: ws@^8.18.0:
version "8.21.3" version "8.21.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc" resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc"
integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw== integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==
xlsx@^0.18.5:
version "0.18.5"
resolved "https://registry.yarnpkg.com/xlsx/-/xlsx-0.18.5.tgz#16711b9113c848076b8a177022799ad356eba7d0"
integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==
dependencies:
adler-32 "~1.3.0"
cfb "~1.2.1"
codepage "~1.15.0"
crc-32 "~1.2.1"
ssf "~0.11.2"
wmf "~1.0.1"
word "~0.3.0"
yallist@^3.0.2: yallist@^3.0.2:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
zod@^4.5.4: zod@^3.25.76:
version "4.6.2" version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.6.2.tgz#91b2ce79ec994eaa42675499166587d3d223c961" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ== integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==