diff --git a/.gitignore b/.gitignore index f76628d..d33ff2e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,10 +45,9 @@ tmp/ temp/ .cache/ -# Frontend / auth build +# Frontend build node_modules/ frontend/dist/ -/auth/ **.pdf **_**_**.py \ No newline at end of file diff --git a/devserver.py b/devserver.py deleted file mode 100644 index 0b6a39e..0000000 --- a/devserver.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""Static dev server for the ATS dashboard. - -Identical to `python3 -m http.server` except it disables caching. The stdlib -server answers conditional requests from Last-Modified, which has one-second -granularity — so a file edited twice within the same second keeps serving the -stale copy and the browser never sees the change. -""" -import os -import sys -from functools import partial -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer - -# Prefixes served by a client-side router rather than by files on disk. -SPA_ROOTS = ("/auth/",) - - -class NoCacheHandler(SimpleHTTPRequestHandler): - def send_head(self): - # Deep links like /auth/confirm-email?token=… are router paths, not files. - # Hand back the SPA shell and let react-router resolve them; its assets are - # referenced absolutely (/auth/assets/…), so nothing needs rewriting. - for root in SPA_ROOTS: - if self.path.startswith(root) and not os.path.exists(self.translate_path(self.path)): - self.path = root + "index.html" - break - return super().send_head() - - def end_headers(self): - self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") - self.send_header("Pragma", "no-cache") - self.send_header("Expires", "0") - super().end_headers() - - def send_header(self, keyword, value): - # Drop the validator entirely so conditional GETs can't 304. - if keyword.lower() == "last-modified": - return - super().send_header(keyword, value) - - def log_message(self, fmt, *args): - pass - - -if __name__ == "__main__": - port = int(sys.argv[1]) if len(sys.argv) > 1 else 4173 - directory = sys.argv[2] if len(sys.argv) > 2 else "." - handler = partial(NoCacheHandler, directory=directory) - print(f"Serving {directory} on http://localhost:{port} (no-cache)") - ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever() diff --git a/frontend/.env.production b/frontend/.env.production index 27786df..54a8a13 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1 +1,7 @@ -VITE_API_BASE=http://localhost:8000 +# The API origin for a production build. +# +# This previously pointed at http://localhost:8000, which meant a production +# bundle called the *user's own machine*. Empty means same-origin requests, which +# works behind a reverse proxy that fronts both the bundle and the API. Set the +# real API origin here if the two are served from different hosts. +VITE_API_BASE= diff --git a/frontend/index.html b/frontend/index.html index 22480fa..f425b9e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,23 @@ + - TalentFlow · Sign in + + + + + + TalentFlow · Applicant Tracking System + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a35b1d3..04a38bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,22 +1,79 @@ { - "name": "hr-ats-auth", - "version": "0.0.1", + "name": "hr-ats-portal", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "hr-ats-auth", - "version": "0.0.1", + "name": "hr-ats-portal", + "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-devtools": "^5.101.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.5.0", + "esbuild": "^0.28.1", + "jsdom": "^30.0.1", "vite": "^6.3.5" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -299,10 +356,163 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -317,9 +527,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -334,9 +544,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -351,9 +561,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -368,9 +578,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -385,9 +595,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -402,9 +612,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -419,9 +629,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -436,9 +646,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -453,9 +663,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -470,9 +680,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -487,9 +697,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -504,9 +714,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -521,9 +731,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -538,9 +748,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -555,9 +765,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -572,9 +782,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -589,9 +799,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -606,9 +816,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -623,9 +833,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -640,9 +850,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -657,9 +867,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -674,9 +884,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -691,9 +901,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -708,9 +918,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -725,9 +935,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -741,6 +951,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1207,6 +1435,59 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz", + "integrity": "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.4.tgz", + "integrity": "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1293,6 +1574,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -1368,6 +1659,49 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1386,6 +1720,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.400", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", @@ -1393,10 +1734,23 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1407,32 +1761,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -1488,6 +1842,26 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1495,6 +1869,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1531,6 +1956,13 @@ "yallist": "^3.0.2" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1567,6 +1999,19 @@ "node": ">=18" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1616,6 +2061,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1685,6 +2140,16 @@ "react-dom": ">=18" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -1731,6 +2196,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1763,6 +2241,13 @@ "node": ">=0.10.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1780,6 +2265,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.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", @@ -1886,6 +2427,555 @@ } } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index bccf6a3..11b6fd5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,20 +1,28 @@ { - "name": "hr-ats-auth", + "name": "hr-ats-portal", "private": true, - "version": "0.0.1", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", + "dev:lan": "vite --host", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "smoke": "node smoke.test.mjs", + "test:token": "node token.test.mjs", + "verify": "vite build && node smoke.test.mjs && node token.test.mjs" }, "dependencies": { + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-devtools": "^5.101.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.5.0", + "esbuild": "^0.28.1", + "jsdom": "^30.0.1", "vite": "^6.3.5" } } diff --git a/frontend/smoke.test.mjs b/frontend/smoke.test.mjs new file mode 100644 index 0000000..ffd1f32 --- /dev/null +++ b/frontend/smoke.test.mjs @@ -0,0 +1,131 @@ +/** + * Render smoke test — mounts all 27 routes (23 app + 4 auth) into jsdom and + * fails on any thrown error, console.error, or empty render. + * + * npm run smoke + * + * Bundled with esbuild (not Vite's SSR loader) because several dependencies + * ship CJS and esbuild's interop handles that cleanly. The render itself lives + * in src/__smoke__/entry.jsx so it exercises the real component tree. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import esbuild from 'esbuild' +import { JSDOM } from 'jsdom' + +// ---------------------------------------------------------------- environment +const dom = new JSDOM('
', { + url: 'http://localhost:5173/', + pretendToBeVisual: true, +}) + +globalThis.window = dom.window +globalThis.document = dom.window.document +// Node 24 defines `navigator` as a getter-only global. +Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) +globalThis.HTMLElement = dom.window.HTMLElement +globalThis.Element = dom.window.Element +globalThis.Node = dom.window.Node +globalThis.getComputedStyle = dom.window.getComputedStyle +globalThis.localStorage = dom.window.localStorage +globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0) +globalThis.cancelAnimationFrame = clearTimeout +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +class RO { observe() {} unobserve() {} disconnect() {} } +class MO { observe() {} disconnect() {} takeRecords() { return [] } } +globalThis.ResizeObserver = RO +globalThis.MutationObserver = MO +dom.window.ResizeObserver = RO +dom.window.MutationObserver = MO +dom.window.matchMedia = () => ({ + matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, +}) +// jsdom has no canvas backend; the retained chart engine only needs a context object. +dom.window.HTMLCanvasElement.prototype.getContext = () => + new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) }) + +// A signed-in session holding all 104 permissions, so no route is gated away. +const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', + 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users'] +const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'] +const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`)) + +dom.window.localStorage.setItem('tf-auth', JSON.stringify({ + access_token: 'test', refresh_token: 'test', expires_in: 1800, + expires_at: Date.now() + 1800_000, + data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions }, +})) + +// The real-API screens must not hit the network here. +globalThis.fetch = async () => ({ + ok: true, status: 200, statusText: 'OK', + text: async () => JSON.stringify({ data: [], status_code: 200 }), +}) + +// ---------------------------------------------------------------- bundle +const outDir = mkdtempSync(join(tmpdir(), 'tf-smoke-')) +const outFile = join(outDir, 'entry.mjs') + +await esbuild.build({ + entryPoints: ['src/__smoke__/entry.jsx'], + outfile: outFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + jsx: 'automatic', + loader: { '.js': 'jsx', '.jsx': 'jsx' }, + logLevel: 'error', + define: { + 'process.env.NODE_ENV': '"development"', + 'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }), + }, +}) + +// ---------------------------------------------------------------- run +const errors = [] +const origError = console.error +console.error = (...args) => { + const msg = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ') + if (msg.includes('React Router Future Flag')) return // advisory, not a defect + errors.push(msg) +} + +let failed = 0 +try { + const mod = await import(pathToFileURL(outFile).href) + mod.boot() + + for (const path of mod.ALL_ROUTES) { + errors.length = 0 + const container = dom.window.document.createElement('div') + dom.window.document.body.appendChild(container) + try { + const text = (await mod.renderRoute(path, container)).trim() + if (errors.length) { + console.log(`FAIL ${path}\n ${errors[0].split('\n').slice(0, 3).join(' | ').slice(0, 260)}`) + failed++ + } else if (text.length < 5) { + console.log(`FAIL ${path} (rendered empty)`) + failed++ + } else { + console.log(`ok ${path} (${text.length} chars)`) + } + } catch (err) { + console.log(`FAIL ${path}\n ${String(err.message).split('\n')[0].slice(0, 260)}`) + failed++ + } finally { + container.remove() + } + } +} finally { + console.error = origError + rmSync(outDir, { recursive: true, force: true }) +} + +console.log(failed ? `\n${failed}/27 routes FAILED` : `\nAll 27 routes rendered clean`) +process.exit(failed ? 1 : 0) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7961107..bd7eb2e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,22 +1,91 @@ +import { lazy } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' + +import AuthProvider from './auth/AuthProvider' +import RequireAuth from './auth/RequireAuth' +import AppLayout from './app/AppLayout' +import LegacyHashRedirect from './app/LegacyHashRedirect' +import { ROUTES } from './app/routes' + import Login from './pages/Login' import Signup from './pages/Signup' import ForgotPassword from './pages/ForgotPassword' import ConfirmEmail from './pages/ConfirmEmail' -const basename = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') || '/' +// Route-level code splitting: putting 23 screens in one bundle would make the +// first paint pay for every screen a user never opens. +const SCREENS = { + dashboard: lazy(() => import('./screens/Dashboard')), + inbox: lazy(() => import('./screens/Inbox')), + jobs: lazy(() => import('./screens/Jobs')), + candidates: lazy(() => import('./screens/Candidates')), + talentpool: lazy(() => import('./screens/TalentPool')), + pipeline: lazy(() => import('./screens/Pipeline')), + import: lazy(() => import('./screens/CvImport')), + jobboard: lazy(() => import('./screens/JobBoard')), + recruiterhub: lazy(() => import('./screens/RecruiterHub')), + tasks: lazy(() => import('./screens/Tasks')), + aiassistant: lazy(() => import('./screens/AiAssistant')), + interviews: lazy(() => import('./screens/Interviews')), + assessments: lazy(() => import('./screens/Assessments')), + offers: lazy(() => import('./screens/Offers')), + managers: lazy(() => import('./screens/Managers')), + calendar: lazy(() => import('./screens/Calendar')), + reports: lazy(() => import('./screens/Reports')), + analytics: lazy(() => import('./screens/Analytics')), + aistudio: lazy(() => import('./screens/AiStudio')), + notifications: lazy(() => import('./screens/Notifications')), + rbac: lazy(() => import('./screens/Rbac')), + settings: lazy(() => import('./screens/Settings')), + help: lazy(() => import('./screens/Help')), +} export default function App() { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - + + + + + {/* Public. These keep the /auth prefix as ROUTE paths so the backend's + CONFIRM_EMAIL_PATH=/auth/confirm-email links resolve unchanged. */} + } /> + } /> + } /> + } /> + } /> + + {/* Protected */} + + + + } + > + {ROUTES.map((r) => { + const Screen = SCREENS[r.path] + return ( + + + + ) : ( + + ) + } + /> + ) + })} + + + } /> + } /> + + ) } diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx new file mode 100644 index 0000000..d5f4914 --- /dev/null +++ b/frontend/src/__smoke__/entry.jsx @@ -0,0 +1,113 @@ +/* Test-only entry. Kept inside src/ so every import resolves through Vite's + module graph exactly as it does in the app — one React instance, one router + instance, one query client. Not shipped: excluded from the build because + nothing in the app imports it. */ + +import React from 'react' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom' +import { QueryClientProvider } from '@tanstack/react-query' + +import { queryClient } from '../lib/queryClient' +import { initializeCache } from '../data/seedQueries' +import ThemeProvider, { initTheme } from '../theme/ThemeProvider' +import ToastProvider from '../ui/Toast' +import AuthProvider from '../auth/AuthProvider' +import AppLayout from '../app/AppLayout' +import RequireAuth from '../auth/RequireAuth' +import { ROUTES as TABLE } from '../app/routes' + +import Login from '../pages/Login' +import Signup from '../pages/Signup' +import ForgotPassword from '../pages/ForgotPassword' +import ConfirmEmail from '../pages/ConfirmEmail' + +import Dashboard from '../screens/Dashboard' +import Inbox from '../screens/Inbox' +import Jobs from '../screens/Jobs' +import Candidates from '../screens/Candidates' +import TalentPool from '../screens/TalentPool' +import Pipeline from '../screens/Pipeline' +import CvImport from '../screens/CvImport' +import JobBoard from '../screens/JobBoard' +import RecruiterHub from '../screens/RecruiterHub' +import Tasks from '../screens/Tasks' +import AiAssistant from '../screens/AiAssistant' +import Interviews from '../screens/Interviews' +import Assessments from '../screens/Assessments' +import Offers from '../screens/Offers' +import Managers from '../screens/Managers' +import Calendar from '../screens/Calendar' +import Reports from '../screens/Reports' +import Analytics from '../screens/Analytics' +import AiStudio from '../screens/AiStudio' +import Notifications from '../screens/Notifications' +import Rbac from '../screens/Rbac' +import Settings from '../screens/Settings' +import Help from '../screens/Help' + +const SCREENS = { + dashboard: Dashboard, inbox: Inbox, jobs: Jobs, candidates: Candidates, + talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard, + recruiterhub: RecruiterHub, tasks: Tasks, aiassistant: AiAssistant, + interviews: Interviews, assessments: Assessments, offers: Offers, + managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics, + aistudio: AiStudio, notifications: Notifications, rbac: Rbac, + settings: Settings, help: Help, +} + +const PAGES = { + '/auth/login': Login, + '/auth/signup': Signup, + '/auth/forgot-password': ForgotPassword, + '/auth/confirm-email': ConfirmEmail, +} + +export const ALL_ROUTES = [ + ...Object.keys(PAGES), + ...TABLE.map((r) => `/${r.path}`), +] + +export function boot() { + initTheme() + initializeCache(queryClient) +} + +/** Mount one route, wait for effects to settle, return its rendered text. */ +export async function renderRoute(path, container) { + const h = React.createElement + const isAuth = path.startsWith('/auth/') + const def = TABLE.find((r) => `/${r.path}` === path) + const Screen = isAuth ? PAGES[path] : SCREENS[def.path] + + const inner = isAuth + ? h(Route, { path, element: h(Screen) }) + : h( + Route, + { element: h(RequireAuth, null, h(AppLayout)) }, + h(Route, { path, element: h(Screen) }), + ) + + const tree = h( + QueryClientProvider, { client: queryClient }, + h(ThemeProvider, null, + h(ToastProvider, null, + h(MemoryRouter, { initialEntries: [path] }, + h(AuthProvider, null, + h(Routes, null, inner, h(Route, { path: '*', element: h(Navigate, { to: path, replace: true }) })), + ), + ), + ), + ), + ) + + const root = createRoot(container) + try { + await act(async () => { root.render(tree) }) + await act(async () => { await new Promise((r) => setTimeout(r, 40)) }) + return container.textContent || '' + } finally { + await act(async () => { root.unmount() }) + } +} diff --git a/frontend/src/__smoke__/token.entry.js b/frontend/src/__smoke__/token.entry.js new file mode 100644 index 0000000..ab56614 --- /dev/null +++ b/frontend/src/__smoke__/token.entry.js @@ -0,0 +1,7 @@ +/* Test-only entry exposing the token layer to the token test harness. */ +export { request, setSessionExpiredHandler } from '../lib/apiClient' +export { refreshSession } from '../lib/refresh' +export { + setSession, getSession, clearSession, getAccessToken, getRefreshToken, isExpiring, +} from '../lib/tokenStore' +export { ApiError, SessionExpiredError } from '../lib/errors' diff --git a/frontend/src/api.js b/frontend/src/api.js deleted file mode 100644 index ff319aa..0000000 --- a/frontend/src/api.js +++ /dev/null @@ -1,125 +0,0 @@ -const API_BASE = import.meta.env.VITE_API_BASE ?? '' - -export class ApiError extends Error { - constructor(message, status, body) { - super(message) - this.name = 'ApiError' - this.status = status - this.body = body - } -} - -function parseDetail(detail) { - if (detail == null || detail === '') return null - if (typeof detail === 'string') return detail - if (Array.isArray(detail)) { - return detail - .map((item) => { - if (typeof item === 'string') return item - if (item && typeof item === 'object') { - return item.msg || item.message || JSON.stringify(item) - } - return String(item) - }) - .filter(Boolean) - .join('; ') - } - if (typeof detail === 'object') { - return detail.msg || detail.message || JSON.stringify(detail) - } - return String(detail) -} - -export function friendlyAuthError(err, fallback = 'Something went wrong. Please try again.') { - if (!(err instanceof ApiError)) { - return err?.message || fallback - } - const fromServer = parseDetail(err.body?.detail) - switch (err.status) { - case 429: - return fromServer || 'Too many attempts. Please wait a moment and try again.' - case 404: - return fromServer || 'No account found for that email.' - case 400: - return fromServer || 'Please check your details and try again.' - case 401: - return fromServer || 'Incorrect email or password.' - case 409: - return fromServer || 'An account with that email already exists.' - case 502: - return fromServer || 'Email service is temporarily unavailable. Please try again later.' - default: - return fromServer || err.message || fallback - } -} - -async function request(path, { method = 'GET', body, token } = {}) { - const headers = { Accept: 'application/json' } - if (body != null) headers['Content-Type'] = 'application/json' - if (token) headers.Authorization = `Bearer ${token}` - - let res - try { - res = await fetch(`${API_BASE}${path}`, { - method, - headers, - body: body != null ? JSON.stringify(body) : undefined, - }) - } catch { - throw new ApiError('Unable to reach the server. Check your connection.', 0, null) - } - - let data = null - const text = await res.text() - if (text) { - try { - data = JSON.parse(text) - } catch { - data = null - } - } - - if (!res.ok) { - throw new ApiError( - parseDetail(data?.detail) || res.statusText || 'Request failed', - res.status, - data, - ) - } - return data -} - -export function login(email, password) { - return request('/users/login', { method: 'POST', body: { email, password } }) -} - -export function signup(name, email, password) { - return request('/users/signup', { method: 'POST', body: { name, email, password } }) -} - -export function confirmEmail(token) { - return request('/users/confirm-email', { method: 'POST', body: { token } }) -} - -export function resendConfirmEmail(email) { - return request('/users/confirm-email/resend', { method: 'POST', body: { email } }) -} - -export function forgetPassword(email) { - return request('/users/forget-password', { method: 'POST', body: { email } }) -} - -export function verifyForgetCode(email, code) { - return request('/users/forget-password/verify-code', { - method: 'POST', - body: { email, code }, - }) -} - -export function setNewPassword(password, resetToken) { - return request('/users/forget-password/new-password', { - method: 'POST', - body: { password }, - token: resetToken, - }) -} diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..1418c66 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,46 @@ +/* Public auth endpoints. All are unauthenticated (`auth: false`) — attaching a + stale bearer to a login request would be harmless but misleading in the + network log, and the reset flow must never trigger a refresh. */ +import { request } from '../lib/apiClient' + +const pub = { auth: false } + +export function login(email, password) { + return request('/users/login', { ...pub, method: 'POST', body: { email, password } }) +} + +export function signup(name, email, password) { + return request('/users/signup', { ...pub, method: 'POST', body: { name, email, password } }) +} + +export function confirmEmail(token) { + return request('/users/confirm-email', { ...pub, method: 'POST', body: { token } }) +} + +export function resendConfirmEmail(email) { + return request('/users/confirm-email/resend', { ...pub, method: 'POST', body: { email } }) +} + +export function forgetPassword(email) { + return request('/users/forget-password', { ...pub, method: 'POST', body: { email } }) +} + +export function verifyForgetCode(email, code) { + return request('/users/forget-password/verify-code', { + ...pub, + method: 'POST', + body: { email, code }, + }) +} + +export function setNewPassword(password, resetToken) { + // `token` is the 10-minute reset JWT, carried in its own Authorization header + // and checked by forget_password/permissions.py's HTTPBearer. Passing `token` + // explicitly also suppresses the refresh path, which is what we want: a reset + // token is not a session. + return request('/users/forget-password/new-password', { + method: 'POST', + body: { password }, + token: resetToken, + }) +} diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js new file mode 100644 index 0000000..8ad8e13 --- /dev/null +++ b/frontend/src/api/inbox.js @@ -0,0 +1,17 @@ +import { request } from '../lib/apiClient' + +/** + * Persisted mailbox messages. + * + * NOTE: this endpoint currently has no auth dependency server-side + * (backend/inbox/app.py). We send the bearer anyway, so adding + * Depends(get_current_user) later is a zero-diff change on this side. + */ +export function listMessages() { + return request('/inbox/fetch') +} + +/** Triggers the Graph proxy to pull new mail and persist it. */ +export function syncMailbox({ token, top, skip } = {}) { + return request('/email/fetch', { params: { token, top, skip } }) +} diff --git a/frontend/src/api/roles.js b/frontend/src/api/roles.js new file mode 100644 index 0000000..e3d17db --- /dev/null +++ b/frontend/src/api/roles.js @@ -0,0 +1,31 @@ +import { request } from '../lib/apiClient' + +/** Roles with their expanded `bundles` and resolved `effective_permissions`. */ +export function listRoles() { + return request('/roles/fetch') +} +export function createRole(body) { + return request('/roles/create', { method: 'POST', body }) +} +export function updateRole(recordId, body) { + return request('/roles/update', { method: 'PUT', params: { record_id: recordId }, body }) +} +export function deleteRole(recordId) { + return request('/roles/delete', { method: 'DELETE', params: { record_id: recordId } }) +} + +/** Permission bundles (41 seeded), each resolving to a set of tag names. */ +export function listPermissions() { + return request('/permissions/fetch') +} +export function createPermission(body) { + return request('/permissions/create', { method: 'POST', body }) +} +export function updatePermission(recordId, body) { + return request('/permissions/update', { method: 'PUT', params: { record_id: recordId }, body }) +} + +/** The 104-tag catalog: 13 modules x 8 actions. */ +export function listPermissionTags() { + return request('/permission-tags/fetch') +} diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js new file mode 100644 index 0000000..54f9589 --- /dev/null +++ b/frontend/src/api/users.js @@ -0,0 +1,34 @@ +import { request } from '../lib/apiClient' + +/** The ONLY endpoint that returns `permissions`. Login and refresh do not. */ +export function me() { + return request('/users/me') +} + +export function list({ record_id, search, top, skip } = {}) { + return request('/users/fetch', { params: { record_id, search, top, skip } }) +} + +export function create(body) { + return request('/users/create', { method: 'POST', body }) +} + +export function update(recordId, body) { + return request('/users/update', { method: 'PUT', params: { record_id: recordId }, body }) +} + +export function assignRole(recordId, roleId) { + return request('/users/assign-role', { + method: 'PUT', + params: { record_id: recordId }, + body: { role_id: roleId }, + }) +} + +export function removeRole(recordId) { + return request('/users/remove-role', { method: 'PUT', params: { record_id: recordId } }) +} + +export function remove(recordId) { + return request('/users/delete', { method: 'DELETE', params: { record_id: recordId } }) +} diff --git a/frontend/src/app/AiDock.jsx b/frontend/src/app/AiDock.jsx new file mode 100644 index 0000000..76acdda --- /dev/null +++ b/frontend/src/app/AiDock.jsx @@ -0,0 +1,41 @@ +import { useNavigate } from 'react-router-dom' +import Chat from './ai/Chat' +import Icon from '../ui/icons' + +/** The slide-over dock — the same chat as the full page, in compact mode. */ +export default function AiDock({ open, onClose }) { + const navigate = useNavigate() + + return ( +
+
+ {open && ( + <> +
+
+

AI Assistant

+
+
+ + +
+
+
+ +
+ + )} +
+
+ ) +} diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx new file mode 100644 index 0000000..de62040 --- /dev/null +++ b/frontend/src/app/AppLayout.jsx @@ -0,0 +1,75 @@ +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { Outlet, useLocation } from 'react-router-dom' + +import Sidebar from './Sidebar' +import Topbar from './Topbar' +import AiDock from './AiDock' +import { ROUTE_BY_PATH } from './routes' +import { useBadges, useHotkeys, useNavOpen, useRouteMeta, useSidebarCollapsed } from './useShell' +import Icon from '../ui/icons' +import Spinner from '../components/Spinner' + +export default function AppLayout() { + const location = useLocation() + const contentRef = useRef(null) + + const [navOpen, setNavOpen] = useNavOpen() + const [collapsed, toggleCollapsed] = useSidebarCollapsed() + const [dockOpen, setDockOpen] = useState(false) + const badges = useBadges() + + const routeKey = location.pathname.split('/')[1] || 'dashboard' + const route = ROUTE_BY_PATH[routeKey] + useRouteMeta(route) + + const onEscape = useCallback(() => { + setNavOpen(false) + setDockOpen(false) + }, [setNavOpen]) + const searchRef = useHotkeys({ onEscape }) + + // Navigating closes the drawer and the dock, and resets scroll — the three + // things Router.render did at the end of every route change (js/app.js:44-51). + useEffect(() => { + setNavOpen(false) + setDockOpen(false) + if (contentRef.current) contentRef.current.scrollTop = 0 + }, [location.pathname, setNavOpen]) + + return ( +
+ + +
+ setNavOpen((o) => !o)} searchRef={searchRef} /> +
+
}> + + + +
+ + + + setDockOpen(false)} /> + +
setNavOpen(false)} + aria-hidden="true" + /> +
+ ) +} diff --git a/frontend/src/app/GlobalSearch.jsx b/frontend/src/app/GlobalSearch.jsx new file mode 100644 index 0000000..686a071 --- /dev/null +++ b/frontend/src/app/GlobalSearch.jsx @@ -0,0 +1,100 @@ +/* Global search — App.search from js/app.js:171-191. Same sources and the same + 4/4/3 caps. The inline onclick="App.searchGo(...)" strings become navigate() + calls, and the setTimeout(cb, 120) hack that waited for the old router to + swap innerHTML is gone: the target screen reads `state.open` instead. */ + +import { useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { seedQuery } from '../data/seedQueries' +import { Avatar, Icon } from '../ui/primitives' + +export default function GlobalSearch({ inputRef }) { + const navigate = useNavigate() + const [q, setQ] = useState('') + const [open, setOpen] = useState(false) + const boxRef = useRef(null) + + const { data: jobs = [] } = useQuery(seedQuery('jobs')) + const { data: candidates = [] } = useQuery(seedQuery('candidates')) + const { data: managers = [] } = useQuery(seedQuery('managers')) + + const results = useMemo(() => { + const term = q.trim().toLowerCase() + if (!term) return null + return { + jobs: jobs.filter((j) => (j.title + j.id + j.department).toLowerCase().includes(term)).slice(0, 4), + candidates: candidates.filter((c) => (c.name + c.email + c.jobTitle).toLowerCase().includes(term)).slice(0, 4), + managers: managers.filter((m) => m.name.toLowerCase().includes(term)).slice(0, 3), + } + }, [q, jobs, candidates, managers]) + + function go(path, state) { + setQ('') + setOpen(false) + navigate(path, { state }) + } + + const empty = results && !results.jobs.length && !results.candidates.length && !results.managers.length + + return ( +
e.stopPropagation()}> + + { + setQ(e.target.value) + setOpen(Boolean(e.target.value.trim())) + }} + onFocus={() => setOpen(Boolean(q.trim()))} + /> +
+ {results && ( + <> + {results.jobs.length > 0 &&
Jobs
} + {results.jobs.map((j) => ( +
go('/jobs', { openJob: j.id })}> + + + +
+
{j.title}
+
{j.id} · {j.department}
+
+
+ ))} + + {results.candidates.length > 0 &&
Candidates
} + {results.candidates.map((c) => ( +
go('/candidates', { openCandidate: c.id })}> + +
+
{c.name}
+
{c.jobTitle}
+
+
+ ))} + + {results.managers.length > 0 &&
Hiring Managers
} + {results.managers.map((m) => ( +
go('/managers', { openManager: m.id })}> + +
+
{m.name}
+
{m.title}
+
+
+ ))} + + {empty &&
No results for “{q}”
} + + )} +
+ ⌘K +
+ ) +} diff --git a/frontend/src/app/LegacyHashRedirect.jsx b/frontend/src/app/LegacyHashRedirect.jsx new file mode 100644 index 0000000..5f585a8 --- /dev/null +++ b/frontend/src/app/LegacyHashRedirect.jsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { ROUTE_BY_PATH } from './routes' + +/** + * The prototype addressed screens by `location.hash` (#dashboard, #candidates). + * Anyone with a bookmark — and the old post-login redirect target + * `/index.html#dashboard` — must not dead-end after the cutover. + * + * Runs once. StrictMode's double-invoke is harmless because the navigation is + * `replace` and the second run finds no hash left to act on. + */ +export default function LegacyHashRedirect() { + const navigate = useNavigate() + + useEffect(() => { + const route = (window.location.hash || '').slice(1) + if (route && ROUTE_BY_PATH[route]) { + navigate(`/${route}`, { replace: true }) + } + }, [navigate]) + + return null +} diff --git a/frontend/src/app/Sidebar.jsx b/frontend/src/app/Sidebar.jsx new file mode 100644 index 0000000..fbbcea7 --- /dev/null +++ b/frontend/src/app/Sidebar.jsx @@ -0,0 +1,83 @@ +import { NavLink } from 'react-router-dom' +import { NAV_GROUPS, ROUTES } from './routes' +import { useAuth } from '../auth/AuthContext' +import Icon from '../ui/icons' +import BrandMark from '../components/BrandMark' + +export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) { + const { can } = useAuth() + + // A group heading renders only if something under it survived the permission + // filter — otherwise a low-privilege user sees orphaned section labels. + const visible = ROUTES.filter((r) => can(r.permission)) + + return ( + + ) +} diff --git a/frontend/src/app/Topbar.jsx b/frontend/src/app/Topbar.jsx new file mode 100644 index 0000000..e87f9df --- /dev/null +++ b/frontend/src/app/Topbar.jsx @@ -0,0 +1,150 @@ +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' + +import Dropdown, { DropdownGroup } from '../ui/Dropdown' +import GlobalSearch from './GlobalSearch' +import { Avatar, Icon } from '../ui/primitives' +import { seedQuery, useSeedMutation } from '../data/seedQueries' +import { useToast } from '../ui/Toast' +import { useTheme } from '../theme/ThemeProvider' +import { useAuth } from '../auth/AuthContext' + +function initialsFromName(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean) + if (!parts.length) return '?' + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} + +export default function Topbar({ onOpenNav, searchRef }) { + const { theme, toggleTheme } = useTheme() + const { user, signOut } = useAuth() + const { toast } = useToast() + + const { data: notifications = [] } = useQuery(seedQuery('notifications')) + const { data: messages = [] } = useQuery(seedQuery('messages')) + const updateNotifications = useSeedMutation('notifications') + + // App.hydrateProfile's DOM sweep is gone — the session is read directly. + const name = user?.name || 'Guest' + const email = user?.email || '' + const role = user?.role_name || user?.role || 'Member' + + function markAllRead() { + updateNotifications((ns) => ns.map((n) => ({ ...n, unread: false }))) + toast('All notifications marked as read', 'success') + } + + return ( +
+ + + + +
+ + + + ( + + )} + > +
Messages
+
+ {messages.map((m) => ( +
+ +
+
{m.name}
+
{m.text}
+
{m.time} ago
+
+
+ ))} +
+
+ Open inbox +
+
+ + ( + + )} + > +
+ Notifications + +
+
+ {notifications.slice(0, 6).map((n) => ( +
+ +
+
{n.title}
+
{n.text}
+
{n.time}
+
+
+ ))} +
+
+ View all +
+
+ +
+ + ( + + )} + > +
+ {initialsFromName(name)} +
+
{name}
+
{email}
+
+
+
+ My Profile + Settings + Help Center +
+ + + +
+
+ ) +} diff --git a/frontend/src/app/ai/Chat.jsx b/frontend/src/app/ai/Chat.jsx new file mode 100644 index 0000000..5ffb6ad --- /dev/null +++ b/frontend/src/app/ai/Chat.jsx @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' + +import Icon from '../../ui/icons' +import { reply } from './replies' +import { seedQuery } from '../../data/seedQueries' +import { aiPrompts } from '../../data/seed' + +/** Shared by the full AI Assistant page and the slide-over dock. */ +export default function Chat({ compact = false, resetKey = 0 }) { + const [messages, setMessages] = useState([]) + const [value, setValue] = useState('') + const inputRef = useRef(null) + const scrollRef = useRef(null) + const timers = useRef([]) + + const { data: candidates = [] } = useQuery(seedQuery('candidates')) + const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) + + useEffect(() => setMessages([]), [resetKey]) + + useEffect(() => { + const list = timers.current + return () => list.forEach(clearTimeout) + }, []) + + useEffect(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight + }, [messages]) + + const send = useCallback( + (text) => { + const body = (text ?? value).trim() + if (!body) return + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}` + setMessages((m) => [...m, { id: `u-${id}`, role: 'you', text: body }, { id: `a-${id}`, role: 'ai', typing: true }]) + setValue('') + if (inputRef.current) inputRef.current.style.height = 'auto' + + // The prototype's artificial 850-1350ms latency, kept so the typing + // indicator is visible rather than flashing. + const t = setTimeout(() => { + setMessages((m) => + m.map((msg) => + msg.id === `a-${id}` + ? { ...msg, typing: false, node: reply(body, { candidates, recruiters }) } + : msg, + ), + ) + }, 850 + Math.random() * 500) + timers.current.push(t) + }, + [value, candidates, recruiters], + ) + + const started = messages.length > 0 + + return ( +
+
+ {!started ? ( + <> +
+
+

AI Recruiter Assistant

+

Ask anything about your candidates, jobs, and pipeline

+
+
+ {aiPrompts.slice(0, compact ? 6 : 12).map((p) => ( + + ))} +
+ + ) : ( + messages.map((m) => ( +
+
+ +
+
+
{m.role === 'you' ? 'You' : 'AI Assistant'}
+ {m.typing ? ( +
+ ) : ( +
{m.text ?? m.node}
+ )} +
+
+ )) + )} +
+ +
+
+ - -
-

