diff --git a/backend/migrations/manual/026_prune_unused_staff_roles.sql b/backend/migrations/manual/026_prune_unused_staff_roles.sql new file mode 100644 index 0000000..687a8e1 --- /dev/null +++ b/backend/migrations/manual/026_prune_unused_staff_roles.sql @@ -0,0 +1,24 @@ +-- 026: The organisation runs four staff roles — system_administrator, +-- recruiter, hiring_manager, department_head. Soft-delete the unused seeded +-- staff roles (hr_administrator, interviewer, ceo) so they stop appearing in +-- the Access Control screen and every role picker (listings filter on +-- is_deleted). +-- +-- NOT touched: +-- * candidate — not a staff role: every applicant account is a role-8 user +-- and the Candidates screen is keyed to it. +-- * any role that still has live members — pruning a role out from under a +-- user would strand their permissions; such a role keeps working until +-- the members are reassigned by hand, and this migration (idempotent) +-- picks it up on a later boot. +UPDATE app.roles r +SET is_deleted = TRUE, + is_active = FALSE, + updated_at = NOW() +WHERE r.role_name IN ('hr_administrator', 'interviewer', 'ceo') + AND r.is_deleted = FALSE + AND NOT EXISTS ( + SELECT 1 FROM app.users u + WHERE u.role_id = r.id + AND COALESCE(u.is_deleted, FALSE) = FALSE + ); diff --git a/backend/migrations/manual/027_consolidate_manager_role.sql b/backend/migrations/manual/027_consolidate_manager_role.sql new file mode 100644 index 0000000..f674007 --- /dev/null +++ b/backend/migrations/manual/027_consolidate_manager_role.sql @@ -0,0 +1,29 @@ +-- 027: The hand-made Access Control role "Manager" duplicates the seeded +-- hiring_manager, which has carried the manager_candidates bundle since 024. +-- Consolidate: move its live members onto hiring_manager, then soft-delete +-- it. Both steps are idempotent and guarded; the seeded hiring_manager row +-- is never matched (name check excludes it, and it is is_system). +UPDATE app.users u +SET role_id = hm.id, + updated_at = NOW() +FROM app.roles hm +WHERE hm.role_name = 'hiring_manager' AND hm.is_deleted = FALSE + AND u.role_id IN ( + SELECT r.id FROM app.roles r + WHERE lower(r.role_name) = 'manager' + AND r.role_name <> 'hiring_manager' + AND r.is_deleted = FALSE + ) + AND COALESCE(u.is_deleted, FALSE) = FALSE; + +UPDATE app.roles r +SET is_deleted = TRUE, + is_active = FALSE, + updated_at = NOW() +WHERE lower(r.role_name) = 'manager' + AND r.role_name <> 'hiring_manager' + AND r.is_deleted = FALSE + AND NOT EXISTS ( + SELECT 1 FROM app.users u + WHERE u.role_id = r.id AND COALESCE(u.is_deleted, FALSE) = FALSE + ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b4fe592..7c9df49 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.4", "@tanstack/react-query-devtools": "^5.101.4", + "exceljs": "^4.4.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.0" @@ -970,6 +971,47 @@ } } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1663,6 +1705,91 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -1676,6 +1803,12 @@ "node": ">=4" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -1691,6 +1824,12 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/bare-events": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", @@ -1780,7 +1919,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -1830,6 +1968,55 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -1868,7 +2055,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -1893,12 +2079,28 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" } }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -1920,6 +2122,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chromium-bidi": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", @@ -1969,6 +2183,27 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1989,6 +2224,37 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2042,6 +2308,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2089,6 +2361,45 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.400", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", @@ -2107,7 +2418,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2244,6 +2554,38 @@ "bare-events": "^2.7.0" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -2265,6 +2607,19 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -2300,6 +2655,18 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2315,6 +2682,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2366,6 +2749,33 @@ "node": ">= 14" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2411,7 +2821,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -2428,6 +2837,29 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.7.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", @@ -2455,6 +2887,12 @@ "dev": true, "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2539,6 +2977,184 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2556,6 +3172,27 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2563,6 +3200,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2609,11 +3258,19 @@ "node": ">=18" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -2653,6 +3310,12 @@ "node": ">= 14" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2666,6 +3329,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -2722,6 +3394,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2877,6 +3555,50 @@ "react-dom": ">=18" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2897,6 +3619,19 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -2943,6 +3678,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -2978,6 +3733,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -3052,6 +3813,15 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3179,6 +3949,15 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", @@ -3205,6 +3984,15 @@ "node": ">=20" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3248,6 +4036,54 @@ "license": "MIT", "optional": true }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3279,6 +4115,22 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -3908,7 +4760,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -3947,7 +4798,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/y18n": { @@ -4007,6 +4857,41 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2120f50..1ab5aea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.4", "@tanstack/react-query-devtools": "^5.101.4", + "exceljs": "^4.4.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.0" diff --git a/frontend/src/app/Topbar.jsx b/frontend/src/app/Topbar.jsx index 1256fd2..4d36506 100644 --- a/frontend/src/app/Topbar.jsx +++ b/frontend/src/app/Topbar.jsx @@ -10,6 +10,7 @@ import { useTheme } from '../theme/ThemeProvider' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' +import { formatRole } from '../lib/format' import * as notificationsApi from '../api/notifications' function initialsFromName(name) { @@ -55,7 +56,7 @@ export default function Topbar({ onOpenNav, searchRef }) { const name = user?.name || 'Guest' const email = user?.email || '' - const role = user?.role_name || user?.role || 'Member' + const role = formatRole(user?.role_name || user?.role) || 'Member' function openNotif(n) { if (n.unread) markOne.mutate(n.id) diff --git a/frontend/src/lib/exportXlsx.js b/frontend/src/lib/exportXlsx.js new file mode 100644 index 0000000..d5fb919 --- /dev/null +++ b/frontend/src/lib/exportXlsx.js @@ -0,0 +1,100 @@ +/* ============================================================ + exportXlsx.js — brand-styled spreadsheet exports. + + One shared exporter so every "Export" button produces the same + dashboard-flavoured file: TalentFlow green title band, mint meta row, + ink-teal header, zebra data rows, frozen header + autofilter. + + exceljs is ~1MB, so it is imported dynamically — Vite splits it into + its own chunk that only ever downloads when an export is clicked. + ============================================================ */ + +/** Utopia Brands palette (ARGB, from styles.css brand constants). */ +const BRAND = { + green: 'FF004D43', // deep green — title band, like the sidebar/action colour + ink: 'FF1A3134', // ink teal — header row + lime: 'FFCEFF71', // signature lime — title text accent + mint: 'FFEAFFF4', // mint white — meta row fill + zebra: 'FFF1F7F4', // app background — alternating data rows + border: 'FFDBE8E2', // hairline borders + text: 'FF10231F', + sub: 'FF4A625C', + white: 'FFFFFFFF', +} + +const thin = { style: 'thin', color: { argb: BRAND.border } } + +/** + * Build and download a styled .xlsx. + * + * @param {object} opts + * @param {string} opts.filename without extension + * @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox" + * @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026" + * @param {Array<{header: string, key: string, width?: number}>} opts.columns + * @param {Array} opts.rows keyed by columns[].key; null/undefined print blank + */ +export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) { + const ExcelJS = (await import('exceljs')).default + const wb = new ExcelJS.Workbook() + wb.creator = 'TalentFlow ATS' + wb.created = new Date() + + const ws = wb.addWorksheet(title.slice(0, 31) || 'Export', { + views: [{ state: 'frozen', ySplit: 3 }], + }) + + ws.columns = columns.map((c) => ({ key: c.key, width: c.width ?? 18 })) + const span = columns.length + + // Row 1 — brand title band + const titleRow = ws.addRow([title]) + ws.mergeCells(1, 1, 1, span) + titleRow.height = 30 + const titleCell = ws.getCell(1, 1) + titleCell.font = { name: 'Calibri', size: 14, bold: true, color: { argb: BRAND.lime } } + titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.green } } + titleCell.alignment = { vertical: 'middle', indent: 1 } + + // Row 2 — meta line + const metaRow = ws.addRow([subtitle]) + ws.mergeCells(2, 1, 2, span) + metaRow.height = 20 + const metaCell = ws.getCell(2, 1) + metaCell.font = { name: 'Calibri', size: 10, color: { argb: BRAND.sub } } + metaCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.mint } } + metaCell.alignment = { vertical: 'middle', indent: 1 } + + // Row 3 — column headers + const headRow = ws.addRow(columns.map((c) => c.header)) + headRow.height = 22 + headRow.eachCell((cell) => { + cell.font = { name: 'Calibri', size: 10, bold: true, color: { argb: BRAND.white } } + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.ink } } + cell.alignment = { vertical: 'middle' } + cell.border = { bottom: { style: 'medium', color: { argb: BRAND.lime } } } + }) + + // Data — zebra rows with hairline borders + for (const [i, r] of rows.entries()) { + const row = ws.addRow(columns.map((c) => r[c.key] ?? '')) + row.eachCell({ includeEmpty: true }, (cell) => { + cell.font = { name: 'Calibri', size: 10, color: { argb: BRAND.text } } + if (i % 2 === 1) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.zebra } } + cell.border = { bottom: thin, right: thin } + cell.alignment = { vertical: 'middle', wrapText: false } + }) + } + + ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: span } } + + const buf = await wb.xlsx.writeBuffer() + const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `${filename}.xlsx` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(a.href) +} diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js new file mode 100644 index 0000000..97ec6a7 --- /dev/null +++ b/frontend/src/lib/format.js @@ -0,0 +1,11 @@ +/** Display formatting for identifiers stored snake_case in the DB. */ + +/** 'system_administrator' → 'System Administrator', 'INTERVIEW' → 'Interview'. */ +export function formatRole(name) { + const s = String(name || '').trim() + if (!s) return s + return s + .split(/[_\s]+/) + .map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w)) + .join(' ') +} diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 36b1f76..4855899 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -23,6 +23,8 @@ import { isHiringManager } from '../auth/permissions' import CandidateProfile from './CandidateProfile' import { useJobTitles } from './ScoredCandidateProfile' import { qk } from '../lib/queryKeys' +import { exportStyledXlsx } from '../lib/exportXlsx' +import { formatRole } from '../lib/format' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' @@ -484,7 +486,39 @@ function RecruiterCandidates() { title="Candidates" sub={<>{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}} actions={<> - + )}
{n > 0 ? ( <> @@ -727,7 +743,7 @@ export default function Inbox() { const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const updateInbox = useSeedMutation('inbox') - const [channel, setChannel] = useState('email') + const [channel, setChannel] = useState('all') const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET) const [tab, setTab] = useState('All Applications') const [skip, setSkip] = useState(0) @@ -738,25 +754,31 @@ export default function Inbox() { const [noting, setNoting] = useState(null) const isForms = channel === 'forms' - const channelTabs = isForms ? FORM_TABS : TABS + // Combined channel: both sources fetched UNPAGED (each endpoint reads a + // missing top/limit as no LIMIT), merged by date, and paged client-side — + // per-source skip/top cannot compose into a correct global page. + const isAllChannel = channel === 'all' + const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS const tabFilter = TAB_FILTERS[tab] ?? {} const formTabFilter = FORM_TAB_FILTERS[tab] ?? {} const listParams = useMemo(() => ({ ...tabFilter, - // 'all' drops the param entirely — the endpoint reads a missing top as unpaged. - top: pageSize === 'all' ? undefined : pageSize, - skip, + // 'all' page size and the All channel drop the param entirely — the + // endpoint reads a missing top as unpaged. + top: pageSize === 'all' || isAllChannel ? undefined : pageSize, + skip: isAllChannel ? 0 : skip, ...(q.trim() ? { search: q.trim() } : {}), - }), [tabFilter, skip, pageSize, q]) + }), [tabFilter, skip, pageSize, q, isAllChannel]) const formParams = useMemo(() => ({ - sheet: formSheet || undefined, - offset: skip, - limit: pageSize === 'all' ? undefined : pageSize, + // All channel spans every sheet tab, not just the selected one. + sheet: isAllChannel ? undefined : (formSheet || undefined), + offset: isAllChannel ? 0 : skip, + limit: pageSize === 'all' || isAllChannel ? undefined : pageSize, ...formTabFilter, ...(q.trim() ? { search: q.trim() } : {}), - }), [formSheet, skip, pageSize, q, formTabFilter]) + }), [formSheet, skip, pageSize, q, formTabFilter, isAllChannel]) const applicationsQuery = useQuery({ queryKey: qk.mailbox.applications(listParams), @@ -777,7 +799,7 @@ export default function Inbox() { const formQuery = useQuery({ queryKey: qk.mailbox.formData(formParams), queryFn: () => fetchFormApplications(formParams), - enabled: isForms, + enabled: isForms || isAllChannel, }) const countsQuery = useQuery({ @@ -786,13 +808,14 @@ export default function Inbox() { enabled: !isForms, }) + const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined) const formCountsQuery = useQuery({ - queryKey: qk.mailbox.formCounts({ sheet: formSheet || undefined }), + queryKey: qk.mailbox.formCounts({ sheet: formCountsSheet }), queryFn: async () => { - const res = await sheetApi.fetchFormCounts({ sheet: formSheet || undefined }) + const res = await sheetApi.fetchFormCounts({ sheet: formCountsSheet }) return res?.data ?? {} }, - enabled: isForms, + enabled: isForms || isAllChannel, }) const emailTotalQuery = useQuery({ @@ -806,12 +829,12 @@ export default function Inbox() { }) const formTotalQuery = useQuery({ - queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }), + queryKey: qk.mailbox.formTotal({ sheet: formCountsSheet }), queryFn: async () => { - const res = await sheetApi.countFormData({ sheet: formSheet || undefined }) + const res = await sheetApi.countFormData({ sheet: formCountsSheet }) return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0) }, - enabled: isForms, + enabled: isForms || isAllChannel, staleTime: Infinity, }) @@ -830,27 +853,55 @@ export default function Inbox() { } }, [isForms, formSheetsQuery.data, formSheet]) - const activeQuery = isForms ? formQuery : applicationsQuery + // All channel: both sources arrive unpaged; merge newest-first and let the + // pager slice the merged array below. + const mergedRows = useMemo(() => { + if (!isAllChannel) return null + const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : [] + const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : [] + return [...emails, ...forms].sort( + (a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0), + ) + }, [isAllChannel, applicationsQuery.data, formQuery.data]) + + const activeQuery = isAllChannel + ? { + isPending: applicationsQuery.isPending || formQuery.isPending, + // one healthy source still renders; error only when both are down + isError: applicationsQuery.isError && formQuery.isError, + isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess, + error: applicationsQuery.error ?? formQuery.error, + data: { rows: mergedRows ?? [], total: mergedRows?.length ?? 0 }, + } + : (isForms ? formQuery : applicationsQuery) const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : [] - const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {}) const counts = useMemo( () => { const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) + const e = countsQuery.data ?? {} + const f = formCountsQuery.data ?? {} + const pick = (key) => (isAllChannel + ? n(e[key]) + n(f[key]) + : n((isForms ? f : e)[key])) return { - 'All Applications': n(serverCounts.all), - Unread: n(serverCounts.unread), - Processed: n(serverCounts.processed), - Rejected: n(serverCounts.rejected), - Duplicates: n(serverCounts.duplicates), + 'All Applications': pick('all'), + Unread: pick('unread'), + Processed: pick('processed'), + Rejected: pick('rejected'), + Duplicates: pick('duplicates'), } }, - [serverCounts], + [countsQuery.data, formCountsQuery.data, isForms, isAllChannel], ) - const poolTotal = isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0) + const poolTotal = isAllChannel + ? (emailTotalQuery.data ?? 0) + (formTotalQuery.data ?? 0) + : (isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0)) const tabTotal = counts[tab] ?? 0 - const countsReady = isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess + const countsReady = isAllChannel + ? countsQuery.isSuccess && formCountsQuery.isSuccess + : (isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess) const total = q.trim() ? (activeQuery.data?.total ?? 0) : (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0))) @@ -868,11 +919,15 @@ export default function Inbox() { setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize)) }, [total, pageSize, skip, showAll]) - const list = inbox + // All channel pages the merged array client-side; single channels page on the server. + const list = isAllChannel && !showAll ? inbox.slice(skip, skip + pageSize) : inbox + // Mixed rows: the row's own kind picks the detail endpoint, not the channel. + const selectedKind = inbox.find((i) => i.id === selectedId)?.kind + ?? (isForms ? 'form' : 'email') const detailQuery = useQuery({ - queryKey: isForms ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), - queryFn: () => (isForms ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), + queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), + queryFn: () => (selectedKind === 'form' ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), enabled: Boolean(selectedId), }) @@ -915,8 +970,8 @@ export default function Inbox() { setSkip(0) setSelectedId(null) setQ('') - // Unread is email-only; leave it behind when opening Sheet Forms. - if (next === 'forms' && tab === 'Unread') setTab('All Applications') + // Unread is email-only; leave it behind when opening Sheet Forms or All. + if (next !== 'email' && tab === 'Unread') setTab('All Applications') selection.clear() } @@ -937,11 +992,12 @@ export default function Inbox() { setReadAll.mutate({ read, filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) }, - ids: list.map((i) => i.id), + // sheet rows carry no mailbox read state — email rows only + ids: list.filter((i) => i.kind !== 'form').map((i) => i.id), }) return } - const ids = list.map((i) => i.id) + const ids = list.filter((i) => i.kind !== 'form').map((i) => i.id) if (!ids.length) return setRead.mutate({ ids, read }, { onSuccess: () => { @@ -988,8 +1044,13 @@ export default function Inbox() { function select(id) { setSelectedId(id) - if (isForms) return + // Phones swap the list for the detail pane — bring its top into view. + if (window.matchMedia?.('(max-width: 900px)').matches) { + window.scrollTo(0, 0) + document.querySelector('.content')?.scrollTo?.(0, 0) + } const item = inbox.find((i) => i.id === id) + if (item?.kind === 'form') return // sheet rows have no mailbox read state if (item?.unread) setRead.mutate({ ids: [id], read: true }) } @@ -1009,6 +1070,51 @@ export default function Inbox() { markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind }) } + /** Styled XLSX of the rows already loaded in this view — the DB filtered + them when the list was fetched (channel, tab, search); no extra request. + Paged channels export the loaded page; the All channel and the "All" + page size hold the whole view, so those export everything. */ + async function exportRows() { + const rows = inbox + if (!rows.length) { + toast('Nothing to export in this view', 'info') + return + } + try { + const channelLabel = CHANNELS.find((c) => c.key === channel)?.label ?? channel + await exportStyledXlsx({ + filename: `inbox-${channel}-${tab.toLowerCase().replaceAll(' ', '-')}-${new Date().toISOString().slice(0, 10)}`, + title: 'Recruitment Inbox', + subtitle: `${tab} · ${channelLabel} channel · ${rows.length} row${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`, + columns: [ + { header: 'Name', key: 'name', width: 26 }, + { header: 'Email', key: 'email', width: 28 }, + { header: 'Phone', key: 'phone', width: 15 }, + { header: 'Position', key: 'position', width: 34 }, + { header: 'Channel', key: 'channel', width: 12 }, + { header: 'Source', key: 'source', width: 20 }, + { header: 'Received', key: 'received', width: 12 }, + { header: 'Status', key: 'status', width: 12 }, + { header: 'City', key: 'city', width: 14 }, + { header: 'Notice period', key: 'notice', width: 13 }, + { header: 'ATS score', key: 'ats', width: 10 }, + { header: 'Assigned job', key: 'job', width: 24 }, + ], + rows: rows.map((r) => ({ + name: r.name, email: r.email, phone: r.phone, position: r.position, + channel: r.kind === 'form' ? 'Sheet Form' : 'Email', + source: r.source, + received: r.received ? r.received.toISOString().slice(0, 10) : '', + status: r.processing, city: r.residingCity, notice: r.noticePeriod, + ats: r.atsScore, job: r.assignedPost?.title, + })), + }) + toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success') + } catch { + toast('Export failed', 'error') + } + } + const [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId()) const syncToastShown = useRef(null) @@ -1071,9 +1177,11 @@ export default function Inbox() {
@@ -1090,12 +1198,6 @@ export default function Inbox() { ))}
- {!isForms && ( - Microsoft Graph API · Connected - )} - {isForms && ( - Google Sheets · Form data - )} {!isForms && ( sync.mutate()} /> )} + @@ -1125,7 +1230,7 @@ export default function Inbox() { />
-
+
{!isForms && applicationsQuery.isSuccess && ( { + setPageSize(showAll ? DEFAULT_PAGE_SIZE : 'all') + setSkip(0) + setSelectedId(null) + selection.clear() + }} /> )}
@@ -1173,11 +1286,31 @@ export default function Inbox() { {activeQuery.isPending && ( )} - {activeQuery.isError && ( + {/* The big error state only when there is truly nothing to + show — cached rows beat a scary banner over a working list. */} + {activeQuery.isError && list.length === 0 && ( {friendlyAuthError(activeQuery.error, 'Request failed')} )} + {/* All channel with one source down: keep the healthy list, + note the gap in one line instead of a full error state. */} + {isAllChannel && applicationsQuery.isError !== formQuery.isError + && (applicationsQuery.isError || formQuery.isError) && ( +
+ {applicationsQuery.isError + ? 'Email applications couldn’t load right now — showing Sheet Forms only.' + : 'Sheet Form applications couldn’t load right now — showing Email only.'} +
+ )} {activeQuery.isSuccess && list.length === 0 ? ( {isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'} @@ -1189,7 +1322,7 @@ export default function Inbox() { className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`} onClick={() => select(i.id)} > - {!isForms && ( + {i.kind !== 'form' && ( selection.toggle(i.id)} @@ -1212,13 +1345,16 @@ export default function Inbox() { {asAtsScore(i.atsScore) != null && (
)} + {i.kind === 'form' && i.noticePeriod && ( +
{i.noticePeriod}
+ )}
{i.processing} {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( - {i.applicationStatus} + {formatRole(i.applicationStatus)} )} - {isForms && i.residingCity && ( + {i.kind === 'form' && i.residingCity && ( {i.residingCity} )}
@@ -1250,6 +1386,15 @@ export default function Inbox() {
+ {selected && ( + + )} {!selected ? (
@@ -1264,7 +1409,7 @@ export default function Inbox() { {friendlyAuthError(detailQuery.error, 'Request failed')}
- ) : isForms ? ( + ) : selected?.kind === 'form' ? ( -
-
Contact & application
-
-
Email
{orDash(i.email)}
-
Phone
{orDash(i.phone)}
-
Applied
{i.received ? fmtDate(i.received) : '—'}
-
Source
{orDash(i.source)}
-
Screened by
{orDash(i.screenedBy)}
-
Notice period
{orDash(i.noticePeriod)}
+
+
+

Contact & application

+
+
+
Email
{orDash(i.email)}
+
Phone
{orDash(i.phone)}
+
Applied
{i.received ? fmtDate(i.received) : '—'}
+
Source
{orDash(i.source)}
+
Screened by
{orDash(i.screenedBy)}
+
Notice period
{orDash(i.noticePeriod)}
+
+
-
Profile
-
-
Gender
{orDash(i.gender)}
-
Date of birth
{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}
-
CNIC
{orDash(i.cnic)}
-
Marital status
{orDash(i.maritalStatus)}
-
Location
{orDash(location)}
-
Education
{orDash(education)}
-
Graduation
{orDash(i.graduationYear)}
-
Other university
{orDash(i.universityOther)}
-
Current salary
{orDash(i.currentSalary)}
-
Expected salary
{orDash(i.expectedSalary)}
+
+

Profile

+
+
+
Gender
{orDash(i.gender)}
+
Date of birth
{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}
+
CNIC
{orDash(i.cnic)}
+
Marital status
{orDash(i.maritalStatus)}
+
Location
{orDash(location)}
+
Education
{orDash(education)}
+
Graduation
{orDash(i.graduationYear)}
+
Other university
{orDash(i.universityOther)}
+
Current salary
{orDash(i.currentSalary)}
+
Expected salary
{orDash(i.expectedSalary)}
+
+
{i.hrComments && ( -
+
HR comment

{i.hrComments}

)} - - {i.sheet && ( -
- Imported from {i.sheet} -
- )}
@@ -1681,10 +1808,84 @@ function FormApplicantDetail({ > Assign +
+
+
+

Matching roles

+ Matched by position: {orDash(i.position)} +
+
+
+ {matchCards.length === 0 && !manualPost ? ( + +

No job post title matches this position — pick one manually.

+
+ ) : ( + <> + {matchCards.map(({ rank, post }) => ( + setSelection(String(id))} + /> + ))} + {manualPost && ( + setSelection(String(id))} + /> + )} + + )} +
+ + {/* Assign only renders once there is a selection to act on — + a permanently disabled primary button read as broken UI. */} + {selection && selection !== i.assignedId && ( + + )} +
+
-
+ {i.sheet && ( +
+ Imported from {i.sheet}{i.rowNumber != null ? ` · row ${i.rowNumber}` : ''} +
+ )} + +
@@ -1841,7 +2042,7 @@ function ApplicationDetail({ {i.processing}{' '} {i.duplicate && <>Duplicate{' '}} {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( - <>{i.applicationStatus}{' '} + <>{formatRole(i.applicationStatus)}{' '} )} {i.resumeStatus} @@ -2070,7 +2271,7 @@ function ApplicationDetail({
-
+
diff --git a/frontend/src/screens/Rbac.jsx b/frontend/src/screens/Rbac.jsx index ecbe1b0..02c1fc5 100644 --- a/frontend/src/screens/Rbac.jsx +++ b/frontend/src/screens/Rbac.jsx @@ -29,6 +29,7 @@ import { useToast } from '../ui/Toast' import { useFormState } from '../components/AuthLayout' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' +import { formatRole } from '../lib/format' import * as rolesApi from '../api/roles' const ROLE_COLORS = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)'] @@ -69,7 +70,9 @@ export default function Rbac() { queryFn: () => rolesApi.listPermissions().then((r) => r.data ?? []), }) - const roles = rolesQuery.data ?? [] + // `candidate` is the applicant account type, not a staff role — it stays in + // the DB (every candidate user sits on it) but is not managed on this screen. + const roles = (rolesQuery.data ?? []).filter((r) => r.role_name !== 'candidate') const tags = tagsQuery.data ?? [] const bundles = bundlesQuery.data ?? [] @@ -178,7 +181,7 @@ export default function Rbac() {
-
{r.role_name}
+
{formatRole(r.role_name)}
{(r.effective_permissions?.length ?? 0)} permissions {r.is_system ? ' · system' : ''} @@ -199,7 +202,7 @@ export default function Rbac() {
-

{role.role_name}

+

{formatRole(role.role_name)}

{role.description || 'No description'}
@@ -300,7 +303,7 @@ export default function Rbac() { {editing && ( setConfirmDelete(null)} footer={ <> @@ -331,7 +334,7 @@ export default function Rbac() { } >

- {confirmDelete.role_name} will be soft-deleted. Anyone currently holding it keeps the + {formatRole(confirmDelete.role_name)} will be soft-deleted. Anyone currently holding it keeps the account but loses every permission the role granted, so reassign them first.

diff --git a/frontend/src/screens/Settings.jsx b/frontend/src/screens/Settings.jsx index 0a8161e..2a4fa2f 100644 --- a/frontend/src/screens/Settings.jsx +++ b/frontend/src/screens/Settings.jsx @@ -18,6 +18,7 @@ import { useFormState } from '../components/AuthLayout' import { usePermission } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' +import { formatRole } from '../lib/format' import * as rolesApi from '../api/roles' import * as usersApi from '../api/users' import * as orgSettingsApi from '../api/orgSettings' @@ -324,7 +325,7 @@ function Users() {
- {u.role_name || 'No role'} + {formatRole(u.role_name) || 'No role'} {!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'} @@ -425,7 +426,7 @@ function Approvals() {
- {u.role_name || 'No role'} + {formatRole(u.role_name) || 'No role'} {u.created_at ? String(u.created_at).slice(0, 10) : '—'}