UI preview · responses are simulated. ${UI.icon('lock')} API-ready for backend integration.

-
-
`; -}; - -AI._bindChat = function () { - const input = document.getElementById('chatInput'); - const send = document.getElementById('chatSend'); - if (!input) return; - input.oninput = () => { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px'; }; - input.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); AI.send(); } }; - send.onclick = AI.send; - AI._started = false; -}; - -AI.usePrompt = function (btn, prompt) { - const input = document.getElementById('chatInput'); - input.value = prompt; - AI.send(); -}; - -AI.send = function () { - const input = document.getElementById('chatInput'); - const scroll = document.getElementById('chatScroll'); - const text = input.value.trim(); - if (!text) return; - - if (!AI._started) { scroll.innerHTML = ''; AI._started = true; } - - // user message - scroll.insertAdjacentHTML('beforeend', ` -
${UI.icon('users')}
-
You
${text.replace(/
`); - input.value = ''; input.style.height = 'auto'; - scroll.scrollTop = scroll.scrollHeight; - - // typing indicator - const typingId = 'typing_' + Math.random().toString(36).slice(2, 7); - scroll.insertAdjacentHTML('beforeend', ` -
${UI.icon('sparkles')}
-
AI Assistant
`); - scroll.scrollTop = scroll.scrollHeight; - - setTimeout(() => { - const t = document.getElementById(typingId); - if (t) t.querySelector('.chat-bubble').innerHTML = `
AI Assistant
${AI._reply(text)}
`; - scroll.scrollTop = scroll.scrollHeight; - }, 850 + Math.random() * 500); -}; - -// ---------------- Full page ---------------- -Views.aiassistant = function () { - const html = ` -
-
-

AI Assistant

Your recruiting copilot — powered by AI (interface preview)

-
- Model endpoint · Not connected - -
-
-
${AI._chatHtml(false)}
-
`; - return { html, onMount() { AI._bindChat(); } }; -}; -AI.newChat = function () { - const mount = document.getElementById('aiChatMount'); - if (mount) { mount.innerHTML = AI._chatHtml(false); AI._bindChat(); } - else { AI._dockOpen(true); } -}; - -// ---------------- Floating dock ---------------- -AI._dockOpen = function (force) { - const dock = document.getElementById('aiDock'); - const inner = document.getElementById('aiDockInner'); - const willOpen = force || !dock.classList.contains('open'); - if (willOpen) { - inner.innerHTML = ` -

${UI.icon('sparkles')} AI Assistant

-
- -
-
${AI._chatHtml(true)}
`; - dock.classList.add('open'); - AI._bindChat(); - } else { AI._dockClose(); } -}; -AI._dockClose = function () { document.getElementById('aiDock').classList.remove('open'); }; - -// ---------------- AI Studio (future modules) ---------------- -Views.aistudio = function () { - const cards = DB.aiModules.map(m => ` -
-
-
- ${UI.icon(m.icon)} - ${UI.badge(m.status, m.status === 'Beta' ? 'b-indigo' : 'b-gray')} -
-
${m.name}
-
${m.desc}
-
${m.status === 'Beta' ? 'Try it' : 'Join waitlist'} ${UI.icon('arrow-right')}
-
-
`).join(''); - - const html = ` -
-
-

AI Studio

Next-generation AI modules — designed and API-ready for backend integration

-
${DB.aiModules.filter(m => m.status === 'Beta').length} in Beta -
-
-
-
- -

Everything is API-ready

-

Each module below ships with a complete, production-grade interface. Connect your model endpoint to activate them — no UI work required.

- -
-
-
${cards}
-
`; - return { html }; -}; -AI.moduleDetail = function (name) { - const m = DB.aiModules.find(x => x.name === name); - UI.modal({ - title: m.name, subtitle: m.status + ' · AI Module', - body: `
${UI.icon(m.icon)} -
${m.name}
${m.desc}
-
-
API Contract (preview)
-
POST /api/ai/${m.name.toLowerCase().replace(/ /g, '-')} -{ - "context": { "jobId": "JOB-1001", "candidateIds": [...] }, - "options": { "model": "claude-opus", "stream": true } -} - -→ 200 OK -{ - "result": { ... }, - "usage": { "tokens": 1240 } -}
-
-

${UI.icon('lock')} This feature's UI is complete. Backend wiring is the only remaining step.

`, - footer: ` - `, - size: 'modal-lg' - }); -}; diff --git a/js/analytics.js b/js/analytics.js deleted file mode 100644 index 6af8a81..0000000 --- a/js/analytics.js +++ /dev/null @@ -1,114 +0,0 @@ -/* ============================================================ - analytics.js — Analytics dashboard (many charts) - ============================================================ */ -window.Views = window.Views || {}; - -Views.analytics = function () { - const a = DB.analytics; - - const html = ` -
-
-

Analytics

Deep-dive metrics across your recruitment funnel

-
-
WeekMonthQuarter
- -
-
- -
-
-

Hiring Trend

Hires vs applications
-
- ${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])}
-
-
-

Applications Received

Monthly volume
-
-
-
- -
-
-

Source Breakdown

-
-
-
-
-
-
-

Offer Acceptance

-
-
-
- Accepted - Pending - Declined -
-
-
-
-

Pipeline Distribution

-
-
-
- -
-
-

Applications by Department

Volume per team
-
-
-
-

Recruiter Performance

Hires by recruiter (top 8)
-
-
-
- -
-
-

Time to Hire

Days, monthly average
-
-
-
-

Time to Fill

Days, monthly average
-
-
-
-
`; - - return { - html, - onMount() { - Charts.line(document.getElementById('anTrend'), { - labels: a.hiringTrend.labels, area: true, - datasets: [ - { label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] }, - { label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] } - ] - }); - Charts.bar(document.getElementById('anApps'), { labels: a.hiringTrend.labels, data: a.hiringTrend.applications }); - Charts.doughnut(document.getElementById('anSource'), { - labels: a.sources.map(s => s.source), data: a.sources.map(s => s.count), - centerValue: DB.candidates.length, centerLabel: 'Total' - }); - document.getElementById('anSourceLegend').innerHTML = a.sources.map((s, i) => - `${s.source}`).join(''); - Charts.doughnut(document.getElementById('anOffer'), { - labels: ['Accepted', 'Pending', 'Declined'], - data: [a.offerAcceptance.accepted, a.offerAcceptance.pending, a.offerAcceptance.declined], - colors: [Charts.token('--success'), Charts.token('--warning'), Charts.token('--danger')], - centerValue: Math.round(a.offerAcceptance.accepted / (a.offerAcceptance.accepted + a.offerAcceptance.declined || 1) * 100) + '%', - centerLabel: 'Accept rate' - }); - Charts.horizontalBar(document.getElementById('anPipeline'), { - labels: a.pipeline.map(p => p.stage), data: a.pipeline.map(p => p.count), - colors: Charts.PALETTE - }); - Charts.bar(document.getElementById('anDept'), { labels: a.departments.map(d => d.dept), data: a.departments.map(d => d.apps) }); - const topRecs = [...DB.recruiters].sort((x, y) => y.hires - x.hires).slice(0, 8); - Charts.horizontalBar(document.getElementById('anRec'), { labels: topRecs.map(r => r.name), data: topRecs.map(r => r.hires) }); - Charts.line(document.getElementById('anTTH'), { labels: a.hiringTrend.labels, area: true, yFmt: v => v + 'd', datasets: [{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] }] }); - Charts.line(document.getElementById('anTTF'), { labels: a.hiringTrend.labels, area: true, yFmt: v => v + 'd', datasets: [{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] }] }); - } - }; -}; diff --git a/js/api.js b/js/api.js deleted file mode 100644 index 304253a..0000000 --- a/js/api.js +++ /dev/null @@ -1,25 +0,0 @@ -/* ============================================================ - api.js — minimal HTTP client for the FastAPI backend - ============================================================ */ -window.Api = { - base: 'http://localhost:8000', - - async get(path, params) { - const url = new URL(path.replace(/^\//, ''), this.base.endsWith('/') ? this.base : this.base + '/'); - if (params) { - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - url.searchParams.set(key, value); - } - }); - } - const res = await fetch(url.toString()); - let body = null; - try { body = await res.json(); } catch (_) { body = null; } - if (!res.ok) { - const detail = body && body.detail != null ? body.detail : res.statusText; - throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail)); - } - return body; - } -}; diff --git a/js/app.js b/js/app.js deleted file mode 100644 index a1ffce7..0000000 --- a/js/app.js +++ /dev/null @@ -1,317 +0,0 @@ -/* ============================================================ - app.js — Core: router, sidebar, topbar, theme, search, init - ============================================================ */ -window.Router = {}; -window.App = {}; - -const ROUTES = { - dashboard: { title: 'Dashboard' }, inbox: { title: 'Recruitment Inbox' }, jobs: { title: 'Jobs' }, candidates: { title: 'Candidates' }, - talentpool: { title: 'Talent Pool' }, pipeline: { title: 'Pipeline' }, interviews: { title: 'Interviews' }, - assessments: { title: 'Assessments' }, offers: { title: 'Offers' }, managers: { title: 'Hiring Managers' }, - calendar: { title: 'Calendar' }, reports: { title: 'Reports' }, analytics: { title: 'Analytics' }, - notifications: { title: 'Notifications' }, settings: { title: 'Settings' }, help: { title: 'Help' }, - import: { title: 'CV Import' }, jobboard: { title: 'Job Board' }, recruiterhub: { title: 'Recruiter Hub' }, - aiassistant: { title: 'AI Assistant' }, aistudio: { title: 'AI Studio' }, rbac: { title: 'Access Control' }, - tasks: { title: 'Tasks' } -}; - -let currentRoute = 'dashboard'; - -Router.go = function (route) { - if (!ROUTES[route]) route = 'dashboard'; - location.hash = route; -}; -Router.reload = function () { Router.render(currentRoute); }; - -Router.render = function (route) { - currentRoute = route; - const view = (window.Views[route] || window.Views.dashboard)(); - const main = document.getElementById('main-content'); - main.innerHTML = view.html; - main.scrollTop = 0; - if (view.onMount) view.onMount(); - - // Expose the route so CSS can react to it (e.g. hide the AI launcher on - // the AI Assistant page, where it sat on top of the chat send button). - // Deliberately NOT `data-route`: initNav() binds a click handler to every - // [data-route] element, and matching that would fire on any click. - document.documentElement.setAttribute('data-view', route); - - // active nav - document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.route === route)); - document.title = 'TalentFlow · ' + (ROUTES[route] ? ROUTES[route].title : 'ATS'); - - // close mobile sidebar + AI dock - if (App.setNavOpen) App.setNavOpen(false); - else { - document.getElementById('sidebar').classList.remove('mobile-open'); - document.getElementById('scrim').classList.remove('open'); - } - const dock = document.getElementById('aiDock'); - if (dock) dock.classList.remove('open'); -}; - -function handleHash() { - const route = (location.hash || '#dashboard').slice(1); - Router.render(ROUTES[route] ? route : 'dashboard'); -} - -// ---------------- Theme ---------------- -// `persist` is false when the OS drives the change, so following the -// system stays the default until the user makes an explicit choice. -App.applyTheme = function (theme, persist) { - document.documentElement.setAttribute('data-theme', theme); - if (persist) { try { localStorage.setItem('tf-theme', theme); } catch (e) {} } - const btn = document.getElementById('themeToggle'); - if (btn) { - btn.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false'); - btn.setAttribute('title', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); - btn.setAttribute('aria-label', btn.getAttribute('title')); - } -}; -App.setTheme = function (theme) { - App.applyTheme(theme, true); - // re-render current view so canvas charts pick up new theme colors - Router.render(currentRoute); -}; -App.toggleTheme = function () { - const cur = document.documentElement.getAttribute('data-theme'); - App.setTheme(cur === 'dark' ? 'light' : 'dark'); -}; - -// ---------------- Session (tf-auth from /auth/) ---------------- -App.getSession = function () { - try { - const raw = localStorage.getItem('tf-auth'); - if (!raw) return null; - return JSON.parse(raw); - } catch (e) { - return null; - } -}; - -function initialsFromName(name) { - const parts = String(name || '').trim().split(/\s+/).filter(Boolean); - if (!parts.length) return '?'; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); -} - -App.hydrateProfile = function () { - const session = App.getSession(); - const data = (session && session.data) || {}; - const name = data.name || 'Guest'; - const email = data.email || ''; - const role = data.role_name || data.role || 'Member'; - const initials = initialsFromName(name); - - document.querySelectorAll('.profile-name').forEach(el => { el.textContent = name; }); - document.querySelectorAll('.profile-role').forEach(el => { el.textContent = role; }); - document.querySelectorAll('.dp-name').forEach(el => { el.textContent = name; }); - document.querySelectorAll('.dp-email').forEach(el => { el.textContent = email; }); - document.querySelectorAll('.avatar-grad').forEach(el => { el.textContent = initials; }); -}; - -App.signOut = function () { - try { localStorage.removeItem('tf-auth'); } catch (e) {} - window.location.assign('/auth/'); -}; - -// ---------------- Toast passthrough ---------------- -App.toast = function (msg, type, title) { UI.toast(msg, type, title); }; - -// ---------------- Badges ---------------- -App.updateBadges = function () { - const openJobs = DB.jobs.filter(j => j.status === 'Open').length; - setBadge('navJobsBadge', openJobs); - const unread = DB.notifications.filter(n => n.unread).length; - setBadge('navNotifBadge', unread); - const emailUnread = (window.Inbox && Array.isArray(Inbox._emails)) - ? Inbox._emails.filter(e => e.unread).length - : 0; - const inboxUnread = DB.inbox.filter(i => i.unread).length + emailUnread; - setBadge('navInboxBadge', inboxUnread); - const openTasks = DB.tasks.filter(t => !t.done).length; - setBadge('navTaskBadge', openTasks); -}; -function setBadge(id, n) { - const el = document.getElementById(id); - if (!el) return; - el.textContent = n; - el.style.display = n ? '' : 'none'; -} -App.markAllNotifsRead = function () { - DB.notifications.forEach(n => n.unread = false); - App.updateBadges(); - App.renderNotifDropdown(); - UI.toast('All notifications marked as read', 'success'); -}; - -// ---------------- Topbar dropdown content ---------------- -App.renderNotifDropdown = function () { - const list = document.getElementById('notifList'); - list.className = 'dd-scroll'; - list.innerHTML = DB.notifications.slice(0, 6).map(n => ` -
- ${UI.icon(n.icon)} -
${n.title}
${n.text}
${n.time}
-
`).join(''); -}; -App.renderMessages = function () { - const list = document.getElementById('messagesList'); - list.className = 'dd-scroll'; - list.innerHTML = DB.messages.map(m => ` -
- ${UI.avatar(m.name, m.initials, m.color)} -
${m.name}
${m.text}
${m.time} ago
-
`).join(''); -}; - -// ---------------- Global search ---------------- -App.search = function (q) { - const box = document.getElementById('searchResults'); - q = q.trim().toLowerCase(); - if (!q) { box.classList.remove('open'); return; } - - const jobs = DB.jobs.filter(j => (j.title + j.id + j.department).toLowerCase().includes(q)).slice(0, 4); - const cands = DB.candidates.filter(c => (c.name + c.email + c.jobTitle).toLowerCase().includes(q)).slice(0, 4); - const mgrs = DB.managers.filter(m => m.name.toLowerCase().includes(q)).slice(0, 3); - - let html = ''; - if (jobs.length) html += `
Jobs
` + jobs.map(j => - `
${UI.icon('briefcase')}
${j.title}
${j.id} · ${j.department}
`).join(''); - if (cands.length) html += `
Candidates
` + cands.map(c => - `
${UI.avatar(c.name, c.initials, c.color)}
${c.name}
${c.jobTitle}
`).join(''); - if (mgrs.length) html += `
Hiring Managers
` + mgrs.map(m => - `
${UI.avatar(m.name, m.initials, m.color)}
${m.name}
${m.title}
`).join(''); - if (!html) html = `
No results for "${q}"
`; - - box.innerHTML = html; - box.classList.add('open'); -}; -App.searchGo = function (route, cb) { - document.getElementById('searchResults').classList.remove('open'); - document.getElementById('globalSearch').value = ''; - Router.go(route); - if (cb) setTimeout(cb, 120); -}; - -// ---------------- Dropdown behavior ---------------- -function initDropdowns() { - document.querySelectorAll('.dropdown').forEach(dd => { - const toggle = dd.querySelector('[data-dd-toggle]'); - toggle.addEventListener('click', e => { - e.stopPropagation(); - const wasOpen = dd.classList.contains('open'); - document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); - if (!wasOpen) dd.classList.add('open'); - }); - dd.querySelector('[data-dd-panel]').addEventListener('click', e => e.stopPropagation()); - }); - document.addEventListener('click', () => { - document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); - document.getElementById('searchResults').classList.remove('open'); - }); -} - -// ---------------- Nav link interception ---------------- -function initNav() { - document.querySelectorAll('[data-route]').forEach(el => { - el.addEventListener('click', e => { - if (el.tagName === 'A') { /* href hash handles it */ } - const route = el.dataset.route; - if (route) { e.preventDefault(); Router.go(route); document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); } - }); - }); -} - -// ---------------- Init ---------------- -function init() { - // Theme: an explicit past choice wins; otherwise follow the OS and keep - // following it until the user picks a side themselves. - const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; - let saved = null; - try { saved = localStorage.getItem('tf-theme'); } catch (e) {} - App.applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false); - if (mq && !saved) { - const onSystemChange = e => { - let s = null; - try { s = localStorage.getItem('tf-theme'); } catch (err) {} - if (s) return; // user has chosen; stop following - App.applyTheme(e.matches ? 'dark' : 'light', false); - Router.render(currentRoute); // recolour canvas charts - }; - mq.addEventListener ? mq.addEventListener('change', onSystemChange) - : mq.addListener(onSystemChange); - } - - document.getElementById('themeToggle').addEventListener('click', App.toggleTheme); - - // Canvas charts are drawn at a fixed pixel size, so they blur when the - // window changes width. Re-render on resize — but never while a modal or - // the AI dock is open, since that would discard what the user is doing. - // Width only: on iOS/Android the address bar collapsing fires `resize` with - // a height change on nearly every scroll, and re-rendering there would tear - // the view out from under the user mid-gesture. - let resizeTimer, lastW = window.innerWidth; - window.addEventListener('resize', () => { - if (window.innerWidth === lastW) return; - lastW = window.innerWidth; - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - const busy = document.getElementById('modalRoot').classList.contains('open') - || document.getElementById('aiDock').classList.contains('open'); - if (!busy) Router.render(currentRoute); - }, 220); - }, { passive: true }); - window.addEventListener('orientationchange', () => { - lastW = -1; // force the next resize through - }); - - // AI Assistant floating dock - document.getElementById('aiFab').addEventListener('click', () => AI._dockOpen()); - - // sidebar collapse (desktop) - document.getElementById('sidebarCollapse').addEventListener('click', () => { - document.getElementById('sidebar').classList.toggle('collapsed'); - }); - // Mobile nav drawer. `nav-open` on is what CSS keys off — the FAB - // sits before .scrim in the DOM, so no sibling selector can reach it, and - // :has() would exclude older Safari/Firefox. - App.setNavOpen = function (open) { - document.getElementById('sidebar').classList.toggle('mobile-open', open); - document.getElementById('scrim').classList.toggle('open', open); - document.documentElement.classList.toggle('nav-open', open); - // Stop the page behind the drawer from scrolling under the user's finger. - document.body.style.overflow = open ? 'hidden' : ''; - }; - document.getElementById('mobileMenu').addEventListener('click', () => { - App.setNavOpen(!document.getElementById('sidebar').classList.contains('mobile-open')); - }); - document.getElementById('scrim').addEventListener('click', () => App.setNavOpen(false)); - - // search - const search = document.getElementById('globalSearch'); - search.addEventListener('input', () => App.search(search.value)); - search.addEventListener('click', e => e.stopPropagation()); - document.addEventListener('keydown', e => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); search.focus(); } - if (e.key === 'Escape') { UI.closeModal(); document.getElementById('searchResults').classList.remove('open'); document.getElementById('aiDock').classList.remove('open'); App.setNavOpen(false); } - }); - - initDropdowns(); - initNav(); - App.hydrateProfile(); - App.renderNotifDropdown(); - App.renderMessages(); - App.updateBadges(); - - window.addEventListener('hashchange', handleHash); - handleHash(); - - // redraw charts on resize (debounced) - let rt; - window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => Router.reload(), 250); }); -} - -document.addEventListener('DOMContentLoaded', init); diff --git a/js/assessments.js b/js/assessments.js deleted file mode 100644 index 0f2eb55..0000000 --- a/js/assessments.js +++ /dev/null @@ -1,126 +0,0 @@ -/* ============================================================ - assessments.js — Assessments listing & assign - ============================================================ */ -window.Views = window.Views || {}; -window.Assessments = {}; - -Views.assessments = function () { - const filters = { q: '', status: '', type: '' }; - let table; - - const stats = { - total: DB.assessments.length, - completed: DB.assessments.filter(a => a.status === 'Completed').length, - pending: DB.assessments.filter(a => ['Pending', 'In Progress'].includes(a.status)).length, - avg: Math.round(DB.assessments.filter(a => a.score).reduce((s, a) => s + a.score, 0) / (DB.assessments.filter(a => a.score).length || 1)) - }; - - function apply() { - const rows = DB.assessments.filter(a => { - if (filters.status && a.status !== filters.status) return false; - if (filters.type && a.type !== filters.type) return false; - if (filters.q && !(a.candidate + a.jobTitle + a.type).toLowerCase().includes(filters.q.toLowerCase())) return false; - return true; - }); - table.update(rows); - } - - table = UI.dataTable({ - pageSize: 8, - rows: DB.assessments, - columns: [ - { key: 'candidate', label: 'Candidate', sortable: true, render: a => `
${UI.avatar(a.candidate, a.initials, a.color)}
${a.candidate}
${a.jobTitle}
` }, - { key: 'type', label: 'Assessment', sortable: true, render: a => `
${a.type}
${a.duration}
` }, - { key: 'assigned', label: 'Assigned', sortable: true, sortValue: a => a.assigned.getTime(), render: a => `${DB.fmtShort(a.assigned)}` }, - { key: 'due', label: 'Due', sortable: true, sortValue: a => a.due.getTime(), render: a => `${DB.fmtShort(a.due)}` }, - { key: 'score', label: 'Score', sortable: true, align: 'center', render: a => a.score !== null ? UI.scoreChip(a.score) : '' }, - { key: 'status', label: 'Status', sortable: true, render: a => UI.badge(a.status) }, - { key: '_a', label: 'Actions', align: 'right', render: a => ` -
- - -
` } - ] - }); - - const statusOpts = [''].concat(['Completed', 'In Progress', 'Pending', 'Expired'].map(s => ``)).join(''); - const typeOpts = [''].concat([...new Set(DB.assessments.map(a => a.type))].map(t => ``)).join(''); - const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; - - const html = ` -
-
-

Assessments

Coding tests, take-homes, and evaluations

-
-
-
- ${statCard('Total Assigned', stats.total, 'file', 'i-indigo')} - ${statCard('Completed', stats.completed, 'check-circle', 'i-green')} - ${statCard('In Progress / Pending', stats.pending, 'clock', 'i-amber')} - ${statCard('Average Score', stats.avg + '%', 'target', 'i-teal')} -
-
-
-
- - - -
-
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - const s = document.getElementById('asSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('asStatus').onchange = e => { filters.status = e.target.value; apply(); }; - document.getElementById('asType').onchange = e => { filters.type = e.target.value; apply(); }; - } - }; -}; - -Assessments.view = function (id) { - const a = DB.assessments.find(x => x.id === id); - const body = ` -
${UI.avatar(a.candidate, a.initials, a.color, 'avatar-lg')} -
${a.candidate}
${a.type} · ${a.jobTitle}
-
${UI.badge(a.status)}
-
-
Type
${a.type}
-
Duration
${a.duration}
-
Assigned
${DB.fmtDate(a.assigned)}
-
Due
${DB.fmtDate(a.due)}
-
- ${a.score !== null ? ` -
-
-
${a.score}%
-
Overall Score
-
-
${UI.pbar(a.score)}
-
Section Breakdown
- ${['Problem Solving', 'Code Quality', 'Communication', 'Time Management'].map(sec => { - const sc = DB.int(60, 98); - return `
${sec}
${UI.pbar(sc)}
${sc}%
`; - }).join('')}` : `
${UI.icon('clock')}

Assessment not completed

Results will appear once the candidate submits.

`}`; - const footer = ` - `; - UI.modal({ title: 'Assessment Result', subtitle: a.id, body, footer }); -}; - -Assessments.assign = function () { - const opt = arr => arr.map(o => ``).join(''); - const body = `
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: 'Assign Assessment', subtitle: 'Send an evaluation to a candidate', body, footer }); -}; diff --git a/js/candidates.js b/js/candidates.js deleted file mode 100644 index ad80747..0000000 --- a/js/candidates.js +++ /dev/null @@ -1,441 +0,0 @@ -/* ============================================================ - candidates.js — Candidate list, filters, profile modal w/ tabs - ============================================================ */ -window.Views = window.Views || {}; -window.Candidates = {}; - -Views.candidates = function () { - const f = { q: '', job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '', manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '' }; - const selected = new Set(); - let sortMode = 'relevance'; - let table; - Candidates._selected = selected; - - function relevance(c) { - // composite relevance: ATS + matched-skill ratio + recency - const req = (DB.getJob(c.jobId) || {}).skills || []; - const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5; - const recency = 1 - Math.min(1, (new Date('2026-07-09') - c.applied) / (90 * 864e5)); - return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10); - } - - function apply() { - let rows = DB.candidates.filter(c => { - if (f.job && c.jobTitle !== f.job) return false; - if (f.skill && !c.skills.includes(f.skill)) return false; - if (f.dept && c.department !== f.dept) return false; - if (f.location && c.location !== f.location) return false; - if (f.exp === '0-2' && c.experience > 2) return false; - if (f.exp === '3-5' && (c.experience < 3 || c.experience > 5)) return false; - if (f.exp === '6-9' && (c.experience < 6 || c.experience > 9)) return false; - if (f.exp === '10+' && c.experience < 10) return false; - if (f.edu && c.education !== f.edu) return false; - if (f.recruiter && c.recruiter !== f.recruiter) return false; - if (f.manager) { const job = DB.getJob(c.jobId); if (!job || job.manager !== f.manager) return false; } - if (f.source && c.source !== f.source) return false; - if (f.ats === '85+' && c.aiScore < 85) return false; - if (f.ats === '70-84' && (c.aiScore < 70 || c.aiScore > 84)) return false; - if (f.ats === '<70' && c.aiScore >= 70) return false; - if (f.stage && c.stage !== f.stage) return false; - if (f.interview && c.interviewStatus !== f.interview) return false; - if (f.notice && c.noticePeriod !== f.notice) return false; - if (f.availability && c.availability !== f.availability) return false; - if (f.q) { const q = f.q.toLowerCase(); if (!(c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase().includes(q)) return false; } - return true; - }); - if (sortMode === 'relevance') rows = [...rows].sort((a, b) => relevance(b) - relevance(a)); - else if (sortMode === 'ats') rows = [...rows].sort((a, b) => b.aiScore - a.aiScore); - else if (sortMode === 'recent') rows = [...rows].sort((a, b) => b.applied - a.applied); - else if (sortMode === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name)); - table.update(rows); - updateBulkBar(); - const cnt = document.getElementById('canResultCount'); - if (cnt) cnt.textContent = rows.length + ' candidate' + (rows.length === 1 ? '' : 's'); - } - - function updateBulkBar() { - const bar = document.getElementById('bulkBar'); - if (!bar) return; - if (selected.size) { bar.style.display = 'flex'; document.getElementById('bulkCount').textContent = selected.size + ' selected'; } - else bar.style.display = 'none'; - } - - table = UI.dataTable({ - pageSize: 10, - rows: DB.candidates, - columns: [ - { key: '_sel', label: '', render: c => `${UI.icon('check')}` }, - { key: 'name', label: 'Candidate', sortable: true, render: c => `
${UI.avatar(c.name, c.initials, c.color)}
${c.name} ${c.favorite ? '' + UI.icon('star') + '' : ''}
${c.currentTitle} · ${c.location}
` }, - { key: 'jobTitle', label: 'Applied Job', sortable: true, render: c => `
${c.jobTitle}
${c.department}
` }, - { key: 'experience', label: 'Exp', sortable: true, align: 'center', render: c => `${c.experience}y` }, - { key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: c => relevance(c), render: c => `${relevance(c)}%` }, - { key: 'stage', label: 'Stage', sortable: true, render: c => UI.badge(c.stage) }, - { key: 'aiScore', label: 'ATS', sortable: true, align: 'center', render: c => `${UI.scoreChip(c.aiScore)}` }, - { key: 'availability', label: 'Availability', render: c => `${c.availability}
${c.noticePeriod} notice
` }, - { key: '_a', label: 'Actions', align: 'right', render: c => ` -
- - - - -
` } - ], - onRender(el) { - el.querySelectorAll('[data-sel]').forEach(chk => chk.onclick = () => { - const id = chk.dataset.sel; - if (selected.has(id)) selected.delete(id); else selected.add(id); - chk.classList.toggle('on'); updateBulkBar(); - }); - el.querySelectorAll('[data-fav]').forEach(st => st.onclick = () => { - const c = DB.getCandidate(st.dataset.fav); c.favorite = !c.favorite; - st.classList.toggle('on'); UI.toast(c.favorite ? c.name + ' added to favorites' : 'Removed from favorites', 'success'); - }); - } - }); - - const optList = (arr, label) => [``].concat(arr.map(o => ``)).join(''); - const jobTitles = [...new Set(DB.candidates.map(c => c.jobTitle))]; - - const filterPanel = ` - `; - - // recently viewed strip - const rv = DB.recentlyViewed.slice(0, 6).map(id => DB.getCandidate(id)).filter(Boolean); - const rvHtml = rv.length ? `
- Recently viewed: - ${rv.map(c => ``).join('')} -
` : ''; - - const html = ` -
-
-

Candidates

${DB.candidates.length} candidates · ranked by AI relevance

-
- - - -
-
- ${rvHtml} - -
-
-
- - -
- - -
- ${filterPanel} -
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - apply(); - const s = document.getElementById('canSearch'); - s.oninput = () => { f.q = s.value; apply(); }; - document.getElementById('canSort').onchange = e => { sortMode = e.target.value; apply(); }; - document.getElementById('filterToggle').onclick = () => { - const p = document.getElementById('filterPanel'); - p.style.display = p.style.display === 'none' ? 'grid' : 'none'; - }; - document.querySelectorAll('#filterPanel [data-f]').forEach(sel => sel.onchange = e => { f[e.target.dataset.f] = e.target.value; apply(); }); - } - }; -}; - -// ---------------- Bulk actions ---------------- -Candidates.bulk = function (action) { - const ids = [...Candidates._selected]; - if (!ids.length) return; - if (action === 'email') UI.toast(`Bulk email drafted to ${ids.length} candidates`, 'success'); - else if (action === 'assign') { - const opts = DB.recruiters.map(r => ``).join(''); - UI.modal({ title: 'Bulk Assign Recruiter', subtitle: ids.length + ' candidates', - body: `
`, - footer: `` }); - return; - } - else if (action === 'advance') { ids.forEach(id => Candidates._advanceSilent(id)); UI.toast(`${ids.length} candidates advanced`, 'success'); Router.reload(); return; } - else if (action === 'reject') { ids.forEach(id => { const c = DB.getCandidate(id); c.stage = 'Rejected'; c.status = 'Rejected'; }); UI.toast(`${ids.length} candidates rejected`, 'warning'); Router.reload(); return; } - Candidates.bulkClear(); -}; -Candidates._bulkAssign = function () { - const rec = document.getElementById('bulkRec').value; - [...Candidates._selected].forEach(id => { DB.getCandidate(id).recruiter = rec; }); - UI.closeModal(); UI.toast('Recruiter assigned to selected candidates', 'success'); - Candidates.bulkClear(); Router.reload(); -}; -Candidates.bulkClear = function () { Candidates._selected.clear(); Router.reload(); }; -Candidates._advanceSilent = function (id) { - const c = DB.getCandidate(id); - const order = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']; - const i = order.indexOf(c.stage); - if (i > -1 && i < order.length - 1) { c.stage = order[i + 1]; c.status = c.stage; } -}; - -// ---------------- ATS Match detail ---------------- -Candidates.atsMatch = function (id) { - const c = DB.getCandidate(id); - const job = DB.getJob(c.jobId) || {}; - const recCls = c.recommendation === 'Strong Match' ? 'recc-strong' : c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'; - const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)'; - const sub = c.subScores; - const subRow = (label, val) => `
${label}
${UI.pbar(val)}
${val}%
`; - - const body = ` -
- ${UI.icon(c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle')} -
${c.recommendation}
-
${c.name} for ${c.jobTitle}
-
-
-
-
${c.aiScore}
ATS MATCH
-
-
- ${subRow('Skills', sub.skills)} - ${subRow('Experience', sub.experience)} - ${subRow('Education', sub.education)} - ${subRow('Keywords', sub.keywords)} - ${subRow('Location', sub.location)} - ${subRow('Salary', sub.salary)} -
-
-
Matched Skills (${c.matchedSkills.length})
-
${c.matchedSkills.length ? c.matchedSkills.map(s => `${UI.icon('check')} ${s}`).join('') : ''}
-
Missing Skills (${c.missingSkills.length})
-
${c.missingSkills.length ? c.missingSkills.map(s => `${UI.icon('x')} ${s}`).join('') : 'None — full match'}
-
-

${UI.icon('sparkles')} Score computed from JD keywords, resume parsing, experience, education, location and salary alignment. Connect an AI model to refine with semantic matching.

`; - UI.modal({ - title: 'ATS Match Analysis', subtitle: c.id + ' · ' + c.jobTitle, body, size: 'modal-lg', - footer: `` - }); -}; - -Candidates.toggleFav = function (id, btn) { - const c = DB.getCandidate(id); c.favorite = !c.favorite; - if (btn) { btn.classList.toggle('on'); btn.innerHTML = UI.icon('star') + (c.favorite ? ' Favorited' : ' Favorite'); } - UI.toast(c.favorite ? c.name + ' added to favorites' : 'Removed from favorites', 'success'); -}; - -Candidates.advance = function (id) { - const c = DB.getCandidate(id); - const order = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']; - const i = order.indexOf(c.stage); - if (i === -1 || i >= order.length - 1) { UI.toast(c.name + ' cannot be advanced further', 'warning'); return; } - c.stage = order[i + 1]; c.status = c.stage; - UI.toast(`${c.name} moved to ${c.stage}`, 'success'); - Router.reload(); -}; - -// ---------------- Candidate profile w/ tabs ---------------- -Candidates.openProfile = function (id) { - const c = DB.getCandidate(id); - // track recently viewed - const rvIdx = DB.recentlyViewed.indexOf(id); - if (rvIdx > -1) DB.recentlyViewed.splice(rvIdx, 1); - DB.recentlyViewed.unshift(id); - if (DB.recentlyViewed.length > 12) DB.recentlyViewed.pop(); - const tabs = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']; - const body = ` -
- ${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')} -
-
${c.name}
-
${c.currentTitle} at ${c.currentCompany}
-
${UI.badge(c.stage)} ${UI.badge(c.source, 'b-gray')} - ${c.experience} yrs exp
-
-
${UI.scoreChip(c.aiScore)}
AI Match
-
-
- ${tabs.map((t, i) => `
${t}
`).join('')} -
-
- ${Candidates._pane('Overview', c)} - ${Candidates._pane('Resume', c)} - ${Candidates._pane('Timeline', c)} - ${Candidates._pane('Interview', c)} - ${Candidates._pane('Notes', c)} - ${Candidates._pane('Activity', c)} - ${Candidates._pane('Documents', c)} - ${Candidates._pane('Feedback', c)} -
`; - const footer = ` - - - `; - UI.modal({ title: 'Candidate Profile', subtitle: c.id, body, footer, size: 'modal-lg' }); - - const paneEls = document.querySelectorAll('#canPanes .tab-pane'); - document.querySelectorAll('#canTabs .tab').forEach(tab => tab.onclick = () => { - document.querySelectorAll('#canTabs .tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - paneEls.forEach(p => p.classList.remove('active')); - paneEls[+tab.dataset.tab].classList.add('active'); - }); -}; - -Candidates._pane = function (name, c) { - const active = name === 'Overview' ? 'active' : ''; - let content = ''; - if (name === 'Overview') { - content = ` -
-
Email
${c.email}
-
Phone
${c.phone}
-
Location
${c.location}
-
Applied For
${c.jobTitle}
-
Current Company
${c.currentCompany}
-
Experience
${c.experience} years
-
Education
${c.education}
-
Source
${c.source}
-
Recruiter
${c.recruiter}
-
Applied On
${DB.fmtDate(c.applied)}
-
Expected Salary
${DB.moneyK(c.salary)}
-
Rating
⭐ ${c.rating} / 5.0
-
-
Skills
-
${c.skills.map(s => `${s}`).join('')}
`; - } else if (name === 'Resume') { - content = `
-

${c.name}

${c.currentTitle} · ${c.location}

-
-
Summary
-

Results-driven ${c.currentTitle.toLowerCase()} with ${c.experience} years of experience across ${c.department.toLowerCase()}. Passionate about building high-quality products and collaborating with cross-functional teams.

-
Experience
-
${c.currentTitle} — ${c.currentCompany}
2021 – Present
-
Associate — ${DB.pick(DB.companies)}
2018 – 2021
-
Education
-
${c.education}
-
- `; - } else if (name === 'Timeline') { - const events = [ - { icon: 'user-plus', title: 'Application received', meta: DB.fmtDate(c.applied), desc: `Applied via ${c.source}` }, - { icon: 'star', title: 'AI screening completed', meta: '1 day later', desc: `Match score: ${c.aiScore}%` }, - { icon: 'phone', title: 'Recruiter screen', meta: '3 days later', desc: `Call with ${c.recruiter}` }, - { icon: 'calendar', title: 'Technical interview', meta: '1 week later', desc: 'Panel of 3 interviewers' }, - { icon: 'check', title: `Moved to ${c.stage}`, meta: 'Recently', desc: 'Current stage in pipeline' } - ]; - content = `
${events.map(e => ` -
${UI.icon(e.icon)}
-
${e.title}
${e.meta}
${e.desc}
`).join('')}
`; - } else if (name === 'Interview') { - const ivs = DB.interviews.filter(i => i.candidateId === c.id); - content = ivs.length ? `
${ivs.map(iv => ` -
${UI.icon('calendar')} -
${iv.type}
${DB.fmtDate(iv.when)} · ${iv.meeting}
-
${UI.badge(iv.status)}
`).join('')}
` - : `
${UI.icon('calendar')}

No interviews scheduled

Schedule an interview to get started.

-
`; - } else if (name === 'Notes') { - content = ` -
- -
-
${UI.avatar(c.recruiter)}
${c.recruiter}
Strong communication skills, great culture fit. Recommend advancing.
2 days ago
-
${UI.avatar('Asfand Ahmed', 'AA')}
Asfand Ahmed
Reviewed portfolio — impressive work. Schedule technical round.
4 days ago
-
`; - } else if (name === 'Activity') { - content = `
-
${UI.icon('eye')}
Profile viewed by ${c.recruiter}
1h ago
-
${UI.icon('mail')}
Email sent: Interview invitation
1 day ago
-
${UI.icon('star')}
Assessment score updated to ${c.aiScore}%
2 days ago
-
${UI.icon('user-plus')}
Applied for ${c.jobTitle}
${DB.fmtDate(c.applied)}
-
`; - } else if (name === 'Documents') { - const docs = [{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' }, { n: 'Portfolio.pdf', s: '4.2 MB' }, { n: 'References.docx', s: '48 KB' }]; - content = `
${docs.map(d => ` -
${UI.icon('file')} -
${d.n}
${d.s}
-
`).join('')}
`; - } else if (name === 'Feedback') { - const scores = ['Strong Hire', 'Hire', 'Lean Hire']; - content = `
- ${[0, 1, 2].map(i => `
${UI.avatar(DB.recruiters[i].name, DB.recruiters[i].initials, DB.recruiters[i].color)} -
${DB.recruiters[i].name}
${['Excellent technical depth and clear communication.', 'Good problem solving, would benefit from more system design exposure.', 'Solid candidate, positive team energy.'][i]}
-
${UI.badge(scores[i])}
`).join('')} -
- `; - } - return `
${content}
`; -}; - -Candidates.openAdd = function () { - const opt = (arr) => arr.map(o => ``).join(''); - const body = `
-
Required
-
Valid email required
-
-
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: 'Add Candidate', subtitle: 'Manually add a candidate to the pipeline', body, footer }); -}; -Candidates._add = function () { - const form = document.getElementById('canForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - let ok = true; - if (!f.name.trim()) { UI.fieldError(form.querySelector('[name=name]'), 'Required'); ok = false; } - if (!/^\S+@\S+\.\S+$/.test(f.email)) { UI.fieldError(form.querySelector('[name=email]'), 'Valid email required'); ok = false; } - if (!ok) { UI.toast('Please fix the highlighted fields', 'error'); return; } - const job = DB.jobs.find(j => j.title === f.job) || DB.jobs[0]; - const score = DB.int(55, 95); - DB.candidates.unshift({ - id: 'CAN-' + (5001 + DB.candidates.length), name: f.name, initials: DB.initials(f.name), color: DB.avatarColor(f.name), - email: f.email, phone: f.phone || '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department, - experience: +f.experience || 1, currentCompany: f.company || '—', currentTitle: job.title, location: job.location, - stage: f.stage, status: f.stage, aiScore: score, source: f.source, recruiter: job.recruiter, recruiterId: job.recruiterId, - applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: 120000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match', - subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' - }); - UI.closeModal(); - UI.toast('Candidate added to pipeline', 'success'); - Router.reload(); -}; diff --git a/js/dashboard.js b/js/dashboard.js deleted file mode 100644 index fc99eb7..0000000 --- a/js/dashboard.js +++ /dev/null @@ -1,170 +0,0 @@ -/* ============================================================ - dashboard.js — Main dashboard view - ============================================================ */ -window.Views = window.Views || {}; - -Views.dashboard = function () { - const k = DB.kpis; - const kpiCards = [ - { label: 'Open Jobs', value: k.openJobs, icon: 'briefcase', cls: 'i-indigo', trend: '+8%', dir: 'up', foot: 'vs last month' }, - { label: 'Total Candidates', value: k.totalCandidates, icon: 'users', cls: 'i-blue', trend: '+12%', dir: 'up', foot: 'active in pipeline' }, - { label: 'Interviews Today', value: k.interviewsToday, icon: 'calendar', cls: 'i-purple', trend: '3 upcoming', dir: 'flat', foot: 'next at 2:00 PM' }, - { label: 'Offers Accepted', value: k.offersAccepted, icon: 'check-circle', cls: 'i-green', trend: '+5%', dir: 'up', foot: `of ${k.offersSent} sent` } - ]; - const kpiCards2 = [ - { label: 'Time to Hire', value: k.timeToHire + ' days', icon: 'clock', cls: 'i-teal', trend: '-3 days', dir: 'up', foot: 'faster than target' }, - { label: 'Time to Fill', value: k.timeToFill + ' days', icon: 'target', cls: 'i-amber', trend: '-2 days', dir: 'up', foot: '41 day average' }, - { label: 'Cost per Hire', value: DB.money(k.costPerHire), icon: 'dollar', cls: 'i-red', trend: '+4%', dir: 'down', foot: 'above budget' }, - { label: 'Closed Jobs', value: k.closedJobs + k.hires, icon: 'award', cls: 'i-purple', trend: '+15%', dir: 'up', foot: 'this quarter' } - ]; - - function trendHtml(dir, txt) { - if (dir === 'flat') return `${txt}`; - const cls = dir === 'up' ? 'trend-up' : 'trend-down'; - const ic = dir === 'up' ? 'trending-up' : 'trending-down'; - return `${UI.icon(ic)}${txt}`; - } - const kpiHtml = arr => arr.map(c => ` -
-
- ${c.label} - ${UI.icon(c.icon)} -
-
${c.value}
-
${trendHtml(c.dir, c.trend)}${c.foot}
-
`).join(''); - - // upcoming interviews - const upcoming = DB.interviews.filter(iv => iv.status === 'Scheduled').slice(0, 5); - const upcomingHtml = upcoming.length ? upcoming.map(iv => ` -
- ${UI.avatar(iv.candidate, iv.candInitials, iv.color)} -
-
${iv.candidate}
-
${iv.type} · ${iv.jobTitle}
-
-
-
${DB.fmtShort(iv.when)}
-
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
-
-
`).join('') : '
No upcoming interviews
'; - - // recent applications - const recentApps = [...DB.candidates].sort((a, b) => b.applied - a.applied).slice(0, 5); - const recentHtml = recentApps.map(c => ` -
- ${UI.avatar(c.name, c.initials, c.color)} -
-
${c.name}
-
${c.jobTitle}
-
-
${UI.scoreChip(c.aiScore)}
-
`).join(''); - - // activity feed - const activityHtml = DB.activity.slice(0, 8).map(a => ` -
- ${UI.icon(a.icon)} -
-
${a.html}
-
${DB.relTime(a.time)}
-
-
`).join(''); - - // recruiter performance - const topRecs = [...DB.recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5); - const recPerf = topRecs.map(r => ` -
- ${UI.avatar(r.name, r.initials, r.color)} -
-
${r.name}
-
${r.openReqs} open reqs · ${r.avgTimeToHire}d avg
-
-
${r.hires}
hires
-
`).join(''); - - const html = ` -
-
-
-

Good morning, Asfand 👋

-

Here's what's happening with your hiring today — Thursday, July 9, 2026

-
-
- - -
-
- -
${kpiHtml(kpiCards)}
-
${kpiHtml(kpiCards2)}
- -
-
-
-

Hiring Trend

Hires vs applications over the last 7 months
-
7M1Y
-
-
-
- ${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])} -
-
-
-

Candidate Pipeline

Active by stage
-
-
-
-
-
- -
-
-

Upcoming Interviews

Next scheduled sessions
-
-
${upcomingHtml}
-
-
-

Source Analytics

Where candidates come from
-
-
-
- -
-
-

Recent Applications

-
-
${recentHtml}
-
-
-

Recruiter Performance

-
${recPerf}
-
-
-

Recent Activity

-
${activityHtml}
-
-
-
`; - - return { - html, - onMount() { - const a = DB.analytics; - Charts.line(document.getElementById('chartTrend'), { - labels: a.hiringTrend.labels, area: true, - datasets: [ - { label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] }, - { label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] } - ] - }); - Charts.horizontalBar(document.getElementById('chartPipeline'), { - labels: a.pipeline.map(p => p.stage), data: a.pipeline.map(p => p.count), - colors: Charts.PALETTE - }); - Charts.bar(document.getElementById('chartSource'), { - labels: a.sources.map(s => s.source), data: a.sources.map(s => s.count) - }); - } - }; -}; diff --git a/js/import.js b/js/import.js deleted file mode 100644 index a9998c9..0000000 --- a/js/import.js +++ /dev/null @@ -1,176 +0,0 @@ -/* ============================================================ - import.js — Manual CV Import (drag&drop, parse, match, dedupe) - ============================================================ */ -window.Views = window.Views || {}; -window.CVImport = {}; - -Views.import = function () { - const queue = []; // {name, size, status, atsScore, matchedJob, duplicate} - - const html = ` -
-
-

CV Import

Upload resumes — we parse, score, match, and dedupe automatically

-
- AI Resume Parser · Ready -
-
- -
-
-
-
-
${UI.icon('upload')}
-

Drag & drop resumes here

-

or click to browse — PDF, DOC, DOCX and ZIP supported · up to 20 files

- -
- ${['PDF', 'DOC', 'DOCX', 'ZIP'].map(t => `${t}`).join('')} -
-
-
- - - Files are processed locally in this demo -
-
- - -
- -
-

Auto-Processing

What happens on upload
-
- ${[ - { i: 'file', t: 'Resume parsing', d: 'Extract name, contact, experience, skills & education' }, - { i: 'target', t: 'ATS scoring', d: 'Generate a match score against the requisition' }, - { i: 'briefcase', t: 'Job matching', d: 'Suggest the best-matching open roles' }, - { i: 'users', t: 'Duplicate detection', d: 'Flag candidates already in the system' }, - { i: 'user-plus', t: 'Profile creation', d: 'Create a candidate profile in Applied stage' } - ].map(s => `
${UI.icon(s.i)}
${s.t}
${s.d}
`).join('')} -
-
-
-
`; - - CVImport._queue = queue; - - return { - html, - onMount() { - const dz = document.getElementById('dropzone'); - const browse = document.getElementById('browseBtn'); - dz.addEventListener('click', () => CVImport.simulate(DB.int(2, 4))); - browse.addEventListener('click', e => { e.stopPropagation(); CVImport.simulate(DB.int(2, 4)); }); - dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); }); - dz.addEventListener('dragleave', () => dz.classList.remove('drag')); - dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('drag'); CVImport.simulate(e.dataTransfer.files.length || DB.int(2, 4)); }); - CVImport._renderQueue(); - } - }; -}; - -CVImport.simulate = function (count, isZip) { - const n = isZip ? 8 : count; - const jobs = DB.jobs.filter(j => j.status === 'Open'); - for (let k = 0; k < n; k++) { - const name = DB.pick(['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']) + ' ' + DB.pick(['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']); - const item = { - id: 'UP-' + Math.random().toString(36).slice(2, 8), name, file: name.split(' ')[0] + '_Resume.' + DB.pick(['pdf', 'docx', 'doc']), - size: DB.int(120, 620) + ' KB', progress: 0, status: 'Uploading', atsScore: null, - job: DB.pick(jobs.length ? jobs : DB.jobs), duplicate: Math.random() < 0.18, imported: false - }; - CVImport._queue.push(item); - CVImport._process(item); - } - document.getElementById('queueCard').style.display = ''; - UI.toast(isZip ? 'ZIP extracted — 8 resumes queued' : n + ' file(s) uploaded', 'info'); - CVImport._renderQueue(); -}; - -CVImport._process = function (item) { - const tick = setInterval(() => { - item.progress += DB.int(12, 30); - if (item.progress >= 100) { - item.progress = 100; clearInterval(tick); - item.status = 'Parsing'; - CVImport._renderQueue(); - setTimeout(() => { - item.status = 'Ready'; item.atsScore = DB.int(52, 96); - CVImport._renderQueue(); - }, 700 + DB.int(0, 500)); - } - CVImport._renderQueue(); - }, 220); -}; - -CVImport._renderQueue = function () { - const el = document.getElementById('queueList'); - if (!el) return; - const q = CVImport._queue; - document.getElementById('queueSub').textContent = q.length + ' file' + (q.length === 1 ? '' : 's') + ' · ' + q.filter(i => i.imported).length + ' imported'; - el.innerHTML = q.map(i => ` -
- ${UI.icon('file')} -
-
${i.name} - ${i.duplicate ? 'DUPLICATE' : ''}
-
${i.file} · ${i.size}
- ${i.status === 'Uploading' || i.status === 'Parsing' ? `
` : - `
Best match: ${i.job.title}
`} -
-
- ${i.status === 'Ready' ? UI.scoreChip(i.atsScore) : `${i.status}${i.status === 'Uploading' ? ' ' + i.progress + '%' : ''}`} -
-
- ${i.imported ? `Imported` : - i.status === 'Ready' ? `` : - ``} -
-
`).join(''); -}; - -CVImport.importOne = function (id) { - const i = CVImport._queue.find(x => x.id === id); - if (!i || i.imported) return; - if (i.duplicate) { - UI.modal({ - title: 'Duplicate Detected', subtitle: i.name, - body: `
${UI.icon('users')} -

A similar candidate already exists

-

${i.name} matches an existing profile (95% similarity on name + email). Importing will create a duplicate.

`, - footer: ` - - ` - }); - return; - } - CVImport._doImport(id); -}; -CVImport._doImport = function (id) { - const i = CVImport._queue.find(x => x.id === id); - const job = i.job; - DB.candidates.unshift({ - id: 'CAN-' + (5001 + DB.candidates.length), name: i.name, initials: DB.initials(i.name), color: DB.avatarColor(i.name), - email: i.name.toLowerCase().replace(/ /g, '.') + '@email.com', phone: '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department, - experience: DB.int(2, 12), currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations), - stage: 'Applied', status: 'Applied', aiScore: i.atsScore, source: 'Manual CV Upload', recruiter: DB.pick(DB.recruiters).name, recruiterId: '', - applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: DB.int(90, 180) * 1000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: i.atsScore >= 82 ? 'Strong Match' : 'Potential Match', - subScores: { skills: i.atsScore, experience: 80, education: 80, keywords: i.atsScore, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' - }); - i.imported = true; - CVImport._renderQueue(); App.updateBadges(); - UI.toast(`${i.name} imported → ${job.title}`, 'success'); -}; -CVImport.importAll = function () { - const ready = CVImport._queue.filter(i => i.status === 'Ready' && !i.imported && !i.duplicate); - if (!ready.length) { UI.toast('No files ready to import', 'warning'); return; } - ready.forEach(i => CVImport._doImport(i.id)); - UI.toast(`${ready.length} candidates imported`, 'success'); -}; diff --git a/js/inbox.js b/js/inbox.js deleted file mode 100644 index 28e0008..0000000 --- a/js/inbox.js +++ /dev/null @@ -1,419 +0,0 @@ -/* ============================================================ - inbox.js — Central Recruitment Inbox + Outlook Email tab - ============================================================ */ -window.Views = window.Views || {}; -window.Inbox = {}; - -Views.inbox = function () { - const state = { tab: 'All Applications', selected: null, emailSelected: null, q: '' }; - const tabs = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']; - - function filtered() { - let list = DB.inbox; - if (state.tab === 'Unread') list = list.filter(i => i.processing === 'Unread'); - else if (state.tab === 'Imported') list = list.filter(i => i.processing === 'Imported'); - else if (state.tab === 'Processed') list = list.filter(i => i.processing === 'Processed'); - else if (state.tab === 'Rejected') list = list.filter(i => i.processing === 'Rejected'); - else if (state.tab === 'Duplicates') list = list.filter(i => i.duplicate); - if (state.q) list = list.filter(i => (i.name + i.position + i.source).toLowerCase().includes(state.q.toLowerCase())); - return list; - } - - function counts() { - return { - 'All Applications': DB.inbox.length, - 'Unread': DB.inbox.filter(i => i.processing === 'Unread').length, - 'Imported': DB.inbox.filter(i => i.processing === 'Imported').length, - 'Processed': DB.inbox.filter(i => i.processing === 'Processed').length, - 'Rejected': DB.inbox.filter(i => i.processing === 'Rejected').length, - 'Duplicates': DB.inbox.filter(i => i.duplicate).length, - 'Email': (Inbox._emails || []).filter(e => e.unread).length - }; - } - - function sourceChip(item) { - const m = item.sourceMeta; - // The dot carries the partner's brand colour; the label uses theme text. - // Rendering 11px labels in the partner colour failed AA in both themes. - // `--chip` carries the source colour; CSS mixes the tint. String-concat - // alpha ("#0a66c214") breaks for the tokenised sources (var(--c1)14). - return `${item.source}`; - } - - function renderList() { - const el = document.getElementById('inboxList'); - if (!el) return; - const list = filtered(); - if (!list.length) { el.innerHTML = `
${UI.icon('inbox')}

Nothing here

No applications in this view.

`; return; } - el.innerHTML = list.map(i => ` -
- ${UI.avatar(i.name, i.initials, i.color)} -
-
${i.name} ${i.duplicate ? 'DUP' : ''}
-
${i.position}
-
${sourceChip(i)} ${UI.badge(i.processing)}
-
-
-
${DB.relTime(Math.round((new Date('2026-07-09T20:00') - i.received) / 60000))}
-
${UI.scoreChip(i.atsScore)}
-
-
`).join(''); - el.querySelectorAll('.inbox-item').forEach(row => row.onclick = () => { - state.selected = row.dataset.id; - const it = DB.inbox.find(x => x.id === state.selected); if (it) it.unread = false; - renderList(); renderDetail(); App.updateBadges(); - }); - } - - function renderDetail() { - const el = document.getElementById('inboxDetail'); - if (!el) return; - const i = DB.inbox.find(x => x.id === state.selected); - if (!i) { el.innerHTML = `
${UI.icon('inbox')}

Select an application

Choose an item from the list to view details and take action.

`; return; } - const rec = DB.atsRecommendationClass; - const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'; - el.innerHTML = ` -
-
- ${UI.avatar(i.name, i.initials, i.color, 'avatar-lg')} -
${i.name}
-
${i.position}
-
${sourceChip(i)} ${UI.badge(i.processing)} ${UI.badge(i.resumeStatus, i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber')}
-
-
-
-
${i.atsScore}
-
ATS Score
-
-
- -
-
Email
${i.email}
-
Phone
${i.phone}
-
Experience
${i.experience} years
-
Assigned Recruiter
${i.recruiter}
-
Received
${DB.fmtDate(i.received)}
-
Match
${UI.badge(recLabel, rec(recLabel))}
-
- -
-
-
${UI.icon('paperclip')} ${i.attachment}
- -
-
${Inbox._resumeText(i)}
-
- -
- - - - - - -
-
`; - } - - function renderBody() { - const body = document.getElementById('inboxBody'); - if (state.tab === 'Email') { body.innerHTML = Inbox._emailView(); Inbox._bindEmail(state); return; } - body.innerHTML = ` -
-
-
- -
-
-
-
-
`; - renderList(); renderDetail(); - const s = document.getElementById('inboxSearch'); - s.oninput = () => { state.q = s.value; renderList(); }; - } - - Inbox._render = { list: renderList, detail: renderDetail, body: renderBody }; - Inbox._state = state; - - const c = counts(); - const tabHtml = tabs.map(t => `
${t} ${c[t]}
`).join(''); - - const html = ` -
-
-

Recruitment Inbox

Every candidate, every source — one unified queue

-
- Microsoft Graph API · Connected - - -
-
-
-
${tabHtml}
-
-
-
`; - - return { - html, - onMount() { - renderBody(); - document.querySelectorAll('#inboxTabs .tab').forEach(tab => tab.onclick = () => { - state.tab = tab.dataset.tab; state.selected = null; - document.querySelectorAll('#inboxTabs .tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - renderBody(); - }); - } - }; -}; - -Inbox._resumeText = function (i) { - return `${i.name.toUpperCase()}\n${i.email} · ${i.phone}\n${'—'.repeat(30)}\nPROFESSIONAL SUMMARY\n${i.experience} years of experience. Applied for ${i.position} via ${i.source}.\n\nEXPERIENCE\n• ${DB.pick(DB.companies)} — Senior role (2021–Present)\n• ${DB.pick(DB.companies)} — Associate (2018–2021)\n\nEDUCATION\n• Bachelor's Degree, Computer Science\n\nSKILLS\n• ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}`; -}; - -Inbox.viewResume = function (id) { - const i = DB.inbox.find(x => x.id === id); - UI.modal({ - title: i.attachment, subtitle: 'Resume preview · ' + i.name, - body: `
${Inbox._resumeText(i)}
`, - footer: ``, - size: 'modal-lg' - }); -}; - -Inbox.parse = function (id) { - const i = DB.inbox.find(x => x.id === id); - i.resumeStatus = 'Parsing'; - Inbox._render.detail(); - UI.toast('Parsing resume with AI…', 'info'); - setTimeout(() => { i.resumeStatus = 'Parsed'; i.atsScore = DB.int(60, 96); Inbox._render.list(); Inbox._render.detail(); UI.toast('Resume parsed — profile fields extracted', 'success'); }, 1100); -}; - -Inbox.import = function (id) { - const i = DB.inbox.find(x => x.id === id); - const job = DB.getJob(i.jobId) || DB.jobs[0]; - // create candidate - const newC = { - id: 'CAN-' + (5001 + DB.candidates.length), name: i.name, initials: i.initials, color: i.color, - email: i.email, phone: i.phone, jobId: job.id, jobTitle: job.title, department: job.department, - experience: i.experience, currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations), - stage: 'Applied', status: 'Applied', aiScore: i.atsScore, source: i.source, recruiter: i.recruiter, recruiterId: '', - applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: DB.int(90, 180) * 1000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: i.atsScore >= 82 ? 'Strong Match' : 'Potential Match', - subScores: { skills: i.atsScore, experience: i.atsScore, education: 80, keywords: i.atsScore, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' - }; - DB.candidates.unshift(newC); - i.processing = 'Imported'; i.unread = false; - Inbox._render.list(); Inbox._render.detail(); App.updateBadges(); - UI.toast(`${i.name} imported → Applied stage of ${job.title}`, 'success'); -}; - -Inbox.moveToPipeline = function (id) { - const i = DB.inbox.find(x => x.id === id); - if (i.processing !== 'Imported' && i.processing !== 'Processed') Inbox.import(id); - i.processing = 'Processed'; - Inbox._render.list(); Inbox._render.detail(); - UI.toast(`${i.name} moved to pipeline`, 'success'); - setTimeout(() => Router.go('pipeline'), 700); -}; - -Inbox.assign = function (id) { - const i = DB.inbox.find(x => x.id === id); - const opts = DB.recruiters.map(r => ``).join(''); - UI.modal({ - title: 'Assign Recruiter', subtitle: i.name, - body: `
-

Current workload is factored automatically. This recruiter has ${DB.getRecruiterByName(i.recruiter) ? DB.getRecruiterByName(i.recruiter).openReqs : 5} open reqs.

`, - footer: `` - }); -}; -Inbox._doAssign = function (id) { - const i = DB.inbox.find(x => x.id === id); - i.recruiter = document.getElementById('assignRec').value; - UI.closeModal(); Inbox._render.detail(); - UI.toast('Recruiter assigned to ' + i.name, 'success'); -}; - -Inbox.note = function (id) { - const i = DB.inbox.find(x => x.id === id); - UI.modal({ - title: 'Add Note', subtitle: i.name, - body: `
`, - footer: `` - }); -}; - -Inbox.reject = function (id) { - const i = DB.inbox.find(x => x.id === id); - i.processing = 'Rejected'; i.unread = false; - Inbox._render.list(); Inbox._render.detail(); App.updateBadges(); - UI.toast(`${i.name} rejected`, 'warning'); -}; - -// ---------------- Email (Outlook) tab ---------------- -Inbox._emails = []; -Inbox._lastSync = null; - -Inbox._mapApiEmail = function (row) { - const from = row.sender_name || row.fromEmail || 'Unknown'; - return { - id: String(row.id), - from, - fromEmail: row.fromEmail || '', - subject: row.subject || '', - body: row.body || '', - when: row.when ? new Date(row.when) : new Date(), - unread: !!row.unread, - attachment: row.attachment_name || 'Resume.pdf', - attachmentSize: '—', - atsScore: 70, - imported: false, - jobId: null, - jobTitle: '' - }; -}; - -Inbox._syncLabel = function () { - if (!Inbox._lastSync) return 'Not synced yet'; - const mins = Math.max(0, Math.round((Date.now() - Inbox._lastSync.getTime()) / 60000)); - if (mins < 1) return 'Just now'; - return DB.relTime(mins); -}; - -Inbox._loadEmails = async function () { - const res = await Api.get('/inbox/fetch'); - const rows = Array.isArray(res.data) ? res.data : []; - Inbox._emails = rows.map(Inbox._mapApiEmail); - Inbox._lastSync = new Date(); - return Inbox._emails; -}; - -Inbox._refreshEmailCounts = function () { - const tab = document.querySelector('#inboxTabs .tab[data-tab="Email"]'); - if (tab) { - const countEl = tab.querySelector('.k-count'); - if (countEl) countEl.textContent = Inbox._emails.filter(e => e.unread).length; - } - if (window.App && App.updateBadges) App.updateBadges(); -}; - -Inbox._emailView = function () { - return ` -
- Outlook · Microsoft Graph API - Loading… - -
-
-
${UI.icon('mail')}

Loading…

Fetching mailbox from the server.

-
-
`; -}; - -Inbox._bindEmail = function (state) { - const detail = document.getElementById('emailDetail'); - - function renderDetail() { - const e = Inbox._emails.find(x => x.id === state.emailSelected); - if (!e) { detail.innerHTML = `
${UI.icon('mail')}

Select an email

Preview email body and resume attachments here.

`; return; } - detail.innerHTML = `
-
-

${e.subject}

${e.imported ? UI.badge('Imported', 'b-green') : UI.badge('New', 'b-blue')}
-
- ${UI.avatar(e.from, e.initials, e.color)} -
${e.from}
${e.fromEmail} · ${DB.fmtDate(e.when)}
-
- -
- ${UI.icon('file')} -
${e.attachment}
${e.attachmentSize} · PDF
-
${UI.scoreChip(e.atsScore)} -
-
-
- ${e.imported ? `` : - ``} - - -
-
`; - } - - function paintList() { - const list = document.getElementById('emailList'); - const meta = document.getElementById('emailSyncMeta'); - if (!list) return; - if (!Inbox._emails.length) { - list.innerHTML = `
${UI.icon('mail')}

Nothing here

No emails in the mailbox.

`; - } else { - list.innerHTML = Inbox._emails.map(e => ` -
- ${UI.avatar(e.from, e.initials, e.color)} -
-
${e.from}
-
${e.subject}
-
Outlook${e.imported ? UI.badge('Imported', 'b-green') : ''}
-
-
${DB.fmtShort(e.when)}
-
`).join(''); - list.querySelectorAll('.inbox-item').forEach(row => row.onclick = () => { - state.emailSelected = row.dataset.email; - const e = Inbox._emails.find(x => x.id === state.emailSelected); if (e) e.unread = false; - list.querySelectorAll('.inbox-item').forEach(r => r.classList.remove('active', 'unread')); - row.classList.add('active'); - renderDetail(); Inbox._refreshEmailCounts(); - }); - } - if (meta) meta.textContent = `Last sync: ${Inbox._syncLabel()} · ${Inbox._emails.filter(e => e.unread).length} unread`; - renderDetail(); - Inbox._refreshEmailCounts(); - } - - Inbox._paintEmail = paintList; - renderDetail(); - - Inbox._loadEmails() - .then(() => paintList()) - .catch(err => { - const list = document.getElementById('emailList'); - const meta = document.getElementById('emailSyncMeta'); - if (list) list.innerHTML = `
${UI.icon('mail')}

Couldn't load mailbox

${err.message || 'Request failed'}

`; - if (meta) meta.textContent = 'Sync failed'; - UI.toast(err.message || 'Failed to load mailbox', 'error'); - renderDetail(); - }); -}; - -Inbox.syncMailbox = async function () { - UI.toast('Fetching from Outlook…', 'info'); - try { - await Inbox._loadEmails(); - if (typeof Inbox._paintEmail === 'function') Inbox._paintEmail(); - UI.toast('Mailbox synced', 'success'); - } catch (err) { - UI.toast(err.message || 'Sync failed', 'error'); - } -}; - -Inbox._importEmail = function (id) { - const e = Inbox._emails.find(x => x.id === id); - if (!e) return; - const job = DB.getJob(e.jobId) || DB.jobs[0]; - DB.candidates.unshift({ - id: 'CAN-' + (5001 + DB.candidates.length), name: e.from, initials: e.initials || DB.initials(e.from), color: e.color || DB.avatarColor(e.from), - email: e.fromEmail, phone: '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department, - experience: DB.int(2, 10), currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations), - stage: 'Applied', status: 'Applied', aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: DB.pick(DB.recruiters).name, recruiterId: '', - applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: 'Potential Match', - subScores: { skills: e.atsScore, experience: 80, education: 80, keywords: e.atsScore, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' - }); - e.imported = true; e.unread = false; - if (typeof Inbox._paintEmail === 'function') Inbox._paintEmail(); - App.updateBadges(); - UI.toast(`${e.from} imported from Outlook → ${job.title}`, 'success'); -}; diff --git a/js/interviews.js b/js/interviews.js deleted file mode 100644 index 54ae7b1..0000000 --- a/js/interviews.js +++ /dev/null @@ -1,209 +0,0 @@ -/* ============================================================ - interviews.js — Interviews list, upcoming, mini calendar - ============================================================ */ -window.Views = window.Views || {}; -window.Interviews = {}; - -Views.interviews = function () { - const filters = { q: '', status: '', type: '' }; - let table; - - const upcoming = DB.interviews.filter(iv => iv.status === 'Scheduled').slice(0, 4); - const stats = { - scheduled: DB.interviews.filter(i => i.status === 'Scheduled').length, - completed: DB.interviews.filter(i => i.status === 'Completed').length, - today: 5, - cancelled: DB.interviews.filter(i => ['Cancelled', 'No Show'].includes(i.status)).length - }; - - function apply() { - const rows = DB.interviews.filter(iv => { - if (filters.status && iv.status !== filters.status) return false; - if (filters.type && iv.type !== filters.type) return false; - if (filters.q && !(iv.candidate + iv.jobTitle + iv.interviewers.join(' ')).toLowerCase().includes(filters.q.toLowerCase())) return false; - return true; - }); - table.update(rows); - } - - table = UI.dataTable({ - pageSize: 8, - rows: DB.interviews, - columns: [ - { key: 'candidate', label: 'Candidate', sortable: true, render: iv => `
${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
${iv.candidate}
${iv.jobTitle}
` }, - { key: 'type', label: 'Round', sortable: true, render: iv => UI.badge(iv.type, 'b-indigo') }, - { key: 'when', label: 'Date & Time', sortable: true, sortValue: iv => iv.when.getTime(), render: iv => `
${DB.fmtShort(iv.when)}
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · ${iv.duration}m
` }, - { key: 'meeting', label: 'Type', render: iv => `${UI.icon(iv.meeting === 'Video Call' ? 'video' : iv.meeting === 'Phone' ? 'phone' : 'map')} ${iv.meeting}` }, - { key: 'interviewers', label: 'Interviewers', render: iv => UI.avatarStack(iv.interviewers) }, - { key: 'status', label: 'Status', sortable: true, render: iv => UI.badge(iv.status) }, - { key: 'feedback', label: 'Feedback', render: iv => iv.feedback ? UI.badge(iv.feedback) : '' }, - { key: '_a', label: 'Actions', align: 'right', render: iv => ` -
- - -
` } - ] - }); - - const statusOpts = [''].concat(['Scheduled', 'Completed', 'Cancelled', 'No Show'].map(s => ``)).join(''); - const typeOpts = [''].concat(DB.interviewTypes.map(t => ``)).join(''); - - const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; - - const upcomingHtml = upcoming.map(iv => ` -
- ${UI.avatar(iv.candidate, iv.candInitials, iv.color)} -
${iv.candidate}
${iv.type} · ${iv.meeting}
-
${DB.fmtShort(iv.when)}
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
-
`).join(''); - - const html = ` -
-
-

Interviews

Manage and track all interview activity

-
- - -
-
-
- ${statCard('Scheduled', stats.scheduled, 'calendar', 'i-blue')} - ${statCard('Completed', stats.completed, 'check-circle', 'i-green')} - ${statCard('Today', stats.today, 'clock', 'i-purple')} - ${statCard('Cancelled / No-show', stats.cancelled, 'x-circle', 'i-red')} -
-
-
-

All Interviews

-
-
- - - -
-
- ${table.html} -
-
-

Up Next

Scheduled sessions
-
${upcomingHtml}
-
-
-
`; - - return { - html, - onMount() { - table.mount(); - const s = document.getElementById('ivSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('ivStatus').onchange = e => { filters.status = e.target.value; apply(); }; - document.getElementById('ivType').onchange = e => { filters.type = e.target.value; apply(); }; - } - }; -}; - -Interviews.feedback = function (id) { - const iv = DB.interviews.find(i => i.id === id); - // pick evaluation template by department - const job = DB.jobs.find(j => j.title === iv.jobTitle); - const dept = job ? job.department : 'All'; - const tmpl = DB.evalTemplates.find(t => t.dept === dept) || DB.evalTemplates.find(t => t.dept === 'All'); - const tmplOpts = DB.evalTemplates.map(t => ``).join(''); - - const ratingRow = (crit) => ` -
-

${crit}

-
- ${[1, 2, 3, 4, 5].map(n => `${UI.icon('star')}`).join('')} -
-
`; - - const body = ` -
${UI.avatar(iv.candidate, iv.candInitials, iv.color, 'avatar-lg')} -
${iv.candidate}
${iv.type} · ${iv.jobTitle}
- ${UI.badge(iv.status)}
- -
-
Dynamic Form
-
Upload Sheet
-
Both
-
- -
-
-
-
${tmpl.criteria.map(ratingRow).join('')}
-
-
-
-
- -
-
-
${UI.icon('upload')}
-

Upload evaluation sheet

-

PDF, DOC, or DOCX · scanned scorecards supported

-
${['PDF', 'DOC', 'DOCX'].map(t => `${t}`).join('')}
-
-
- -
-

Capture structured ratings and attach a signed sheet — both are stored on the scorecard.

-
${tmpl.criteria.slice(0, 3).map(ratingRow).join('')}
-
${UI.icon('file')} -
Interviewer_Scorecard.pdf
Attached · 214 KB
${UI.badge('Uploaded', 'b-green')}
-
-
`; - - const footer = ` - `; - UI.modal({ title: 'Interview Evaluation', subtitle: iv.id + ' · ' + iv.type, body, footer, size: 'modal-lg' }); - - // wire tabs - const panes = document.querySelectorAll('#evalPanes .tab-pane'); - document.querySelectorAll('#evalTabs .tab').forEach(tab => tab.onclick = () => { - document.querySelectorAll('#evalTabs .tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - panes.forEach(p => p.classList.remove('active')); - panes[+tab.dataset.etab].classList.add('active'); - }); - // wire star ratings - Interviews._bindStars(); - // template switch rebuilds criteria - const tmplSel = document.getElementById('evalTmpl'); - if (tmplSel) tmplSel.onchange = () => { - const t = DB.evalTemplates.find(x => x.name === tmplSel.value); - document.getElementById('critList').innerHTML = t.criteria.map(ratingRow).join(''); - Interviews._bindStars(); - }; - // recommendation seg - document.querySelectorAll('[data-rec]').forEach(b => b.onclick = () => { - b.parentElement.querySelectorAll('button').forEach(x => x.classList.remove('active')); - b.classList.add('active'); - }); -}; -Interviews._bindStars = function () { - document.querySelectorAll('.rating-stars').forEach(group => { - group.querySelectorAll('.rs').forEach(star => star.onclick = () => { - const val = +star.dataset.val; - group.querySelectorAll('.rs').forEach(s => s.classList.toggle('on', +s.dataset.val <= val)); - }); - }); -}; - -Interviews.schedule = function () { - const opt = arr => arr.map(o => ``).join(''); - const body = `
-
-
-
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: 'Schedule Interview', subtitle: 'Set up a new interview session', body, footer }); -}; diff --git a/js/jobboard.js b/js/jobboard.js deleted file mode 100644 index efaf466..0000000 --- a/js/jobboard.js +++ /dev/null @@ -1,172 +0,0 @@ -/* ============================================================ - jobboard.js — Job Posting Center: publish + track performance - ============================================================ */ -window.Views = window.Views || {}; -window.JobBoard = {}; - -Views.jobboard = function () { - const totals = DB.publishings.reduce((a, p) => ({ views: a.views + p.views, clicks: a.clicks + p.clicks, apps: a.apps + p.apps }), { views: 0, clicks: 0, apps: 0 }); - const conv = totals.views ? ((totals.apps / totals.views) * 100).toFixed(1) : 0; - - const statCard = (label, val, icn, cls, sub) => `
${label}${UI.icon(icn)}
${val}
${sub}
`; - - // per-platform aggregate - const platAgg = {}; - DB.publishings.forEach(p => { - if (!platAgg[p.platform]) platAgg[p.platform] = { views: 0, clicks: 0, apps: 0, jobs: 0 }; - platAgg[p.platform].views += p.views; platAgg[p.platform].clicks += p.clicks; platAgg[p.platform].apps += p.apps; platAgg[p.platform].jobs++; - }); - const platRows = Object.entries(platAgg).sort((a, b) => b[1].apps - a[1].apps); - - // publishing table - const table = UI.dataTable({ - pageSize: 8, - rows: DB.publishings, - columns: [ - { key: 'jobTitle', label: 'Job', sortable: true, render: p => `
${p.jobTitle}
${p.jobId}
` }, - { key: 'platform', label: 'Platform', sortable: true, render: p => { const pl = DB.publishPlatforms.find(x => x.name === p.platform) || {}; return `${p.platform}`; } }, - { key: 'status', label: 'Status', sortable: true, render: p => UI.badge(p.status, p.status === 'Live' ? 'b-green' : p.status === 'Paused' ? 'b-amber' : 'b-blue') }, - { key: 'views', label: 'Views', sortable: true, align: 'right', render: p => p.views.toLocaleString() }, - { key: 'clicks', label: 'Clicks', sortable: true, align: 'right', render: p => p.clicks.toLocaleString() }, - { key: 'apps', label: 'Applications', sortable: true, align: 'right', render: p => `${p.apps}` }, - { key: '_conv', label: 'Conversion', sortable: true, sortValue: p => p.apps / p.views, render: p => `${((p.apps / p.views) * 100).toFixed(1)}%` }, - { key: '_a', label: '', align: 'right', render: p => `` } - ] - }); - - const html = ` -
-
-

Job Board

Publish requisitions across channels and track performance

-
- - -
-
- -
- ${statCard('Total Views', totals.views.toLocaleString(), 'eye', 'i-blue', 'across all platforms')} - ${statCard('Total Clicks', totals.clicks.toLocaleString(), 'target', 'i-purple', ((totals.clicks / totals.views) * 100).toFixed(1) + '% CTR')} - ${statCard('Applications', totals.apps.toLocaleString(), 'users', 'i-green', 'from job boards')} - ${statCard('Conversion Rate', conv + '%', 'trending-up', 'i-teal', 'view → application')} -
- -
-
-

Platform Performance

Applications by channel
-
-
-
-

Connected Platforms

-
- ${DB.publishPlatforms.map(p => `
- -
${p.name}
${p.cost === 'Free' ? 'Free posting' : 'Paid · ' + p.cost}
- ${p.connected ? UI.badge('Connected', 'b-green') : ``} -
`).join('')} -
-
-
- -
-

Active Postings

${DB.publishings.length} live postings across ${platRows.length} platforms
-
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - Charts.horizontalBar(document.getElementById('jbChart'), { - labels: platRows.map(p => p[0]), data: platRows.map(p => p[1].apps) - }); - } - }; -}; - -// ---------------- Publish flow (stepper modal) ---------------- -JobBoard.publishFlow = function (jobId) { - const state = { step: 1, jobId: jobId || DB.jobs.filter(j => j.status === 'Open')[0].id, platforms: ['Career Portal'] }; - JobBoard._state = state; - JobBoard._renderFlow(); -}; - -JobBoard._renderFlow = function () { - const state = JobBoard._state; - const steps = ['Select Job', 'Approval', 'Platforms', 'Publish']; - const stepper = `
${steps.map((s, i) => { - const n = i + 1; - const cls = n < state.step ? 'done' : n === state.step ? 'active' : ''; - return `
${n < state.step ? '✓' : n}
${s}
${i < steps.length - 1 ? `
` : ''}`; - }).join('')}
`; - - let body = stepper; - if (state.step === 1) { - const opts = DB.jobs.filter(j => j.status !== 'Draft').map(j => ``).join(''); - const job = DB.getJob(state.jobId); - body += `
-
-
${UI.icon('briefcase')} -
${job.title}
${job.department} · ${job.location} · ${job.type}
-
`; - } else if (state.step === 2) { - body += `
-
${UI.icon('check-circle')} -
Approval granted
Approved by Department Head · Budget confirmed
-

Hiring Manager sign-off

${UI.badge('Approved', 'b-green')}
-

Finance budget approval

${UI.badge('Approved', 'b-green')}
-

Compliance review

${UI.badge('Approved', 'b-green')}
-
`; - } else if (state.step === 3) { - body += `

Select the platforms to publish this role to

-
- ${DB.publishPlatforms.map(p => `
- -
${p.name}
${p.cost === 'Free' ? 'Free' : 'Paid · ' + p.cost}
- ${UI.icon('check')} -
`).join('')} -
`; - } else if (state.step === 4) { - body += `
-
${UI.icon('check-circle')}
-

Published Successfully

-

${DB.getJob(state.jobId).title} is now live on ${state.platforms.length} platform${state.platforms.length > 1 ? 's' : ''}

-
- ${state.platforms.map(p => { const pl = DB.publishPlatforms.find(x => x.name === p); return `${pl.name}`; }).join('')} -
-
`; - } - - let footer; - if (state.step === 4) footer = ``; - else footer = ` - `; - - UI.modal({ title: 'Publish Job', subtitle: 'Distribute this requisition to job boards', body, footer, size: 'modal-lg' }); - - if (state.step === 3) { - document.querySelectorAll('#platGrid .platform-card').forEach(card => card.onclick = () => { - const name = card.dataset.plat; - const idx = state.platforms.indexOf(name); - if (idx > -1) state.platforms.splice(idx, 1); else state.platforms.push(name); - card.classList.toggle('selected'); - }); - } -}; -JobBoard._next = function () { - const state = JobBoard._state; - if (state.step === 1) { const sel = document.getElementById('pubJob'); if (sel) state.jobId = sel.value; } - if (state.step === 3 && !state.platforms.length) { UI.toast('Select at least one platform', 'warning'); return; } - state.step++; - if (state.step === 4) { - // create publishing records - const job = DB.getJob(state.jobId); - state.platforms.forEach(p => { - DB.publishings.unshift({ jobId: job.id, jobTitle: job.title, platform: p, status: 'Live', views: DB.int(0, 30), clicks: 0, apps: 0, published: new Date('2026-07-09') }); - }); - } - JobBoard._renderFlow(); -}; -JobBoard._back = function () { JobBoard._state.step--; JobBoard._renderFlow(); }; diff --git a/js/jobs.js b/js/jobs.js deleted file mode 100644 index ff58987..0000000 --- a/js/jobs.js +++ /dev/null @@ -1,252 +0,0 @@ -/* ============================================================ - jobs.js — Jobs listing, filters, create/edit/view/delete - ============================================================ */ -window.Views = window.Views || {}; -window.Jobs = {}; - -Views.jobs = function () { - const filters = { q: '', dept: '', status: '', type: '' }; - let table; - - function apply() { - let rows = DB.jobs.filter(j => { - if (filters.dept && j.department !== filters.dept) return false; - if (filters.status && j.status !== filters.status) return false; - if (filters.type && j.type !== filters.type) return false; - if (filters.q) { - const q = filters.q.toLowerCase(); - if (!(j.title + j.id + j.department + j.manager + j.recruiter + j.location).toLowerCase().includes(q)) return false; - } - return true; - }); - table.update(rows); - } - - table = UI.dataTable({ - pageSize: 8, - rows: DB.jobs, - columns: [ - { key: 'id', label: 'Job ID', sortable: true, render: j => `${j.id}` }, - { key: 'title', label: 'Job Title', sortable: true, render: j => `
${j.title}
${j.businessUnit} · ${j.grade}
` }, - { key: 'department', label: 'Department', sortable: true }, - { key: 'manager', label: 'Hiring Manager', sortable: true, render: j => `
${UI.avatar(j.manager)}${j.manager}
` }, - { key: 'location', label: 'Location', sortable: true, render: j => `${j.location}` }, - { key: 'type', label: 'Type', render: j => UI.badge(j.type, 'b-gray') }, - { key: 'applications', label: 'Apps', sortable: true, align: 'center', render: j => `${j.applications}` }, - { key: 'status', label: 'Status', sortable: true, render: j => UI.badge(j.status) }, - { key: 'created', label: 'Created', sortable: true, sortValue: j => j.created.getTime(), render: j => `${DB.fmtShort(j.created)}` }, - { key: '_a', label: 'Actions', align: 'right', render: j => ` -
- - - - -
` } - ] - }); - - const deptOpts = [''].concat(DB.departments.map(d => ``)).join(''); - const statusOpts = [''].concat(DB.jobStatuses.map(s => ``)).join(''); - const typeOpts = [''].concat(DB.empTypes.map(t => ``)).join(''); - - const html = ` -
-
-

Jobs

${DB.jobs.length} requisitions · ${DB.kpis.openJobs} currently open

-
- - -
-
-
-
-
- - - - -
-
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - const s = document.getElementById('jobSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('jobDept').onchange = e => { filters.dept = e.target.value; apply(); }; - document.getElementById('jobStatus').onchange = e => { filters.status = e.target.value; apply(); }; - document.getElementById('jobType').onchange = e => { filters.type = e.target.value; apply(); }; - } - }; -}; - -// ---------------- View job ---------------- -Jobs.view = function (id) { - const j = DB.getJob(id); - const body = ` -
- ${UI.icon('briefcase')} -
-
${j.title}
-
${j.id} · ${j.department} · ${j.businessUnit}
-
-
${UI.badge(j.status)}
-
-
-
Hiring Manager
${j.manager}
-
Assigned Recruiter
${j.recruiter} ${(() => { const r = DB.getRecruiterByName(j.recruiter); return r ? `${r.workload}% load` : ''; })()}
-
Location
${j.location}
-
Employment Type
${j.type}
-
Grade
${j.grade}
-
Vacancies
${j.vacancies}
-
Salary Range
${DB.moneyK(j.salaryMin)} – ${DB.moneyK(j.salaryMax)}
-
Experience
${j.experience}
-
Education
${j.education}
-
Deadline
${DB.fmtDate(j.deadline)}
-
-
-
Description

${j.description}

-
Key Responsibilities
-
    ${j.responsibilities.map(r => `
  • ${r}
  • `).join('')}
-
Required Skills
-
${j.skills.map(s => `${s}`).join('')}
-
Benefits
-
${j.benefits.map(s => `${s}`).join('')}
-
-
Hiring progress
${UI.pbar(j.progress)}
${j.progress}%
`; - const footer = ` - - `; - UI.modal({ title: 'Job Details', subtitle: j.id, body, footer, size: 'modal-lg' }); -}; - -Jobs.reassign = function (id) { - const j = DB.getJob(id); - const opts = DB.recruiters.map(r => ``).join(''); - UI.modal({ - title: 'Reassign Recruiter', subtitle: j.title, - body: `
-

Workload is recalculated automatically across the recruiter's assigned requisitions.

`, - footer: `` - }); -}; -Jobs._doReassign = function (id) { - const j = DB.getJob(id); - j.recruiter = document.getElementById('reassignRec').value; - UI.closeModal(); UI.toast('Recruiter reassigned', 'success'); - if (typeof Router !== 'undefined') Router.reload(); -}; - -// ---------------- Create / Edit form ---------------- -Jobs.openCreate = function () { Jobs._form(null); }; -Jobs.openEdit = function (id) { Jobs._form(DB.getJob(id)); }; - -Jobs._form = function (job) { - const isEdit = !!job; - const opt = (arr, sel) => arr.map(o => ``).join(''); - const body = ` -
-
-
- - - Job title is required -
-
-
-
-
-
-
-
-
-
-
- - Enter a valid amount
-
-
-
-
-
-
- - Description is required
-
-
-
-
-
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: isEdit ? 'Edit Job' : 'Create New Job', subtitle: isEdit ? job.id : 'Fill in the details to post a requisition', body, footer, size: 'modal-lg' }); -}; - -Jobs._save = function (id) { - const form = document.getElementById('jobForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - let ok = true; - const req = (name, cond) => { if (!cond) { UI.fieldError(form.querySelector(`[name="${name}"]`), 'Required'); ok = false; } }; - req('title', f.title.trim()); - req('description', f.description.trim()); - req('salaryMin', f.salaryMin && +f.salaryMin > 0); - if (!ok) { UI.toast('Please fix the highlighted fields', 'error'); return; } - - const skills = f.skills.split(',').map(s => s.trim()).filter(Boolean); - const benefits = f.benefits.split(',').map(s => s.trim()).filter(Boolean); - const responsibilities = f.responsibilities.split('\n').map(s => s.trim()).filter(Boolean); - - if (id) { - const job = DB.getJob(id); - Object.assign(job, { - title: f.title, department: f.department, businessUnit: f.businessUnit, grade: f.grade, type: f.type, - manager: f.manager, recruiter: f.recruiter, salaryMin: +f.salaryMin, salaryMax: +f.salaryMax || +f.salaryMin + 20000, - experience: f.experience, education: f.education, location: f.location, vacancies: +f.vacancies || 1, - description: f.description, responsibilities, skills: skills.length ? skills : job.skills, benefits: benefits.length ? benefits : job.benefits, status: f.status - }); - UI.toast('Job updated successfully', 'success'); - } else { - const newJob = { - id: 'JOB-' + (1001 + DB.jobs.length), title: f.title, department: f.department, businessUnit: f.businessUnit, - grade: f.grade, manager: f.manager, managerId: '', recruiter: f.recruiter, recruiterId: '', location: f.location, - type: f.type, vacancies: +f.vacancies || 1, applications: 0, status: f.status, created: new Date('2026-07-09'), - deadline: f.deadline ? new Date(f.deadline) : new Date('2026-08-09'), salaryMin: +f.salaryMin, salaryMax: +f.salaryMax || +f.salaryMin + 20000, - experience: f.experience || '3+ years', education: f.education, skills, benefits, description: f.description, - responsibilities: responsibilities.length ? responsibilities : ['Own key projects'], progress: 0 - }; - DB.jobs.unshift(newJob); - UI.toast('Job created successfully', 'success'); - App.updateBadges(); - } - UI.closeModal(); - Router.reload(); -}; - -Jobs.confirmDelete = function (id) { - const j = DB.getJob(id); - const body = `
- ${UI.icon('trash')} -

Delete "${j.title}"?

-

This will permanently remove requisition ${j.id} and its ${j.applications} applications. This action cannot be undone.

`; - const footer = ` - `; - UI.modal({ title: 'Confirm Deletion', body, footer }); -}; -Jobs._delete = function (id) { - const i = DB.jobs.findIndex(j => j.id === id); - if (i > -1) DB.jobs.splice(i, 1); - UI.closeModal(); - UI.toast('Job deleted', 'success'); - App.updateBadges(); - Router.reload(); -}; diff --git a/js/misc.js b/js/misc.js deleted file mode 100644 index 0edc541..0000000 --- a/js/misc.js +++ /dev/null @@ -1,207 +0,0 @@ -/* ============================================================ - misc.js — Hiring Managers, Calendar, Notifications, Help - ============================================================ */ -window.Views = window.Views || {}; - -// ---------------- Hiring Managers ---------------- -Views.managers = function () { - function card(m) { - const jobs = DB.jobs.filter(j => j.manager === m.name && j.status === 'Open'); - return `
-
-
- ${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')} -
${m.name}
${m.title}
-
-
-
${m.openReqs}Open Reqs
-
${m.teamSize}Team Size
-
-
-
- ${UI.icon('mail')} ${m.email.split('@')[0]} - -
-
-
`; - } - const html = ` -
-
-

Hiring Managers

${DB.managers.length} managers · ${DB.managers.reduce((s, m) => s + m.openReqs, 0)} active requisitions

-
-
-
${DB.managers.map(card).join('')}
-
`; - return { html }; -}; -Views._mgrDetail = function (id) { - const m = DB.getManager(id); - const jobs = DB.jobs.filter(j => j.manager === m.name); - const body = ` -
${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')} -
${m.name}
${m.title}
-
${UI.badge(m.department, 'b-indigo')}${m.teamSize} reports
-
-
${m.openReqs}Open Reqs
-
${jobs.length}Total Jobs
-
${jobs.reduce((s, j) => s + j.applications, 0)}Applications
-
-
Hiring Manager Portal
-
- - - - -
-
Requisitions
-
${jobs.length ? jobs.map(j => `
- ${UI.icon('briefcase')} -
${j.title}
${j.applications} applications
${UI.badge(j.status)}
`).join('') : '

No requisitions

'}
`; - UI.modal({ title: 'Hiring Manager', subtitle: m.id, body, size: 'modal-lg', footer: `` }); -}; - -// ---------------- Calendar ---------------- -Views.calendar = function () { - const state = { month: 6, year: 2026 }; // July 2026 (0-indexed) - const evColors = { 'Phone Screen': 'b-blue', 'Technical': 'b-indigo', 'System Design': 'b-purple', 'Onsite Loop': 'b-teal', 'Hiring Manager': 'b-amber', 'Culture Fit': 'b-green', 'Final Round': 'b-red' }; - - function build() { - const first = new Date(state.year, state.month, 1); - const startDow = first.getDay(); - const daysInMonth = new Date(state.year, state.month + 1, 0).getDate(); - const prevDays = new Date(state.year, state.month, 0).getDate(); - const cells = []; - for (let i = startDow - 1; i >= 0; i--) cells.push({ day: prevDays - i, other: true }); - for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, other: false, date: new Date(state.year, state.month, d) }); - while (cells.length % 7 !== 0 || cells.length < 42) cells.push({ day: cells.length - daysInMonth - startDow + 1, other: true }); - - const today = new Date('2026-07-09'); - const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - let html = dow.map(d => `
${d}
`).join(''); - cells.slice(0, 42).forEach(c => { - let evs = ''; - if (!c.other && c.date) { - const dayEvents = DB.interviews.filter(iv => iv.when.toDateString() === c.date.toDateString()); - evs = dayEvents.slice(0, 3).map(iv => `
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} ${iv.candidate.split(' ')[0]}
`).join(''); - if (dayEvents.length > 3) evs += `
+${dayEvents.length - 3} more
`; - } - const isToday = !c.other && c.date && c.date.toDateString() === today.toDateString(); - html += `
${c.day}
${evs}
`; - }); - return html; - } - - const monthName = new Date(state.year, state.month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - const todayIvs = DB.interviews.filter(iv => iv.when.toDateString() === new Date('2026-07-09').toDateString()); - - const html = ` -
-
-

Calendar

Interview schedule at a glance

-
-
- - ${monthName} - -
- -
-
-
-
${build()}
-

Today

July 9, 2026
-
${todayIvs.length ? todayIvs.map(iv => ` -
${UI.avatar(iv.candidate, iv.candInitials, iv.color)} -
${iv.candidate}
${iv.type}
-
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
`).join('') : '

No interviews today

'}
-
-
-
`; - - return { - html, - onMount() { - const upd = () => { - document.getElementById('calGrid').innerHTML = build(); - document.getElementById('calMonth').textContent = new Date(state.year, state.month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - }; - document.getElementById('calPrev').onclick = () => { state.month--; if (state.month < 0) { state.month = 11; state.year--; } upd(); }; - document.getElementById('calNext').onclick = () => { state.month++; if (state.month > 11) { state.month = 0; state.year++; } upd(); }; - } - }; -}; - -// ---------------- Notifications ---------------- -Views.notifications = function () { - const rows = DB.notifications.map((n, i) => ` -
- ${UI.icon(n.icon)} -
${n.title}
${n.text}
${n.time}
- ${n.unread ? '' : ''} -
`).join(''); - const html = ` -
-
-

Notifications

Stay on top of hiring activity

-
- - -
-
-
${rows}
-
`; - return { html }; -}; - -// ---------------- Help ---------------- -Views.help = function () { - const faqs = [ - { q: 'How do I create a new job requisition?', a: 'Navigate to Jobs and click "Create Job". Fill in the required fields marked with an asterisk and click Save. The job will immediately appear in your listings.' }, - { q: 'How does the AI candidate score work?', a: 'The AI score (0–100) evaluates how well a candidate matches the job requirements based on skills, experience, and education. Higher scores indicate stronger matches.' }, - { q: 'Can I move candidates between pipeline stages?', a: 'Yes. Open the Pipeline view and simply drag any candidate card between stage columns. The candidate\'s status updates automatically.' }, - { q: 'How do I schedule an interview?', a: 'Go to Interviews or Calendar and click "Schedule Interview". Select the candidate, round, date, time, and interviewers.' }, - { q: 'How do I export reports?', a: 'On the Reports page, use the "Export Report" button for a full PDF, or the CSV buttons on individual tables.' } - ]; - const resources = [ - { icn: 'file', t: 'Documentation', d: 'Complete product guides', cls: 'i-indigo' }, - { icn: 'video', t: 'Video Tutorials', d: 'Watch step-by-step walkthroughs', cls: 'i-red' }, - { icn: 'message', t: 'Live Chat', d: 'Chat with our support team', cls: 'i-green' }, - { icn: 'users', t: 'Community', d: 'Connect with other recruiters', cls: 'i-purple' } - ]; - const html = ` -
-

Help Center

Find answers and get support

-
-
-

How can we help you?

-

Search our knowledge base or browse the topics below

- -
-
-
- ${resources.map(r => `
- ${UI.icon(r.icn)} -
${r.t}
${r.d}
`).join('')} -
-
-

Frequently Asked Questions

-
- ${faqs.map((f, i) => `
-

${f.q}

${UI.icon('chevron-right')}
-
`).join('')} -
-
-
`; - return { html }; -}; -Views._toggleFaq = function (i) { - const a = document.getElementById('faqA' + i); - const chev = document.getElementById('faqChev' + i); - const open = a.style.display === 'block'; - a.style.display = open ? 'none' : 'block'; - chev.style.transform = open ? 'rotate(0deg)' : 'rotate(90deg)'; -}; diff --git a/js/offers.js b/js/offers.js deleted file mode 100644 index ddb61a8..0000000 --- a/js/offers.js +++ /dev/null @@ -1,139 +0,0 @@ -/* ============================================================ - offers.js — Offer management - ============================================================ */ -window.Views = window.Views || {}; -window.Offers = {}; - -Views.offers = function () { - const filters = { q: '', status: '' }; - let table; - - const stats = { - sent: DB.offers.filter(o => o.status !== 'Draft').length, - accepted: DB.offers.filter(o => o.status === 'Accepted').length, - pending: DB.offers.filter(o => ['Sent', 'Negotiating'].includes(o.status)).length, - rate: Math.round(DB.offers.filter(o => o.status === 'Accepted').length / (DB.offers.filter(o => ['Accepted', 'Declined'].includes(o.status)).length || 1) * 100) - }; - - function apply() { - const rows = DB.offers.filter(o => { - if (filters.status && o.status !== filters.status) return false; - if (filters.q && !(o.candidate + o.jobTitle + o.recruiter).toLowerCase().includes(filters.q.toLowerCase())) return false; - return true; - }); - table.update(rows); - } - - table = UI.dataTable({ - pageSize: 8, - rows: DB.offers, - columns: [ - { key: 'candidate', label: 'Candidate', sortable: true, render: o => `
${UI.avatar(o.candidate, o.initials, o.color)}
${o.candidate}
${o.jobTitle}
` }, - { key: 'department', label: 'Department', sortable: true }, - { key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: o => `${DB.money(o.base)}` }, - { key: 'equity', label: 'Equity', render: o => `${o.equity}` }, - { key: 'bonus', label: 'Bonus', align: 'center', render: o => `${o.bonus}` }, - { key: 'sent', label: 'Sent', sortable: true, sortValue: o => o.sent.getTime(), render: o => `${DB.fmtShort(o.sent)}` }, - { key: 'status', label: 'Status', sortable: true, render: o => UI.badge(o.status) }, - { key: '_a', label: 'Actions', align: 'right', render: o => ` -
- - -
` } - ] - }); - - const statusOpts = [''].concat(['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map(s => ``)).join(''); - const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; - - const html = ` -
-
-

Offers

Track offer letters and acceptance

-
-
-
- ${statCard('Offers Sent', stats.sent, 'send', 'i-indigo')} - ${statCard('Accepted', stats.accepted, 'check-circle', 'i-green')} - ${statCard('Awaiting Response', stats.pending, 'clock', 'i-amber')} - ${statCard('Acceptance Rate', stats.rate + '%', 'trending-up', 'i-teal')} -
-
-
-
- - -
-
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - const s = document.getElementById('ofSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('ofStatus').onchange = e => { filters.status = e.target.value; apply(); }; - } - }; -}; - -Offers.view = function (id) { - const o = DB.offers.find(x => x.id === id); - const total = o.base + Math.round(o.base * parseInt(o.bonus) / 100); - const body = ` -
${UI.avatar(o.candidate, o.initials, o.color, 'avatar-lg')} -
${o.candidate}
${o.jobTitle} · ${o.department}
-
${UI.badge(o.status)}
-
-
Compensation Package
-
-
Base Salary
${DB.money(o.base)}
-
Annual Bonus
${o.bonus}
-
Equity
${o.equity}
-
Est. Total Cash
${DB.money(total)}
-
-
-
-
Sent On
${DB.fmtDate(o.sent)}
-
Expires
${DB.fmtDate(o.expires)}
-
Recruiter
${o.recruiter}
-
Offer ID
${o.id}
-
`; - const footer = ` - - `; - UI.modal({ title: 'Offer Details', subtitle: o.id, body, footer, size: 'modal-lg' }); -}; - -Offers.create = function () { - const opt = arr => arr.map(o => ``).join(''); - const body = `
-
-
Required
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: 'Create Offer', subtitle: 'Generate and send an offer letter', body, footer }); -}; -Offers._save = function () { - const form = document.getElementById('offerForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - if (!f.base || +f.base <= 0) { UI.fieldError(form.querySelector('[name=base]'), 'Required'); UI.toast('Enter a base salary', 'error'); return; } - const cand = DB.candidates.find(c => c.name === f.candidate) || DB.candidates[0]; - DB.offers.unshift({ - id: 'OFR-' + (9001 + DB.offers.length), candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color, - jobTitle: cand.jobTitle, department: cand.department, status: 'Sent', base: +f.base, equity: f.equity || '10k RSU', - bonus: (f.bonus || 10) + '%', sent: new Date('2026-07-09'), expires: f.expires ? new Date(f.expires) : new Date('2026-07-23'), recruiter: cand.recruiter - }); - UI.closeModal(); - UI.toast('Offer sent successfully', 'success'); - Router.reload(); -}; diff --git a/js/pipeline.js b/js/pipeline.js deleted file mode 100644 index 2f54be5..0000000 --- a/js/pipeline.js +++ /dev/null @@ -1,166 +0,0 @@ -/* ============================================================ - pipeline.js — Kanban board (drag & drop) + Talent Pool - ============================================================ */ -window.Views = window.Views || {}; -window.Pipeline = {}; - -// Stage colours reference CSS tokens so the board re-tints with the theme. -const KANBAN_STAGES = [ - { name: 'Applied', color: 'var(--stage-1)' }, - { name: 'Screening', color: 'var(--stage-2)' }, - { name: 'Assessment', color: 'var(--stage-3)' }, - { name: 'Interview', color: 'var(--stage-4)' }, - { name: 'Offer', color: 'var(--stage-5)' }, - { name: 'Hired', color: 'var(--stage-6)' }, - { name: 'Rejected', color: 'var(--stage-7)' } -]; - -Views.pipeline = function () { - const jobFilter = { id: '' }; - - function columns() { - const list = jobFilter.id ? DB.candidates.filter(c => c.jobId === jobFilter.id) : DB.candidates; - return KANBAN_STAGES.map(st => { - const cards = list.filter(c => c.stage === st.name); - return `
-

${st.name}

${cards.length}
-
- ${cards.map(c => Pipeline._card(c)).join('')} -
`; - }).join(''); - } - - const jobOpts = [''].concat(DB.jobs.filter(j => j.status === 'Open').map(j => ``)).join(''); - - const html = ` -
-
-

Pipeline

Drag candidates between stages to update their status

-
- - -
-
-
${columns()}
-
`; - - return { - html, - onMount() { - Pipeline._bindDnd(); - document.getElementById('pipeJob').onchange = e => { - jobFilter.id = e.target.value; - document.getElementById('kanban').innerHTML = columns(); - Pipeline._bindDnd(); - }; - } - }; -}; - -Pipeline._card = function (c) { - return `
-
${UI.avatar(c.name, c.initials, c.color)} -
${c.name}
${c.currentTitle}
-
${c.jobTitle}
-
${c.skills.slice(0, 3).map(s => `${s}`).join('')}
-
${c.currentCompany}${UI.scoreChip(c.aiScore)}
-
`; -}; - -Pipeline._bindDnd = function () { - let dragged = null; - document.querySelectorAll('.k-card').forEach(card => { - card.addEventListener('dragstart', e => { - dragged = card; card.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', card.dataset.id); - }); - card.addEventListener('dragend', () => { card.classList.remove('dragging'); dragged = null; }); - // prevent click-through opening profile right after drag - card.addEventListener('click', e => { if (card._justDropped) { e.stopPropagation(); card._justDropped = false; } }); - }); - document.querySelectorAll('.kanban-cards').forEach(zone => { - zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); - zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); - zone.addEventListener('drop', e => { - e.preventDefault(); - zone.classList.remove('drag-over'); - if (!dragged) return; - const id = dragged.dataset.id; - const cand = DB.getCandidate(id); - const newStage = zone.dataset.stage; - if (cand.stage === newStage) return; - cand.stage = newStage; cand.status = newStage; - zone.appendChild(dragged); - // update counts - document.querySelectorAll('.kanban-col').forEach(col => { - col.querySelector('.k-count').textContent = col.querySelectorAll('.k-card').length; - }); - UI.toast(`${cand.name} moved to ${newStage}`, 'success'); - }); - }); -}; - -// ---------------- Talent Pool ---------------- -Views.talentpool = function () { - const filters = { q: '', dept: '' }; - // Talent pool = candidates not currently in active loop (silver medalists / passive talent) - const pool = DB.candidates.filter(c => ['Rejected', 'Applied', 'Hired'].includes(c.stage)); - - function render(list) { - const grid = document.getElementById('poolGrid'); - if (!grid) return; - if (!list.length) { grid.innerHTML = `
${UI.icon('search')}

No talent found

`; return; } - grid.innerHTML = list.map(c => ` -
-
-
- ${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')} -
${c.name}
${c.currentTitle}
- ${UI.scoreChip(c.aiScore)} -
-
${c.skills.slice(0, 4).map(s => `${s}`).join('')}
-
-
- ${UI.icon('briefcase')} ${c.experience} yrs - ${c.currentCompany} - ${UI.badge(c.source, 'b-gray')} -
-
-
`).join(''); - } - function apply() { - let list = pool.filter(c => { - if (filters.dept && c.department !== filters.dept) return false; - if (filters.q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(filters.q.toLowerCase())) return false; - return true; - }); - render(list); - } - const deptOpts = [''].concat(DB.departments.map(d => ``)).join(''); - - const html = ` -
-
-

Talent Pool

${pool.length} silver-medalists & passive candidates to re-engage

-
-
-
-
- - -
-
-
-
`; - - return { - html, - onMount() { - apply(); - const s = document.getElementById('poolSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('poolDept').onchange = e => { filters.dept = e.target.value; apply(); }; - } - }; -}; diff --git a/js/rbac.js b/js/rbac.js deleted file mode 100644 index 31c9981..0000000 --- a/js/rbac.js +++ /dev/null @@ -1,117 +0,0 @@ -/* ============================================================ - rbac.js — Enterprise Role Based Access Control - Microsoft Admin Center-style permission matrix - ============================================================ */ -window.Views = window.Views || {}; -window.RBAC = {}; - -Views.rbac = function () { - const state = { roleIdx: 0 }; - RBAC._state = state; - - const html = ` -
-
-

Access Control

Enterprise RBAC — configure permissions for every role and module

-
- - -
-
-
-
- -
-
-
-
-
`; - - return { - html, - onMount() { RBAC._renderRoles(); RBAC._renderDetail(); } - }; -}; - -RBAC._renderRoles = function () { - const el = document.getElementById('roleList'); - el.innerHTML = DB.rbacRoles.map((r, i) => ` -
- ${UI.icon('shield')} -
${r.name}
${r.users} user${r.users === 1 ? '' : 's'}
-
`).join(''); - el.querySelectorAll('.role-item').forEach(item => item.onclick = () => { - RBAC._state.roleIdx = +item.dataset.idx; - RBAC._renderRoles(); RBAC._renderDetail(); - }); -}; - -RBAC._renderDetail = function () { - const r = DB.rbacRoles[RBAC._state.roleIdx]; - const el = document.getElementById('rbacDetail'); - - const matrixRows = DB.rbacModules.map(mod => ` - - ${mod} - ${DB.permTypes.map((pt, pi) => `${UI.icon('check')}`).join('')} - `).join(''); - - el.innerHTML = ` -
-
${UI.icon('shield')} -

${r.name}

${r.desc}
-
- ${r.users} users - - -
-
-
-
- ${DB.permTypes.map(p => ``).join('')} - ${matrixRows} -
Module${p}
-
`; - - el.querySelectorAll('.perm-check').forEach(chk => chk.onclick = () => { - const mod = chk.dataset.mod, pi = +chk.dataset.perm; - r.matrix[mod][pi] = !r.matrix[mod][pi]; - chk.classList.toggle('on'); - }); -}; - -RBAC.toggleAll = function (on) { - const r = DB.rbacRoles[RBAC._state.roleIdx]; - DB.rbacModules.forEach(mod => r.matrix[mod] = r.matrix[mod].map(() => on)); - RBAC._renderDetail(); - UI.toast(on ? 'All permissions granted for ' + r.name : 'All permissions revoked for ' + r.name, on ? 'success' : 'warning'); -}; - -RBAC.addRole = function () { - UI.modal({ - title: 'Create Role', subtitle: 'Define a new access role', - body: `
-
Required
-
-
-
-
`, - footer: `` - }); -}; -RBAC._saveRole = function () { - const form = document.getElementById('roleForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - if (!f.name.trim()) { UI.fieldError(form.querySelector('[name=name]'), 'Required'); return; } - const levelMap = { 'View only': 'View', 'Editor': 'Edit', 'Approver': 'Approve', 'Manager': 'Manage', 'Administrator': 'Administrator' }; - const level = levelMap[f.template] || 'View'; - const idxMap = { 'View': 1, 'Edit': 3, 'Approve': 5, 'Manage': 7, 'Administrator': 8 }; - const cutoff = idxMap[level]; - const matrix = {}; - DB.rbacModules.forEach(mod => matrix[mod] = DB.permTypes.map((p, i) => i < cutoff)); - DB.rbacRoles.push({ name: f.name, users: 0, color: f.color, desc: f.desc || 'Custom role', level, matrix }); - RBAC._state.roleIdx = DB.rbacRoles.length - 1; - UI.closeModal(); RBAC._renderRoles(); RBAC._renderDetail(); - UI.toast('Role "' + f.name + '" created', 'success'); -}; diff --git a/js/recruiterhub.js b/js/recruiterhub.js deleted file mode 100644 index b9622e7..0000000 --- a/js/recruiterhub.js +++ /dev/null @@ -1,121 +0,0 @@ -/* ============================================================ - recruiterhub.js — Personalized recruiter dashboard + leaderboard - ============================================================ */ -window.Views = window.Views || {}; -window.RecruiterHub = {}; - -Views.recruiterhub = function () { - const state = { recId: DB.recruiters[0].id }; - RecruiterHub._state = state; - - const recOpts = DB.recruiters.map(r => ``).join(''); - - const html = ` -
-
-

Recruiter Hub

Personalized performance dashboard & workload

-
- - -
-
-
-
`; - - return { - html, - onMount() { - RecruiterHub._render(); - document.getElementById('recSelect').onchange = e => { state.recId = e.target.value; RecruiterHub._render(); }; - } - }; -}; - -RecruiterHub._render = function () { - const r = DB.getRecruiter(RecruiterHub._state.recId); - const el = document.getElementById('recHubBody'); - const slaCls = r.sla === 'On Track' ? 'b-green' : r.sla === 'At Risk' ? 'b-amber' : 'b-red'; - - const kpi = (label, val, icn, cls, sub) => `
${label}${UI.icon(icn)}
${val}
${sub ? `
${sub}
` : ''}
`; - - // leaderboard - const board = [...DB.recruiters].sort((a, b) => b.hires - a.hires).slice(0, 8); - const leaderHtml = board.map((rec, i) => { - const rankCls = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''; - return `
- ${i + 1} - ${UI.avatar(rec.name, rec.initials, rec.color)} -
${rec.name}
${rec.efficiency}% efficiency · ${rec.avgTimeToHire}d avg
-
${rec.hires}
hires
-
`; - }).join(''); - - // heatmap - const maxHeat = 5; - const heatColor = v => { const t = v / maxHeat; return t === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + t * 0.8})`; }; - const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - const heatHtml = `
-
${['W1', 'W2', 'W3', 'W4', 'W5'].map(w => `
${w}
`).join('')} - ${days.map((d, di) => `
${d}
${r.heatmap[di].map(v => `
`).join('')}`).join('')} -
-
Less ${[0, 1, 2, 3, 5].map(v => ``).join('')} More
`; - - el.innerHTML = ` -
-
- ${UI.avatar(r.name, r.initials, 'rgba(255,255,255,.18)', 'avatar-lg')} -
${r.name}
${r.department} Recruiter · ⭐ ${r.rating} rating
-
${r.workload}%
Workload
-
${r.efficiency}%
Efficiency
-
${UI.badge(r.sla, slaCls)}
-
-
- -
- ${kpi('Open Positions', r.openPositions, 'briefcase', 'i-indigo', 'active reqs')} - ${kpi('Closed Positions', r.closedPositions, 'check-circle', 'i-green', 'this year')} - ${kpi('Avg Time to Hire', r.avgTimeToHire + 'd', 'clock', 'i-teal', 'target 30d')} - ${kpi('Avg Time to Fill', r.avgTimeToFill + 'd', 'target', 'i-amber', 'req → offer')} -
-
- ${kpi('Interviews Today', r.interviewsToday, 'calendar', 'i-purple')} - ${kpi('Offers Pending', r.offersPending, 'file', 'i-blue')} - ${kpi('Awaiting Approval', r.jobsAwaitingApproval, 'clock', 'i-amber')} - ${kpi('Jobs Overdue', r.jobsOverdue, 'alert', 'i-red')} -
-
- ${kpi('Conversion Rate', r.conversionRate + '%', 'trending-up', 'i-green', 'applicant → hire')} - ${kpi('Interview Completion', r.interviewCompletion + '%', 'check-square', 'i-teal')} - ${kpi('Avg Response Time', r.avgResponseTime + 'h', 'zap', 'i-purple', 'to candidates')} - ${kpi('TAT Performance', r.tat + '%', 'award', 'i-indigo', 'turnaround')} -
- -
-
-

Monthly Hiring Trend

Hires per month
-
-
-
-

Workload Heatmap

Interview load
-
${heatHtml}
-
-
- -
-
-

Recruiter Leaderboard

Top performers by hires
-
${leaderHtml}
-
-
-

Candidate Pipeline

This recruiter's active candidates
-
-
-
`; - - Charts.line(document.getElementById('recTrend'), { labels: DB.analytics.hiringTrend.labels, area: true, datasets: [{ label: 'Hires', data: r.monthlyTrend, color: Charts.PALETTE[0] }] }); - const stageCounts = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'].map(() => DB.int(2, 14)); - Charts.horizontalBar(document.getElementById('recPipeline'), { - labels: ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'], data: stageCounts, - colors: Charts.PALETTE - }); -}; diff --git a/js/reports.js b/js/reports.js deleted file mode 100644 index 6ee9e97..0000000 --- a/js/reports.js +++ /dev/null @@ -1,109 +0,0 @@ -/* ============================================================ - reports.js — Reports page (cards, charts, table, export) - ============================================================ */ -window.Views = window.Views || {}; - -Views.reports = function () { - const a = DB.analytics; - const reportCards = [ - { title: 'Total Hires (YTD)', val: a.hiringTrend.hires.reduce((s, v) => s + v, 0), icn: 'award', cls: 'i-green', sub: '+18% vs last year' }, - { title: 'Total Applications', val: a.hiringTrend.applications.reduce((s, v) => s + v, 0).toLocaleString(), icn: 'users', cls: 'i-blue', sub: 'across all channels' }, - { title: 'Avg. Time to Hire', val: '27 days', icn: 'clock', cls: 'i-teal', sub: '3 days faster' }, - { title: 'Avg. Cost per Hire', val: '$4,280', icn: 'dollar', cls: 'i-amber', sub: 'within budget' } - ]; - - const deptRows = a.departments.map(d => { - const rate = Math.round((d.open ? d.apps / (d.open * 40) : 0.5) * 100); - return { dept: d.dept, open: d.open, apps: d.apps, hires: DB.int(1, 8), ttf: DB.int(28, 52), rate: Math.min(rate, 98) }; - }); - - const table = UI.dataTable({ - pageSize: 10, - rows: deptRows, - columns: [ - { key: 'dept', label: 'Department', sortable: true, render: r => `${r.dept}` }, - { key: 'open', label: 'Open Roles', sortable: true, align: 'center' }, - { key: 'apps', label: 'Applications', sortable: true, align: 'center', render: r => `${r.apps}` }, - { key: 'hires', label: 'Hires', sortable: true, align: 'center' }, - { key: 'ttf', label: 'Time to Fill', sortable: true, align: 'center', render: r => r.ttf + ' days' }, - { key: 'rate', label: 'Fill Rate', sortable: true, render: r => `
${UI.pbar(r.rate)}
${r.rate}%
` } - ] - }); - - const reportTypes = [ - { name: 'Hiring Funnel Report', desc: 'Conversion rates across each pipeline stage', icn: 'filter', cls: 'i-indigo' }, - { name: 'Source Effectiveness', desc: 'ROI and quality by sourcing channel', icn: 'target', cls: 'i-teal' }, - { name: 'Diversity & Inclusion', desc: 'Demographic breakdown of the pipeline', icn: 'users', cls: 'i-purple' }, - { name: 'Recruiter Scorecard', desc: 'Individual performance metrics', icn: 'award', cls: 'i-amber' }, - { name: 'Offer Analysis', desc: 'Acceptance rates and compensation trends', icn: 'file', cls: 'i-green' }, - { name: 'Interview Analytics', desc: 'Interviewer load and feedback quality', icn: 'calendar', cls: 'i-blue' } - ]; - - const html = ` -
-
-

Reports

Recruitment metrics and downloadable insights

-
- - -
-
- -
- ${reportCards.map(c => `
${c.title}${UI.icon(c.icn)}
${c.val}
${c.sub}
`).join('')} -
- -
-
-

Hiring Funnel

Stage-by-stage conversion
-
-
-
-
-

Time to Hire vs Fill

Monthly trend (days)
-
- ${Charts.legend([{ label: 'Time to Hire', color: Charts.PALETTE[0] }, { label: 'Time to Fill', color: Charts.PALETTE[2] }])}
-
-
- -
-

Department Performance

Hiring breakdown by team
-
- ${table.html} -
- -
-

Report Library

Generate a detailed report
-
- ${reportTypes.map(r => ` -
-
- ${UI.icon(r.icn)} -
${r.name}
-
${r.desc}
-
Generate ${UI.icon('chevron-right')}
-
-
`).join('')} -
-
-
`; - - return { - html, - onMount() { - table.mount(); - const funnel = [{ stage: 'Applied', v: 100 }, { stage: 'Screened', v: 62 }, { stage: 'Assessed', v: 41 }, { stage: 'Interviewed', v: 28 }, { stage: 'Offered', v: 14 }, { stage: 'Hired', v: 9 }]; - Charts.bar(document.getElementById('rptFunnel'), { - labels: funnel.map(f => f.stage), data: funnel.map(f => f.v), - colors: Charts.PALETTE, yFmt: v => v + '%' - }); - Charts.groupedBar(document.getElementById('rptTime'), { - labels: DB.analytics.hiringTrend.labels, - datasets: [ - { label: 'Time to Hire', data: DB.analytics.timeToHire, color: Charts.PALETTE[0] }, - { label: 'Time to Fill', data: DB.analytics.timeToFill, color: Charts.PALETTE[2] } - ], yFmt: v => v + 'd' - }); - } - }; -}; diff --git a/js/settings.js b/js/settings.js deleted file mode 100644 index 2db2378..0000000 --- a/js/settings.js +++ /dev/null @@ -1,193 +0,0 @@ -/* ============================================================ - settings.js — Settings page with many tabs - ============================================================ */ -window.Views = window.Views || {}; -window.Settings = {}; - -Views.settings = function () { - const tabs = ['General', 'Users', 'Roles', 'Permissions', 'Notifications', 'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance']; - - const html = ` -
-
-

Settings

Configure your workspace and team preferences

-
-
-
${tabs.map((t, i) => `
${t}
`).join('')}
-
- ${tabs.map((t, i) => `
${Settings.pane(t)}
`).join('')} -
-
`; - - return { - html, - onMount() { - const panes = document.querySelectorAll('#setPanes .tab-pane'); - document.querySelectorAll('#setTabs .tab').forEach(tab => tab.onclick = () => { - document.querySelectorAll('#setTabs .tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - panes.forEach(p => p.classList.remove('active')); - panes[+tab.dataset.tab].classList.add('active'); - if (tab.textContent === 'Appearance') Settings._bindTheme(); - }); - Settings._bindTheme(); - } - }; -}; - -function toggleRow(title, desc, checked) { - return `

${title}

${desc}

-
`; -} - -Settings.pane = function (name) { - if (name === 'General') { - return `
-
-
-
-
-
-
-
-
-
- ${toggleRow('Auto-archive stale jobs', 'Automatically close requisitions inactive for 90 days', true)} - ${toggleRow('Duplicate detection', 'Flag candidates that already exist in the system', true)} -
`; - } - if (name === 'Users') { - const rows = DB.users.map(u => ` -
${UI.avatar(u.name, u.initials, u.color)}
${u.name}
${u.email}
- ${UI.badge(u.role, 'b-indigo')} - ${UI.badge(u.status)} - ${u.lastActive} -
- `).join(''); - return `
-

Team Members

${DB.users.length} users
-
-
${rows}
UserRoleStatusLast ActiveActions
-
`; - } - if (name === 'Roles') { - return `

Roles

Define access levels
-
-
- ${DB.roles.map(r => `
- ${UI.icon('users')} -
${r.name}
${r.desc}
-
${r.users} users
${r.perms}
- -
`).join('')} -
`; - } - if (name === 'Permissions') { - const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings']; - const perms = ['View', 'Create', 'Edit', 'Delete']; - return `

Permission Matrix

Recruiter role
-
-
${perms.map(p => ``).join('')} - ${modules.map(m => `${perms.map((p, i) => ``).join('')}`).join('')}
Module${p}
${m} -
-
`; - } - if (name === 'Notifications') { - return `
-
Email Notifications
- ${toggleRow('New applications', 'Get notified when a candidate applies', true)} - ${toggleRow('Interview reminders', 'Reminders 30 minutes before interviews', true)} - ${toggleRow('Offer responses', 'When candidates accept or decline offers', true)} - ${toggleRow('Weekly digest', 'A summary of hiring activity every Monday', false)} -
In-App Notifications
- ${toggleRow('Mentions', 'When a teammate @mentions you', true)} - ${toggleRow('Stage changes', 'When a candidate moves stages', false)} - ${toggleRow('Task assignments', 'When you are assigned a task', true)} -
`; - } - if (name === 'Email Templates') { - const templates = ['Application Received', 'Interview Invitation', 'Assessment Assignment', 'Offer Letter', 'Rejection — Post Interview', 'Reference Request']; - return `

Email Templates

-
-
- ${templates.map(t => `
${UI.icon('mail')} -
${t}
Last edited 3 days ago
- ${UI.badge('Active', 'b-green')}
`).join('')} -
`; - } - if (name === 'Career Portal') { - return `
-
-
-
-
-
-
- ${toggleRow('Public job board', 'Make open roles visible to the public', true)} - ${toggleRow('Allow one-click apply', 'Let candidates apply with LinkedIn', true)} - ${toggleRow('Show salary ranges', 'Display compensation on job listings', false)} - ${toggleRow('Enable referrals', 'Employees can refer candidates', true)} -
`; - } - if (name === 'Branding') { - return `
-

Company Logo

Displayed on career pages and emails

-
-

Brand Color

Primary accent across the portal

-
- ${['#004d43', '#ceff71', '#25e9a5', '#8e92ff', '#1a3134', '#eafff4'].map(c => ``).join('')} -
-
-
-
-
-
`; - } - if (name === 'Security') { - return `
- ${toggleRow('Two-factor authentication', 'Require 2FA for all team members', true)} - ${toggleRow('Single Sign-On (SSO)', 'Enable SAML-based SSO login', false)} - ${toggleRow('IP allowlist', 'Restrict access to approved IP ranges', false)} - ${toggleRow('Audit logging', 'Track all data access and changes', true)} -
-
-
-
-
-

Data Retention

Auto-delete candidate data after set period

-
-
`; - } - if (name === 'Appearance') { - return `
-
Theme
-
-
-
-
Light
Clean and bright
-
-
-
-
Dark
Easy on the eyes
-
-
-
-
System
Match OS setting
-
-
-
- ${toggleRow('Compact mode', 'Reduce spacing for denser layouts', false)} - ${toggleRow('Show animations', 'Enable transitions and motion', true)} -
`; - } - return ''; -}; - -Settings._bindTheme = function () { - document.querySelectorAll('.theme-opt').forEach(opt => opt.onclick = () => { - const mode = opt.dataset.themeSet; - if (mode === 'system') { App.setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); } - else App.setTheme(mode); - UI.toast('Theme updated to ' + mode, 'success'); - }); -}; diff --git a/js/tasks.js b/js/tasks.js deleted file mode 100644 index 8a70485..0000000 --- a/js/tasks.js +++ /dev/null @@ -1,131 +0,0 @@ -/* ============================================================ - tasks.js — Recruitment Tasks (with saved searches & favorites) - ============================================================ */ -window.Views = window.Views || {}; -window.Tasks = {}; - -Views.tasks = function () { - const state = { filter: 'All' }; - - function render() { - const el = document.getElementById('taskList'); - if (!el) return; - let list = DB.tasks; - if (state.filter === 'Open') list = list.filter(t => !t.done); - else if (state.filter === 'Completed') list = list.filter(t => t.done); - else if (state.filter === 'Overdue') list = list.filter(t => !t.done && t.due < new Date('2026-07-09')); - else if (['High', 'Medium', 'Low'].includes(state.filter)) list = list.filter(t => t.priority === state.filter); - - if (!list.length) { el.innerHTML = `
${UI.icon('check-square')}

All caught up

No tasks in this view.

`; return; } - const prCls = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }; - el.innerHTML = list.map(t => { - const overdue = !t.done && t.due < new Date('2026-07-09'); - return `
- ${UI.icon('check')} -
-
${t.title}
-
${UI.icon('users')} ${t.assignee} · ${t.type}
-
-
- ${UI.badge(t.priority, prCls[t.priority])} -
${overdue ? 'Overdue · ' : 'Due '}${DB.fmtShort(t.due)}
-
-
`; - }).join(''); - el.querySelectorAll('.checkbox').forEach(chk => chk.onclick = e => { - e.stopPropagation(); - const t = DB.tasks.find(x => x.id === chk.dataset.id); - t.done = !t.done; render(); App.updateBadges(); - UI.toast(t.done ? 'Task completed' : 'Task reopened', t.done ? 'success' : 'info'); - }); - } - Tasks._render = render; - - const openCount = DB.tasks.filter(t => !t.done).length; - const overdueCount = DB.tasks.filter(t => !t.done && t.due < new Date('2026-07-09')).length; - const filters = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low']; - - // saved searches sidebar - const savedHtml = DB.savedSearches.map(s => ` -
- ${UI.icon('bookmark')} -
${s.name}
${s.filters}
- ${s.count} -
`).join(''); - - const html = ` -
-
-

Tasks

${openCount} open · ${overdueCount} overdue

-
-
-
-
-
-
${filters.map((f, i) => ``).join('')}
-
-
-
-
-

Saved Searches

Quick candidate filters
-
-
${savedHtml}
-
-
-
`; - - return { - html, - onMount() { - render(); - document.querySelectorAll('#taskSeg button').forEach(b => b.onclick = () => { - document.querySelectorAll('#taskSeg button').forEach(x => x.classList.remove('active')); - b.classList.add('active'); state.filter = b.dataset.f; render(); - }); - } - }; -}; - -Tasks.open = function (id) { - const t = DB.tasks.find(x => x.id === id); - const c = DB.getCandidate(t.candidateId); - UI.modal({ - title: t.title, subtitle: t.id + ' · ' + t.type, - body: `
-
Assignee
${t.assignee}
-
Priority
${t.priority}
-
Due Date
${DB.fmtDate(t.due)}
-
Status
${t.done ? 'Completed' : 'Open'}
- ${c ? `
Candidate
${c.name}
` : ''} -
-
`, - footer: ` - ${c ? `` : ''} - ` - }); -}; -Tasks._complete = function (id) { const t = DB.tasks.find(x => x.id === id); t.done = true; Tasks._render(); App.updateBadges(); UI.toast('Task completed', 'success'); }; - -Tasks.add = function () { - const opt = arr => arr.map(o => ``).join(''); - UI.modal({ - title: 'New Task', subtitle: 'Create a recruitment task', - body: `
-
Required
-
-
-
-
-
`, - footer: `` - }); -}; -Tasks._save = function () { - const form = document.getElementById('taskForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - if (!f.title.trim()) { UI.fieldError(form.querySelector('[name=title]'), 'Required'); return; } - DB.tasks.unshift({ id: 'TSK-' + (50001 + DB.tasks.length), title: f.title, candidateId: null, priority: f.priority, due: f.due ? new Date(f.due) : new Date('2026-07-16'), assignee: f.assignee, done: false, type: f.type }); - UI.closeModal(); Tasks._render(); App.updateBadges(); - UI.toast('Task created', 'success'); -}; diff --git a/js/ui.js b/js/ui.js deleted file mode 100644 index 97ad730..0000000 --- a/js/ui.js +++ /dev/null @@ -1,252 +0,0 @@ -/* ============================================================ - ui.js — Reusable UI primitives & helpers - Exposes global `UI` - ============================================================ */ -(function () { - 'use strict'; - - const ICONS = { - 'user-plus': '', - 'calendar': '', - 'check': '', - 'check-circle': '', - 'x': '', - 'x-circle': '', - 'file': '', - 'star': '', - 'message': '', - 'info': '', - 'alert': '', - 'eye': '', - 'edit': '', - 'trash': '', - 'more': '', - 'plus': '', - 'download': '', - 'filter': '', - 'clock': '', - 'mail': '', - 'phone': '', - 'map': '', - 'briefcase': '', - 'trending-up': '', - 'trending-down': '', - 'users': '', - 'award': '', - 'dollar': '', - 'target': '', - 'send': '', - 'video': '', - 'search': '', - 'chevron-left': '', - 'chevron-right': '', - 'refresh': '', - 'copy': '', - 'upload': '', - 'linkedin': '', - 'inbox': '', - 'sparkles': '', - 'zap': '', - 'grid': '', - 'bookmark': '', - 'paperclip': '', - 'external': '', - 'shield': '', - 'lock': '', - 'flame': '', - 'bell': '', - 'layers': '', - 'list': '', - 'check-square': '', - 'arrow-right': '' - }; - - function icon(name, cls) { return `${ICONS[name] || ICONS['info']}`; } - - function avatar(name, initials, color, cls) { - const bg = color || DB.avatarColor(name || ''); - const init = initials || DB.initials(name || '?'); - return `${init}`; - } - - // ---------- badge helpers ---------- - const statusMap = { - 'Open': 'b-green', 'Closed': 'b-gray', 'On Hold': 'b-amber', 'Draft': 'b-blue', - 'Applied': 'b-blue', 'Screening': 'b-purple', 'Assessment': 'b-amber', 'Interview': 'b-indigo', - 'Offer': 'b-teal', 'Hired': 'b-green', 'Rejected': 'b-red', - 'Scheduled': 'b-blue', 'Completed': 'b-green', 'Cancelled': 'b-red', 'No Show': 'b-amber', - 'Sent': 'b-blue', 'Accepted': 'b-green', 'Negotiating': 'b-amber', 'Declined': 'b-red', 'Expired': 'b-gray', - 'In Progress': 'b-amber', 'Pending': 'b-gray', 'Active': 'b-green', 'Invited': 'b-amber', - 'Strong Hire': 'b-green', 'Hire': 'b-teal', 'Lean Hire': 'b-amber', 'No Hire': 'b-red' - }; - function badge(text, cls) { return `${text}`; } - - function scoreChip(score) { - // Theme tokens, not fixed hex — the old greens/blues dropped to ~2.6:1 on dark cards. - const color = score >= 85 ? 'var(--success)' : score >= 70 ? 'var(--warning)' : score >= 55 ? 'var(--info)' : 'var(--danger)'; - return `${score}`; - } - - function pbar(pct, cls) { - const c = pct >= 80 ? 'green' : pct >= 50 ? '' : pct >= 30 ? 'amber' : 'red'; - return `
`; - } - - function avatarStack(names, max) { - max = max || 3; - const shown = names.slice(0, max); - const extra = names.length - max; - let html = '
'; - shown.forEach(n => html += avatar(n, DB.initials(n))); - if (extra > 0) html += `+${extra}`; - return html + '
'; - } - - // ---------- Modal ---------- - function modal({ title, subtitle, body, footer, size }) { - const root = document.getElementById('modalRoot'); - root.innerHTML = ` - - `; - root.classList.add('open'); - document.body.style.overflow = 'hidden'; - root.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal)); - return root; - } - function closeModal() { - const root = document.getElementById('modalRoot'); - root.classList.remove('open'); - root.innerHTML = ''; - document.body.style.overflow = ''; - } - - // ---------- Toast ---------- - function toast(msg, type = 'info', title) { - const root = document.getElementById('toastRoot'); - const cfg = { - success: { i: 'check-circle', c: 'i-green', t: 'Success' }, - error: { i: 'x-circle', c: 'i-red', t: 'Error' }, - info: { i: 'info', c: 'i-blue', t: 'Notice' }, - warning: { i: 'alert', c: 'i-amber', t: 'Warning' } - }[type] || { i: 'info', c: 'i-blue', t: 'Notice' }; - const el = document.createElement('div'); - el.className = 'toast'; - el.innerHTML = ` - ${icon(cfg.i)} -
${title || cfg.t}
${msg}
- `; - root.appendChild(el); - const remove = () => { el.classList.add('out'); setTimeout(() => el.remove(), 300); }; - el.querySelector('.toast-close').onclick = remove; - setTimeout(remove, 4200); - } - - // ---------- Sortable / paginated table ---------- - function dataTable(config) { - // config: { columns:[{key,label,sortable,render,align}], rows, pageSize, empty } - const state = { sortKey: null, sortDir: 1, page: 1, rows: config.rows }; - const pageSize = config.pageSize || 10; - const id = 'tbl_' + Math.random().toString(36).slice(2, 8); - - function sorted() { - let r = state.rows; - if (state.sortKey) { - const col = config.columns.find(c => c.key === state.sortKey); - r = [...r].sort((a, b) => { - let va = col.sortValue ? col.sortValue(a) : a[state.sortKey]; - let vb = col.sortValue ? col.sortValue(b) : b[state.sortKey]; - if (typeof va === 'string') { va = va.toLowerCase(); vb = (vb || '').toLowerCase(); } - if (va < vb) return -1 * state.sortDir; - if (va > vb) return 1 * state.sortDir; - return 0; - }); - } - return r; - } - function render() { - const rows = sorted(); - const total = rows.length; - const pages = Math.max(1, Math.ceil(total / pageSize)); - if (state.page > pages) state.page = pages; - const start = (state.page - 1) * pageSize; - const pageRows = rows.slice(start, start + pageSize); - - const thead = config.columns.map(c => { - const sortedCls = state.sortKey === c.key ? (state.sortDir === 1 ? 'sorted-asc' : 'sorted-desc') : ''; - const ind = c.sortable ? `${state.sortKey === c.key ? (state.sortDir === 1 ? '▲' : '▼') : '⇅'}` : ''; - return `${c.label}${ind}`; - }).join(''); - - let tbody; - if (!pageRows.length) { - tbody = ` -
${icon('search')}

No results found

${config.empty || 'Try adjusting your filters or search.'}

`; - } else { - tbody = pageRows.map(row => `${config.columns.map(c => - `${c.render ? c.render(row) : (row[c.key] ?? '')}`).join('')}`).join(''); - } - - const from = total ? start + 1 : 0, to = Math.min(start + pageSize, total); - const pager = pageButtons(state.page, pages); - - const el = document.getElementById(id); - el.innerHTML = ` -
- ${thead}${tbody}
- `; - - el.querySelectorAll('th.sortable').forEach(th => th.onclick = () => { - const k = th.dataset.sort; - if (state.sortKey === k) state.sortDir *= -1; else { state.sortKey = k; state.sortDir = 1; } - render(); - }); - el.querySelectorAll('[data-page]').forEach(b => b.onclick = () => { - const p = b.dataset.page; - if (p === 'prev') state.page = Math.max(1, state.page - 1); - else if (p === 'next') state.page = Math.min(pages, state.page + 1); - else state.page = +p; - render(); - }); - if (config.onRender) config.onRender(el); - } - function pageButtons(cur, pages) { - let btns = ``; - const list = []; - for (let i = 1; i <= pages; i++) { - if (i === 1 || i === pages || Math.abs(i - cur) <= 1) list.push(i); - else if (list[list.length - 1] !== '…') list.push('…'); - } - list.forEach(i => btns += i === '…' ? `` : ``); - btns += ``; - return btns; - } - // public API - return { - html: `
`, - mount: render, - update(rows) { state.rows = rows; state.page = 1; render(); } - }; - } - - function fieldError(inputEl, msg) { - inputEl.classList.add('err'); - let err = inputEl.parentElement.querySelector('.field-error'); - if (err) { err.textContent = msg; err.classList.add('show'); } - } - function clearErrors(form) { - form.querySelectorAll('.err').forEach(e => e.classList.remove('err')); - form.querySelectorAll('.field-error').forEach(e => e.classList.remove('show')); - } - - window.UI = { icon, avatar, badge, scoreChip, pbar, avatarStack, modal, closeModal, toast, dataTable, fieldError, clearErrors, ICONS }; -})(); diff --git a/tools/check_evidence_citations.py b/tools/check_evidence_citations.py index 353991a..b494496 100755 --- a/tools/check_evidence_citations.py +++ b/tools/check_evidence_citations.py @@ -37,11 +37,42 @@ from __future__ import annotations import os import re +import subprocess import sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DOCS = os.path.join(REPO, 'docs', 'architecture') +# --- prototype evidence outlives the prototype ------------------------------ +# The React migration deletes the browser-only prototype (`index.html`, `js/`, +# and moves `css/styles.css` into the web app). Around 600 citations in this +# package are evidence *about that prototype* — they were true when written and +# stay true of it forever, so they must not rot just because the working tree +# moved on. Resolve those paths from the `prototype-final` tag, which marks the +# last commit where the prototype existed intact. +# +# If the tag is absent (a fresh clone that never fetched tags, or a checkout +# from before the migration) this falls back to the working tree, so the script +# keeps working either way. +LEGACY_TAG = 'prototype-final' +LEGACY_PREFIXES = ('js/', 'index.html', 'css/', 'devserver.py') + + +def is_legacy(path: str) -> bool: + return path.startswith(LEGACY_PREFIXES) + + +def read_from_tag(path: str) -> list[str] | None: + """Contents of `path` at LEGACY_TAG, or None if unavailable.""" + try: + out = subprocess.run( + ['git', '-C', REPO, 'show', f'{LEGACY_TAG}:{path}'], + capture_output=True, check=True, + ) + except (OSError, subprocess.CalledProcessError): + return None + return out.stdout.decode('utf-8', 'replace').split('\n') + # Only extensions that exist in this repository today. Citations to planned files # (.sql migrations, .tsx components, .yml workflows) are intentionally not matched — # they cannot be verified and are design intent, not evidence. @@ -158,15 +189,28 @@ def main() -> int: return 1 cache: dict[str, list[str] | None] = {} + from_tag: set[str] = set() + + def read_worktree(path: str) -> list[str] | None: + try: + with open(os.path.join(REPO, path), encoding='utf-8') as fh: + return fh.read().split('\n') + except OSError: + return None def source(path: str) -> list[str] | None: if path not in cache: - full = os.path.join(REPO, path) - try: - with open(full, encoding='utf-8') as fh: - cache[path] = fh.read().split('\n') - except OSError: - cache[path] = None + if is_legacy(path): + # Prefer the tagged prototype so these citations are stable whether + # or not the files still exist in the tree. + tagged = read_from_tag(path) + if tagged is not None: + from_tag.add(path) + cache[path] = tagged + else: + cache[path] = read_worktree(path) + else: + cache[path] = read_worktree(path) return cache[path] docs = [] @@ -241,13 +285,20 @@ def main() -> int: f'{claim_checked} claim/anchor pairings checked ' f'({len(CLAIM_RULES)} rules)' ) + if from_tag: + print( + f'{len(from_tag)} prototype file(s) resolved from `{LEGACY_TAG}` ' + f'rather than the working tree: {", ".join(sorted(from_tag))}' + ) if problems: print(f'\n{len(problems)} bad citation(s):\n', file=sys.stderr) for p in problems: print(f' {p}', file=sys.stderr) print( '\nFix the citation, or update ANCHORS in this script if the source moved ' - 'deliberately.', + f'deliberately. Prototype paths ({", ".join(LEGACY_PREFIXES)}) resolve from ' + f'the `{LEGACY_TAG}` tag, so moving or deleting them in the working tree ' + 'does not affect this check.', file=sys.stderr, ) return 1